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
53 changes: 43 additions & 10 deletions cloudflare/GITHUB.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ and installation tokens stay inside the Worker; agents receive neither.
| --------------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------- |
| Metadata: read | Resolve repository identity and maintainer authority | Every operation |
| Contents: write | Read quest files, write job branches on service forks and iterated PR heads | Read for quest reads; write only for that publication or merge |
| Issues: write | Read native issues and publish approved comments | Read or write for that operation |
| Issues: write | Read native issues, publish approved comments, label merge jobs' PRs | Read or write for that operation |
| Pull requests: write | Observe, open and update job PRs, explicitly merge an exact head | Read or write for that operation |
| Checks: read | Observe checks on a commit | Check reads only |
| Commit statuses: read | Observe legacy CI status contexts | Check reads only |
Expand Down Expand Up @@ -83,9 +83,10 @@ and moves one branch. Before writing anything it checks, in order:
absolute path, backslash, or control character. Each workflow publishes
only its own changes: `plan` only quest documents under `quest/` (Markdown
other than `AGENTS.md` and `CLAUDE.md`), `bootstrap` only `flake.nix` and
`flake.lock`, `start` and `iterate` their task's changes. No workflow changes
`flake.lock`, `start`, `iterate`, and `merge` their task's changes. No workflow changes
`.github/workflows/`, because the App does not request the workflows
permission. A refused change pauses the job with a `publication` decision
permission; a merge commit may keep a pull request's own workflow changes,
but not change the workflows of the head it updates. A refused change pauses the job with a `publication` decision
listing each path and reason, instead of being dropped; the maintainer's
answer resumes the agent's session.
- The job's requester still holds maintainer authority, and the repository's
Expand All @@ -108,7 +109,7 @@ and moves one branch. Before writing anything it checks, in order:

`plan`, `start`, and `bootstrap` jobs write their branch on the service fork
`GITHUB_FORK_OWNER/<repository>`, whose immediate parent must be the upstream's
repository ID. An `iterate` job writes its pull request's actual head: an
repository ID. An `iterate` or `merge` job writes its pull request's actual head: an
upstream branch through an upstream-scoped token, or a service fork branch.
GitHub gives an installation no write access to a contributor's fork, so that
case pauses with a decision reporting the blocked update; kix never opens a
Expand Down Expand Up @@ -138,7 +139,7 @@ not replaced.
Planning pull requests are always drafts, as are those of jobs asked to stop.
Other jobs set `draft`, which defaults to `true` while decisions or unfinished
work remain; a later delivery with `draft: false` marks the pull request ready.
kix never converts a ready pull request back. Delivery requires an enabled
kix never converts a ready pull request back. Delivery and landing require an enabled
project; a paused project's running jobs can still preserve their work on their
branches. Pull requests use `maintainer_can_modify: false`, because an
upstream-scoped token cannot grant edits on the fork branch.
Expand All @@ -162,9 +163,41 @@ merges only an exact head a maintainer explicitly chose, with the first method
the repository allows: squash, then a merge commit, then rebase. GitHub's
branch rules and refusals stand, and an agent's recommendation is never
approval. The decision inbox offers it on a pull request item only when
GitHub reports the PR's merge state as `CLEAN` or `HAS_HOOKS` and no job holds
the PR. The form binds the head on screen. A moved head asks for another look,
GitHub's refusal is shown in its own words, and neither is retried.
GitHub reports the PR's merge state as `CLEAN` or `HAS_HOOKS` and no job or
session holds the PR. The form binds the head on screen. A moved head asks for
another look, GitHub's refusal is shown in its own words, and neither is
retried.

### Merge jobs

On any other open, non-draft pull request that nothing holds, the inbox's
merge button starts a `merge` job instead. Picking it covers the job's own
fixes for conflicts, failing checks, and review findings; a head moved by
anyone else, a design change, and the merge skill's design checkpoint pause it
on a decision.

- The job holds the PR with the `quest:merge` label, under the contract
`quest merge --help` states for local sessions: the bot adds it when the job
is created, and refuses the pick while another `quest:*` label holds the PR.
A `quest:merge` whose landing already ended unmerged is spent, and removed
first. The job keeps the timeline event that added the label as its `hold`.
- The job's agent resolves conflicts by merging the base branch with Git. A
publication may name that `merge` commit, which kix accepts only when the
PR's upstream base branch already holds it; kix then publishes a merge
commit whose files are the changes applied to the merged commit's.
- Landing is a publication with `land`: kix verifies the published head and
the job's hold, then enables auto-merge bound to that head, entering the
merge queue when the base has one, or merges it at once when GitHub would
refuse auto-merge because nothing is left to wait for. It uses the method the
job names, else the first the repository allows. A refusal pauses the job on
a decision, in GitHub's words.
- The label stays while the job runs or waits, and while the PR is queued or
auto-merging. When the job ends without a landing in progress, or a
`pull_request` webhook arrives and GitHub shows the landing ended unmerged,
the App removes its own label. The PR is then an idle decision item again.

Labels need `issues: write` on the upstream; landing needs `contents: write`
and `pull_requests: write` there, as a direct merge does.

Fork pull requests do not make privileged CI safe. Workflows that run a pull
request's code with secrets or write tokens, such as `pull_request_target` or
Expand All @@ -178,8 +211,8 @@ Installation grants are broader than any one publication: `contents: write`
and `pull_requests: write` cover every installed repository. Each token names
one repository ID and only the permissions that step needs. Fork writes get
`contents: write` on the fork alone, pull request delivery gets
`pull_requests: write` on the upstream alone, and only an iteration on an
upstream head gets upstream `contents: write`. That token could write any
`pull_requests: write` on the upstream alone, and only an iteration or merge
job on an upstream head gets upstream `contents: write`. That token could write any
unprotected upstream branch; the service restricts it to the pull request's
head and refuses the default branch, and repositories should still protect
their default branch.
Expand Down
11 changes: 7 additions & 4 deletions cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ connections and keeps no records of its own.

### Jobs

A job is one maintainer-authorized workflow (`plan`, `start`, `iterate`, or
`bootstrap`) on one target, pinned to a revision (`src/jobs/`). Maintainers
A job is one maintainer-authorized workflow (`plan`, `start`, `iterate`,
`merge`, or `bootstrap`) on one target, pinned to a revision (`src/jobs/`). Maintainers
create, answer, and cancel jobs at `/projects/github/<owner>/<repo>/jobs`, and
every request rechecks current GitHub authority. `start` takes only quests that
observations show ready and unclaimed, except that retrying a failed job does
Expand All @@ -90,8 +90,11 @@ target; a retry keeps both. A completed job's result may list suggested
idea and takes the follow-ups and summary as context from the job's own
result, never from the request, and offers them once. A job on an issue or pull request
rereads it from GitHub, refusing one that closed or whose head moved since
observations read it. `iterate` jobs are untrusted and route
only to Linux runners that declare VM builds. Without a `flake.nix`, the route
observations read it. `iterate` and `merge` jobs are untrusted and route
only to Linux runners that declare VM builds. A `merge` job runs the merge
skill's loop on an open, non-draft pull request and lands it; it holds the PR
with the `quest:merge` label that local `quest merge` sessions share
(`src/jobs/landing.ts`), as described in [GITHUB.md](GITHUB.md#merge-jobs). Without a `flake.nix`, the route
offers a `bootstrap` job instead. A paused project gets no new jobs and none are
claimed; resuming it dispatches the jobs that waited. Disconnecting a project
deletes its jobs and their output and tells their runners to stop. Duplicate
Expand Down
123 changes: 122 additions & 1 deletion cloudflare/dev/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ export type Pull = Issue & {
merge?: "CLEAN" | "BLOCKED" | "DIRTY" | "UNSTABLE" | "UNKNOWN";
/** When it was opened, in milliseconds; defaults to the world's fixed update time. */
createdAt?: number;
labels?: string[];
/** Timeline events that added a label, or with a null label enabled auto-merge, oldest first. */
timeline?: { id: string; label: string | null }[];
/** Auto-merge is enabled, so it lands once its requirements pass. */
landing?: boolean;
};
export type Commit = { parents: string[]; tree: string; message: string };
/** A tree written through the API: its base tree and the entries changed on it. */
Expand All @@ -59,6 +64,7 @@ export type Repo = {
* Upstream branches and their commits. A pull request's branch that is not
* listed is at a commit derived from its number; a `quest/*` branch with no
* open pull request is a claim, unless it is still at a merged one's head.
* Listing `main` moves the default branch on for comparisons with it.
*/
branches: Record<string, string>;
/** Branches on the repository's service fork, `<forks>/<name>`, and their commits. */
Expand Down Expand Up @@ -118,6 +124,8 @@ function entity(repo: Repo, item: Issue) {
};
}

const mergeState = (pull: Pull) => pull.merge ?? (pull.draft ? "DRAFT" : "BLOCKED");

function pullNode(repo: Repo, pull: Pull) {
const oid = headOf(repo, pull);
const reviews = pull.reviews ?? [];
Expand All @@ -128,7 +136,8 @@ function pullNode(repo: Repo, pull: Pull) {
headRefOid: oid,
isDraft: !!pull.draft,
reviewDecision: pull.review,
mergeStateStatus: pull.merge ?? (pull.draft ? "DRAFT" : "BLOCKED"),
mergeStateStatus: mergeState(pull),
labels: { nodes: (pull.labels ?? []).map((name) => ({ name })) },
reviews: { totalCount: reviews.length },
latestReviews: {
nodes: reviews.map((r) => ({ state: r.state, author: { login: r.author }, commit: { oid } })),
Expand Down Expand Up @@ -207,6 +216,27 @@ function pullTarget(repo: Repo, pull: Pull, after: unknown) {
};
}

/** A pull request's labels, the timeline that added them, and its landing, as a merge hold reads them. */
function holdsTarget(repo: Repo, pull: Pull) {
return {
id: nodeId(repo, pull),
state: pull.merged ? "MERGED" : pull.closed ? "CLOSED" : "OPEN",
headRefOid: headOf(repo, pull),
mergeStateStatus: mergeState(pull),
isMergeQueueEnabled: false,
autoMergeRequest: pull.landing ? { enabledAt: updatedAt } : null,
mergeQueueEntry: null,
labels: { nodes: (pull.labels ?? []).map((name) => ({ name })) },
timelineItems: {
nodes: (pull.timeline ?? []).map((event) =>
event.label === null
? { __typename: "AutoMergeEnabledEvent", id: event.id }
: { __typename: "LabeledEvent", id: event.id, label: { name: event.label } },
),
},
};
}

const page = <T>(nodes: T[]) => ({ nodes, pageInfo: { hasNextPage: false, endCursor: null } });

function graphql(repo: Repo, query: string, variables: Record<string, unknown>) {
Expand All @@ -221,6 +251,10 @@ function graphql(repo: Repo, query: string, variables: Record<string, unknown>)
);
return { data: { repository: objects } };
}
if (query.includes("timelineItems(")) {
const pull = repo.pulls.find((p) => p.number === variables.number);
return { data: { repository: { pullRequest: pull ? holdsTarget(repo, pull) : null } } };
}
const open = repo.pulls.filter((pull) => !pull.closed);
const result: Record<string, unknown> = {};
const errors: unknown[] = [];
Expand Down Expand Up @@ -305,6 +339,21 @@ function connect(repo: Repo) {
for (const tip of tips) repo.commits[tip] ??= { parents: [main], tree: treeOf(repo), message: "Work" };
}

/** A tree's `.github/workflows` files and their blob IDs: the default branch's files, then each written tree's changes. */
function workflowsOf(repo: Repo, sha: string): Record<string, string> {
const workflow = (path: string) => path.startsWith(".github/workflows/");
if (sha === treeOf(repo))
return Object.fromEntries(repo.files.filter((f) => workflow(f.path)).map((f) => [f.path, blob(f.content)]));
const written = repo.trees[sha];
if (!written) return {};
const files = workflowsOf(repo, written.base);
for (const entry of written.entries as { path: string; sha?: string | null; content?: string }[])
if (workflow(entry.path))
if (entry.sha === null) delete files[entry.path];
else files[entry.path] = entry.sha ?? blob(entry.content ?? "");
return files;
}

type Permission = "metadata" | "contents" | "issues" | "pull_requests" | "checks" | "statuses";
/** What an installation token may do: the repositories (`owner/name`) and permissions it was minted for. */
type Grant = { repositories: Set<string>; permissions: Partial<Record<Permission, string>> };
Expand All @@ -317,6 +366,8 @@ export function fakeGitHub(world: World) {
for (const repo of world.repos) connect(repo);
const grants = new Map<string, Grant>();
let minted = 0;
let events = 0;
const nextEvent = () => ++events;
const json = (body: unknown, status = 200) => Response.json(body, { status });
const notFound = () => json({ message: "Not Found" }, 404);
const missing = (method: string, url: string) => {
Expand Down Expand Up @@ -444,6 +495,32 @@ export function fakeGitHub(world: World) {
}
return json({ errors: [{ type: "NOT_FOUND" }] });
}
const landing = query.match(/\b(enablePullRequestAutoMerge|mergePullRequest)\(/)?.[1];
if (landing) {
for (const repo of world.repos)
for (const pull of repo.pulls)
if (nodeId(repo, pull) === variables.id) {
if (
!scope.repositories.has(`${repo.owner}/${repo.name}`) ||
!writes("contents") ||
!writes("pull_requests")
)
return json({ errors: [{ type: "FORBIDDEN" }] });
if (pull.closed) return json({ errors: [{ message: "Pull request is closed" }] });
if (variables.sha !== headOf(repo, pull))
return json({ errors: [{ message: "Head sha didn't match expected head" }] });
if (landing === "mergePullRequest") {
if (!["CLEAN", "HAS_HOOKS", "UNSTABLE"].includes(mergeState(pull)))
return json({ errors: [{ message: "Pull Request is not mergeable" }] });
pull.closed = pull.merged = true;
} else {
pull.landing = true;
pull.timeline = [...(pull.timeline ?? []), { id: `AME_${nextEvent()}`, label: null }];
}
return json({ data: { [landing]: { clientMutationId: null } } });
}
return json({ errors: [{ type: "NOT_FOUND" }] });
}
const repo = world.repos.find((r) => r.owner === variables.owner && r.name === variables.name);
if (!repo || !scope.repositories.has(`${repo.owner}/${repo.name}`))
return json({ errors: [{ type: "NOT_FOUND", message: "Not Found" }] });
Expand Down Expand Up @@ -552,6 +629,50 @@ export function fakeGitHub(world: World) {
pull.merged = true;
return json({ sha: commitOf(repo), merged: true, message: "Pull Request successfully merged" });
}
const labels = tail.match(/^\/issues\/(\d+)\/labels(?:\/([^/]+))?$/);
if (labels && (method === "POST" || method === "DELETE")) {
if (!writes("issues")) return forbidden();
const pull = repo.pulls.find((p) => String(p.number) === labels[1]);
if (!pull) return notFound();
const current = pull.labels ?? [];
if (method === "POST" && !labels[2]) {
for (const name of body.labels as string[]) {
if (!current.includes(name)) current.push(name);
pull.timeline = [...(pull.timeline ?? []), { id: `LE_${nextEvent()}`, label: name }];
}
pull.labels = current;
return json(current.map((name) => ({ name })));
}
const name = labels[2] && decodeURIComponent(labels[2]);
if (method !== "DELETE" || !name) return missing(method, request.url);
if (!current.includes(name)) return json({ message: "Label does not exist" }, 404);
pull.labels = current.filter((label) => label !== name);
return json(pull.labels.map((label) => ({ name: label })));
}
const compare = tail.match(/^\/compare\/([0-9a-f]{40})\.\.\.main$/);
if (compare && method === "GET") {
const base = repo.branches.main ?? commitOf(repo);
const from = compare[1]!;
if (!repo.commits[from]) return notFound();
const reaches = (sha: string, target: string): boolean =>
sha === target || (repo.commits[sha]?.parents ?? []).some((parent) => reaches(parent, target));
const status =
from === base
? "identical"
: reaches(base, from)
? "ahead"
: reaches(from, base)
? "behind"
: "diverged";
return json({ status });
}
if (tail === "/contents/.github" && method === "GET") {
const tree = repo.commits[url.searchParams.get("ref") ?? ""]?.tree;
const files = Object.entries(tree ? workflowsOf(repo, tree) : {}).sort();
if (!files.length) return notFound();
const sha = sha1(`workflows ${JSON.stringify(files)}`);
return json([{ name: "workflows", path: ".github/workflows", type: "dir", sha }]);
}
if (tail.startsWith("/contents/")) {
const file = repo.files.find((f) => f.path === decodeURIComponent(tail.slice("/contents/".length)));
return file ? json({ type: "file", path: file.path }) : notFound();
Expand Down
Loading
Loading