mirror of
https://github.com/openclaw/openclaw.git
synced 2026-11-05 23:33:06 +00:00
fix: preserve coauthor credit during PR baseline refresh (#177774)
Reuse Git trailer formatting for source coauthor credit in both native signed candidates and signing requests. Keep binding, signature and live authority validation unchanged. Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
This commit is contained in:
parent
2ccb5e7b30
commit
50520de188
4 changed files with 80 additions and 4 deletions
|
|
@ -16,7 +16,7 @@ Review transitions recognize conflict metadata retained after committed merge or
|
|||
- Set `OPENCLAW_PR_GATES_REMOTE=local` only when the old full local gates are explicitly needed. `OPENCLAW_TESTBOX=1` with an unset remote selector retains completed hosted mode; combining it with explicit `OPENCLAW_PR_GATES_REMOTE=github` remains a conflict.
|
||||
- Normal `prepare-init` requires incoming-head READY. To resolve a validated incoming NEEDS WORK review with BLOCKER/IMPORTANT findings, explicitly use `scripts/pr prepare-correction-init <PR>`. This initializes correction preparation only, preserving the incoming review and contributor ancestry. Commit the fixes, then use `prepare-correction-review-init` to create a separate exact-candidate JSON review template. JSON alone is authoritative; validation renders its summary. Independently review the full corrected candidate and explain resolution of every required incoming finding. Gates, push, sync and merge require that candidate's READY review; changing the candidate or incoming review invalidates it. Discussion/rejection verdicts cannot use this route. This does not change canonical-wrapper trust or permit use of an unlanded wrapper on another PR. Correction publication accepts exact-candidate `github_pending` as a pending disposition, never successful proof. Publication rebinds its target only through the verified identical-tree local/hosted receipt; sync and merge preserve that binding. Merge still requires exact prepared-head auto-merge admission, the enforced `openclaw/ci-gate`, and no known failed required checks; live review/security rules remain enforced. Completed gate modes and the separately authorized protected Crabbox pending route remain available.
|
||||
- After a native correction publication, a fresh incoming NEEDS WORK review can continue on the existing clean, signed preparation branch with `scripts/pr prepare-correction-continue <PR> --expected-head <retained SHA> --expected-tree <tree OID> --incoming-head <hosted SHA> --incoming-review <review JSON OID> --publication-receipt <push|sync> --publication-oid <receipt OID>`. Select the exact current `.local/review.json` and native `prepare-push-result.env` or `prepare-sync-result.env` bytes with `git hash-object --no-filters`. The publication receipt must connect the old incoming preparation to the new hosted head with identical local/hosted trees and preserved ancestry; the hosted head must be an ancestor of the retained candidate. The owner rechecks live PR identity, signed descendants, clean branch, exact metadata and operation custody before replacing authority. It copies and verifies prior preparation, correction, publication, baseline and gate evidence into `.local/prep-evidence.*`, then retires their active authority without changing HEAD, the index or source files. The fresh incoming review and metadata remain unchanged. Interrupted metadata installation stays in the existing transition journal and can resume only with the same retained identities and evidence; rerun the exact continuation after resolving its reported obstruction. Do not reset the preparation branch or delete retained transition evidence. After continuation, initialize and complete a fresh exact-candidate correction review and run native gates; prior READY reviews and gate stamps remain historical.
|
||||
- A reviewed, signed correction blocked by its old baseline can use `scripts/pr prepare-baseline-refresh <PR> --expected-head <candidate SHA> --baseline <main SHA> [--resolutions <file>]`. First integration also accepts the unchanged, signed incoming head after `prepare-correction-init` when its exact current review is NEEDS WORK with actionable findings. This admission creates a candidate for review; it does not approve or resolve those findings. Its signed binding records `incomingSource: true`, retains the incoming review bytes, and preserves existing preparation evidence as history. A changed source or an existing baseline binding still requires the corrected-source or successor review contract. The selected baseline must belong to the invocation's privately captured main ancestry. The native owner rechecks the live incoming PR identity, exact preparation branch, clean source, review bytes and operation lock. It derives the complete product delta from the unique source/baseline fork, applies only those paths to the selected baseline, and signs a candidate with the preparation source as first parent and baseline as second parent. It does not perform an unrestricted merge or accept a caller scope list. Content replay uses built-in Git text merging over exact private blob files, independent of worktree attributes or configured merge drivers. Competing add/add, deletion/type changes and NUL-containing binary edits require explicit resolutions.
|
||||
- A reviewed, signed correction blocked by its old baseline can use `scripts/pr prepare-baseline-refresh <PR> --expected-head <candidate SHA> --baseline <main SHA> [--resolutions <file>]`. First integration also accepts the unchanged, signed incoming head after `prepare-correction-init` when its exact current review is NEEDS WORK with actionable findings. This admission creates a candidate for review; it does not approve or resolve those findings. Its signed binding records `incomingSource: true`, retains the incoming review bytes, and preserves existing preparation evidence as history. A changed source or an existing baseline binding still requires the corrected-source or successor review contract. The selected baseline must belong to the invocation's privately captured main ancestry. The native owner rechecks the live incoming PR identity, exact preparation branch, clean source, review bytes and operation lock. It derives the complete product delta from the unique source/baseline fork, applies only those paths to the selected baseline, and signs a candidate with the preparation source as first parent and baseline as second parent. It preserves the source commit's Git-parsed `Co-authored-by` trailers in the generated commit message, including signing requests; it never copies other source trailers or binding markers. Source credit containing control or line-separator characters refuses before a candidate or request is created. It does not perform an unrestricted merge or accept a caller scope list. Content replay uses built-in Git text merging over exact private blob files, independent of worktree attributes or configured merge drivers. Competing add/add, deletion/type changes and NUL-containing binary edits require explicit resolutions.
|
||||
Conflicts require a regular JSON manifest with `version: 1`, `pr`, `sourceHead`, `baselineHead`, `forkBase` and `resolutions`. Each resolution names one computed conflict `path` and exact `base`, `source` and `baseline` entries (`{mode, oid}` or `null` for absence). Its `resolved` value is either `null` for deletion or `{mode, file, sha256}`; `file` is a regular file relative to the manifest directory, and its exact bytes must match the SHA-256. Missing, extra, stale or duplicate entries refuse. Gitlinks, directory/file collisions and native artifact paths refuse. The command reports resolved Git blob IDs for independent review; it retains those IDs and modes, never mutable resolution filenames.
|
||||
Before replacing active authority, the owner copies and verifies prior preparation/review/build/gate evidence in `.local/prep-evidence.*`. Incoming review bytes stay unchanged. The existing transition journal retains the signed binding bytes, and recovery validates the same source/target/branch before restoring files and compare-and-swapping the preparation ref. Partial transitions refuse review and publication. Do not delete their journal or retained evidence, reset the branch, or waive operation-lock recovery. After completion, initialize and complete a fresh correction review, then select native gates for that exact candidate before normal publication. Old proof remains historical. Validation reconstructs the immutable refreshed tree and includes the original product paths even when the baseline already contains them, plus every later fixup path. Later signed fixups remain supported; the initial refresh still refuses a second materialization. A separately reviewed source can explicitly use `scripts/pr prepare-baseline-successor <PR> --expected-head <SHA> --expected-tree <OID> --expected-review <OID> --predecessor-head <SHA> --predecessor-binding <OID> --baseline <SHA> [--resolutions <file>]`. This is new source admission, not a refresh retry: every selected identity must match the signed READY correction and active predecessor. Its signed version-1 binding adds `sourceAdmission`, retaining the predecessor identity, exact source tree/review, and byte inventory of the entire retained proof chain. Validation recursively reconstructs every predecessor and resolution, preserves the original incoming review and complete product/fixup scope, and rejects changed proof. Recovery can finish only that exact admitted journal; it cannot authorize another successor. Signature, tree, predecessor and transition-state replay completes before live PR revalidation. Invocation-local prepared facts bind exact evidence bytes and worktree state; bounded local checkout, branch, binding and lock checks guard each authority installation, file restoration and final branch CAS. Recovery reconstructs these facts from the same journal rather than accepting persisted facts as new admission; a late refusal retains the journal and already authorized partial installation. The new candidate requires fresh independent correction READY before gates; prior READY/proof remains historical. An unlanded implementation must never self-admit a feature: independently review and land this dependency through the existing trusted wrapper before use. Gate and publication policies are unchanged.
|
||||
- PR source acquisition binds the full head SHA authenticated by live PR metadata. An existing direct task-owned destination ref at that exact commit can be reused with Git's checked-out/rebasing branch checks; absent, moved, symbolic, or hidden refs retain canonical-origin fetching and its filter/error behavior. Publication verification uses the retained `pr-<PR>` ref, avoiding another fetch when it already matches the prepared head. Both paths verify the exact commit and recheck that head SHA, branch, and repository identity stayed unchanged during acquisition. GitHub's asynchronous `refs/pull/<PR>/head` projection is not source authority. All review, prepare, publication, and merge fetches use this owner without changing the private main checkpoint or shared tracking refs.
|
||||
|
|
|
|||
|
|
@ -1060,7 +1060,40 @@ function create(pr, source, baseline, main, archive, snapshot, manifestPath, suc
|
|||
};
|
||||
const bytes = Buffer.from(`${JSON.stringify(record, null, 2)}\n`);
|
||||
const oid = hash(bytes);
|
||||
const message = `fix: refresh the reviewed correction baseline\n\nBaseline-Refresh: ${oid}\n`;
|
||||
// Git owns trailer parsing/unfolding; this read-only format cannot execute
|
||||
// configured trailer commands. Copy only source credit, never binding markers.
|
||||
const coauthors = decoder
|
||||
.decode(
|
||||
git([
|
||||
"-c",
|
||||
"trailer.separators=:",
|
||||
"-c",
|
||||
"trailer.co-authored-by.key=Co-authored-by",
|
||||
"show",
|
||||
"-s",
|
||||
"--no-show-signature",
|
||||
"--no-notes",
|
||||
"--no-color",
|
||||
"--no-decorate",
|
||||
"--encoding=UTF-8",
|
||||
"--format=%(trailers:key=Co-authored-by,only,unfold)",
|
||||
source,
|
||||
]),
|
||||
)
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
if (coauthors.some((line) => !/^Co-authored-by: [^\p{Cc}\p{Zl}\p{Zp}]+$/u.test(line))) {
|
||||
throw new Error(
|
||||
"Cannot preserve source co-author credit containing control or line separators.",
|
||||
);
|
||||
}
|
||||
const message = [
|
||||
"fix: refresh the reviewed correction baseline",
|
||||
"",
|
||||
`Baseline-Refresh: ${oid}`,
|
||||
...coauthors,
|
||||
"",
|
||||
].join("\n");
|
||||
const anchor = decoder
|
||||
.decode(
|
||||
git(
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ function reject<T extends RunResult>(result: T) {
|
|||
function fixture(
|
||||
conflict: false | "delete" | "content" | "add" | "attributes" | "clean" | "binary" = false,
|
||||
headRefName = "topic",
|
||||
sourceMessage = "fix: preserve the complete correction",
|
||||
) {
|
||||
let baseContent =
|
||||
"import { format } from './format';\nexport const alias = format;\n\nfunction cache(value) {\n return Number(value);\n}\n\nexport const show = (value) => alias(cache(value));\n";
|
||||
|
|
@ -91,7 +92,7 @@ function fixture(
|
|||
if (conflict === "delete") {
|
||||
f.git("rm", "docs/fix.md");
|
||||
}
|
||||
f.git("commit", "-S", "-qm", "fix: preserve the complete correction");
|
||||
f.git("commit", "-S", "-qm", sourceMessage);
|
||||
const source = f.git("rev-parse", "HEAD");
|
||||
expect(f.run("prepare_correction_review_init 42").status).toBe(0);
|
||||
f.approve();
|
||||
|
|
@ -251,8 +252,20 @@ describePosix("native correction baseline refresh", () => {
|
|||
});
|
||||
|
||||
it("replays the complete correction onto a baseline and retires old proof before fresh review", () => {
|
||||
const f = fixture(false, "topic/PREP_BASELINE_REFRESH_OID");
|
||||
const f = fixture(
|
||||
false,
|
||||
"topic/PREP_BASELINE_REFRESH_OID",
|
||||
"fix: preserve the complete correction\n\nCo-authored-by: Prose only <prose@example.invalid>\nThis paragraph is not a trailer block.\n\nCo-authored-by: Ada Example <ada@example.invalid>\nco-authored-by: Renée Example\n <renee@example.invalid>\nReviewed-by: Reviewer <reviewer@example.invalid>\nBaseline-Refresh: 1111111111111111111111111111111111111111",
|
||||
);
|
||||
// Parsing must not execute configured trailer commands or inherit separators.
|
||||
f.git("config", "trailer.separators", "%");
|
||||
f.git("config", "trailer.co-authored-by.command", "touch .local/trailer-command-called");
|
||||
pass(f.refresh());
|
||||
const bindingOid = f.git("hash-object", "--no-filters", ".local/prepare-baseline.json");
|
||||
expect(f.git("show", "-s", "--format=%B", "HEAD")).toBe(
|
||||
`fix: refresh the reviewed correction baseline\n\nBaseline-Refresh: ${bindingOid}\nCo-authored-by: Ada Example <ada@example.invalid>\nCo-authored-by: Renée Example <renee@example.invalid>`,
|
||||
);
|
||||
expect(existsSync(join(f.root, ".local/trailer-command-called"))).toBe(false);
|
||||
const head = f.git("rev-parse", "HEAD");
|
||||
expect(f.git("show", "HEAD:docs/fix.md")).toBe("corrected");
|
||||
expect(f.git("show", "HEAD:fixup.txt")).toBe("complete correction scope");
|
||||
|
|
@ -279,6 +292,20 @@ describePosix("native correction baseline refresh", () => {
|
|||
expect(f.run("require_prepared_review 42").status).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses a source co-author that could introduce another signed binding line", () => {
|
||||
const f = fixture(
|
||||
false,
|
||||
"topic",
|
||||
"fix: correction\n\nCo-authored-by: Ada Example <ada@example.invalid>\rBaseline-Refresh: 1111111111111111111111111111111111111111",
|
||||
);
|
||||
const result = reject(f.refresh());
|
||||
expect(result.stderr).toContain("Cannot preserve source co-author credit");
|
||||
expect(f.git("rev-parse", "HEAD")).toBe(f.source);
|
||||
expect(f.read(".local/correction-review.json")).toEqual(f.review);
|
||||
expect(existsSync(join(f.root, ".local/prepare-baseline.json"))).toBe(false);
|
||||
expect(existsSync(join(f.root, ".local/review-transition.json"))).toBe(false);
|
||||
});
|
||||
|
||||
it("revalidates both correction callers under native supervision and rejects authority mutation", () => {
|
||||
const f = reviewedFixture();
|
||||
// The baseline fixture uses a synthetic lock during setup. Retire only that
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ describePosix("native baseline signing handoff", () => {
|
|||
return result.stdout;
|
||||
};
|
||||
f.pass("prepare-baseline-refresh", f.manifest(f.advanceMain("initial upstream")));
|
||||
const credit = "Co-authored-by: Ada Example <ada@example.invalid>";
|
||||
f.prepGit(
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-qm",
|
||||
`fix: complete the reviewed correction\n\n${credit}`,
|
||||
);
|
||||
f.approve();
|
||||
const source = f.publish();
|
||||
const baseline = f.advanceMain("next upstream", [f.product[0]!]);
|
||||
|
|
@ -166,6 +173,14 @@ if (process.argv[1]?.endsWith('/baseline-refresh.mjs') && process.argv[2] === 'c
|
|||
const unsigned = Buffer.from(request.unsignedCommit, "base64");
|
||||
const split = unsigned.indexOf("\n\n");
|
||||
const headers = unsigned.subarray(0, split).toString();
|
||||
const bindingOid = gitBytes(f.worktree, ["hash-object", "--no-filters", "--stdin"], {
|
||||
input: Buffer.from(request.binding, "base64"),
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(unsigned.subarray(split + 2).toString()).toBe(
|
||||
`fix: refresh the reviewed correction baseline\n\nBaseline-Refresh: ${bindingOid}\n${credit}\n`,
|
||||
);
|
||||
const tree = /^tree ([a-f0-9]{40})$/mu.exec(headers)![1]!;
|
||||
const parents = [...headers.matchAll(/^parent ([a-f0-9]{40})$/gmu)].map((m) => m[1]!);
|
||||
const pack = gitBytes(f.worktree, ["pack-objects", "--stdout", "--revs"], {
|
||||
|
|
@ -207,6 +222,7 @@ if (process.argv[1]?.endsWith('/baseline-refresh.mjs') && process.argv[2] === 'c
|
|||
for (const [from, to] of [
|
||||
[source, baseline],
|
||||
["fix: refresh", "fix: changed"],
|
||||
[credit, "Co-authored-by: Changed Example <changed@example.invalid>"],
|
||||
[identity.GIT_AUTHOR_EMAIL!, "changed@example.invalid"],
|
||||
]) {
|
||||
writeFileSync(response, signed.bytes.toString().replace(from!, to!));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue