Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -625,12 +625,15 @@ Any other moved head hands back. Forks refusing maintainer edits, refused pushes
crashes, or the fixer deadline hand back with a marked comment naming the exit
status or signal, with the tail of the job's retained log. A validated fix whose push
GitHub refuses, such as a merged base's `.github/workflows` changes that the App lacks
the `workflows` permission to push, is handed back with its resolution: the PR head,
the base, the resolved tree, and the fixer's edits as a patch against the merge's
conflicted result, sized to GitHub's 65,536-character comment limit. A maintainer
the `workflows` permission to push, is handed back with its resolution. So is a fix
that edits protected paths beyond what the base merge carries, such as a conflict in
`flake.nix` the fixer resolved: those never land automatically. The resolution is the
PR head, the base, the resolved tree, and the fixer's edits as a patch against the
merge's conflicted result, or against the head itself for a fix that merges no base,
sized to GitHub's 65,536-character comment limit. A maintainer
runs `quest land apply <pr>`, which refuses a moved head, rebuilds and checks that
tree, and prints an unpushed commit and its push command. A resolution too large for
the comment hands back plainly. A handback disables
tree, and prints an unpushed commit and its push command to run after reviewing it.
A resolution too large for the comment hands back plainly. A handback disables
auto-merge and carries the reason marker `quest status`
reads. Each failure a fixer job blames a PR for is a round of its maintainer pick; a PR
that only shared a broken batch has none. The fixer keeps going while each round
Expand Down Expand Up @@ -818,8 +821,8 @@ base it conflicted with. The pick keeps going while each round makes progress:
a round with no new commit for a failure that isn't flaky, the failure a fix
addressed recurring right after it, or three flaky rounds in a row hand it
back. A fix is validated, lease-pushed, and recorded in Review as for the host
gate, and a push GitHub refuses is handed back with its resolution for
`quest land apply`. The watcher then puts the fixed, merged, or flaky head back
gate, and a push GitHub refuses, or a fix that edits protected paths, is handed
back with its resolution for `quest land apply`. The watcher then puts the fixed, merged, or flaky head back
in the queue with `expectedHeadOid` bound to it: it enqueues a head whose
required checks have passed, and arms auto-merge on one whose checks still
run, so GitHub enqueues it once they pass. Either joins the back of the queue.
Expand Down
15 changes: 10 additions & 5 deletions assets/land-fix.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,16 @@ flaky test in its `failure` so it can be followed up), commit a
mechanical fix on its own branch, or hand it back with diagnosis and recommendation.
Allowed fixes: preserve both sides' intent in conflicts, format/lint, regenerate
lockfiles/generated files, or follow a base rename. Merge base where practical.
Never edit flake.nix, flake.lock, shell.nix, default.nix, nix/, .envrc, .github/,
.gitmodules, or gitlinks (submodule entries) anywhere. Merging the base may carry
the base's own changes to those paths: that is allowed when they come out exactly as
a plain `git merge` of the base leaves them, with no conflict there for you to edit. Never push or contact
GitHub. Anything needing behavior changes or review must be handed back.
flake.nix, flake.lock, shell.nix, default.nix, nix/, .envrc, .github/, .gitmodules,
and gitlinks (submodule entries) anywhere are protected. Merging the base may carry
the base's own changes to those paths: that is no edit of yours when they come out
exactly as a plain `git merge` of the base leaves them, with no conflict there for you
to edit. Prefer a fix that leaves them alone. When the mechanical fix itself must
edit one, such as a conflict there that a resolution preserving both sides settles,
make it and report it `fixed`, naming the protected paths it edits in the reason:
quest land never pushes it, but hands the PR back with your commit ready for a
maintainer to review and apply. Never push or contact GitHub. Anything needing
behavior changes or review must be handed back.

Build tools may fetch the dependencies the repository's manifests and lockfiles
name: from their package registries, such as crates.io, npm, PyPI, and the Go
Expand Down
2 changes: 1 addition & 1 deletion quest/m0/host-land/fixer.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Maintainer decision, 2026-10-30: a `fixed` head lands without further review.
prompt-injection risk is accepted. Protected paths stay blocked: on 2026-11-01 the
maintainer briefly let the fixer edit them (moq-dev/quest#357), then reverted that
after an audit, routing fixer-written protected edits through a ready commit
instead ([Protected edits as ready commits](/quest/m0/land-fixer/protected-commit.md)). The built-in prompt treats contributor content
instead ([moq-dev/quest#409](https://github.com/moq-dev/quest/pull/409)). The built-in prompt treats contributor content
as untrusted and ignores instructions in the PRs' diffs, without handing the PR
back for them.

Expand Down
5 changes: 2 additions & 3 deletions quest/m0/land-fixer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ moq-dev/rust2, moq-dev/rind and moq-dev/quest.
| 98 | "already used its mechanical fix attempt" after a collateral requeue or an earlier conflict fix; each needed a manual re-pick | stop rules ([moq-dev/quest#384](https://github.com/moq-dev/quest/pull/384)), batch triage ([moq-dev/quest#355](https://github.com/moq-dev/quest/pull/355)) |
| ~75 | "requeued unchanged": collateral of a batch another PR broke | batch triage ([moq-dev/quest#355](https://github.com/moq-dev/quest/pull/355)) |
| 36 | picked again while fixing; the newer pick is kept, so nothing is lost | not planned |
| 17 | conflicts in protected paths (`flake.nix`, `.github/`), mostly mechanical; resolved by hand on moq-dev/moq#7344, #7315, #7318 | blocked by design; fixer-written protected edits become a ready commit ([Protected edits as ready commits](/quest/m0/land-fixer/protected-commit.md)) |
| 17 | conflicts in protected paths (`flake.nix`, `.github/`), mostly mechanical; resolved by hand on moq-dev/moq#7344, #7315, #7318 | kept blocked; a fixer-written protected edit is handed back with a ready commit for `quest land apply` by [moq-dev/quest#409](https://github.com/moq-dev/quest/pull/409) |
| 17 of the last 40 jobs | `cargo` fails offline ("failed to get `uniffi`"), so Rust fixes were requeued uncompiled: moq-dev/moq#7357 sank three batches, and #6985's lockfile could not be regenerated | fixed by [moq-dev/quest#356](https://github.com/moq-dev/quest/pull/356): the prompt allows dependency fetches and requires a build and test |
| 2 | GitHub refused the push to a fork because the merge touches workflows: moq-dev/moq#7439, #7467 | handed back with a ready commit for `quest land apply` by [moq-dev/quest#358](https://github.com/moq-dev/quest/pull/358) |
| several batches | a flaky ffmpeg timeout ([moq-dev/moq#7480](https://github.com/moq-dev/moq/pull/7480)) ejected whole batches | [Flaky failures](/quest/m0/queue-watcher/flaky.md) |
Expand Down Expand Up @@ -111,7 +111,7 @@ Decision prompts, selected options marked ✅:
PRs broken against the current base, and not fixer capability. Decisions:
- Protected paths: ✅ keep `validateFix`'s block and route fixer-written
protected edits through moq-dev/quest#358's ready commit
([Protected edits as ready commits](/quest/m0/land-fixer/protected-commit.md)); moq-dev/quest#357 closed · keep #357 · #357 minus workflows
([moq-dev/quest#409](https://github.com/moq-dev/quest/pull/409)); moq-dev/quest#357 closed · keep #357 · #357 minus workflows
- Fixer comments: ✅ the daemon posts them from the fixer's result, no
token in the container (moq-bot comments carry trust: `quest land apply`,
the daemon's markers, `quest status`) · keep the token
Expand Down Expand Up @@ -153,7 +153,6 @@ work, done once the Required quests below have merged.

- [Workflow permission](/quest/m0/land-fixer/workflows-permission.md) - waits on the maintainer to grant moq-bot `workflows`, so pushes touching workflows are accepted
- [Lockfile merges](/quest/m0/land-fixer/lockfile.md) - a conflicted lockfile is rebuilt from the base's copy, so a fix never reverts the base's dependency changes
- [Protected edits as ready commits](/quest/m0/land-fixer/protected-commit.md) - a fixer edit `validateFix` rejects for touching protected paths comes back as a commit the maintainer applies
- [PR CI as a signal](/quest/m0/land-fixer/pr-signal.md) - `/quest-merge` and the fixer read a PR's own CI without gating on it; public repos may run it, private ones need not

## Related
Expand Down
9 changes: 2 additions & 7 deletions quest/m0/land-fixer/lockfile.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,9 @@ The prompt already lets build tools fetch from package registries (from
regeneration needs. `flake.lock` stays a protected path in `validateFix`, and
this quest does not open it: a fixer-written `flake.lock` comes back as a
ready commit through
[Protected edits as ready commits](/quest/m0/land-fixer/protected-commit.md),
[moq-dev/quest#409](https://github.com/moq-dev/quest/pull/409),
and that edit follows the same rule, rebuilt from the base's copy with
`nix flake lock`. Both quests edit `assets/land-fix.md`, so whichever lands
second merges the other's wording.
`nix flake lock`. That PR also edited `assets/land-fix.md`: merge its wording.

Cover the rule in `test/land.test.ts`, which already asserts the prompt's
contents.

## Related

- [Protected edits as ready commits](/quest/m0/land-fixer/protected-commit.md) - returns a fixer-written `flake.lock` as a ready commit, built by this quest's rule
30 changes: 0 additions & 30 deletions quest/m0/land-fixer/protected-commit.md

This file was deleted.

61 changes: 49 additions & 12 deletions src/cli/land-apply.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// A fixer resolution GitHub refused to publish travels in the handback comment, and a maintainer's checkout rebuilds it.
// A fixer resolution the daemon cannot publish travels in the handback comment, and a maintainer's checkout rebuilds it.
import { spawnSync } from "node:child_process";
import { rmSync } from "node:fs";
import { resolve } from "node:path";
Expand All @@ -20,13 +20,19 @@ export const COMMENT_LIMIT = 65_536;

/**
* A validated fix as data: the PR head and base it merges, the tree it must produce, and the fixer's edits as a
* gzipped, base64 patch against the merge's conflicted result, so it stays as small as the resolution itself.
* gzipped, base64 patch against the merge's conflicted result, so it stays as small as the resolution itself. A fix
* that merges no base names the head as its base, so its patch applies to the head's own tree.
*/
export interface Resolution {
head: string;
base: string;
tree: string;
patch: string;
/**
* Why the daemon handed it back rather than pushing it: GitHub refused the push, or the fix edits protected
* paths, which only a maintainer's push lands. The comment says it in prose; its data carries no cause.
*/
cause?: "protected";
}

const SHA = "[0-9a-f]{40}(?:[0-9a-f]{24})?";
Expand Down Expand Up @@ -83,9 +89,24 @@ function bytes(root: string, args: string[], input?: Buffer, env: Record<string,
return result.stdout;
}

/** The fixed head as a resolution of `base` merged into `head`, or nothing when it does not contain that base. */
export function resolution(root: string, head: string, base: string, fixed: string): Resolution | undefined {
if (!contains(root, base, fixed)) return undefined;
/**
* The fixed head as a resolution of `base` merged into `head`, or of `head` alone when the fix merges no base. Nothing
* when it merges part of the base: a rebuild from either side would not reach its tree.
*/
export function resolution(
root: string,
head: string,
base: string,
fixed: string,
cause?: Resolution["cause"],
): Resolution | undefined {
// A head that already has the base merges nothing either.
if (contains(root, base, head)) base = head;
else if (!contains(root, base, fixed)) {
const count = (...not: string[]) => git(root, ["rev-list", "--count", fixed, ...not.map((ref) => `^${ref}`)]);
if (count(head) !== count(head, base)) return undefined;
base = head;
}
const patch = bytes(root, [
"diff",
"--binary",
Expand All @@ -100,16 +121,28 @@ export function resolution(root: string, head: string, base: string, fixed: stri
conflicted(root, head, base),
fixed,
]);
return { head, base, tree: git(root, ["rev-parse", `${fixed}^{tree}`]), patch: gzipSync(patch).toString("base64") };
return {
head,
base,
tree: git(root, ["rev-parse", `${fixed}^{tree}`]),
patch: gzipSync(patch).toString("base64"),
...(cause ? { cause } : {}),
};
}

/** The handback's section carrying `resolution`, or a plain handback when it would not fit in `room` characters. */
export function resolutionSection(number: number, resolution: Resolution, room: number): string {
const { head, base, tree, patch } = resolution;
const { head, base, tree, patch, cause } = resolution;
const why =
cause === "protected"
? "The fixer's resolution edits protected paths beyond what the base merge carries, and only a maintainer's push lands those"
: "GitHub refused the fixer's push";
const merge = base === head ? "no base merge" : `base \`${base}\``;
const review = cause === "protected" ? "reviews its protected edits" : "reviews it";
const text =
`\n\nGitHub refused the fixer's push, so its validated resolution travels in this comment: PR head \`${head}\`, ` +
`base \`${base}\`, resolved tree \`${tree}\`. A maintainer session rebuilds the commit with ` +
`\`quest land apply ${number}\`, reviews it, pushes it to the PR branch, and picks the PR again.` +
`\n\n${why}, so its validated resolution travels in this comment: PR head \`${head}\`, ` +
`${merge}, resolved tree \`${tree}\`. A maintainer session rebuilds the commit with ` +
`\`quest land apply ${number}\`, ${review}, pushes it to the PR branch, and picks the PR again.` +
`\n\n<!-- quest land apply v1 head=${head} base=${base} tree=${tree} patch=${patch} -->`;
if (text.length <= room) return text;
return `\n\nThe fixer's resolution is too large for a comment (${patch.length} characters encoded), so redo the merge by hand.`;
Expand Down Expand Up @@ -167,7 +200,7 @@ export interface Applied {
}

/**
* Rebuild the commit a quest land fixer handed back because GitHub refused its push: the latest resolution an App
* Rebuild the commit a quest land fixer handed back because GitHub refused its push or it edits protected paths: the latest resolution an App
* posted on the PR, while the PR's head is still the one it resolved. The commit is left unpushed.
*/
export async function apply(root: string, pr: PullRequest, deps: GitHubDependencies): Promise<Applied> {
Expand Down Expand Up @@ -212,10 +245,14 @@ export async function apply(root: string, pr: PullRequest, deps: GitHubDependenc
const tree = rebuild(root, found);
const parents = contains(root, found.base, found.head) ? [found.head] : [found.head, found.base];
const branch = string(head.ref);
const subject =
found.base === found.head
? `Apply the quest land fixer's fix to ${branch}`
: `Merge ${found.base} into ${branch}`;
const commit = userGit(
root,
["commit-tree", tree, ...parents.flatMap((parent) => ["-p", parent])],
`Merge ${found.base} into ${branch}\n\nThe quest land fixer's resolution, rebuilt by \`quest land apply\` after GitHub refused the fixer's push.\n`,
`${subject}\n\nThe quest land fixer's resolution, rebuilt by \`quest land apply\` after the daemon handed it back unpushed.\n`,
);
const holder = object(head.repo);
return {
Expand Down
Loading
Loading