Skip to content
Open
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
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,11 +361,14 @@ Notes worth knowing before you grant them:
lives in the base repository and is nobody's base: not named by an
allow-list, not protected on GitHub, not the default branch, and not the
base of another open pull request — so a back-merge `release → dev` keeps
`release` even though `release` is in no allow-list.
a `release` that no allow-list names, as long as GitHub protects it, it is
the default branch, or another open pull request targets it.
- `update_branch` merges the base into a pull request's head branch, as
GitHub's "Update branch" button does. That writes a commit, so it takes a
merge's fences: an open pull request into a base `GITHUB_MERGE_BASE_ALLOW`
names, and a head that is not itself a base branch.
names, and a head branch of the pull request's own repository that is
nobody's base, judged as for `delete_branch`. A fork's branch is left to
its owner.
- `dispatch_workflow` and `rerun_workflow_run` act only on workflow files
`GITHUB_WORKFLOW_ALLOW` names (empty = none), and a dispatch runs only on a
base branch, so the workflow that runs is one that was reviewed and merged.
Expand Down Expand Up @@ -497,9 +500,9 @@ reach metadata and never reach the model.
without. (RFC-005 references for logs and diffs are on the roadmap, not
here.)
- **No push.** Commits are written only by GitHub's own pull-request merge
(onto an allowed base) and "Update branch" (onto the head of a pull request
into an allowed base). No tool pushes a commit, creates a branch or touches
a branch outside a pull request.
(onto an allowed base) and "Update branch" (onto a head branch that is
nobody's base, of a pull request into an allowed base). No tool pushes a
commit, creates a branch or touches a branch outside a pull request.
- **Polling is no faster than 60 s** and cannot see pushes that touch no
thread.
- **No reactions, no labels, no assignees, no projects, no releases.**
Expand Down
9 changes: 6 additions & 3 deletions changelog.d/merge-and-actions.added.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
at the head sha the caller read, optionally deleting the merged head branch
when it is nobody's base) and `update_branch`. Both act only on pull
requests into the bases `GITHUB_MERGE_BASE_ALLOW` names, which is empty by
default: granting the feature set alone writes nothing. Drafts, closed and
merged pull requests and a moved head are refused before GitHub is asked;
branch protection stays GitHub's.
default: granting the feature set alone writes nothing. `merge_pull_request`
refuses a draft. Both refuse a closed or merged pull request, and, given
the `sha` the caller read, a head that has moved since, before GitHub is
asked to write; `update_branch` writes only to a head branch of the pull
request's own repository that is nobody's base.
Branch protection stays GitHub's.
- `github.actions` feature set: `list_workflow_runs`, `dispatch_workflow` and
`rerun_workflow_run`. Dispatch and re-run act only on the workflow files
`GITHUB_WORKFLOW_ALLOW` names (empty by default), and a dispatch runs only
Expand Down
82 changes: 63 additions & 19 deletions src/tools-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@
* granting github.pr: opening a pull request and landing one are different
* decisions. Not rollback-capable — a merge cannot be unmerged.
*
* The refusals are the point of this file, and all of them are taken before
* GitHub is asked:
* The refusals are the point of this file, and each is taken before GitHub is
* asked for the write it guards:
*
* - the base must be in GITHUB_MERGE_BASE_ALLOW, which is empty by default:
* a deployment that never set it cannot merge anything, whatever the host
* grants;
* - a draft, a closed or an already-merged pull request is refused by name;
* - a closed or an already-merged pull request is refused by name, and a
* draft is refused when merging;
* - with `sha` (full, or a prefix of 7 or more hex digits), a head that has
* moved since the caller read it is refused — a merge only ever lands the
* commit somebody looked at (GitHub enforces the full sha again, with a
Expand All @@ -27,8 +28,10 @@
* protected on GitHub, the default branch, or the base of another open
* pull request;
* - `update_branch` writes a merge commit onto the head branch, so it takes
* the same fences as a merge — an open pull request into an allowed base —
* and refuses a head that is itself a base branch.
* the allowed base, the open pull request and the head sha from a merge's
* fences, though not the draft, and writes only to a head branch of the
* pull request's own repository that is nobody's base, judged as for
* `delete_branch`.
*
* Branch protection — required checks, required approvals, linear history —
* is GitHub's to enforce, and its refusal (405) comes back as it is.
Expand All @@ -41,7 +44,6 @@ import {
TOOL_UPDATE_BRANCH,
allowedBases,
repoSlug,
sameRepo,
type MergeMethod,
type PullRequest,
type RepoRef,
Expand Down Expand Up @@ -95,6 +97,15 @@ function notMergeable(pr: PullRequest, action: 'merging' | 'updating'): string |
return null;
}

/**
* Whether two references name one repository. GitHub's owner and repository
* names are case-insensitive, and a pull request's own repository keeps the
* spelling GITHUB_REPOS gave it while its head repository has GitHub's.
*/
function sameRepository(a: RepoRef, b: RepoRef): boolean {
return a.owner.toLowerCase() === b.owner.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase();
}

/** A sha as a caller may pass one: 7 to 40 hex digits, any case. */
const SHA_PREFIX = /^[0-9a-f]{7,40}$/;

Expand Down Expand Up @@ -134,13 +145,13 @@ function baseBranches(pr: PullRequest, ctx: ToolContext): Set<string> {
* Whether the merged head branch may be deleted, as far as the pull request
* itself can tell, and if not, why not. Only a branch of the base repository
* itself — a fork's branch is its owner's — and never a branch an allow-list
* names as a base. {@link deletionBlockedOnGitHub} asks GitHub the rest.
* names as a base. {@link baseOnGitHub} asks GitHub the rest.
*/
function branchDeletionBlocked(pr: PullRequest, ctx: ToolContext): string | null {
const branch = pr.head.ref;
if (!branch) return 'the pull request names no head branch';
if (!pr.headRepo) return 'GitHub no longer knows the head repository (a deleted fork?)';
if (!sameRepo(pr.headRepo, pr.repo)) {
if (!sameRepository(pr.headRepo, pr.repo)) {
return `the head branch lives in ${field(repoSlug(pr.headRepo), FIELD.slug)}, not in ${repoSlug(pr.repo)}`;
}
if (baseBranches(pr, ctx).has(branch)) {
Expand All @@ -150,13 +161,15 @@ function branchDeletionBlocked(pr: PullRequest, ctx: ToolContext): string | null
}

/**
* The part of "is this somebody's base?" only GitHub knows. A release branch
* kept *out* of the allow-lists — so that nothing here can target it — is
* still a base: a back-merge `release → dev` must not delete `release`. So a
* protected branch, the default branch, and a branch other open pull requests
* target are all kept.
* The part of "is this somebody's base?" only GitHub knows, and why, or null.
* A release branch kept *out* of the allow-lists — so that nothing here can
* target it — is still a base, which a back-merge `release → dev` should
* neither delete nor write into. GitHub can tell when the branch is protected,
* is the default branch, or is the base of another open pull request; an
* unprotected branch that isn't the default and that nothing targets reads as
* nobody's base.
*/
function deletionBlockedOnGitHub(branch: string, facts: BranchFacts): string | null {
function baseOnGitHub(branch: string, facts: BranchFacts): string | null {
if (facts.isDefault) return `${echo(branch)} is the repository's default branch`;
if (facts.protected) return `${echo(branch)} is protected on GitHub`;
if (facts.openPullsBasedOn > 0) return `${echo(branch)} is the base of another open pull request`;
Expand Down Expand Up @@ -202,9 +215,10 @@ export const mergeTools: ToolDefinition[] = [
name: TOOL_UPDATE_BRANCH,
description:
'Bring a pull request’s head branch up to date by merging its base into it (GitHub’s ' +
'"Update branch"). Only an open pull request into a base GITHUB_MERGE_BASE_ALLOW names, and never ' +
'when the head is itself a base branch. GitHub does the merge in the background; the head sha ' +
'changes when it lands. With `sha`, a head that has moved is refused.',
'"Update branch"). Only an open pull request into a base GITHUB_MERGE_BASE_ALLOW names, and only ' +
'when the head is a branch of this repository that is nobody’s base: not allow-listed, not protected, ' +
'not the default branch, not the base of another open pull request. GitHub does the merge in the ' +
'background; the head sha changes when it lands. With `sha`, a head that has moved is refused.',
inputSchema: {
type: 'object',
properties: {
Expand Down Expand Up @@ -255,7 +269,7 @@ async function callMerge(name: string, args: Record<string, unknown>, ctx: ToolC
out.push(`head branch kept: ${why}`);
} else {
try {
const onGitHub = deletionBlockedOnGitHub(pr.head.ref, await ctx.api.branchFacts(pull.repo, pr.head.ref));
const onGitHub = baseOnGitHub(pr.head.ref, await ctx.api.branchFacts(pull.repo, pr.head.ref));
if (onGitHub) {
out.push(`head branch kept: ${onGitHub}`);
} else {
Expand All @@ -274,18 +288,48 @@ async function callMerge(name: string, args: Record<string, unknown>, ctx: ToolC
const expected = optionalString(args, 'sha');
// A merge commit onto the head branch is a write, so it takes a merge's
// fences: an open pull request into an allowed base, and a head that is
// not itself a base (on `dev → main`, this would merge main into dev).
// nobody's base (on `dev → main`, this would merge main into dev). The
// head must be a branch of this repository: a write lands only in
// GITHUB_REPOS, and a fork's branch is its owner's.
const pr = await ctx.api.getPullRequest(pull);
const blocked = notMergeable(pr, 'updating');
if (blocked) refuse(blocked);
requireMergeBase(pr.base.ref, pull.repo, ctx);
if (!pr.headRepo) {
refuse(`GitHub no longer knows the head repository of ${repoSlug(pull.repo)}#${pull.number} (a deleted fork?)`);
}
if (!sameRepository(pr.headRepo, pr.repo)) {
refuse(
`the head branch of ${repoSlug(pull.repo)}#${pull.number} lives in ` +
`${field(repoSlug(pr.headRepo), FIELD.slug)}, not in ${repoSlug(pr.repo)}; update_branch writes only ` +
`to a branch of the pull request's own repository`,
);
}
if (baseBranches(pr, ctx).has(pr.head.ref)) {
refuse(
`the head of ${repoSlug(pull.repo)}#${pull.number}, ${echo(pr.head.ref)}, is a base branch here ` +
Comment on lines +298 to 310

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. update_branch does not refuse an empty head ref the way the deletion path does.

branchDeletionBlocked refuses when pr.head.ref is empty ('the pull request names no head branch'). The new update path does not, and passes pr.head.ref straight to branchFacts. That call would then send GET /repos/{owner}/{repo}/branches/ and an unfiltered pulls?base= query, so a missing head ref gets a confusing answer instead of a clear refusal. Add the same guard to keep the two paths consistent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left as it is. A pull request still reports its head ref even when its head repository is gone: cli/cli#14544, whose fork was deleted, answers head.ref "fix/pr-merge-local-cleanup-scope" with head.repo null. If the ref were ever empty, branchFacts would ask for GET /repos/{owner}/{repo}/branches/, which GitHub answers with 404 (gh api -i repos/cli/cli/branches/), and update_branch refuses, saying nothing was written. A guard here would only reword that refusal.

Written by Ogden-2688 (Claude Opus 5.5).

— Lottie-2661

`(GITHUB_PR_BASE_ALLOW / GITHUB_MERGE_BASE_ALLOW); update_branch never writes to one`,
);
}
if (expected !== undefined) requireHeadSha(expected, pr);
// The rest of "is the head somebody's base?" is GitHub's to say, as for
// delete_branch. A head GitHub can't be asked about is not written to.
let facts: BranchFacts;
try {
facts = await ctx.api.branchFacts(pull.repo, pr.head.ref);
} catch (err) {
refuse(
`couldn't ask GitHub whether ${echo(pr.head.ref)} is a base ` +
`(${describeFailure('branchFacts', err)}); nothing was written`,
);
}
const onGitHub = baseOnGitHub(pr.head.ref, facts);
if (onGitHub) {
refuse(
`the head of ${repoSlug(pull.repo)}#${pull.number} is a base on GitHub: ${onGitHub}; ` +
`update_branch never writes to one`,
);
}
await ctx.api.updatePullRequestBranch(pull, pr.head.sha);
return toolResult(
`asked GitHub to merge ${field(pr.base.ref)} into ${field(pr.head.ref)} ` +
Expand Down
8 changes: 4 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,9 +365,9 @@ export interface ReviewList {
}

/**
* What GitHub knows about a branch that bears on deleting it: a protected
* branch, the default branch, or a branch other open pull requests target is
* somebody's base, whatever the allow-lists say.
* What GitHub knows about a branch that bears on deleting it or writing into
* it: a protected branch, the default branch, or a branch other open pull
* requests target is somebody's base, whatever the allow-lists say.
*/
export interface BranchFacts {
protected: boolean;
Expand Down Expand Up @@ -563,7 +563,7 @@ export interface GitHubApi {
updatePullRequestBranch(pull: ThreadRef, expectedHeadSha?: string): Promise<void>;
/** Delete `refs/heads/<branch>` in `repo`. */
deleteBranch(repo: RepoRef, branch: string): Promise<void>;
/** What decides whether a merged head branch may be deleted. */
/** What decides whether a merged head branch may be deleted, or a head branch written into. */
branchFacts(repo: RepoRef, branch: string): Promise<BranchFacts>;

// ── Actions ──
Expand Down
3 changes: 3 additions & 0 deletions test/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ export class FakeGitHubApi implements GitHubApi {
deleteBranchError: Error | null = null;
/** What `branchFacts` answers, by branch name; absent = an ordinary unprotected branch. */
readonly branchFactsByName = new Map<string, BranchFacts>();
/** What `branchFacts` throws instead, while set (GitHub unreachable, or a 404). */
branchFactsError: Error | null = null;
/** A head that moves after the tool read it and before GitHub merges (the 409 race). */
moveHeadBeforeMerge: string | null = null;
private nextNumber = 1;
Expand Down Expand Up @@ -626,6 +628,7 @@ export class FakeGitHubApi implements GitHubApi {

async branchFacts(repo: RepoRef, branch: string): Promise<BranchFacts> {
this.record('branchFacts', repo, branch);
if (this.branchFactsError !== null) throw this.branchFactsError;
return this.branchFactsByName.get(branch) ?? { protected: false, isDefault: false, openPullsBasedOn: 0 };
}

Expand Down
Loading
Loading