mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-04 02:00:10 +00:00
fix(pr): read complete REST facts for prior-CI admission
When GraphQL keeps a valid merge projection UNKNOWN, explicitly approved prior-CI admission may select the existing complete writer-bound REST observation. Preserve known facts and REST provenance, and revalidate live authority after the final complete snapshot. Ordinary merge and security requirements remain enforced. Validation: 138 composed native cases, causal original refusal and late-authority mutant, selected static/type/lint checks, and independent review. No partial snapshot or synthetic passing CI state is introduced.
This commit is contained in:
parent
efd9b9811e
commit
28340c41f8
8 changed files with 309 additions and 22 deletions
|
|
@ -174,6 +174,14 @@ It dispatches the protected REST merge with the exact head pinned and retains
|
|||
the prior run, inspected delta, scoped evidence, and operator in the existing
|
||||
merge outcome. Accepted or uncertain outcomes still require reconciliation.
|
||||
|
||||
If GraphQL cannot determine mergeability, this explicit mode can switch to a
|
||||
complete REST observation and retain that reader for the attempt. It preserves
|
||||
known GraphQL facts and reads the repository, PR head, main, rules, and required
|
||||
checks together; a blocked CI projection remains blocked. The existing admin
|
||||
verifier rechecks live authority, enforced reviews, security, and exact CI evidence
|
||||
after the final REST reread. Missing or changed evidence still refuses before
|
||||
intent. This adds no implicit admin route or mutation retry.
|
||||
|
||||
#### Explicitly approved pre-existing failures
|
||||
|
||||
When the operator specifically authorizes ignoring independently attributed
|
||||
|
|
|
|||
|
|
@ -186,6 +186,7 @@ for the evidence fields and supported policy limits.
|
|||
- Ordinary non-admin admission permits main to advance while retaining its pinned tree-proof/intent base and rechecking every PR/head/lifecycle/policy fact; GitHub owns applying the pinned head to current main. Active prior-CI admin admission may also accept forward main-only movement after proving ancestry from both its observed and CI-verification main anchors and checking the new merge is conflict-free and nonempty. Materialization precedes final live authority verification; the last reread uses only local objects (no lazy or explicit fetch), refusing a newly unavailable main before intent. Crabbox admin and OPEN/CLOSED/pending/uncertain reconciliation retain exact full PR/main snapshot stability. Only a validated MERGED receipt may accept forward main advancement between its two observations: every PR fact must remain equal, acquire the reread's exact main commit through the same canonical trusted URL when absent locally, and prove the original observed main is its ancestor. Equal snapshots keep the fast path. Both snapshots stay pinned; never add a third reread loop. This completes an already-proven merge, never grants authority for a future merge; historical tree/source-base checks and receipt/comment/cleanup ownership remain unchanged.
|
||||
- Local object-availability probes and strict retained-record reads use command-scoped `GIT_NO_LAZY_FETCH=1`. Upstream Git 2.45 first supports this environment variable; older Git may still hydrate objects implicitly and cannot promise local-only probes. The immutable-head reuse probe and final prior-CI local-only check also use Git's explicit `--no-lazy-fetch` switch: unsupported Git falls back to canonical head acquisition or refuses that final main-advance optimization. This is not a new all-command minimum or an offline workflow: explicit canonical fetches and actual tree/diff/archive/push/checkout operations remain available. Completed-receipt correctness depends on pinned facts and ancestry/tree proof, not this download avoidance.
|
||||
- Initial admission alone waits for UNKNOWN mergeability projections for at most three observations, sleeping one then two seconds, with PR and policy facts and each already-known projection pinned; full final rereads and retained-outcome reconciliation never poll. Before intent on PRs without a merge queue, ordinary merges reject gh's BLOCKED/BEHIND/DIRTY refusals and admin merges reject DIRTY; queue and auto retain their distinct admission contracts.
|
||||
- Explicit prior-CI admission may select the complete REST observation owner after a valid GraphQL snapshot has an UNKNOWN projection. The next observation must preserve every known PR/projection fact; REST rereads retain repository identity, exact head, main, rules, and complete required-check evidence without converting BLOCKED to CLEAN. That reader remains selected through the attempt. Both observation and new intent record REST transport, while the existing prior-CI verifier still owns all CI exceptions, enforced reviews, and security clearance. It runs again after the final complete REST reread, before unchanged evidence/artifact checks and intent CAS. Unknown or malformed REST projections, conflicts, unsupported policy, and changed authority refuse admission. Ordinary REST remains passing-check/CLEAN-only; Crabbox and retained-outcome reconciliation gain no new dispatch authority.
|
||||
- When GraphQL is selected, ordinary immediate squash dispatch uses the selected writer's exact-head mutation directly after one final explicit pre-merge revalidation. It preserves the captured body, omits the headline so GitHub retains its existing defaults, and cannot enqueue or arm auto-merge. Its initial and final public snapshots bind the head; existing REST source acquisition binds the branch, and both independent post-dispatch receipt observations remain fresh. Queue, auto, admin, merge, and rebase retain native `gh pr merge` dispatch and their additional authority windows.
|
||||
- `merge-run` owns remote dispatch separately from the process lock. Before any merge/auto/queue request it records the exact repository identity, PR, main target, prepared head, observed main, method, route, and attempt in `refs/openclaw/pr-merge-outcomes/<PR>`. Private Git commits retain the required objects across worktree removal and GC. Do not delete or push these refs; process-lock recovery never clears them.
|
||||
- A failed request can already have merged. After the reported exact process-lock recovery, repeat `scripts/pr merge-run <PR>` only for reconciliation. `OPEN`, a different head, reverted/partially applied content, elapsed time, or an absent process never proves non-execution. There is no automatic clear/retry override. Inspect the PR timeline, authoritative main history, and `git show refs/openclaw/pr-merge-outcomes/<PR>:outcome.json`; unresolved uncertainty requires operator action outside this automatic path. Keep the record for that investigation. If an older wrapper left `.local/merge-output.log` without an outcome record, even an empty capture blocks a fresh dispatch; preserve it and reconcile the earlier request manually.
|
||||
|
|
|
|||
|
|
@ -184,7 +184,8 @@ merge_outcome_load_local() {
|
|||
else true end) and
|
||||
(.method == "squash" or .method == "merge" or .method == "rebase") and
|
||||
(.route == "immediate" or .route == "admin" or .route == "auto" or .route == "queue") and
|
||||
(if has("transport") then .transport == "rest" and .method == "squash" and .route == "immediate" else true end) and
|
||||
(if has("transport") then .transport == "rest" and .method == "squash" and
|
||||
(.route == "immediate" or (.route == "admin" and .priorCiAdmin.dispatchTransport == "rest")) else true end) and
|
||||
(if has("priorCiAdmin") then . as $record | .route == "admin" and .method == "squash" and
|
||||
(.priorCiAdmin | .version == 1 and .head == $record.head and .pr == $record.pr and
|
||||
.repository == $record.repo.nameWithOwner and (.priorHead | oid) and
|
||||
|
|
@ -409,6 +410,13 @@ merge_outcome_dispatch_prior_ci_squash() (
|
|||
|
||||
merge_read() {
|
||||
local mode="$1" pr="$2" repo="${3:-${MERGE_REPO_URL:-}}" first="${MERGE_TRANSPORT:-rest}" second response status query checks_err checks_error
|
||||
if [ "$mode" = observe ] && [ "${MERGE_PRIOR_CI_REST_OBSERVATION:-false}" = true ] &&
|
||||
[ "${MERGE_USE_PRIOR_CI_ADMIN:-false}" = true ]; then
|
||||
# This complete read reports policy/check facts; prior-CI admission still owns their verdict.
|
||||
response=$(merge_rest observe-prior-ci "$pr") || return 1
|
||||
printf '%s\n' "$response" | jq -c '{transport:"rest",payload:.}'
|
||||
return
|
||||
fi
|
||||
if [ "$first" = rest ]; then second=graphql; else second=rest; fi
|
||||
local transport
|
||||
for transport in "$first" "$second"; do
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ import { parseGithubResponse } from "./gh-api-preflight.mjs";
|
|||
import { execPrGh, execPrGhJson } from "./github.mjs";
|
||||
|
||||
const OID = /^[0-9a-f]{40}$/;
|
||||
// REST values normalize into GitHub's MergeStateStatus enum, never arbitrary admission states.
|
||||
// https://docs.github.com/en/graphql/reference/pulls#mergestatestatus
|
||||
const MERGE_STATES = new Set(
|
||||
"behind blocked clean dirty draft has_hooks unknown unstable".split(" "),
|
||||
);
|
||||
const RULE_TYPES = new Set([
|
||||
"deletion",
|
||||
"non_fast_forward",
|
||||
|
|
@ -173,7 +178,7 @@ function readPullRequest(repo, authority, pr) {
|
|||
typeof record.merged === "boolean" &&
|
||||
typeof record.draft === "boolean" &&
|
||||
[true, false, null].includes(record.mergeable) &&
|
||||
nonemptyString(record.mergeable_state) &&
|
||||
MERGE_STATES.has(record.mergeable_state) &&
|
||||
Object.hasOwn(record, "auto_merge") &&
|
||||
(record.auto_merge === null ||
|
||||
["squash", "merge", "rebase"].includes(record.auto_merge?.merge_method)),
|
||||
|
|
@ -557,8 +562,10 @@ function mergeBody(value) {
|
|||
}
|
||||
|
||||
function main([mode, repository, prValue, head, bodySnapshot, expectedObservation, ...extra]) {
|
||||
const observing = ["observe", "observe-admission", "observe-prior-ci"].includes(mode);
|
||||
const priorCiObservation = mode === "observe-prior-ci";
|
||||
requireEvidence(
|
||||
["observe", "observe-admission", "checks", "preview", "merge"].includes(mode) &&
|
||||
(observing || ["checks", "preview", "merge"].includes(mode)) &&
|
||||
/^[1-9][0-9]*$/.test(prValue ?? "") &&
|
||||
Number.isSafeInteger(Number(prValue)) &&
|
||||
extra.length === 0 &&
|
||||
|
|
@ -571,7 +578,6 @@ function main([mode, repository, prValue, head, bodySnapshot, expectedObservatio
|
|||
);
|
||||
const repo = parseRepository(repository);
|
||||
const pr = Number(prValue);
|
||||
const observing = mode === "observe" || mode === "observe-admission";
|
||||
const body = mode === "merge" ? mergeBody(bodySnapshot) : undefined;
|
||||
const snapshot = beginRead(repo, pr, observing);
|
||||
const checks =
|
||||
|
|
@ -580,13 +586,13 @@ function main([mode, repository, prValue, head, bodySnapshot, expectedObservatio
|
|||
: undefined;
|
||||
if (mode !== "checks" && checks !== undefined) {
|
||||
requireEvidence(
|
||||
checks.every((check) => check.bucket === "pass"),
|
||||
priorCiObservation || checks.every((check) => check.bucket === "pass"),
|
||||
"required checks are not passing",
|
||||
);
|
||||
snapshot.policy.requiredChecks = checks;
|
||||
}
|
||||
const current = finishRead(repo, pr, snapshot, mode === "observe");
|
||||
if (observing && current.state === "open") {
|
||||
const current = finishRead(repo, pr, snapshot, observing && mode !== "observe-admission");
|
||||
if (observing && !priorCiObservation && current.state === "open") {
|
||||
// REST can still be calculating after GraphQL is ready. Select the alternate
|
||||
// reader before retaining intent; mutation dispatch never changes transports.
|
||||
requireRestSupport(
|
||||
|
|
|
|||
|
|
@ -601,6 +601,7 @@ merge_run() {
|
|||
local MERGE_REFUSAL_DIRECTORY=""
|
||||
local MERGE_ADMIN_EVIDENCE="${9:-}" confirmed_admin="${10:-false}" MERGE_PRIOR_CI_PROOF=""
|
||||
local MERGE_USE_PRIOR_CI_ADMIN=false
|
||||
local MERGE_PRIOR_CI_REST_OBSERVATION=false
|
||||
if [ -n "$MERGE_ADMIN_EVIDENCE" ] || [ "$confirmed_admin" = true ]; then
|
||||
[ -n "$MERGE_ADMIN_EVIDENCE" ] && [ "$confirmed_admin" = true ] && [ "$auto_merge_requested" = false ] &&
|
||||
[ -z "$legacy_directory$refusal_directory" ] && [ "$cancel_auto" = false ] &&
|
||||
|
|
@ -830,7 +831,7 @@ merge_run() {
|
|||
# Pin PR/policy facts and each projection as soon as it becomes known.
|
||||
for admission_attempt in 1 2 3; do
|
||||
merge_outcome_observe "$pr" || return 1
|
||||
if [ "$MERGE_TRANSPORT" = rest ] &&
|
||||
if [ "$MERGE_TRANSPORT" = rest ] && [ "$MERGE_PRIOR_CI_REST_OBSERVATION" = false ] &&
|
||||
{ [ "$merge_method" != squash ] || [ "$auto_merge_requested" = true ] || [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = true ] || [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ]; }; then
|
||||
merge_outcome_stop "REST fallback supports ordinary immediate squash only; auto, queue, and admin routes require GraphQL"
|
||||
return 1
|
||||
|
|
@ -883,18 +884,23 @@ merge_run() {
|
|||
merge_outcome_stop "mergeability remained UNKNOWN after 3 observations; stopped before intent/dispatch"
|
||||
return 1
|
||||
fi
|
||||
if [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ] && [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = false ] &&
|
||||
[ "$MERGE_TRANSPORT" = graphql ]; then
|
||||
# Pin the alternate reader; the next whole observation must preserve every known fact.
|
||||
MERGE_PRIOR_CI_REST_OBSERVATION=true
|
||||
fi
|
||||
if [ "$admission_attempt" -eq 1 ]; then
|
||||
echo "Waiting for GitHub mergeability to settle (up to 3 observations, waiting 1 then 2 seconds for UNKNOWN samples)."
|
||||
fi
|
||||
previous_observation="$MERGE_OBSERVATION"
|
||||
sleep "$admission_attempt"
|
||||
done
|
||||
if [ "$MERGE_TRANSPORT" = rest ] &&
|
||||
if [ "$MERGE_TRANSPORT" = rest ] && [ "$MERGE_PRIOR_CI_REST_OBSERVATION" = false ] &&
|
||||
[ "$(printf '%s\n' "$MERGE_OBSERVATION" | jq -r .pr.mergeStateStatus)" != CLEAN ]; then
|
||||
merge_outcome_stop "REST fallback requires a CLEAN merge projection without bypass"
|
||||
return 1
|
||||
fi
|
||||
if [ "$MERGE_TRANSPORT" = rest ]; then
|
||||
if [ "$MERGE_TRANSPORT" = rest ] && [ "$MERGE_PRIOR_CI_REST_OBSERVATION" = false ]; then
|
||||
# Quota can expire after the first preview. Compose source credit through
|
||||
# the same owner before selecting a REST mutation or retaining its intent.
|
||||
if [ "$MERGE_BODY_TRANSPORT" != rest ]; then
|
||||
|
|
@ -990,14 +996,18 @@ merge_run() {
|
|||
# A final stability read can exhaust GraphQL after route/body selection.
|
||||
# Revalidate the selected route before retaining any REST mutation intent.
|
||||
if [ "$MERGE_TRANSPORT" = rest ]; then
|
||||
if [ "$merge_method" != squash ] || [ "$route" != immediate ] ||
|
||||
if [ "$MERGE_PRIOR_CI_REST_OBSERVATION" = true ]; then
|
||||
[ "$MERGE_USE_PRIOR_CI_ADMIN" = true ] && [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = false ] &&
|
||||
[ "$route" = admin ] && [ "$merge_method" = squash ] && [ "$auto_merge_requested" = false ] || return 1
|
||||
elif [ "$merge_method" != squash ] || [ "$route" != immediate ] ||
|
||||
[ "$auto_merge_requested" = true ] || [ "$MERGE_USE_CRABBOX_ADMIN_BYPASS" = true ] || [ "$MERGE_USE_PRIOR_CI_ADMIN" = true ]; then
|
||||
merge_outcome_stop "REST fallback supports ordinary immediate squash only; auto, queue, and admin routes require GraphQL"
|
||||
return 1
|
||||
fi
|
||||
if [ -z "$merge_body_snapshot" ] || ! printf '%s\n' "$MERGE_OBSERVATION" | jq -e '
|
||||
if [ "$MERGE_PRIOR_CI_REST_OBSERVATION" = false ] &&
|
||||
{ [ -z "$merge_body_snapshot" ] || ! printf '%s\n' "$MERGE_OBSERVATION" | jq -e '
|
||||
.pr.mergeable == "MERGEABLE" and .pr.mergeStateStatus == "CLEAN"
|
||||
' >/dev/null; then
|
||||
' >/dev/null; }; then
|
||||
merge_outcome_stop "REST fallback requires a CLEAN merge projection and verified squash body"
|
||||
return 1
|
||||
fi
|
||||
|
|
@ -1023,6 +1033,10 @@ merge_run() {
|
|||
verify_prior_ci_admin "$pr" "$PREP_HEAD_SHA" || return 1
|
||||
# A later main may reuse local objects, never start another lazy/explicit fetch.
|
||||
GIT_NO_LAZY_FETCH=1 merge_outcome_stable "$pr" true || return 1
|
||||
if [ "$MERGE_PRIOR_CI_REST_OBSERVATION" = true ]; then
|
||||
# Complete REST snapshots read policy/checks too; revalidate live authority after that work.
|
||||
verify_prior_ci_admin "$pr" "$PREP_HEAD_SHA" || return 1
|
||||
fi
|
||||
# No awaited operation may replace the operator's bytes after validation.
|
||||
node "$script_parent_dir/pr-lib/merge-prior-ci.mjs" unchanged \
|
||||
"$MERGE_ADMIN_EVIDENCE" "$(printf '%s\n' "$MERGE_PRIOR_CI_PROOF" | jq -r .evidenceSha256)" >/dev/null || return 1
|
||||
|
|
|
|||
|
|
@ -167,10 +167,15 @@ export function createMergeOutcomeFixtureHarness() {
|
|||
main: string;
|
||||
},
|
||||
restObservation: null as null | {
|
||||
main?: string;
|
||||
pr?: Record<string, unknown>;
|
||||
advanceMain?: boolean;
|
||||
gates?: string;
|
||||
restPolicy?: string;
|
||||
priorCi?: Partial<ReturnType<typeof createPriorCiFixtureState>>;
|
||||
postAuthorityRestBoundary?: "start" | "complete";
|
||||
},
|
||||
restObservationAppliedAt: 0,
|
||||
restMergePayload: null as null | {
|
||||
sha: string;
|
||||
merge_method: string;
|
||||
|
|
@ -186,6 +191,10 @@ export function createMergeOutcomeFixtureHarness() {
|
|||
landing: "requested",
|
||||
reads: 0,
|
||||
observationReads: 0,
|
||||
graphqlMergeProjection: null as null | {
|
||||
mergeable?: string;
|
||||
mergeStateStatus?: string;
|
||||
},
|
||||
settlementSleeps: [] as number[],
|
||||
observations: [] as Array<{
|
||||
pr?: Record<string, unknown>;
|
||||
|
|
@ -373,6 +382,16 @@ const advanceMain=()=>{
|
|||
git(["--git-dir="+process.env.FIXTURE_REMOTE,"update-ref","refs/heads/main",next,parent]);
|
||||
s.mainAdvances.push(next);
|
||||
};
|
||||
const applyRestObservation=()=>{
|
||||
const next=s.restObservation;
|
||||
if(next.main) git(["push","-q","--force","origin",next.main+":refs/heads/main"]);
|
||||
if(next.pr) Object.assign(s.pr,next.pr);
|
||||
if(next.advanceMain) advanceMain();
|
||||
if(next.gates) s.gates=next.gates;
|
||||
if(next.restPolicy) s.restPolicy=next.restPolicy;
|
||||
if(next.priorCi) Object.assign(s.priorCi,next.priorCi);
|
||||
s.restObservationAppliedAt=s.restMainReads;s.restObservation=null;save();
|
||||
};
|
||||
${priorCiSecurityFixtureSource}
|
||||
if(securityResponse()) {}
|
||||
else if(args[0]==="browse") out(s.repo.url);
|
||||
|
|
@ -413,11 +432,9 @@ else if(args[0]==="api"&&args.includes("user")) {
|
|||
}
|
||||
else if(args[0]==="api"&&args.includes("repos/fixture/repo/pulls/123")) {
|
||||
if(s.repoAuthorityUnavailable) fail("repository metadata unavailable");
|
||||
if(s.restObservation&&s.quotaTriggered) {
|
||||
if(s.restObservation.pr) Object.assign(s.pr,s.restObservation.pr);
|
||||
if(s.restObservation.advanceMain) advanceMain();
|
||||
if(s.restObservation.gates) s.gates=s.restObservation.gates;
|
||||
s.restObservation=null;save();
|
||||
if(s.restObservation&&s.restObservation.postAuthorityRestBoundary===undefined&&
|
||||
(s.quotaTriggered||(s.graphqlMergeProjection&&s.observationReads>0))) {
|
||||
applyRestObservation();
|
||||
}
|
||||
const record={node_id:s.pr.id,number:s.pr.number,html_url:s.pr.url,title:"Fixture repair",body:s.previewBody,
|
||||
state:s.pr.state==="OPEN"?"open":"closed",merged:s.pr.state==="MERGED",merged_at:s.pr.state==="MERGED"?"2026-09-20T00:00:00Z":null,
|
||||
|
|
@ -430,6 +447,7 @@ else if(args[0]==="api"&&args.includes("repos/fixture/repo/pulls/123")) {
|
|||
out(args.includes("--include")?"HTTP/2.0 200 OK\\n\\n"+JSON.stringify(record):record);
|
||||
}
|
||||
else if(args[0]==="api"&&args.includes("repos/fixture/repo/git/ref/heads/main")) {
|
||||
if(s.restObservation?.main&&s.restMainReads===0&&s.observationReads>0) applyRestObservation();
|
||||
s.restMainReads++;
|
||||
if(s.restMainAdvance&&s.pr.state==="OPEN") {
|
||||
const retained=spawnSync("git",["show","refs/openclaw/pr-merge-outcomes/123:outcome.json"],{cwd:process.env.FIXTURE_REPO,encoding:"utf8"});
|
||||
|
|
@ -451,6 +469,15 @@ else if(args[0]==="api"&&args.includes("repos/fixture/repo/git/ref/heads/main"))
|
|||
if(s.restMainFault==="invalid-sha") reference.object.sha="not-a-commit";
|
||||
}
|
||||
out(reference);
|
||||
if(s.restObservation?.postAuthorityRestBoundary) {
|
||||
const isMainRead=(call)=>call.includes("repos/fixture/repo/git/ref/heads/main");
|
||||
const authority=s.calls.findLastIndex((call)=>call.includes("orgs/fixture/memberships/fixture-operator"));
|
||||
// The initial authority read precedes REST selection; target the next observation after revalidation.
|
||||
if(authority>=0&&s.calls.slice(0,authority).some(isMainRead)) {
|
||||
const reads=s.calls.slice(authority+1).filter(isMainRead).length;
|
||||
if(reads===(s.restObservation.postAuthorityRestBoundary==="start"?1:2)) applyRestObservation();
|
||||
}
|
||||
}
|
||||
if(s.pr.state==="MERGED"&&s.restAdvanceMain) {s.restAdvanceMain=false;advanceMain();}
|
||||
}
|
||||
else if(args[0]==="api"&&args.includes("repos/fixture/repo/branches/main/protection")) {
|
||||
|
|
@ -613,6 +640,7 @@ else if(args[0]==="pr"&&args[1]==="view") {
|
|||
if(step?.unavailable) fail("metadata unavailable");
|
||||
if(step?.invalid) {save();out({data:{repository:{}}});process.exit(0);}
|
||||
const {headRefName,...pr}=s.pr;if(s.drift&&s.reads%2===0) pr.baseRefName="changed";
|
||||
if(s.pr.state==="OPEN"&&s.graphqlMergeProjection) Object.assign(pr,s.graphqlMergeProjection);
|
||||
if(s.pooledMergeBlocked&&!args.includes("--include")) pr.mergeStateStatus="BLOCKED";
|
||||
const repository={...s.repoGraphql,ref:{target:{oid:step?.reportedMain??main()}},pullRequest:pr};
|
||||
out({data:{repository}});
|
||||
|
|
|
|||
|
|
@ -71,16 +71,27 @@ describePosix("prior-CI forward main admission", () => {
|
|||
const state = f.state();
|
||||
state.observations =
|
||||
stage === "settlement"
|
||||
? [
|
||||
{ pr: { mergeable: "UNKNOWN", mergeStateStatus: "UNKNOWN" } },
|
||||
{ main, pr: { mergeable: "MERGEABLE", mergeStateStatus: "BLOCKED" } },
|
||||
]
|
||||
? [{ pr: { mergeable: "UNKNOWN", mergeStateStatus: "UNKNOWN" } }]
|
||||
: [{}, {}, {}, {}, { main }];
|
||||
if (stage === "settlement") {
|
||||
state.restObservation = {
|
||||
main,
|
||||
pr: { mergeable: "MERGEABLE", mergeStateStatus: "BLOCKED" },
|
||||
};
|
||||
}
|
||||
f.save(state);
|
||||
|
||||
const result = f.adminPriorCi(f.path);
|
||||
|
||||
expect(result.status, result.output).toBe(0);
|
||||
if (stage === "settlement") {
|
||||
expect(f.state()).toMatchObject({
|
||||
observationReads: 1,
|
||||
observations: [],
|
||||
restObservation: null,
|
||||
restObservationAppliedAt: 0,
|
||||
});
|
||||
}
|
||||
expect(f.state().mutations).toBe(1);
|
||||
expect(f.state().restMergePayload).toMatchObject({ sha: f.head, merge_method: "squash" });
|
||||
expect(f.record()).toMatchObject({
|
||||
|
|
|
|||
211
test/scripts/pr-merge-prior-ci-rest-observation.test.ts
Normal file
211
test/scripts/pr-merge-prior-ci-rest-observation.test.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { expect, it } from "vitest";
|
||||
import { createMergeOutcomeFixtureHarness } from "./pr-merge-outcome.test-support.js";
|
||||
import { createPriorCiCandidateFactory } from "./pr-merge-prior-ci.test-support.js";
|
||||
import { landingSnapshotQuery } from "./pr-merge-snapshot.test-support.js";
|
||||
|
||||
const { fixture, describePosix, outcomeRef } = createMergeOutcomeFixtureHarness();
|
||||
const { preExistingCandidate } = createPriorCiCandidateFactory(fixture);
|
||||
type Candidate = ReturnType<typeof preExistingCandidate>;
|
||||
|
||||
function unknownGraphqlCandidate() {
|
||||
const f = preExistingCandidate();
|
||||
f.save({ ...f.state(), graphqlMergeProjection: { mergeStateStatus: "UNKNOWN" } });
|
||||
return f;
|
||||
}
|
||||
|
||||
function expectNoDispatch(f: Candidate) {
|
||||
expect(f.state()).toMatchObject({ mutations: 0, posts: 0, restMergePayload: null });
|
||||
expect(() => f.git(["rev-parse", "--verify", outcomeRef])).toThrow();
|
||||
expect(f.captures()).toEqual([]);
|
||||
}
|
||||
|
||||
describePosix("prior-CI whole REST observation fallback", () => {
|
||||
it("lands qualified blocked CI through a complete REST observation after GraphQL remains UNKNOWN", () => {
|
||||
const f = unknownGraphqlCandidate();
|
||||
const result = f.adminPriorCi(f.path);
|
||||
expect(result.status, result.output).toBe(0);
|
||||
const state = f.state();
|
||||
expect(state).toMatchObject({
|
||||
mutations: 1,
|
||||
posts: 1,
|
||||
gates: "fail",
|
||||
restMergePayload: { sha: f.head, merge_method: "squash" },
|
||||
});
|
||||
const dispatch = state.calls.findIndex(
|
||||
(call) => call.includes("repos/fixture/repo/pulls/123/merge") && call.includes("PUT"),
|
||||
);
|
||||
expect(dispatch).toBeGreaterThan(0);
|
||||
const reads = state.calls.slice(0, dispatch);
|
||||
expect(reads.filter((call) => call.includes(landingSnapshotQuery))).toHaveLength(1);
|
||||
const finalRestRead = reads.findLastIndex((call) =>
|
||||
call.includes("repos/fixture/repo/git/ref/heads/main"),
|
||||
);
|
||||
expect(finalRestRead).toBeGreaterThan(0);
|
||||
expect(
|
||||
reads.filter((call) => call.includes("repos/fixture/repo/git/ref/heads/main")),
|
||||
).toHaveLength(10);
|
||||
expect(
|
||||
reads.findLastIndex((call) => call.includes("orgs/fixture/memberships/fixture-operator")),
|
||||
).toBeGreaterThan(finalRestRead);
|
||||
expect(f.record()).toMatchObject({
|
||||
phase: "complete",
|
||||
route: "admin",
|
||||
transport: "rest",
|
||||
head: f.head,
|
||||
priorCiAdmin: { head: f.head, runId: 501, runAttempt: 2, dispatchTransport: "rest" },
|
||||
});
|
||||
expect(f.git(["rev-parse", `${f.record().landed}^1`])).toBe(f.base);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["head", "Merge precondition headRefOid"],
|
||||
["lifecycle", "require OPEN"],
|
||||
["known projection", "PR or main changed while waiting for mergeability"],
|
||||
["conflict", "no conflicts"],
|
||||
["unknown", "mergeability remained UNKNOWN"],
|
||||
["malformed projection", "invalid PR identity or lifecycle evidence"],
|
||||
["renewed auto", "open PR already has an auto-merge request"],
|
||||
["queue policy", "unsupported effective branch rule"],
|
||||
["changed policy", "evidence or authority changed during admission"],
|
||||
["main within snapshot", "main changed while reading evidence"],
|
||||
["CI attempt", "newer or running CI attempt"],
|
||||
] as const)(
|
||||
"refuses REST %s rather than combining incompatible observations",
|
||||
(fault, diagnostic) => {
|
||||
const f = unknownGraphqlCandidate();
|
||||
const state = f.state();
|
||||
const observation: NonNullable<typeof state.restObservation> = {};
|
||||
if (fault === "head") {
|
||||
observation.pr = { headRefOid: f.base };
|
||||
}
|
||||
if (fault === "lifecycle") {
|
||||
observation.pr = { state: "CLOSED" };
|
||||
}
|
||||
if (fault === "known projection") {
|
||||
state.graphqlMergeProjection = { mergeable: "UNKNOWN", mergeStateStatus: "BLOCKED" };
|
||||
observation.pr = { mergeStateStatus: "CLEAN" };
|
||||
}
|
||||
if (fault === "conflict") {
|
||||
observation.pr = { mergeable: "CONFLICTING", mergeStateStatus: "DIRTY" };
|
||||
}
|
||||
if (fault === "unknown") {
|
||||
observation.pr = { mergeStateStatus: "UNKNOWN" };
|
||||
}
|
||||
if (fault === "malformed projection") {
|
||||
observation.pr = { mergeStateStatus: "NOT_A_STATE" };
|
||||
}
|
||||
if (fault === "renewed auto") {
|
||||
observation.pr = { autoMergeRequest: { mergeMethod: "SQUASH" } };
|
||||
}
|
||||
if (fault === "queue policy") {
|
||||
observation.restPolicy = "queue";
|
||||
}
|
||||
if (fault === "changed policy") {
|
||||
observation.priorCi = { reviewCount: 2 };
|
||||
}
|
||||
if (fault === "main within snapshot") {
|
||||
observation.advanceMain = true;
|
||||
}
|
||||
if (fault === "CI attempt") {
|
||||
observation.priorCi = { latestAttempt: 3 };
|
||||
}
|
||||
state.restObservation = observation;
|
||||
f.save(state);
|
||||
|
||||
const result = f.adminPriorCi(f.path);
|
||||
expect(result.status, result.output).toBe(1);
|
||||
expect(result.output).toContain(diagnostic);
|
||||
expect(f.state().restObservationAppliedAt).toBe(1);
|
||||
expectNoDispatch(f);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not replace a known GraphQL conflict with a mergeable REST projection", () => {
|
||||
const f = unknownGraphqlCandidate();
|
||||
f.save({
|
||||
...f.state(),
|
||||
graphqlMergeProjection: { mergeable: "CONFLICTING", mergeStateStatus: "UNKNOWN" },
|
||||
});
|
||||
const result = f.adminPriorCi(f.path);
|
||||
expect(result.status, result.output).toBe(1);
|
||||
expect(result.output).toContain("no conflicts");
|
||||
expect(f.state().restMainReads).toBe(0);
|
||||
expectNoDispatch(f);
|
||||
});
|
||||
|
||||
it.each(["head", "unknown"] as const)("refuses %s in the final REST snapshot", (fault) => {
|
||||
const f = unknownGraphqlCandidate();
|
||||
f.save({
|
||||
...f.state(),
|
||||
restObservation: {
|
||||
postAuthorityRestBoundary: "start",
|
||||
pr: fault === "head" ? { headRefOid: f.base } : { mergeStateStatus: "UNKNOWN" },
|
||||
},
|
||||
});
|
||||
const result = f.adminPriorCi(f.path);
|
||||
expect(result.status, result.output).toBe(1);
|
||||
expect(result.output).toContain("PR or main changed during observation");
|
||||
expect(f.state().restObservationAppliedAt).toBe(9);
|
||||
expectNoDispatch(f);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["admin", "active organization admin"],
|
||||
["review", "current enforced reviews must be satisfied"],
|
||||
["security", "unsuccessful openclaw/security-sensitive-review"],
|
||||
] as const)(
|
||||
"revalidates %s authority revoked by the final complete REST read",
|
||||
(fault, diagnostic) => {
|
||||
const f = unknownGraphqlCandidate();
|
||||
const state = f.state();
|
||||
state.restObservation = {
|
||||
// Revoke after the complete observation that follows the existing final authority check.
|
||||
postAuthorityRestBoundary: "complete",
|
||||
priorCi:
|
||||
fault === "admin"
|
||||
? { membership: "member" }
|
||||
: fault === "review"
|
||||
? { reviewDecision: "REVIEW_REQUIRED" }
|
||||
: { security: { ...state.priorCi.security, fault: "failed-guard" } },
|
||||
};
|
||||
f.save(state);
|
||||
const result = f.adminPriorCi(f.path);
|
||||
expect(result.status, result.output).toBe(1);
|
||||
expect(result.output).toContain(diagnostic);
|
||||
const finalState = f.state();
|
||||
expect(finalState.restObservationAppliedAt).toBe(10);
|
||||
expect(finalState.restMainReads).toBe(finalState.restObservationAppliedAt);
|
||||
expect(
|
||||
finalState.calls.findLastIndex((call) =>
|
||||
call.includes("orgs/fixture/memberships/fixture-operator"),
|
||||
),
|
||||
).toBeGreaterThan(
|
||||
finalState.calls.findLastIndex((call) =>
|
||||
call.includes("repos/fixture/repo/git/ref/heads/main"),
|
||||
),
|
||||
);
|
||||
expectNoDispatch(f);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["fail", "REST fallback does not authorize an admin bypass"],
|
||||
["pass", "selected merge route is blocked by policy"],
|
||||
])(
|
||||
"keeps ordinary REST admission strict for blocked projection with %s checks",
|
||||
(gates, diagnostic) => {
|
||||
const f = fixture();
|
||||
f.save({
|
||||
...f.state(),
|
||||
restPolicy: "rules",
|
||||
gates,
|
||||
pr: { ...f.state().pr, mergeStateStatus: "BLOCKED" },
|
||||
});
|
||||
const result = f.run();
|
||||
expect(result.status, result.output).toBe(1);
|
||||
expect(result.output).toContain(diagnostic);
|
||||
expect(f.state().mutations).toBe(0);
|
||||
expect(() => f.record()).toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue