@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,1109 @@
|
|
|
1
|
+
// `workit pr create` and `workit pr merge` (design §2.1 S11): forge writes with
|
|
2
|
+
// a SHA bound before the call and verified after it.
|
|
3
|
+
//
|
|
4
|
+
// create: the branch must already be pushed (remote tip == local head); that
|
|
5
|
+
// SHA is bound, an open PR/MR for the branch is reused (`created: false`),
|
|
6
|
+
// and success means the forge reports the bound SHA as the PR head. The
|
|
7
|
+
// `pr.created` ledger row is what `ledger check --pr` resolves through (S13).
|
|
8
|
+
//
|
|
9
|
+
// merge: three independent gates, all required:
|
|
10
|
+
// 1. the S10 status document reads READY (required checks green, no
|
|
11
|
+
// conflicts, threads, review or draft blockers);
|
|
12
|
+
// 2. an accepted S13 verdict for that exact head (fresh or carried). Under
|
|
13
|
+
// `merge: true` only, `--unverified --reason` bypasses it, and the bypass
|
|
14
|
+
// is recorded (`merge.unverified`) before the merge call;
|
|
15
|
+
// 3. the `merge` grant (autonomy.ts `requireGrant`; S16 fills the defaults).
|
|
16
|
+
// The merge call carries the head SHA (GitHub `sha=`, GitLab `sha=`), so a
|
|
17
|
+
// head that moves after the gates refuses instead of merging unverified code.
|
|
18
|
+
// --delete-branch deletes with a lease on that same SHA.
|
|
19
|
+
// A squash with --subject/--body, or in a workspace with `trailers: false`,
|
|
20
|
+
// sends its own message (the PR title and the branch's commits by default,
|
|
21
|
+
// on stdin) with `Workit-Session:` lines dropped from each trailer block.
|
|
22
|
+
import {
|
|
23
|
+
requireGrant,
|
|
24
|
+
resolveAutonomy,
|
|
25
|
+
type AutonomySource,
|
|
26
|
+
type DefaultEndpoint,
|
|
27
|
+
type VerificationMode,
|
|
28
|
+
} from "../autonomy";
|
|
29
|
+
import { branchKindOf, flowRoute, gitFlowOf, isProtectedTarget } from "../core/branch";
|
|
30
|
+
import { forgeTarget } from "../core/remotes";
|
|
31
|
+
import { vcsConfig } from "../core/vcs-config";
|
|
32
|
+
import { workspaceTrailers } from "../core/workspaces";
|
|
33
|
+
import { workspaceReleaseTracks } from "../core/release-tracks";
|
|
34
|
+
import { deleteRemoteBranch, stripSessionTrailers } from "../git/ops";
|
|
35
|
+
import { spawnSync } from "node:child_process";
|
|
36
|
+
import { currentBranch, fetchRefs, GIT_TIMEOUTS, remoteRefTip, resolveRef } from "../git/rev";
|
|
37
|
+
import {
|
|
38
|
+
appendObserved,
|
|
39
|
+
tamperedUnblock,
|
|
40
|
+
readLedger,
|
|
41
|
+
unboundRecovery,
|
|
42
|
+
type CarryNote,
|
|
43
|
+
type LedgerActor,
|
|
44
|
+
type VerdictCheck,
|
|
45
|
+
} from "../ledger";
|
|
46
|
+
import { checkVerdictsCovered, type StackCover } from "../verdict-cover";
|
|
47
|
+
import type { LaneNeed, ReviewLane } from "../core/policy/lane";
|
|
48
|
+
import { laneLabel, prLaneGate } from "../core/policy/pr-lane";
|
|
49
|
+
import {
|
|
50
|
+
baseRedNextStep,
|
|
51
|
+
classifyBaseRed,
|
|
52
|
+
previousBaseChecks,
|
|
53
|
+
type BaseRedCause,
|
|
54
|
+
} from "./merge-gate-base-red";
|
|
55
|
+
import { buildStatusDoc, selectPr, type PrStatusDoc } from "./report";
|
|
56
|
+
import type { ResolvedForge } from "./resolve";
|
|
57
|
+
import {
|
|
58
|
+
failure,
|
|
59
|
+
success,
|
|
60
|
+
type ForgeCheck,
|
|
61
|
+
type ForgeResult,
|
|
62
|
+
type MergeMethod,
|
|
63
|
+
type PrRef,
|
|
64
|
+
} from "./types";
|
|
65
|
+
|
|
66
|
+
export type Sleep = (ms: number) => Promise<void>;
|
|
67
|
+
|
|
68
|
+
/** Post-verification retries: the forge may take a moment to show a new head or merge. */
|
|
69
|
+
const VERIFY_DELAYS_MS = [1_000, 2_000, 4_000];
|
|
70
|
+
|
|
71
|
+
const noun = (resolved: ResolvedForge): string =>
|
|
72
|
+
resolved.forge.kind === "github" ? "pull request" : "merge request";
|
|
73
|
+
|
|
74
|
+
/** The base repository a PR or merge acts on (#549: a fork's PR to a claimed upstream is capped). */
|
|
75
|
+
export const baseTarget = (resolved: ResolvedForge) =>
|
|
76
|
+
forgeTarget(resolved.forge.apiHost, resolved.forge.repo);
|
|
77
|
+
|
|
78
|
+
const grantBlocked = (decision: Extract<ReturnType<typeof requireGrant>, { allowed: false }>) =>
|
|
79
|
+
failure("blocked", decision.error, decision.unblock);
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// create
|
|
83
|
+
|
|
84
|
+
export type CreateInput = {
|
|
85
|
+
base: string;
|
|
86
|
+
title: string;
|
|
87
|
+
body: string;
|
|
88
|
+
draft: boolean;
|
|
89
|
+
actor: LedgerActor;
|
|
90
|
+
/** Applied after the PR is opened or found (`--label`, `--reviewer`). */
|
|
91
|
+
labels?: string[];
|
|
92
|
+
reviewers?: string[];
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type CreateOutcome = {
|
|
96
|
+
number: number;
|
|
97
|
+
url: string;
|
|
98
|
+
branch: string;
|
|
99
|
+
base: string;
|
|
100
|
+
head: string;
|
|
101
|
+
created: boolean;
|
|
102
|
+
draft: boolean;
|
|
103
|
+
recorded: { id: string } | { error: string };
|
|
104
|
+
/** The review step and what the effective endpoint does after it. */
|
|
105
|
+
next: string;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const AFTER_REVIEW: Record<DefaultEndpoint, string> = {
|
|
109
|
+
commit: "stop here; review comes when the user asks",
|
|
110
|
+
pr: "stop here; review comes when the user asks",
|
|
111
|
+
green: "babysit it to merge-ready, never merging",
|
|
112
|
+
merged: "babysit it, then land it after verification",
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const createNext = (cwd: string): string => {
|
|
116
|
+
const endpoint = resolveAutonomy(cwd).effectiveEndpoint;
|
|
117
|
+
return `a non-author verifies the head (workit-review); the endpoint is ${endpoint}, so ${AFTER_REVIEW[endpoint]}`;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* `--fill`: the title and body from the branch's commits on top of the base
|
|
122
|
+
* (fetched first). One commit gives its subject and body; several give the
|
|
123
|
+
* oldest subject and every subject with its body, like `gh pr create
|
|
124
|
+
* --fill-verbose`. Workit's own `Workit-Session:` trailers (ledger
|
|
125
|
+
* provenance, not PR prose) are left out of each commit's trailer block.
|
|
126
|
+
*/
|
|
127
|
+
export function fillFromCommits(
|
|
128
|
+
cwd: string,
|
|
129
|
+
resolved: ResolvedForge,
|
|
130
|
+
base: string,
|
|
131
|
+
): ForgeResult<{ title: string; body: string }> {
|
|
132
|
+
const tracking = `refs/remotes/${resolved.baseRemote}/${base}`;
|
|
133
|
+
if (!base.startsWith("-"))
|
|
134
|
+
fetchRefs(cwd, resolved.baseRemote, [`+refs/heads/${base}:${tracking}`]);
|
|
135
|
+
const from = resolveRef(cwd, tracking) ?? resolveRef(cwd, `refs/heads/${base}`);
|
|
136
|
+
if (!from)
|
|
137
|
+
return failure(
|
|
138
|
+
"not_found",
|
|
139
|
+
`base ${base} is not available locally`,
|
|
140
|
+
`git fetch ${resolved.baseRemote} ${base}`,
|
|
141
|
+
);
|
|
142
|
+
const log = spawnSync(
|
|
143
|
+
"git",
|
|
144
|
+
["log", "--reverse", "--no-merges", "--format=%s%x1f%b%x1e", `${from}..HEAD`],
|
|
145
|
+
{
|
|
146
|
+
cwd,
|
|
147
|
+
encoding: "utf8",
|
|
148
|
+
timeout: GIT_TIMEOUTS.local,
|
|
149
|
+
killSignal: "SIGKILL",
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
const commits = (log.status === 0 ? log.stdout : "")
|
|
153
|
+
.split("\x1e")
|
|
154
|
+
.map((entry) => entry.trim())
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.map((entry) => {
|
|
157
|
+
const [subject = "", body = ""] = entry.split("\x1f");
|
|
158
|
+
return {
|
|
159
|
+
subject: subject.trim(),
|
|
160
|
+
body: stripSessionTrailers(body, { subject: false }).trim(),
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
if (commits.length === 0)
|
|
164
|
+
return failure(
|
|
165
|
+
"invalid_input",
|
|
166
|
+
`no commits on top of ${base}; nothing to describe`,
|
|
167
|
+
"pass --title <t>",
|
|
168
|
+
);
|
|
169
|
+
if (commits.length === 1) return success({ title: commits[0].subject, body: commits[0].body });
|
|
170
|
+
return success({
|
|
171
|
+
title: commits[0].subject,
|
|
172
|
+
body: commits
|
|
173
|
+
.map((commit) =>
|
|
174
|
+
[`- ${commit.subject}`, ...(commit.body ? ["", indent(commit.body), ""] : [])].join("\n"),
|
|
175
|
+
)
|
|
176
|
+
.join("\n")
|
|
177
|
+
.trim(),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const indent = (text: string): string =>
|
|
182
|
+
text
|
|
183
|
+
.split("\n")
|
|
184
|
+
.map((line) => (line ? ` ${line}` : line))
|
|
185
|
+
.join("\n");
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Labels and reviewers a PR write may send, checked before any forge call
|
|
189
|
+
* (so `pr create --label/--reviewer` never fails after the PR is open):
|
|
190
|
+
* comma-free names (GitLab joins labels with commas), GitLab reviewers are
|
|
191
|
+
* usernames, and a GitHub `org/team` belongs to the repository's owner.
|
|
192
|
+
*/
|
|
193
|
+
export function checkNames(
|
|
194
|
+
kind: "github" | "gitlab",
|
|
195
|
+
repo: string,
|
|
196
|
+
labels: readonly string[],
|
|
197
|
+
reviewers: readonly string[],
|
|
198
|
+
): string | null {
|
|
199
|
+
const bad = [...labels, ...reviewers].find(
|
|
200
|
+
(value) => !value.trim() || value.includes(",") || value !== value.trim(),
|
|
201
|
+
);
|
|
202
|
+
if (bad !== undefined) return `invalid label or reviewer "${bad}"`;
|
|
203
|
+
const owner = repo.split("/")[0]?.toLowerCase() ?? "";
|
|
204
|
+
for (const reviewer of reviewers) {
|
|
205
|
+
if (!reviewer.includes("/")) continue;
|
|
206
|
+
if (kind === "gitlab") return `GitLab reviewers are usernames, not teams ("${reviewer}")`;
|
|
207
|
+
const [org = "", team = "", ...rest] = reviewer.split("/");
|
|
208
|
+
if (!team || rest.length) return `invalid team reviewer "${reviewer}" (use <org>/<team>)`;
|
|
209
|
+
if (org.toLowerCase() !== owner)
|
|
210
|
+
return `team ${reviewer} is not in ${owner}; GitHub only requests teams of the repository's owner`;
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The pushed head of the current branch, bound before any forge write. */
|
|
216
|
+
export function boundSource(
|
|
217
|
+
cwd: string,
|
|
218
|
+
resolved: ResolvedForge,
|
|
219
|
+
): ForgeResult<{ branch: string; sha: string }> {
|
|
220
|
+
const branch = currentBranch(cwd);
|
|
221
|
+
if (!branch) return failure("invalid_input", "HEAD is detached; check out the PR branch");
|
|
222
|
+
const sha = resolveRef(cwd, `refs/heads/${branch}`);
|
|
223
|
+
if (!sha) return failure("not_found", `branch ${branch} has no commit`);
|
|
224
|
+
const remote = remoteRefTip(cwd, resolved.remote, `refs/heads/${branch}`);
|
|
225
|
+
if (!remote.ok) return failure(remote.code, remote.error);
|
|
226
|
+
if (remote.sha !== sha)
|
|
227
|
+
return failure(
|
|
228
|
+
"blocked",
|
|
229
|
+
remote.sha === null
|
|
230
|
+
? `not_pushed: ${branch} is not on ${resolved.remote}`
|
|
231
|
+
: `not_pushed: ${resolved.remote}/${branch} is at ${remote.sha.slice(0, 12)}, local ${branch} is at ${sha.slice(0, 12)}`,
|
|
232
|
+
"workit git push",
|
|
233
|
+
);
|
|
234
|
+
return success({ branch, sha });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function verifiedHead(
|
|
238
|
+
resolved: ResolvedForge,
|
|
239
|
+
branch: string,
|
|
240
|
+
sha: string,
|
|
241
|
+
first: PrRef,
|
|
242
|
+
sleep: Sleep,
|
|
243
|
+
base: string,
|
|
244
|
+
): Promise<ForgeResult<PrRef>> {
|
|
245
|
+
let ref = first;
|
|
246
|
+
for (const delay of [0, ...VERIFY_DELAYS_MS]) {
|
|
247
|
+
if (ref.headSha === sha) return success(ref);
|
|
248
|
+
if (delay) await sleep(delay);
|
|
249
|
+
const found = resolved.forge.findPr(branch, {
|
|
250
|
+
owner: resolved.headRepo.split("/")[0] ?? null,
|
|
251
|
+
projectId: resolved.headProjectId,
|
|
252
|
+
sha,
|
|
253
|
+
base,
|
|
254
|
+
});
|
|
255
|
+
if (!found.ok) return found;
|
|
256
|
+
if (found.data && found.data.number === first.number) ref = found.data;
|
|
257
|
+
}
|
|
258
|
+
return ref.headSha === sha
|
|
259
|
+
? success(ref)
|
|
260
|
+
: failure(
|
|
261
|
+
"failed",
|
|
262
|
+
`head_unverified: ${noun(resolved)} #${first.number} reports head ${ref.headSha?.slice(0, 12) ?? "(none)"}, not the pushed ${sha.slice(0, 12)}`,
|
|
263
|
+
`workit pr status --pr ${first.number}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function createPullRequest(
|
|
268
|
+
cwd: string,
|
|
269
|
+
resolved: ResolvedForge,
|
|
270
|
+
input: CreateInput,
|
|
271
|
+
sleep: Sleep,
|
|
272
|
+
): Promise<ForgeResult<CreateOutcome>> {
|
|
273
|
+
const names = checkNames(
|
|
274
|
+
resolved.forge.kind,
|
|
275
|
+
resolved.forge.repo,
|
|
276
|
+
input.labels ?? [],
|
|
277
|
+
input.reviewers ?? [],
|
|
278
|
+
);
|
|
279
|
+
if (names) return failure("invalid_input", names);
|
|
280
|
+
const grant = requireGrant(cwd, "pr", { target: baseTarget(resolved) });
|
|
281
|
+
if (!grant.allowed) return grantBlocked(grant);
|
|
282
|
+
const source = boundSource(cwd, resolved);
|
|
283
|
+
if (!source.ok) return source;
|
|
284
|
+
const { branch, sha } = source.data;
|
|
285
|
+
if (branch === input.base)
|
|
286
|
+
return failure(
|
|
287
|
+
"invalid_input",
|
|
288
|
+
`the PR source and base are both ${branch}`,
|
|
289
|
+
"pass --base <branch>",
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
// Head AND base: a release branch with an open PR into main still gets
|
|
293
|
+
// its back-merge PR into develop.
|
|
294
|
+
const existing = resolved.forge.findPr(branch, {
|
|
295
|
+
owner: resolved.headRepo.split("/")[0] ?? null,
|
|
296
|
+
projectId: resolved.headProjectId,
|
|
297
|
+
sha,
|
|
298
|
+
base: input.base,
|
|
299
|
+
});
|
|
300
|
+
if (!existing.ok) return existing;
|
|
301
|
+
let created = false;
|
|
302
|
+
let ref: PrRef;
|
|
303
|
+
if (existing.data && existing.data.state === "open") ref = existing.data;
|
|
304
|
+
else {
|
|
305
|
+
const made = resolved.forge.createPr({
|
|
306
|
+
head: branch,
|
|
307
|
+
headRepo: resolved.headRepo,
|
|
308
|
+
headProjectId: resolved.headProjectId,
|
|
309
|
+
base: input.base,
|
|
310
|
+
title: input.title,
|
|
311
|
+
body: input.body,
|
|
312
|
+
draft: input.draft,
|
|
313
|
+
});
|
|
314
|
+
if (!made.ok) return made;
|
|
315
|
+
ref = made.data;
|
|
316
|
+
created = true;
|
|
317
|
+
}
|
|
318
|
+
const verified = await verifiedHead(resolved, branch, sha, ref, sleep, input.base);
|
|
319
|
+
if (!verified.ok) return verified;
|
|
320
|
+
const row = appendObserved(cwd, {
|
|
321
|
+
type: "pr.created",
|
|
322
|
+
actor: input.actor,
|
|
323
|
+
branch,
|
|
324
|
+
head: sha,
|
|
325
|
+
pr: ref.number,
|
|
326
|
+
base: input.base,
|
|
327
|
+
url: ref.url,
|
|
328
|
+
forge: resolved.forge.kind,
|
|
329
|
+
repo: resolved.forge.repo,
|
|
330
|
+
created,
|
|
331
|
+
});
|
|
332
|
+
const labels = input.labels ?? [];
|
|
333
|
+
const reviewers = input.reviewers ?? [];
|
|
334
|
+
if (labels.length || reviewers.length) {
|
|
335
|
+
const edited = resolved.forge.editPr(ref.number, {
|
|
336
|
+
addLabels: labels,
|
|
337
|
+
removeLabels: [],
|
|
338
|
+
addReviewers: reviewers,
|
|
339
|
+
});
|
|
340
|
+
if (!edited.ok)
|
|
341
|
+
return failure(
|
|
342
|
+
edited.code,
|
|
343
|
+
`${noun(resolved)} #${ref.number} is open (${ref.url}), but its labels or reviewers were not set: ${edited.error}`,
|
|
344
|
+
`workit pr edit --pr ${ref.number}${labels.map((name) => ` --add-label ${name}`).join("")}${reviewers.map((name) => ` --add-reviewer ${name}`).join("")}`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
return success({
|
|
348
|
+
number: ref.number,
|
|
349
|
+
url: ref.url,
|
|
350
|
+
branch,
|
|
351
|
+
base: input.base,
|
|
352
|
+
head: sha,
|
|
353
|
+
created,
|
|
354
|
+
draft: input.draft,
|
|
355
|
+
recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
|
|
356
|
+
next: createNext(cwd),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
// merge
|
|
362
|
+
|
|
363
|
+
export type MergeInput = {
|
|
364
|
+
pr: number | null;
|
|
365
|
+
method: MergeMethod;
|
|
366
|
+
/** `--delete-branch`: delete, refusing a protected branch before the merge. */
|
|
367
|
+
deleteBranch: boolean;
|
|
368
|
+
/**
|
|
369
|
+
* The workspace default (`deleteBranch`, no `--keep-branch`): delete when it
|
|
370
|
+
* is safe, otherwise skip with a note; never refuses the merge.
|
|
371
|
+
*/
|
|
372
|
+
implicitDelete?: boolean;
|
|
373
|
+
actor: LedgerActor;
|
|
374
|
+
/**
|
|
375
|
+
* Stack landing (S12): refuse unless the PR still targets this base and
|
|
376
|
+
* comes from this branch, re-checked right before the merge call.
|
|
377
|
+
*/
|
|
378
|
+
expect?: { base: string; branch: string };
|
|
379
|
+
/** Merge without a verdict (`merge: true` only); the reason is recorded. */
|
|
380
|
+
unverified?: { reason: string };
|
|
381
|
+
/** Merge onto a base whose latest CI is red (`--base-red-ok`); the reason is recorded. */
|
|
382
|
+
baseRedOk?: { reason: string };
|
|
383
|
+
/** Merge a PR someone else authored (`--others-pr`); the reason is recorded. */
|
|
384
|
+
othersPr?: { reason: string };
|
|
385
|
+
/**
|
|
386
|
+
* Squash commit subject and body (`--subject`, `--body`); unset parts use the
|
|
387
|
+
* defaults. Squash only: any other method with either is refused.
|
|
388
|
+
*/
|
|
389
|
+
subject?: string;
|
|
390
|
+
body?: string;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
export type MergeOutcome = {
|
|
394
|
+
number: number;
|
|
395
|
+
url: string;
|
|
396
|
+
branch: string;
|
|
397
|
+
base: string;
|
|
398
|
+
head: string;
|
|
399
|
+
method: MergeMethod;
|
|
400
|
+
mergeSha: string | null;
|
|
401
|
+
/**
|
|
402
|
+
* `required`: the gate asked for a verdict (false under lane none or a
|
|
403
|
+
* bypass); `accepted`: an accepted independent verdict covers the head;
|
|
404
|
+
* `verdictId`: the verdict the merge relied on (the author's own under lane
|
|
405
|
+
* self, shown as self-reviewed); `lane`/`need`: the review lane and what it
|
|
406
|
+
* asked (`need` is non-author outside `verification: proportional`).
|
|
407
|
+
*/
|
|
408
|
+
verdict: {
|
|
409
|
+
required: boolean;
|
|
410
|
+
accepted: boolean;
|
|
411
|
+
verdictId: string | null;
|
|
412
|
+
review: "verified" | "self-reviewed" | "none";
|
|
413
|
+
lane: ReviewLane;
|
|
414
|
+
need: LaneNeed;
|
|
415
|
+
verification: VerificationMode;
|
|
416
|
+
/** The stack verdict above this PR that covers it (#334), when it has none of its own. */
|
|
417
|
+
cover?: StackCover | null;
|
|
418
|
+
/** How a verdict on an older head carried to this one over a follow-up change (#334). */
|
|
419
|
+
carry?: CarryNote | null;
|
|
420
|
+
};
|
|
421
|
+
/** Set when `--unverified` bypassed the verdict: the reason and its ledger row. */
|
|
422
|
+
unverified: { reason: string; recorded: string } | null;
|
|
423
|
+
/** Set when `--others-pr` merged another author's PR: who, and the recorded reason. */
|
|
424
|
+
othersPr: { author: string; login: string; reason: string } | null;
|
|
425
|
+
/** The base branch's CI at its head: a one-line note when it was not green, and the override used. */
|
|
426
|
+
baseCi: { note: string | null; override: { reason: string } | null };
|
|
427
|
+
grant: { source: AutonomySource };
|
|
428
|
+
/** `skipped`: the implicit default found a reason to keep the branch; the merge is unaffected. */
|
|
429
|
+
deletedBranch: boolean | { error: string } | { skipped: string };
|
|
430
|
+
recorded: { id: string } | { error: string };
|
|
431
|
+
/** The `pr.merge.verified` row confirming `recorded`, or why it was not written. */
|
|
432
|
+
confirmed: { id: string } | { error: string };
|
|
433
|
+
/**
|
|
434
|
+
* Release tracks: when the PR landed on a track's production branch, the
|
|
435
|
+
* branches it must flow back into (mergeBackBranches); empty otherwise.
|
|
436
|
+
* Workit reports them; it does not open the merge-back PRs itself.
|
|
437
|
+
*/
|
|
438
|
+
mergeBack: string[];
|
|
439
|
+
mergeBackTrack: string | null;
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The merge-back a merge into `base` owes: the track whose production branch
|
|
444
|
+
* `base` is, by ownership alone (no --track, no WORKIT_RELEASE_TRACK, no
|
|
445
|
+
* history); without tracks, a git-flow release/* or hotfix/* landing on the
|
|
446
|
+
* production branch owes develop. Best effort: a config problem never stops
|
|
447
|
+
* the pr.merged record.
|
|
448
|
+
*/
|
|
449
|
+
const mergeBackFor = (
|
|
450
|
+
cwd: string,
|
|
451
|
+
base: string,
|
|
452
|
+
head: string,
|
|
453
|
+
): { track: string | null; branches: string[] } => {
|
|
454
|
+
try {
|
|
455
|
+
const read = workspaceReleaseTracks(cwd);
|
|
456
|
+
if (!read.tracks.length) {
|
|
457
|
+
const flow = gitFlowOf(cwd);
|
|
458
|
+
const kind = branchKindOf(head);
|
|
459
|
+
const route = flow && kind ? flowRoute(flow, kind) : null;
|
|
460
|
+
return route?.mergeBack && route.target === base
|
|
461
|
+
? { track: "git-flow", branches: [route.mergeBack] }
|
|
462
|
+
: { track: null, branches: [] };
|
|
463
|
+
}
|
|
464
|
+
const owners = read.tracks.filter((track) => track.productionBranch === base);
|
|
465
|
+
if (owners.length !== 1) return { track: null, branches: [] };
|
|
466
|
+
return {
|
|
467
|
+
track: owners[0].name,
|
|
468
|
+
branches: owners[0].mergeBackBranches.filter((branch) => branch !== base),
|
|
469
|
+
};
|
|
470
|
+
} catch {
|
|
471
|
+
return { track: null, branches: [] };
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
/** Why `pr merge` refused, for the envelope `data` (agents branch on `reason`). */
|
|
476
|
+
export type MergeRefusal = {
|
|
477
|
+
reason:
|
|
478
|
+
| "grant_required"
|
|
479
|
+
| "unverified_refused"
|
|
480
|
+
| "failed_verdict"
|
|
481
|
+
| "not_ready"
|
|
482
|
+
| "needs_verdict"
|
|
483
|
+
| "head_mismatch"
|
|
484
|
+
| "already_merged"
|
|
485
|
+
| "closed"
|
|
486
|
+
| "protected_branch"
|
|
487
|
+
| "others_pr"
|
|
488
|
+
| "identity_unknown"
|
|
489
|
+
| "base_mismatch"
|
|
490
|
+
| "base_red"
|
|
491
|
+
| "ledger_tampered"
|
|
492
|
+
| "intent_unrecorded";
|
|
493
|
+
next?: string;
|
|
494
|
+
blockers?: string[];
|
|
495
|
+
/** base_red: whether the previous base run makes it look like a flake. */
|
|
496
|
+
baseRedCause?: BaseRedCause["kind"];
|
|
497
|
+
verdict?: Pick<VerdictCheck, "head" | "accepted" | "authors">;
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
export type MergeResult =
|
|
501
|
+
| { ok: true; data: MergeOutcome }
|
|
502
|
+
| {
|
|
503
|
+
ok: false;
|
|
504
|
+
code: "blocked" | "failed" | "unavailable" | "not_found" | "busy" | "invalid_input";
|
|
505
|
+
error: string;
|
|
506
|
+
unblock?: string;
|
|
507
|
+
refusal?: MergeRefusal;
|
|
508
|
+
/** merge_unverified: the `pr.merged` row (verified:false) written when the forge accepted it. */
|
|
509
|
+
recorded?: { id: string } | { error: string };
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const refuse = (error: string, unblock: string, refusal: MergeRefusal): MergeResult => ({
|
|
513
|
+
ok: false,
|
|
514
|
+
code: "blocked",
|
|
515
|
+
error,
|
|
516
|
+
unblock,
|
|
517
|
+
refusal,
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The command (or step) that clears a `next` blocker, for `pr status` and a
|
|
522
|
+
* `pr merge` refusal; null for READY and the terminal states.
|
|
523
|
+
*/
|
|
524
|
+
export function nextHint(next: string, number: number): string | null {
|
|
525
|
+
const pr = `--pr ${number}`;
|
|
526
|
+
switch (next) {
|
|
527
|
+
case "RESOLVE_CONFLICTS":
|
|
528
|
+
return "rebase onto the base, resolve, then workit git push --force-with-lease";
|
|
529
|
+
case "REBASE":
|
|
530
|
+
return "rebase onto the base, then workit git push --force-with-lease";
|
|
531
|
+
case "RESOLVE_THREADS":
|
|
532
|
+
return `workit pr threads ${pr}; fix or answer each: workit pr reply ${pr} --thread <id> --body-file <f> [--resolve]`;
|
|
533
|
+
case "FIX_CI":
|
|
534
|
+
return "fix the failing checks (workit pr status shows the log tails), push, then workit ci wait";
|
|
535
|
+
case "WAITING_CI":
|
|
536
|
+
return "workit ci wait";
|
|
537
|
+
case "ADDRESS_REVIEW":
|
|
538
|
+
return `address the requested changes; answer threads with workit pr reply ${pr} --thread <id> --body-file <f>`;
|
|
539
|
+
case "REVIEW":
|
|
540
|
+
return `get the required review (workit pr edit ${pr} --add-reviewer <login>)`;
|
|
541
|
+
case "MARK_READY":
|
|
542
|
+
return `workit pr ready ${pr}`;
|
|
543
|
+
case "IN_MERGE_QUEUE":
|
|
544
|
+
return "the PR is already in the merge queue";
|
|
545
|
+
case "MERGED":
|
|
546
|
+
return "workit verify-delivery merged";
|
|
547
|
+
case "CLOSED":
|
|
548
|
+
return "the PR is closed; reopen it or open a new one: workit pr create --fill";
|
|
549
|
+
case "NOT_MERGEABLE":
|
|
550
|
+
return "workit pr status # the forge reports it cannot merge yet";
|
|
551
|
+
default:
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* The squash message workit sends, or null to leave the forge default: only a
|
|
558
|
+
* squash with --subject/--body or in a workspace with `trailers: false`.
|
|
559
|
+
* Defaults: the PR title (GitHub appends `(#n)` like its own default) and the
|
|
560
|
+
* branch's commits as GitHub lists them; Workit-Session lines never survive.
|
|
561
|
+
*/
|
|
562
|
+
function squashMessageFor(
|
|
563
|
+
cwd: string,
|
|
564
|
+
resolved: ResolvedForge,
|
|
565
|
+
input: MergeInput,
|
|
566
|
+
doc: PrStatusDoc,
|
|
567
|
+
): ForgeResult<{ subject: string; body: string }> | null {
|
|
568
|
+
if (input.method !== "squash") return null;
|
|
569
|
+
if (input.subject === undefined && input.body === undefined && workspaceTrailers(cwd).enabled)
|
|
570
|
+
return null;
|
|
571
|
+
let subject = input.subject?.trim() ?? "";
|
|
572
|
+
if (!subject) {
|
|
573
|
+
const meta = resolved.forge.prMeta(doc.number);
|
|
574
|
+
if (!meta.ok) return meta;
|
|
575
|
+
subject =
|
|
576
|
+
resolved.forge.kind === "github" ? `${meta.data.title} (#${doc.number})` : meta.data.title;
|
|
577
|
+
}
|
|
578
|
+
// An explicit body is kept byte for byte but for its trailer block.
|
|
579
|
+
const body =
|
|
580
|
+
input.body === undefined
|
|
581
|
+
? squashCommitList(cwd, resolved, doc.base, doc.head.sha)
|
|
582
|
+
: stripSessionTrailers(input.body, { subject: false });
|
|
583
|
+
return success({ subject, body });
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Bounds of the generated squash body: a forge message, not an archive. */
|
|
587
|
+
const SQUASH_LIST_MAX_COMMITS = 100;
|
|
588
|
+
const SQUASH_LIST_MAX_CHARS = 60_000;
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* `* subject` and body of each non-merge commit on `base..head`, oldest
|
|
592
|
+
* first, from the local clone ("" when absent); each commit loses its
|
|
593
|
+
* Workit-Session trailers, and past the bounds the rest is counted.
|
|
594
|
+
*/
|
|
595
|
+
function squashCommitList(
|
|
596
|
+
cwd: string,
|
|
597
|
+
resolved: ResolvedForge,
|
|
598
|
+
base: string,
|
|
599
|
+
head: string,
|
|
600
|
+
): string {
|
|
601
|
+
const from =
|
|
602
|
+
resolveRef(cwd, `refs/remotes/${resolved.baseRemote}/${base}`) ??
|
|
603
|
+
resolveRef(cwd, `refs/heads/${base}`);
|
|
604
|
+
if (!from) return "";
|
|
605
|
+
const log = spawnSync(
|
|
606
|
+
"git",
|
|
607
|
+
["log", "--reverse", "--no-merges", "--format=%B%x1e", `${from}..${head}`],
|
|
608
|
+
{
|
|
609
|
+
cwd,
|
|
610
|
+
encoding: "utf8",
|
|
611
|
+
timeout: GIT_TIMEOUTS.local,
|
|
612
|
+
killSignal: "SIGKILL",
|
|
613
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
614
|
+
},
|
|
615
|
+
);
|
|
616
|
+
if (log.status !== 0) return "";
|
|
617
|
+
const entries = log.stdout
|
|
618
|
+
.split("\x1e")
|
|
619
|
+
.map((message) => stripSessionTrailers(message.replace(/^\n+/u, ""), { subject: true }))
|
|
620
|
+
.filter((message) => message.trim())
|
|
621
|
+
.map((message) => {
|
|
622
|
+
const [subject = "", ...rest] = message.trimEnd().split("\n");
|
|
623
|
+
const body = rest.join("\n").replace(/^\n+/u, "");
|
|
624
|
+
return body ? `* ${subject}\n\n${body}` : `* ${subject}`;
|
|
625
|
+
});
|
|
626
|
+
const listed: string[] = [];
|
|
627
|
+
let size = 0;
|
|
628
|
+
for (const entry of entries) {
|
|
629
|
+
if (listed.length >= SQUASH_LIST_MAX_COMMITS || size + entry.length > SQUASH_LIST_MAX_CHARS)
|
|
630
|
+
break;
|
|
631
|
+
listed.push(entry);
|
|
632
|
+
size += entry.length + 2;
|
|
633
|
+
}
|
|
634
|
+
const more = entries.length - listed.length;
|
|
635
|
+
return [...listed, ...(more ? [`… and ${more} more commit${more === 1 ? "" : "s"}`] : [])].join(
|
|
636
|
+
"\n\n",
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Why the implicit delete keeps `branch`, or null when it is safe to delete:
|
|
642
|
+
* a protected, base or default-target branch, a fork's branch, a branch another
|
|
643
|
+
* open PR targets (a stack child), and a PR by someone other than the
|
|
644
|
+
* workspace's `vcs.account`. What cannot be read keeps the branch too.
|
|
645
|
+
*/
|
|
646
|
+
function implicitDeleteSkip(
|
|
647
|
+
cwd: string,
|
|
648
|
+
resolved: ResolvedForge,
|
|
649
|
+
doc: PrStatusDoc,
|
|
650
|
+
branch: string,
|
|
651
|
+
): string | null {
|
|
652
|
+
const resolvedVcs = vcsConfig("resolve", cwd);
|
|
653
|
+
const defaultTarget =
|
|
654
|
+
resolvedVcs.ok === false ? null : String(resolvedVcs.defaultTargetBranch ?? "") || null;
|
|
655
|
+
const account = resolved.expectedAccount?.trim() ?? "";
|
|
656
|
+
const lower = branch.toLowerCase();
|
|
657
|
+
if (isProtectedTarget(cwd, branch))
|
|
658
|
+
return `${branch} is protected by the workspace branch policy`;
|
|
659
|
+
if (lower === doc.base.toLowerCase()) return `${branch} is the PR base`;
|
|
660
|
+
if (defaultTarget !== null && lower === defaultTarget.toLowerCase())
|
|
661
|
+
return `${branch} is the default target branch`;
|
|
662
|
+
if (doc.headRepo !== null) return `${branch} is a fork's branch (${doc.headRepo})`;
|
|
663
|
+
const meta = resolved.forge.prMeta(doc.number);
|
|
664
|
+
if (!meta.ok) return `could not read the PR author and head repository (${meta.error})`;
|
|
665
|
+
if (meta.data.fork !== false) return `${branch} is not known to be in ${resolved.forge.repo}`;
|
|
666
|
+
if (account && (meta.data.author ?? "").toLowerCase() !== account.toLowerCase())
|
|
667
|
+
return `the PR is by ${meta.data.author ?? "an unknown author"}, not ${account} (vcs.account)`;
|
|
668
|
+
const children = resolved.forge.openPrsInto(branch);
|
|
669
|
+
if (!children.ok) return `could not list the open PRs into ${branch} (${children.error})`;
|
|
670
|
+
if (children.data.length)
|
|
671
|
+
return `open PR ${children.data.map((n) => `#${n}`).join(", ")} targets ${branch} (a stack)`;
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
type BaseCi =
|
|
676
|
+
| { ok: true; note: string | null }
|
|
677
|
+
| { ok: false; failing: ForgeCheck[]; sha: string | null; requiredKnown: boolean };
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* The base branch head's CI before a merge lands on it: red (a failing check)
|
|
681
|
+
* refuses, pending or unreadable proceeds with a note, green or no checks
|
|
682
|
+
* proceeds quietly. Only a protected target is guarded: a stack's lower
|
|
683
|
+
* branch is its own PR's business.
|
|
684
|
+
*/
|
|
685
|
+
function baseCiState(cwd: string, resolved: ResolvedForge, base: string): BaseCi {
|
|
686
|
+
if (!isProtectedTarget(cwd, base)) return { ok: true, note: null };
|
|
687
|
+
const read = resolved.forge.branchChecks(base);
|
|
688
|
+
if (!read.ok)
|
|
689
|
+
return { ok: true, note: `base ${base} CI not read (${read.error}); merging anyway` };
|
|
690
|
+
// Only checks branch protection requires gate; with the required set
|
|
691
|
+
// unreadable, count hard failures not known to be optional.
|
|
692
|
+
const { requiredKnown } = read.data;
|
|
693
|
+
const failing = read.data.checks.filter(
|
|
694
|
+
(check) =>
|
|
695
|
+
check.state === "failing" &&
|
|
696
|
+
(requiredKnown
|
|
697
|
+
? check.required === true
|
|
698
|
+
: check.required !== false && check.conclusion === "failure"),
|
|
699
|
+
);
|
|
700
|
+
if (failing.length) return { ok: false, failing, sha: read.data.sha, requiredKnown };
|
|
701
|
+
const pending = read.data.checks.filter((check) => check.state === "pending");
|
|
702
|
+
return {
|
|
703
|
+
ok: true,
|
|
704
|
+
note: pending.length
|
|
705
|
+
? `base ${base} CI is still running (${pending.map((check) => check.name).join(", ")}); merging without waiting`
|
|
706
|
+
: null,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** The forge login (the account the CLI acts as), or null when it cannot be read. */
|
|
711
|
+
export function forgeLogin(resolved: ResolvedForge): string | null {
|
|
712
|
+
const account = resolved.expectedAccount?.trim() || null;
|
|
713
|
+
const identity = resolved.forge.identity(account);
|
|
714
|
+
return (identity.ok ? identity.data.login : null) ?? account;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* The PR's author versus the forge login: null when the PR is the caller's
|
|
719
|
+
* own. `pr merge` and `stack land`'s pre-flight share it; a caller checking
|
|
720
|
+
* many PRs passes the login it read once.
|
|
721
|
+
*/
|
|
722
|
+
export function otherAuthor(
|
|
723
|
+
resolved: ResolvedForge,
|
|
724
|
+
number: number,
|
|
725
|
+
knownLogin?: string | null,
|
|
726
|
+
): { ok: true; author: string; login: string } | { ok: false; error: string } | null {
|
|
727
|
+
const login = knownLogin === undefined ? forgeLogin(resolved) : knownLogin;
|
|
728
|
+
if (!login) return { ok: false, error: "the forge login could not be read" };
|
|
729
|
+
const meta = resolved.forge.prMeta(number);
|
|
730
|
+
if (!meta.ok) return { ok: false, error: `the PR author could not be read (${meta.error})` };
|
|
731
|
+
if (!meta.data.author) return { ok: false, error: "the forge did not name the PR author" };
|
|
732
|
+
return meta.data.author.toLowerCase() === login.toLowerCase()
|
|
733
|
+
? null
|
|
734
|
+
: { ok: true, author: meta.data.author, login };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
export async function mergePullRequest(
|
|
738
|
+
cwd: string,
|
|
739
|
+
resolved: ResolvedForge,
|
|
740
|
+
input: MergeInput,
|
|
741
|
+
sleep: Sleep,
|
|
742
|
+
): Promise<MergeResult> {
|
|
743
|
+
// Only a squash takes a message from workit: a merge commit or a rebase
|
|
744
|
+
// gets the forge's own, so a subject or body there would be dropped silently.
|
|
745
|
+
if (input.method !== "squash" && (input.subject !== undefined || input.body !== undefined))
|
|
746
|
+
return failure(
|
|
747
|
+
"invalid_input",
|
|
748
|
+
`--subject and --body set a squash commit message; a ${input.method} merge takes the forge's own message`,
|
|
749
|
+
"merge with --method squash, or drop --subject/--body",
|
|
750
|
+
);
|
|
751
|
+
const grant = requireGrant(cwd, "merge", { target: baseTarget(resolved) });
|
|
752
|
+
if (!grant.allowed) return refuse(grant.error, grant.unblock, { reason: "grant_required" });
|
|
753
|
+
const bypass = input.unverified ?? null;
|
|
754
|
+
if (bypass && !bypass.reason.trim())
|
|
755
|
+
return failure("invalid_input", "--unverified needs --reason <why the user asked for it>");
|
|
756
|
+
if (bypass && !grant.allowUnverified)
|
|
757
|
+
return refuse(
|
|
758
|
+
`unverified_refused: workspace ${grant.workspace ? `"${grant.workspace}"` : "(none)"} grants merge: "verified", which never merges without an accepted independent verdict`,
|
|
759
|
+
"a non-author session verifies the head (workit-review), then merge without --unverified",
|
|
760
|
+
{ reason: "unverified_refused" },
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
const baseRed = input.baseRedOk ?? null;
|
|
764
|
+
if (baseRed && !baseRed.reason.trim())
|
|
765
|
+
return failure(
|
|
766
|
+
"invalid_input",
|
|
767
|
+
"--base-red-ok needs --reason <why merging onto a red base is fine>",
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
const number = selectPr(cwd, resolved, { pr: input.pr });
|
|
771
|
+
if (!number.ok) return number;
|
|
772
|
+
const status = resolved.forge.prStatus(number.data);
|
|
773
|
+
if (!status.ok) return status;
|
|
774
|
+
const built = buildStatusDoc(cwd, resolved, status.data, { logLines: 0, behind: false });
|
|
775
|
+
if (!built.ok) return built;
|
|
776
|
+
const doc: PrStatusDoc = built.data;
|
|
777
|
+
const label = `${resolved.forge.kind === "github" ? "PR #" : "MR !"}${doc.number}`;
|
|
778
|
+
if (doc.state === "merged")
|
|
779
|
+
return refuse(
|
|
780
|
+
`already_merged: ${label} is already merged`,
|
|
781
|
+
`workit verify-delivery merged --pr ${doc.number}`,
|
|
782
|
+
{
|
|
783
|
+
reason: "already_merged",
|
|
784
|
+
},
|
|
785
|
+
);
|
|
786
|
+
if (doc.state === "closed")
|
|
787
|
+
return refuse(`closed: ${label} is closed`, "reopen it on the forge first", {
|
|
788
|
+
reason: "closed",
|
|
789
|
+
});
|
|
790
|
+
if (doc.next !== "READY")
|
|
791
|
+
return refuse(
|
|
792
|
+
`not_ready: ${label} is ${doc.next} (${doc.blockers.join(", ") || doc.next.toLowerCase()})`,
|
|
793
|
+
nextHint(doc.next, doc.number) ?? "workit pr status",
|
|
794
|
+
{ reason: "not_ready", next: doc.next, blockers: doc.blockers },
|
|
795
|
+
);
|
|
796
|
+
|
|
797
|
+
if (input.expect && (doc.base !== input.expect.base || doc.head.branch !== input.expect.branch))
|
|
798
|
+
return refuse(
|
|
799
|
+
`base_mismatch: ${label} is ${doc.head.branch} -> ${doc.base}, expected ${input.expect.branch} -> ${input.expect.base}`,
|
|
800
|
+
"workit stack sync # retarget it, then land again",
|
|
801
|
+
{ reason: "base_mismatch" },
|
|
802
|
+
);
|
|
803
|
+
const branch = doc.head.branch;
|
|
804
|
+
const head = doc.head.sha;
|
|
805
|
+
// --delete-branch never deletes a protected branch, the base, or the
|
|
806
|
+
// default target (a develop -> main release PR must keep develop). Refused
|
|
807
|
+
// before merging, so the caller decides without a half-done delivery.
|
|
808
|
+
if (input.deleteBranch) {
|
|
809
|
+
const resolvedVcs = vcsConfig("resolve", cwd);
|
|
810
|
+
const defaultTarget =
|
|
811
|
+
resolvedVcs.ok === false ? null : String(resolvedVcs.defaultTargetBranch ?? "") || null;
|
|
812
|
+
const lower = branch.toLowerCase();
|
|
813
|
+
const reason = isProtectedTarget(cwd, branch)
|
|
814
|
+
? "is protected by the workspace branch policy"
|
|
815
|
+
: lower === doc.base.toLowerCase()
|
|
816
|
+
? "is the PR base"
|
|
817
|
+
: defaultTarget !== null && lower === defaultTarget.toLowerCase()
|
|
818
|
+
? "is the default target branch"
|
|
819
|
+
: null;
|
|
820
|
+
if (reason)
|
|
821
|
+
return refuse(
|
|
822
|
+
`protected_branch: --delete-branch would delete ${branch}, which ${reason}; workit never deletes it`,
|
|
823
|
+
`workit pr merge${input.pr ? ` --pr ${doc.number}` : ""} --method ${input.method} # without --delete-branch`,
|
|
824
|
+
{ reason: "protected_branch" },
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
const othersBypass = input.othersPr ?? null;
|
|
828
|
+
if (othersBypass && !othersBypass.reason.trim())
|
|
829
|
+
return failure(
|
|
830
|
+
"invalid_input",
|
|
831
|
+
"--others-pr needs --reason <why the user asked you to merge a PR someone else authored>",
|
|
832
|
+
);
|
|
833
|
+
const prFlag = input.pr ? ` --pr ${doc.number}` : "";
|
|
834
|
+
const foreign = otherAuthor(resolved, doc.number);
|
|
835
|
+
let othersPr: { author: string; login: string; reason: string } | null = null;
|
|
836
|
+
if (foreign !== null && !foreign.ok)
|
|
837
|
+
return refuse(
|
|
838
|
+
`identity_unknown: ${foreign.error}, so ${label} cannot be shown to be yours`,
|
|
839
|
+
`fix: gh auth status / gh auth login (or glab auth login), then retry workit pr merge${prFlag}`,
|
|
840
|
+
{ reason: "identity_unknown" },
|
|
841
|
+
);
|
|
842
|
+
if (foreign !== null) {
|
|
843
|
+
if (!othersBypass)
|
|
844
|
+
return refuse(
|
|
845
|
+
`others_pr: ${label} is by ${foreign.author}, not ${foreign.login} (the forge login); workit never merges another author's PR on its own`,
|
|
846
|
+
`only if the user asks you to merge it: workit pr merge${prFlag} --others-pr --reason "<why>"`,
|
|
847
|
+
{ reason: "others_pr" },
|
|
848
|
+
);
|
|
849
|
+
othersPr = {
|
|
850
|
+
author: foreign.author,
|
|
851
|
+
login: foreign.login,
|
|
852
|
+
reason: othersBypass.reason.trim(),
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
const ledger = readLedger(cwd, { integrity: true });
|
|
856
|
+
if (!ledger.ok) return ledger;
|
|
857
|
+
// A lower PR of a stack may be covered by the verdict at its top (#334).
|
|
858
|
+
const check = checkVerdictsCovered(cwd, branch, ledger.value.rows);
|
|
859
|
+
const gate = prLaneGate(cwd, check, { branch, base: doc.base, head });
|
|
860
|
+
const summary = {
|
|
861
|
+
head: check.head,
|
|
862
|
+
accepted: check.accepted,
|
|
863
|
+
authors: check.authors,
|
|
864
|
+
lane: gate.lane.lane,
|
|
865
|
+
need: gate.need,
|
|
866
|
+
};
|
|
867
|
+
// Lane none needs no verdict, so no verdict head either.
|
|
868
|
+
const covered = gate.satisfied && (gate.need === "none" || check.head === head);
|
|
869
|
+
let verdict: MergeOutcome["verdict"] = {
|
|
870
|
+
required: gate.need !== "none",
|
|
871
|
+
accepted: check.head === head && check.accepted.accepted,
|
|
872
|
+
verdictId: covered ? gate.verdictId : null,
|
|
873
|
+
review: covered ? gate.review : "none",
|
|
874
|
+
lane: gate.lane.lane,
|
|
875
|
+
need: gate.need,
|
|
876
|
+
verification: gate.verification,
|
|
877
|
+
...(covered && gate.review === "verified"
|
|
878
|
+
? { cover: check.cover, carry: check.cover ? null : check.carry }
|
|
879
|
+
: {}),
|
|
880
|
+
};
|
|
881
|
+
let unverified: MergeOutcome["unverified"] = null;
|
|
882
|
+
// The bypass covers a missing verdict, never a rejection.
|
|
883
|
+
if (!covered && bypass && check.accepted.reasons.includes("failing_verdict"))
|
|
884
|
+
return refuse(
|
|
885
|
+
`failed_verdict: ${label} has a current independent failed verdict${typeof check.accepted.verdict?.id === "string" ? ` (${check.accepted.verdict.id})` : ""}; --unverified never merges over a rejection`,
|
|
886
|
+
"a new independent verdict on the head supersedes it",
|
|
887
|
+
{ reason: "failed_verdict", verdict: summary },
|
|
888
|
+
);
|
|
889
|
+
if (!covered && bypass) {
|
|
890
|
+
// Recorded before the merge call: a bypass that cannot be audited never merges.
|
|
891
|
+
const reason = bypass.reason.trim();
|
|
892
|
+
const recorded = appendObserved(cwd, {
|
|
893
|
+
type: "merge.unverified",
|
|
894
|
+
actor: input.actor,
|
|
895
|
+
branch,
|
|
896
|
+
head,
|
|
897
|
+
pr: doc.number,
|
|
898
|
+
base: doc.base,
|
|
899
|
+
reason,
|
|
900
|
+
grant: grant.source,
|
|
901
|
+
});
|
|
902
|
+
if (!recorded.ok)
|
|
903
|
+
return failure(
|
|
904
|
+
"failed",
|
|
905
|
+
`unverified_unrecorded: the --unverified bypass could not be recorded (${recorded.error}); nothing was merged`,
|
|
906
|
+
);
|
|
907
|
+
verdict = { ...verdict, required: false, accepted: false, verdictId: null, review: "none" };
|
|
908
|
+
unverified = { reason, recorded: String(recorded.value.id) };
|
|
909
|
+
} else if (!covered && check.head !== head)
|
|
910
|
+
return refuse(
|
|
911
|
+
`head_mismatch: the verdict check reads ${branch} at ${check.head?.slice(0, 12) ?? "(missing)"} but ${label} is at ${head.slice(0, 12)}`,
|
|
912
|
+
`git fetch && git switch ${branch} && git merge --ff-only @{u} # then re-verify that head`,
|
|
913
|
+
{ reason: "head_mismatch", verdict: summary },
|
|
914
|
+
);
|
|
915
|
+
else if (!covered && check.accepted.reasons.includes("ledger_tampered"))
|
|
916
|
+
return refuse(
|
|
917
|
+
`ledger_tampered: a ledger row was edited, removed or moved, so no verdict for ${label} is accepted until it is repaired`,
|
|
918
|
+
tamperedUnblock(cwd),
|
|
919
|
+
{ reason: "ledger_tampered", verdict: summary },
|
|
920
|
+
);
|
|
921
|
+
else if (!covered) {
|
|
922
|
+
const bypassHint = grant.allowUnverified
|
|
923
|
+
? `; or, only if the user asks to merge unverified: workit pr merge --unverified --reason "<why>"`
|
|
924
|
+
: "";
|
|
925
|
+
const lane = gate.verification === "proportional" ? `, ${laneLabel(gate)}` : "";
|
|
926
|
+
return refuse(
|
|
927
|
+
gate.need === "self"
|
|
928
|
+
? `NEEDS_VERDICT: ${label} has no verdict for ${head.slice(0, 12)}${lane}: the author's own --self verdict is enough (${check.accepted.reasons.join(", ")})`
|
|
929
|
+
: `NEEDS_VERDICT: ${label} has no accepted independent verdict for ${head.slice(0, 12)}${lane} (${check.accepted.reasons.join(", ")})`,
|
|
930
|
+
gate.need === "self"
|
|
931
|
+
? `after an observed passing \`workit check test\`, the author runs: workit ledger verdict tests-verified --self --how "<what you ran>" --branch ${branch} (or a verifier records one)${bypassHint}`
|
|
932
|
+
: check.accepted.reasons.includes("unbound_session")
|
|
933
|
+
? `${unboundRecovery(check.accepted.verdict?.actor.session ?? check.current.verdict?.actor.session ?? null, branch)}${bypassHint}`
|
|
934
|
+
: `an independent session verifies the head and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${branch}${bypassHint}`,
|
|
935
|
+
{ reason: "needs_verdict", verdict: summary },
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const baseCi = baseCiState(cwd, resolved, doc.base);
|
|
940
|
+
let baseRedOverride: { reason: string } | null = null;
|
|
941
|
+
if (!baseCi.ok) {
|
|
942
|
+
const names = baseCi.failing.map((failed) => failed.name).join(", ");
|
|
943
|
+
const url = baseCi.failing.find((failed) => failed.url)?.url ?? null;
|
|
944
|
+
if (!baseRed) {
|
|
945
|
+
const cause = classifyBaseRed(baseCi.failing, previousBaseChecks(resolved, doc.base));
|
|
946
|
+
const next = baseRedNextStep(
|
|
947
|
+
doc.base,
|
|
948
|
+
cause,
|
|
949
|
+
`workit pr merge${input.pr ? ` --pr ${doc.number}` : ""} --base-red-ok --reason "<why>"`,
|
|
950
|
+
);
|
|
951
|
+
return refuse(
|
|
952
|
+
`base_red: ${doc.base} at ${baseCi.sha?.slice(0, 12) ?? "its head"} has failing CI (${names}${baseCi.requiredKnown ? "" : "; the required checks could not be read, so every failed check counts"})${url ? `: ${url}` : ""}; merging ${label} onto it would land on a red base; ${next.detail}`,
|
|
953
|
+
next.unblock,
|
|
954
|
+
{
|
|
955
|
+
reason: "base_red",
|
|
956
|
+
blockers: baseCi.failing.map((failed) => failed.name),
|
|
957
|
+
baseRedCause: cause.kind,
|
|
958
|
+
},
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
baseRedOverride = baseRed;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const squashMessage = squashMessageFor(cwd, resolved, input, doc);
|
|
965
|
+
if (squashMessage && !squashMessage.ok) return squashMessage;
|
|
966
|
+
// The intent goes in before the forge write: a merge call that errors after
|
|
967
|
+
// the forge did merge still leaves a record `pr status` reads. A merge that
|
|
968
|
+
// cannot be recorded never starts.
|
|
969
|
+
const intent = appendObserved(cwd, {
|
|
970
|
+
type: "pr.merge.intent",
|
|
971
|
+
actor: input.actor,
|
|
972
|
+
branch,
|
|
973
|
+
head,
|
|
974
|
+
pr: doc.number,
|
|
975
|
+
base: doc.base,
|
|
976
|
+
method: input.method,
|
|
977
|
+
verdictId: verdict.verdictId,
|
|
978
|
+
review: verdict.review,
|
|
979
|
+
lane: verdict.lane,
|
|
980
|
+
...(unverified ? { unverified: unverified.recorded } : {}),
|
|
981
|
+
...(baseRedOverride ? { baseRedOk: baseRedOverride.reason } : {}),
|
|
982
|
+
...(othersPr ? { othersPr } : {}),
|
|
983
|
+
grant: grant.source,
|
|
984
|
+
});
|
|
985
|
+
if (!intent.ok)
|
|
986
|
+
return refuse(
|
|
987
|
+
`intent_unrecorded: the merge intent for ${label} could not be recorded (${intent.error}); nothing was merged`,
|
|
988
|
+
"fix the ledger (workit ledger check), then merge again",
|
|
989
|
+
{ reason: "intent_unrecorded" },
|
|
990
|
+
);
|
|
991
|
+
const intentId = String(intent.value.id);
|
|
992
|
+
const merged = resolved.forge.merge(doc.number, {
|
|
993
|
+
sha: head,
|
|
994
|
+
method: input.method,
|
|
995
|
+
...(squashMessage ? { squashMessage: squashMessage.data } : {}),
|
|
996
|
+
});
|
|
997
|
+
if (!merged.ok)
|
|
998
|
+
return {
|
|
999
|
+
...merged,
|
|
1000
|
+
error: `${merged.error} (merge intent ${intentId} is recorded: if the forge merged anyway, workit pr status reads it)`,
|
|
1001
|
+
};
|
|
1002
|
+
// Recorded as soon as the forge accepts it (N3): a failed read below must
|
|
1003
|
+
// not leave a merge that `pr status`, the stop hook and `verify-delivery
|
|
1004
|
+
// merged` cannot see. The post-verify then confirms it in its own row.
|
|
1005
|
+
const row = appendObserved(cwd, {
|
|
1006
|
+
type: "pr.merged",
|
|
1007
|
+
actor: input.actor,
|
|
1008
|
+
branch,
|
|
1009
|
+
head,
|
|
1010
|
+
pr: doc.number,
|
|
1011
|
+
base: doc.base,
|
|
1012
|
+
mergeSha: merged.data.mergeSha,
|
|
1013
|
+
method: input.method,
|
|
1014
|
+
verdictId: verdict.verdictId,
|
|
1015
|
+
review: verdict.review,
|
|
1016
|
+
lane: verdict.lane,
|
|
1017
|
+
...(unverified ? { unverified: unverified.recorded } : {}),
|
|
1018
|
+
...(baseRedOverride ? { baseRedOk: baseRedOverride.reason } : {}),
|
|
1019
|
+
...(othersPr ? { othersPr } : {}),
|
|
1020
|
+
grant: grant.source,
|
|
1021
|
+
intent: intentId,
|
|
1022
|
+
verified: false,
|
|
1023
|
+
});
|
|
1024
|
+
const recorded = row.ok ? { id: String(row.value.id) } : { error: row.error };
|
|
1025
|
+
// Post-verify: the forge must report the PR merged.
|
|
1026
|
+
let state = "open";
|
|
1027
|
+
for (const delay of [0, ...VERIFY_DELAYS_MS]) {
|
|
1028
|
+
if (delay) await sleep(delay);
|
|
1029
|
+
const after = resolved.forge.prStatus(doc.number);
|
|
1030
|
+
if (after.ok) state = after.data.state;
|
|
1031
|
+
if (state === "merged") break;
|
|
1032
|
+
}
|
|
1033
|
+
if (state !== "merged")
|
|
1034
|
+
return {
|
|
1035
|
+
ok: false,
|
|
1036
|
+
code: "failed",
|
|
1037
|
+
error: `merge_unverified: the forge accepted the merge of ${label} but still reports it ${state}${state === "open" ? ", so it is not merged yet (a merge queue or auto-merge may hold it)" : ""}${"id" in recorded ? `; recorded as pr.merged ${recorded.id} (verified: false)` : `; the pr.merged row could not be written (${recorded.error})`}`,
|
|
1038
|
+
unblock: `workit verify-delivery merged --pr ${doc.number}`,
|
|
1039
|
+
recorded,
|
|
1040
|
+
};
|
|
1041
|
+
const confirmation =
|
|
1042
|
+
"id" in recorded
|
|
1043
|
+
? appendObserved(cwd, {
|
|
1044
|
+
type: "pr.merge.verified",
|
|
1045
|
+
actor: input.actor,
|
|
1046
|
+
branch,
|
|
1047
|
+
head,
|
|
1048
|
+
pr: doc.number,
|
|
1049
|
+
base: doc.base,
|
|
1050
|
+
merged: recorded.id,
|
|
1051
|
+
mergeSha: merged.data.mergeSha,
|
|
1052
|
+
})
|
|
1053
|
+
: null;
|
|
1054
|
+
const confirmed: MergeOutcome["confirmed"] =
|
|
1055
|
+
confirmation === null
|
|
1056
|
+
? { error: "no pr.merged row to confirm" }
|
|
1057
|
+
: confirmation.ok
|
|
1058
|
+
? { id: String(confirmation.value.id) }
|
|
1059
|
+
: { error: confirmation.error };
|
|
1060
|
+
|
|
1061
|
+
let deletedBranch: MergeOutcome["deletedBranch"] = false;
|
|
1062
|
+
const skipReason =
|
|
1063
|
+
!input.deleteBranch && input.implicitDelete
|
|
1064
|
+
? implicitDeleteSkip(cwd, resolved, doc, branch)
|
|
1065
|
+
: null;
|
|
1066
|
+
if (othersPr && (input.deleteBranch || input.implicitDelete))
|
|
1067
|
+
deletedBranch = {
|
|
1068
|
+
skipped: `the PR is by ${othersPr.author}, not ${othersPr.login}; workit never deletes another author's branch`,
|
|
1069
|
+
};
|
|
1070
|
+
else if (!input.deleteBranch && input.implicitDelete && skipReason !== null)
|
|
1071
|
+
deletedBranch = { skipped: skipReason };
|
|
1072
|
+
else if (input.deleteBranch || input.implicitDelete) {
|
|
1073
|
+
const deleted = deleteRemoteBranch(cwd, resolved.remote, branch, head);
|
|
1074
|
+
// The forge may delete merged branches itself; gone is deleted.
|
|
1075
|
+
const gone = !deleted.ok && remoteRefTip(cwd, resolved.remote, `refs/heads/${branch}`);
|
|
1076
|
+
deletedBranch =
|
|
1077
|
+
deleted.ok || (gone && gone.ok && gone.sha === null) ? true : { error: deleted.error };
|
|
1078
|
+
}
|
|
1079
|
+
const mergeBack = mergeBackFor(cwd, doc.base, branch);
|
|
1080
|
+
return {
|
|
1081
|
+
ok: true,
|
|
1082
|
+
data: {
|
|
1083
|
+
number: doc.number,
|
|
1084
|
+
url: doc.url,
|
|
1085
|
+
branch,
|
|
1086
|
+
base: doc.base,
|
|
1087
|
+
head,
|
|
1088
|
+
method: input.method,
|
|
1089
|
+
mergeSha: merged.data.mergeSha,
|
|
1090
|
+
verdict,
|
|
1091
|
+
unverified,
|
|
1092
|
+
othersPr,
|
|
1093
|
+
baseCi: {
|
|
1094
|
+
note: baseRedOverride
|
|
1095
|
+
? `base ${doc.base} CI is red; merged anyway (--base-red-ok: ${baseRedOverride.reason})`
|
|
1096
|
+
: baseCi.ok
|
|
1097
|
+
? baseCi.note
|
|
1098
|
+
: null,
|
|
1099
|
+
override: baseRedOverride,
|
|
1100
|
+
},
|
|
1101
|
+
grant: { source: grant.source },
|
|
1102
|
+
deletedBranch,
|
|
1103
|
+
recorded,
|
|
1104
|
+
confirmed,
|
|
1105
|
+
mergeBack: mergeBack.branches,
|
|
1106
|
+
mergeBackTrack: mergeBack.track,
|
|
1107
|
+
},
|
|
1108
|
+
};
|
|
1109
|
+
}
|