@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.
- package/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- 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
|
+
}
|