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
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ remaining batch. Transient refusals are retried; a racing base rebuilds the queu
a log tail in each comment and the full log retained on the host. The optional
credential-free `quest land fix` container makes one mechanical attempt per
maintainer pick. The daemon validates and lease-pushes fixes, records the new
head in Review, and requeues healthy PRs; other results are handed back. The admin bypass of `main`'s
head in Review, and requeues healthy PRs; other results are handed back. A fix
whose push GitHub refuses is handed back with its resolution, which
`quest land apply <pr>` rebuilds as a commit for a maintainer to push. The admin bypass of `main`'s
ruleset is for emergencies.

Every AI-authored GitHub post must end with `(written by <model>)`, naming the
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,14 @@ checks every object and link, since the import enables `fetch.fsckObjects=true`.
`## Review`, enables head-pinned auto-merge, and adds `quest:merge`. Healthy PRs
requeue unchanged. Forks refusing maintainer edits, refused pushes, invalid results,
crashes, or the 60-minute fixer deadline hand back with a marked comment naming the exit
status or signal, with the tail of the job's retained log. A flaky
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
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 flaky
requeue suggests a follow-up. Each maintainer pick gets one attempt; the daemon
records the fixer's new auto-merge `enabledAt` so its second ejection hands back.
A new maintainer pick resets the attempt. Host provisioning and isolation
Expand Down
6 changes: 3 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.
| 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 | [Protected edits](/quest/m0/land-fixer/protected.md) |
| 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 | [Fork commits](/quest/m0/land-fixer/fork-commit.md) |
| 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 tests](/quest/m0/land-fixer/flaky.md) |
| ~15 | design conflicts, correctly handed back | none |

Expand All @@ -61,7 +61,8 @@ Decision prompts, selected options marked ✅:
and makes the fixer build and test what it reports.
- Leave it
- Protected paths: see [Protected edits](/quest/m0/land-fixer/protected.md).
- Fork pushes GitHub refuses: see [Fork commits](/quest/m0/land-fixer/fork-commit.md).
- Fork pushes GitHub refuses: ✅ hand back a ready commit, which `quest land apply` rebuilds
for a maintainer to push (Grant moq-bot the `workflows` permission · Leave it manual).
- Flaky tests: see [Flaky tests](/quest/m0/land-fixer/flaky.md).
- Milestone for the new quests: ✅ m0 · m1
- Restated 2026-11-01 (user, a second /quest-plan): "the merge queue can
Expand Down Expand Up @@ -108,7 +109,6 @@ resolves itself and what it hands back; that docs update finishes the epic.
- [Flaky tests](/quest/m0/land-fixer/flaky.md) - the gate reruns a failed module, disables a flaky test on the batch's base PR, and files a follow-up
- [Local mergeability](/quest/m0/land-fixer/mergeability.md) - `quest land` merges with the quest driver instead of ejecting on GitHub's conflict flag
- [Protected edits](/quest/m0/land-fixer/protected.md) - the fixer edits protected paths when it judges the edit mechanical, and the fix lands
- [Fork commits](/quest/m0/land-fixer/fork-commit.md) - a fork push GitHub refuses hands back a commit a maintainer session applies as is

## Related

Expand Down
38 changes: 0 additions & 38 deletions quest/m0/land-fixer/fork-commit.md

This file was deleted.

4 changes: 0 additions & 4 deletions quest/m0/land-fixer/protected.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,3 @@ protected-path rule in `assets/land-fix.md` with the same mechanical bar
every other path has. Instructions in the PRs' diffs stay ignored on these
paths as on any other. Update the decision line in
[Fixer proxy access](/quest/m0/host-land/fixer.md).

## Related

- [Fork commits](/quest/m0/land-fixer/fork-commit.md) - workflow edits GitHub refuses to take from the App on forks
6 changes: 2 additions & 4 deletions quest/m0/land-fixer/workflows-permission.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,8 @@ GitHub refused the fixer's push on
[#7467](https://github.com/moq-dev/moq/pull/7467), and
[#7370](https://github.com/moq-dev/moq/pull/7370); a maintainer session pushed
each by hand. Once this permission is granted, the refusal that
[Fork commits](/quest/m0/land-fixer/fork-commit.md) handles should stop
happening; whether to keep that quest and
[moq-dev/quest#358](https://github.com/moq-dev/quest/pull/358) is for its
review.
[moq-dev/quest#358](https://github.com/moq-dev/quest/pull/358)'s
`quest land apply` handback handles should stop happening.

Delete this quest once the permission is granted and accepted.

Expand Down
218 changes: 218 additions & 0 deletions src/cli/land-apply.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
// A fixer resolution GitHub refused to 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";
import { gunzipSync, gzipSync } from "node:zlib";
import { GitHub, type GitHubDependencies, type PullRequest, object, remoteRepository, string } from "./github";
import { contains, git, gitEnvironment, tryGit } from "./land";
import { Refusal } from "./start";

/** GitHub refuses a comment body longer than this many characters. */
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.
*/
export interface Resolution {
head: string;
base: string;
tree: string;
patch: string;
}

const SHA = "[0-9a-f]{40}(?:[0-9a-f]{24})?";
const DATA = new RegExp(
`<!-- quest land apply v1 head=(${SHA}) base=(${SHA}) tree=(${SHA}) patch=([A-Za-z0-9+/]*={0,2}) -->`,
);

/**
* Settings the merge's conflicted result depends on, pinned so the daemon's bare repository and a maintainer's
* checkout write the same tree. Attributes would pick merge drivers and conversions, so they come from the empty tree.
*/
function pinned(root: string): string[] {
const empty = git(root, ["mktree"], { input: "" });
return [
`--attr-source=${empty}`,
"-c",
"merge.conflictStyle=merge",
"-c",
"merge.renames=true",
"-c",
"merge.directoryRenames=conflict",
"-c",
"merge.renormalize=false",
];
}

/** The tree `git merge-tree` writes for merging `base` into `head`, conflict markers included. */
export function conflicted(root: string, head: string, base: string): string {
const merged = tryGit(root, [
...pinned(root),
"merge-tree",
"--write-tree",
"--name-only",
"--no-messages",
head,
base,
]);
const tree = merged.stdout.split("\n")[0];
if ((merged.status !== 0 && merged.status !== 1) || !new RegExp(`^${SHA}$`).test(tree))
throw new Error(`cannot merge ${base} into ${head}: ${merged.stderr.trim()}`);
return tree;
}

/** Run trusted git on bytes, since a patch need not be UTF-8. */
function bytes(root: string, args: string[], input?: Buffer, env: Record<string, string> = {}): Buffer {
const result = spawnSync("git", ["-c", "core.hooksPath=/dev/null", ...args], {
cwd: root,
input,
env: { ...gitEnvironment(), ...env },
maxBuffer: 1 << 30,
});
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`git ${args[0]} failed: ${result.stderr.toString().trim()}`);
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;
const patch = bytes(root, [
"diff",
"--binary",
"--full-index",
"--no-renames",
"--no-ext-diff",
"--no-textconv",
"--no-color",
"--no-relative",
"--src-prefix=a/",
"--dst-prefix=b/",
conflicted(root, head, base),
fixed,
]);
return { head, base, tree: git(root, ["rev-parse", `${fixed}^{tree}`]), patch: gzipSync(patch).toString("base64") };
}

/** 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 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<!-- 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.`;
}

/** The resolution a handback comment carries. */
export function parseResolution(body: string): Resolution | undefined {
const match = DATA.exec(body);
return match ? { head: match[1], base: match[2], tree: match[3], patch: match[4] } : undefined;
}

/** Merge the resolution's base into its head, apply its patch, and return the tree, refusing any other tree. */
export function rebuild(root: string, resolution: Resolution): string {
const index = resolve(root, git(root, ["rev-parse", "--git-path", `quest-land-apply-${process.pid}.index`]));
const env = { GIT_INDEX_FILE: index };
try {
git(root, ["read-tree", conflicted(root, resolution.head, resolution.base)], { env });
const patch = gunzipSync(Buffer.from(resolution.patch, "base64"));
if (patch.length)
bytes(root, ["-c", "apply.whitespace=nowarn", "apply", "--cached", "--binary", "-"], patch, env);
const tree = git(root, ["write-tree"], { env });
if (tree !== resolution.tree)
throw new Refusal(
`the rebuilt tree ${tree} differs from the fixer's ${resolution.tree}; this Git's merge differs from the daemon's, so redo the merge by hand`,
);
return tree;
} finally {
rmSync(index, { force: true });
}
}

/** Git as the maintainer runs it, with their credentials, identity, and signing; hooks stay off. */
function userGit(root: string, args: string[], input?: string): string {
const result = spawnSync("git", ["-c", "core.hooksPath=/dev/null", ...args], {
cwd: root,
input,
encoding: "utf8",
});
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`git ${args[0]} failed: ${result.stderr.trim()}`);
return result.stdout.trim();
}

/** The command that pushes `applied`, each argument shell-quoted: a contributor names the branch. */
export function pushCommand(applied: Applied): string {
const quote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`;
return ["git", "push", applied.remote, `${applied.commit}:refs/heads/${applied.branch}`].map(quote).join(" ");
}

export interface Applied {
commit: string;
/** Where the maintainer pushes the commit: the PR's head repository and branch. */
remote: string;
branch: string;
}

/**
* Rebuild the commit a quest land fixer handed back because GitHub refused its push: 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> {
const github = new GitHub(deps);
const path = `/repos/${pr.owner}/${pr.repo}`;
const url = `https://github.com/${pr.owner}/${pr.repo}/pull/${pr.number}`;
// Only an App can post as a `[bot]` login, so a contributor cannot plant a resolution; the fixer's own
// handback marker sets it apart from another App quoting one.
const found = (await github.all(`${path}/issues/${pr.number}/comments?per_page=100`))
.filter(
(item) =>
item.user != null &&
String(object(item.user).login).endsWith("[bot]") &&
String(item.body ?? "").includes("<!-- quest land fixer: "),
)
.flatMap((item) => parseResolution(String(item.body ?? "")) ?? [])
.at(-1);
if (!found) throw new Refusal(`${url} carries no quest land fixer resolution`);
const pull = object(await (await github.request(`${path}/pulls/${pr.number}`)).json());
if (pull.state !== "open") throw new Refusal(`${url} is not open`);
const head = object(pull.head);
if (string(head.sha) !== found.head)
throw new Refusal(
`${url}'s head moved to ${string(head.sha)} since the fixer resolved ${found.head}; redo the merge`,
);
if (head.repo == null) throw new Refusal(`${url}'s head repository is gone`);

const origin = remoteRepository(root, "origin");
const same =
origin.repository?.owner.toLowerCase() === pr.owner.toLowerCase() &&
origin.repository.repo.toLowerCase() === pr.repo.toLowerCase();
// GitHub keeps every PR's head in the base repository, a fork's included.
userGit(root, [
"fetch",
"--quiet",
"--no-tags",
"--no-write-fetch-head",
same ? origin.url : `https://github.com/${pr.owner}/${pr.repo}.git`,
found.head,
found.base,
]);
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 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`,
);
const holder = object(head.repo);
return {
commit,
remote: `https://github.com/${string(object(holder.owner).login)}/${string(holder.name)}.git`,
branch,
};
}
Loading
Loading