Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Repository transfer with redirects, agent-safe auto-merge with an admin approval waiver, and workflow trigger fixes (#44)

Repositories can be transferred to your own account or an organization you own or
administer, and old owner/name URLs keep working for readers (web 301, REST 301/307,
git and MCP under the old name, registry image names). Emptied organizations can be
deleted.

Pull requests get auto-merge. Agents land on protected branches only through auto-merge
or the merge queue, where an admin allowed it (allowAgentAutoMerge) and a required check
gates it, never by bypass. agentAutoMergeWithoutApproval lets an admin waive the approval
for those landings on green required checks; requested changes still block and workflow
changes still need a person.

A job's repository_dispatch starts runs, and workflow_run followers of agent code act as
the agent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL
Canberk Demirci authored · GitHub committed signed1 parent 6d95237commit 728ca608bec23a848984a0bd9055d518c7cc3293Browse files
54 files changed+2001−103
Files (54)
modifiedREADME.md+35−3View file
@@ -207,7 +207,8 @@
207207
208208 Tools include `get_file`, `list_tree`, `commit_files` (no git client needed),
209209 `create_branch`, `create_pull_request` (with an `intent` for reviewers),
210-`review_pull_request`, `merge_pull_request` (policy-guarded), issues,
210+`review_pull_request`, `merge_pull_request` (policy-guarded),
211+`enable_auto_merge`/`disable_auto_merge`, issues,
211212 `list_workflow_runs` and `get_job_logs`, `rerun_workflow`, releases
212213 (`create_release`, `upload_release_asset`, `generate_release_notes`),
213214 `sandbox_create`/`sandbox_exec` for running builds and tests, and
@@ -218,15 +219,37 @@
218219
219220 - An agent's role is capped at write, and never exceeds its owner's role.
220221 - Agents can't push to the default branch or protected branches, and can't
221- merge there. Turn this on per branch with `agentsMayPush` or `agentsMayMerge`.
222+ merge there. Turn this on per branch with `agentsMayPush` or `agentsMayMerge`,
223+ or let CI decide with `allowAgentAutoMerge` (below).
222224 - A pull request opened by an agent needs at least one human approval, and
223- approvals from agents don't count toward it.
225+ approvals from agents don't count toward it. An admin can waive that for
226+ what auto-merge lands on green required checks (`agentAutoMergeWithoutApproval`).
224227 - Agents can't change branch protection or read or set secrets.
225228 - Agents can't change or delete environments or their branch policies, and
226229 can't dismiss reviews.
227230 - Commit statuses and check runs that agents report never satisfy a required
228231 check.
229232
233+### How agents land changes
234+
235+Agents never merge into the default branch or a protected branch themselves,
236+and never get a bypass. Instead, a person who administers the repository turns
237+on **Allow agents to land pull requests through auto-merge**
238+(`allowAgentAutoMerge`) on the branch's protection rule, which must also have
239+at least one required status check. An agent then opens its pull request and
240+turns on auto-merge (`PUT /repos/{owner}/{repo}/pulls/{n}/auto-merge` or the
241+MCP tool `enable_auto_merge`). Diffinery merges it, as the agent on behalf of
242+its owner, as soon as every rule is met: required checks green on the head,
243+approvals from people of its latest commit (an agent's pull request still
244+needs one, unless an admin turned on `agentAutoMergeWithoutApproval` for the
245+branch, which lets green required checks alone land it), linear history and an up-to-date branch where required. On a branch with a merge
246+queue it joins the queue instead. Without the setting the agent gets a 403,
247+and without a required check a 422; a pull request that changes a workflow
248+file needs a person. A push by anyone else turns auto-merge off, the merge
249+commit carries the `diffinery-agent` header, and the workflows it triggers run
250+under the agent rules. Workflow tokens are held to the same rule. Enabling,
251+disabling, the merge and changes to the setting are audited.
252+
230253 ## Workflows
231254
232255 Put workflow files in `.github/workflows/` (or `.diffinery/workflows/`).
@@ -394,6 +417,15 @@
394417 with `sha`; a stale `sha` returns 409), compare, commits, `merges`,
395418 `dispatches`, and `zipball`/`tarball` archives. `private: false` makes a
396419 repository public.
420+- **Transfers and renames:** `POST /repos/{owner}/{repo}/transfer` moves a
421+ repository to your account or an organization you own or administer (people
422+ only, never agents or `GITHUB_TOKEN`), with its history, issues, pull
423+ requests, runs, secrets and linked container images. Like GitHub, the old
424+ name keeps working for anyone who can read the repository: pages and API
425+ reads answer 301, other API calls 307, and Git, MCP and image names are
426+ served under it, until a repository is created there again. Renamed
427+ repositories and accounts redirect the same way. An organization that still
428+ owns repositories can't be deleted (`DELETE /orgs/{org}`).
397429 - **Archives:** `zipball` and `tarball` are built on demand and cached in R2.
398430 The cache can be evicted at any time; the archive is then built again. An
399431 archive holds at most 24 MB of files and 100,000 tree entries. A zip holds
modifiedapps/diffinery/docs/accounts/notifications.md+1−0View file
@@ -244,6 +244,7 @@
244244 | `repo_access` | `[acme/website] You now have write access to acme/website` | Repositories | `account` | Your collaborator role on a repository was added, changed or removed |
245245 | `repo_created` | `[acme/website] New private repository` | Repositories | `your_activity` or `admin` | You created a repository in the web UI (not when a token or script creates it), or someone created one in an organization where you are an owner or admin |
246246 | `repo_deleted` | `[acme/website] Repository deleted` | Repositories | `admin` | Someone else deleted a repository you own or that belongs to an organization where you are an owner or admin |
247+| `repo_transferred` | `[acme-labs/website] Repository transferred from acme/website` | Repositories | `admin` | Someone else transferred a repository away from or to you, or an organization where you are an owner or admin |
247248
248249 Issue and pull request emails (`thread`) come in these variants:
249250
modifiedapps/diffinery/docs/accounts/organizations.md+8−1View file
@@ -216,8 +216,14 @@
216216 git remote set-url origin https://diffinery.com/acme-inc/website.git
217217 ```
218218
219-URLs with the old name stop working right away; Diffinery doesn't redirect them.
219+URLs with the old name keep working, like those of a [transferred repository](/docs/repositories/overview#transfer-a-repository): web pages and the API redirect, and Git and container images are served under the old name, until another person or organization takes it.
220220
221+## Delete an organization
222+
223+Owners open the **Settings** tab, scroll to **Danger zone → Delete this organization**, type the organization's name and click **Delete this organization**; with the API, `DELETE /api/v3/orgs/{org}` returns `202`. An organization that still owns repositories can't be deleted: transfer them (see [Transfer a repository](/docs/repositories/overview#transfer-a-repository)) or delete them first, or the request fails with `acme still owns 3 repositories; transfer or delete them before deleting the organization` (HTTP 422). Container images and an import in progress block it the same way.
224+
225+Deleting removes the organization's members, pending invitations, secrets and variables, disables its agents and revokes their tokens, and frees its name. Repositories transferred out of it keep redirecting from their old URLs. The deletion is recorded in the audit log as `org.delete`.
226+
221227 ## Organizations API
222228
223229 | Method and path | Description |
@@ -226,6 +232,7 @@
226232 | `POST /api/v3/user/orgs` | Create an organization: `{"login": "acme", "name": "Acme Inc."}`. Needs an `admin` token. |
227233 | `GET /api/v3/orgs/{org}` | The organization; members, their agents and site administrators also get `default_repository_permission` |
228234 | `PATCH /api/v3/orgs/{org}` | Change `name` or `default_repository_permission` (`none`, `read`, `write` or `admin`). Owners only; needs an `admin` token. |
235+| `DELETE /api/v3/orgs/{org}` | Delete the organization once it owns no repositories; returns `202`. Owners only; needs an `admin` token. |
229236 | `GET /api/v3/orgs/{org}/members` | Members and their roles. Members and site administrators only; others get `404`. |
230237 | `POST /api/v3/orgs/{org}/invitations` | Invite by email: `{"email": "...", "role": "member"}`. Needs an `admin` token. See [Invitations](/docs/accounts/invitations#invite-with-the-api). |
231238 | `GET /api/v3/orgs/{org}/invitations` | Pending invitations, each with an `id`. Owners and admins. |
modifiedapps/diffinery/docs/accounts/profile-and-preferences.md+3−3View file
@@ -44,13 +44,13 @@
4444
4545 A username starts with a letter or digit, then uses letters, digits, `-` and `_`. Dots aren't allowed, and the name can't be reserved (for example `api`, `settings`, `login`, `admin`, `docs`) or already used by a person, organization or agent.
4646
47-Renaming changes the URL of every repository you own. Update existing clones:
47+Renaming changes the URL of every repository you own. The old URLs keep working (see [Transfer a repository](/docs/repositories/overview#transfer-a-repository) for how redirects behave), but update existing clones:
4848
4949 ```sh
5050 git remote set-url origin https://diffinery.com/new-name/website.git
5151 ```
5252
53-After a rename, `https://diffinery.com/old-name` returns 404, and the old name is free for someone else to take. Your picture moves with you: Diffinery forgets cached copies of the picture under the old name.
53+After a rename, `https://diffinery.com/old-name` and your repositories' old web, API and Git URLs redirect to the new name, until someone else takes the old name, which is free for them to take. Your picture moves with you: Diffinery forgets cached copies of the picture under the old name.
5454
5555 | Message | What to do |
5656 | --- | --- |
@@ -251,7 +251,7 @@
251251 - Profile pictures are uploaded to Diffinery. There is no Gravatar support (`gravatar_id` is always empty in API responses).
252252 - Diffinery has an API to upload and remove profile pictures for users and organizations; GitHub doesn't.
253253 - GitHub's theme settings have more options (for example separate day and night themes and high-contrast themes). Diffinery has light, dark and sync with system.
254-- After a username change, GitHub redirects your repositories' old web and Git URLs. Diffinery doesn't: URLs with the old name stop working right away.
254+- After a username change, Diffinery redirects your profile and your repositories' old web, API and Git URLs until someone takes the old name; GitHub redirects the repositories only.
255255
256256 ## Troubleshooting
257257
modifiedapps/diffinery/docs/admin/site-administration.md+1−1View file
@@ -140,7 +140,7 @@
140140 | `POST /api/v3/diffinery/admin/emails/samples` | Send every template to yourself now, or only some with `{"only": ["signin", "invite-org"]}`. Returns `to`, `sent`, `total` and each result. | site administrator, with an `admin` token for all repositories or a browser session |
141141 | `GET /api/v3/diffinery/emails?all=1` | Everyone's recent emails (without `all=1`, only yours). Also takes `kind`, `since` and `per_page` (up to 500). | site administrator for `all=1` |
142142
143-The sample IDs are `pr-opened`, `issue-opened`, `comment`, `review-approved`, `review-changes`, `review-requested`, `assigned`, `pushed`, `merged`, `closed`, `reopened`, `run-failed`, `signin`, `passkey-added`, `passkey-removed`, `token-created`, `token-expiring`, `agent-created`, `push-bypassed`, `push-blocked`, `account-disabled`, `account-enabled`, `invite-org`, `invite-repo`, `invite-accepted`, `welcome`, `signin-link`, `access-request`, `org-member`, `repo-access`, `repo-created`, `repo-deleted` and `test`.
143+The sample IDs are `pr-opened`, `issue-opened`, `comment`, `review-approved`, `review-changes`, `review-requested`, `assigned`, `pushed`, `merged`, `closed`, `reopened`, `run-failed`, `signin`, `passkey-added`, `passkey-removed`, `token-created`, `token-expiring`, `agent-created`, `push-bypassed`, `push-blocked`, `account-disabled`, `account-enabled`, `invite-org`, `invite-repo`, `invite-accepted`, `welcome`, `signin-link`, `access-request`, `org-member`, `repo-access`, `repo-created`, `repo-deleted`, `repo-transferred` and `test`.
144144
145145 ## Administration from the CLI
146146
modifiedapps/diffinery/docs/agents/overview.md+4−3View file
@@ -118,7 +118,7 @@
118118 | Rule | Details | How to relax it |
119119 | --- | --- | --- |
120120 | No pushes to the default branch or protected branches | Pushes, `commit_files`, the contents API, the git data refs API, the merges API, update-branch and branch updates are refused with `agents may not push to protected or default branches; open a pull request`. | Tick **Allow agents to push directly** (`agentsMayPush`) on that branch's protection rule. |
121-| No merges into the default branch or protected branches | The merge blocker `agents may not merge into this branch; a human must merge`. This also stops agents from adding pull requests to a merge queue. | Tick **Allow agents to merge pull requests** (`agentsMayMerge`) on the rule. |
121+| No merges into the default branch or protected branches | The merge blocker `agents may not merge into this branch; a human must merge`. Agents can't add pull requests to a merge queue or turn on auto-merge there either. | Tick **Allow agents to merge pull requests** (`agentsMayMerge`) on the rule, or, so that CI decides instead of the agent, **Allow agents to land pull requests through auto-merge** (`allowAgentAutoMerge`, see [Agents landing changes](/docs/repositories/branch-protection#agents-landing-changes)). |
122122 | Pull requests opened by agents need a human approval | At least one approval from a person, even on a branch with no protection rule. | None. |
123123 | Agent approvals don't count | An agent's Approve review is shown but never counts toward required approvals. An agent's Request changes review still blocks. | None. |
124124 | No branch protection changes | `agents cannot change branch protection` | None. |
@@ -147,7 +147,8 @@
147147 - the `commit_files` MCP tool and `POST /repos/{owner}/{repo}/diffinery/commits`;
148148 - the contents API (`PUT` and `DELETE /repos/{owner}/{repo}/contents/{path}`);
149149 - the git data API (`POST /repos/{owner}/{repo}/git/commits`);
150-- the merges API (`POST /repos/{owner}/{repo}/merges`) and updating a pull request's branch (`PUT /repos/{owner}/{repo}/pulls/{number}/update-branch`).
150+- the merges API (`POST /repos/{owner}/{repo}/merges`) and updating a pull request's branch (`PUT /repos/{owner}/{repo}/pulls/{number}/update-branch`);
151+- merge and squash commits of pull requests the agent merges, directly or through auto-merge.
151152
152153 The author and committer are the agent, for example `review-bot <review-bot@users.noreply.diffinery.com>`, unless the request sets `author` or `committer` (the contents and git data APIs accept them). The header is added either way.
153154
@@ -255,7 +256,7 @@
255256 | Message | Cause and fix |
256257 | --- | --- |
257258 | `agents may not push to protected or default branches; open a pull request` | Push to a new branch and open a pull request, or enable **Allow agents to push directly** on that branch's rule. |
258-| `agents may not merge into this branch; a human must merge` | A person must merge, or enable **Allow agents to merge pull requests** on the rule. |
259+| `agents may not merge into this branch; a human must merge` | A person must merge. Or turn on auto-merge (`enable_auto_merge`) on a branch whose rule allows it (`allowAgentAutoMerge`), or enable **Allow agents to merge pull requests** on the rule. |
259260 | `needs 1 more approving review(s)` on an agent's pull request | A person with write access must approve it. Agent approvals don't count. |
260261 | `only people can create agents` | You called the agents API with an agent's token. Use your own. |
261262 | `agents cannot manage secrets` | A person with admin access must set or delete repository, environment and organization secrets. |
modifiedapps/diffinery/docs/collaboration/merge-queue.md+3−3View file
@@ -119,7 +119,7 @@
119119
120120 ## Add a pull request to the queue
121121
122-The pull request must have no merge blockers: approvals, required checks on its head, no conflicts, not a draft (mark a draft ready first with **Ready for review** or `diffinery pr ready 12`; see [Drafts](/docs/collaboration/pull-requests#drafts)). You need write access, and through the API a token with the `write` scope. Agents can queue a pull request only into a branch whose rule has **Allow agents to merge pull requests**.
122+The pull request must have no merge blockers: approvals, required checks on its head, no conflicts, not a draft (mark a draft ready first with **Ready for review** or `diffinery pr ready 12`; see [Drafts](/docs/collaboration/pull-requests#drafts)). You need write access, and through the API a token with the `write` scope. Agents (and workflow tokens) can queue a pull request into the default branch or a protected one only when its rule has **Allow agents to land pull requests through auto-merge** and at least one required check, or, for agents, **Allow agents to merge pull requests**. An agent can also turn on auto-merge, which adds the pull request to the queue once it is ready (see [Agents landing changes](/docs/repositories/branch-protection#agents-landing-changes)).
123123
124124 ### Queue in the web UI
125125
@@ -203,7 +203,7 @@
203203 | `squash` | One commit on top of the previous group. Subject `<title> (#12)`, body a `* <subject>` line per commit. Author: the pull request's author. |
204204 | `rebase` | Each of the pull request's commits replayed on top of the previous group, keeping its author and message. Pull requests that contain merge commits can't be rebased. |
205205
206-The committer is the person who queued the pull request (`<login>@users.noreply.diffinery.com`). If the base branch requires linear history, the `merge` method is refused: `main requires linear history; use squash or rebase`.
206+The committer is the person who queued the pull request (`<login>@users.noreply.diffinery.com`). If the base branch requires linear history, the `merge` method is refused: `main requires linear history; use squash or rebase`. A rule can't combine the two in the queue's own method either: it is refused with `422`, and a queue without a method squashes there.
207207
208208 ## When a pull request merges
209209
@@ -278,7 +278,7 @@
278278
279279 A repository admin who is a person can merge a pull request directly, skipping the queue, when it has no conflicts, isn't a draft, and the rule doesn't have **Do not allow administrators to bypass these rules**. Through a token, that needs the `admin` scope. The bypass must be asked for explicitly:
280280
281-- **Web UI:** **Merge now (bypass the queue)**, which asks `Merge directly, bypassing the merge queue?` first. It merges with the queue's merge method.
281+- **Web UI:** **Merge now (bypass the queue)**, which asks `Merge directly, bypassing the merge queue?` first. It merges with the queue's merge method. When the pull request doesn't meet the rules, the button reads **Merge now (bypass the queue and the unmet requirements)** instead and also sends `bypass_rules`; the bypass is audited as `pull_request.merge_bypassed`.
282282 - **CLI:** `diffinery pr merge 12 --admin`. It sends `bypass_merge_queue` and `bypass_rules`, so the CLI doesn't fall back to queueing the pull request.
283283 - **REST API:** `PUT /repos/{owner}/{repo}/pulls/{number}/merge` with `"bypass_merge_queue": true`. `"bypass_rules": true` alone doesn't skip the queue.
284284
modifiedapps/diffinery/docs/packages/container-registry.md+4−0View file
@@ -156,6 +156,10 @@
156156
157157 You need admin access to the package and admin access to the repository you link it to. The repository must belong to the package's owner.
158158
159+### When the repository moves
160+
161+When a linked repository is [transferred](/docs/repositories/overview#transfer-a-repository) to another owner, its packages move with it and keep following its access: `diffinery.com/acme/website` becomes `diffinery.com/acme-labs/website`. The old name keeps working for pulls and pushes, for anyone the moved package allows (everyone else gets `NAME_UNKNOWN`), until the old owner gets a repository of that name again. Images under a renamed owner's old login resolve the same way. Unlinked packages stay with their owner.
162+
159163 ## Permissions
160164
161165 Each caller gets one of four roles on a package:
modifiedapps/diffinery/docs/reference/rest-api.md+1−1View file
@@ -74,7 +74,7 @@
7474 | Release | `latest`, whether it is the repository's latest release |
7575 | Pull request file | `binary` |
7676
77-Fields that describe GitHub features Diffinery doesn't have are present with fixed values, so typed clients don't break: for example `fork: false`, `forks_count: 0`, `stargazers_count: 0`, `watchers_count: 0`, `language: null`, `license: null`, `has_wiki: false`, `milestone: null` and `auto_merge: null`.
77+Fields that describe GitHub features Diffinery doesn't have are present with fixed values, so typed clients don't break: for example `fork: false`, `forks_count: 0`, `stargazers_count: 0`, `watchers_count: 0`, `language: null`, `license: null`, `has_wiki: false` and `milestone: null`.
7878
7979 ### IDs
8080
modifiedapps/diffinery/docs/repositories/branch-protection.md+30−4View file
@@ -65,6 +65,8 @@
6565 | `enforceAdmins` | Do not allow administrators to bypass these rules | boolean | `false` | Applies the rules to repository admins too. |
6666 | `agentsMayPush` | Allow agents to push directly | boolean | `false` | Lets agents push to matching branches. |
6767 | `agentsMayMerge` | Allow agents to merge pull requests | boolean | `false` | Lets agents merge pull requests into matching branches. |
68+| `allowAgentAutoMerge` | Allow agents to land pull requests through auto-merge | boolean | `false` | Lets agents (and workflow tokens) turn on auto-merge or add pull requests to the merge queue for matching branches, so Diffinery merges once every rule is met. Needs at least one required check. See [Agents landing changes](#agents-landing-changes). |
69+| `agentAutoMergeWithoutApproval` | Let agents' auto-merges land without an approving review | boolean | `false` | With `allowAgentAutoMerge`: what Diffinery lands for agents needs no approval from a person, only green required checks. Requested changes still block it, and a workflow change still needs a person. See [Waiving approvals for agents](#waiving-approvals-for-agents). |
6870 | `mergeQueue` | Require a merge queue | object | none | Pull requests merge through a merge queue. See [Merge queue settings](#merge-queue-settings). |
6971
7072 ### Required checks
@@ -131,6 +133,29 @@
131133
132134 To let agents push to or merge into the default branch, create a rule whose pattern matches the default branch and turn on the option you need.
133135
136+### Agents landing changes
137+
138+`allowAgentAutoMerge` lets agents land their work on a protected or default branch without ever merging it themselves and without any bypass. An agent turns on auto-merge for its pull request (`PUT /repos/{owner}/{repo}/pulls/{number}/auto-merge`, or the MCP tool `enable_auto_merge`), and Diffinery merges it once every rule of the branch is met: required checks green on the head commit, approvals from people (an agent's pull request still needs one), no changes requested, linear history and an up-to-date branch where required. On a branch with a merge queue, the pull request joins the queue instead, and the queue's own checks gate it.
139+
140+- The rule must set `allowAgentAutoMerge` and have at least one required check. Without the setting the agent gets `403` (`agents may not have pull requests merged into main automatically unless its branch protection allows it ...`); without a required check, `422` (`main has no required status checks: allowAgentAutoMerge needs at least one ...`). The same holds for adding a pull request to the merge queue.
141+- A pull request that adds, changes or deletes a workflow file can't land this way (`the pull request changes workflow file ...; a person must merge it`), since the workflows are what gate it.
142+- When a pull request lands this way, a person's approval counts only for the commit it approved, whatever `dismissStaleReviews` says: if the agent pushes again, someone approves again.
143+- Only people with the admin role change the setting, like every other rule; agents and workflow tokens never can. Turning it on or off is audited as `repo.protection.agent_auto_merge`.
144+- The merge is made as the agent, on behalf of its owner, and its commit carries the `diffinery-agent` header. Workflows that the merge triggers run as code from an agent, so their tokens are held to the agent rules too.
145+- A workflow's `GITHUB_TOKEN` is held to the same rule as agents (and needs `contents: write`), even when the run's code didn't come from an agent.
146+- The agent's access is checked again when the merge happens, and auto-merge turns itself off, with a comment saying why, if the rule no longer allows it, or if someone else pushes to the pull request's branch.
147+
148+Turning on auto-merge, turning it off and the merge itself are audited (`pull_request.auto_merge_enabled`, `pull_request.auto_merge_disabled`, `pull_request.auto_merged`).
149+
150+#### Waiving approvals for agents
151+
152+By default an agent's pull request needs an approving review from a person before it lands, even through auto-merge. Where a repository's agents should land work on green CI alone, an admin turns on **Let agents' auto-merges land without an approving review** (`agentAutoMergeWithoutApproval`) on the branch's rule, next to `allowAgentAutoMerge`, which it needs (`422` otherwise).
153+
154+- It applies only to what Diffinery lands for an agent or a workflow token through auto-merge or the merge queue. A direct merge by an agent is still refused, and people's own pull requests keep the rule's `requiredApprovals`.
155+- The required checks must pass on the head commit, as always; the setting can't be on without at least one, since `allowAgentAutoMerge` needs one.
156+- A person's requested changes still block the merge, and a pull request that changes a workflow file still needs a person to merge it.
157+- It is set per branch rule, only by people with the admin role, and never comes in with an import. Turning `allowAgentAutoMerge` off turns this off too. Every change is audited as `repo.protection.agent_approval_waiver`.
158+
134159 ### Merge queue settings
135160
136161 `mergeQueue` turns on a merge queue for matching branches. Pull requests are then added to the queue instead of being merged directly, and each is tested on top of the base branch and the pull requests ahead of it. See [Merge queue](/docs/collaboration/merge-queue) for how the queue works.
@@ -141,7 +166,7 @@
141166 | `buildConcurrency` | Build concurrency | `5` | 1 to 20: how many queued pull requests are built and checked at once |
142167 | `checkTimeoutMinutes` | Check timeout (minutes) | `60` | 1 to 360: how long to wait for required checks on a merge group before removing it from the queue |
143168
144-Values outside the allowed range are clamped. An unknown `method` becomes `"merge"`. `"mergeQueue": true` turns the queue on with the defaults.
169+Values outside the allowed range are clamped. An unknown `method` becomes `"merge"`. `"mergeQueue": true` turns the queue on with the defaults. On a rule that also sets `requireLinearHistory`, the queue can't create merge commits: a `method` of `"merge"` (or an unknown one) is refused with `422`, and without a `method` the queue squashes.
145170
146171 ## Who can bypass the rules
147172
@@ -154,7 +179,7 @@
154179 | Direct push, force push or deletion on a protected branch | Automatic for admins. An agent with the admin role gets this only on branches where `agentsMayPush` is on. |
155180 | Merging a pull request that has blockers | Only on request, and only for admins who are people, when the pull request has no merge conflicts and is not a draft. In the web UI the button reads **Merge without waiting for requirements (bypass)** and asks for confirmation. Through the API send `"bypass_rules": true` on `PUT /repos/{owner}/{repo}/pulls/{number}/merge`; with the CLI use `diffinery pr merge 12 --admin`. Without the flag the merge fails with `405`, and the message ends with `(as an admin you can bypass with bypass_rules)`. |
156181 | A merge commit on a branch that requires linear history | Refused with `405` and `main requires linear history; use squash or rebase`, unless the admin asks for a bypass as above. |
157-| Merging directly when a merge queue is required | Only on request: **Merge now (bypass the queue)** in the web UI, `"bypass_merge_queue": true` on `PUT /repos/{owner}/{repo}/pulls/{number}/merge`, or `diffinery pr merge 12 --admin`. This also bypasses unmet rules. |
182+| Merging directly when a merge queue is required | Only on request: **Merge now (bypass the queue)** in the web UI, `"bypass_merge_queue": true` on `PUT /repos/{owner}/{repo}/pulls/{number}/merge`, or `diffinery pr merge 12 --admin`. This skips only the queue: a pull request that doesn't meet the rules also needs `"bypass_rules": true`. In the web UI the button then reads **Merge now (bypass the queue and the unmet requirements)** and sends both. |
158183 | Deleting the default branch | Never allowed. |
159184 | Agent rules | Never bypassed by admin status for merges. |
160185
@@ -336,7 +361,8 @@
336361 | `required check '<name>' <conclusion>` | The check finished with a conclusion other than success, skipped or neutral, for example `required check 'ci / test' failure`. A commit status in the `failure` or `error` state shows as `failure`. |
337362 | `branch is behind its base; update it (linear history required)` | With `requireLinearHistory`, the head branch must contain the latest base commit. |
338363 | `write access is required to merge` | Your role is below write. |
339-| `agents may not merge into this branch; a human must merge` | An agent tried to merge into the default branch or a protected branch without `agentsMayMerge`. |
364+| `agents may not merge into this branch; a human must merge` | An agent tried to merge into the default branch or a protected branch without `agentsMayMerge`. Agents can [land changes through auto-merge](#agents-landing-changes) instead. |
365+| `the pull request changes workflow file <path>; a person must merge it` | An agent's auto-merge or merge queue entry relies on `allowAgentAutoMerge`, and the pull request changes a workflow. |
340366
341367 Example response from `GET /api/v3/repos/acme/website/pulls/42`, trimmed:
342368
@@ -388,7 +414,7 @@
388414 - Rules are set by pattern with Diffinery's own JSON fields; GitHub's branch protection request body is not supported (see [Managing rules with the API](#managing-rules-with-the-api)).
389415 - Commit status contexts count as required checks, as on GitHub. Where GitHub binds a required check to a GitHub App, Diffinery binds it to an account login (or `diffinery-actions`) with `requiredCheckSources`, and a check that Diffinery Actions reports is decided by Actions alone even without a binding.
390416 - Reports from agents never satisfy a required check.
391-- Agent controls (`agentsMayPush`, `agentsMayMerge`) and the human-approval requirement for agents' pull requests have no GitHub equivalent.
417+- Agent controls (`agentsMayPush`, `agentsMayMerge`, `allowAgentAutoMerge`, `agentAutoMergeWithoutApproval`) and the human-approval requirement for agents' pull requests have no GitHub equivalent.
392418 - Required Actions check names include the workflow name: `ci / test`.
393419 - Rules have no options for restricting pushes to specific users or teams, requiring signed commits, requiring conversation resolution or requiring code owner reviews.
394420 - A "changes requested" review blocks merging even on branches without a rule.
modifiedapps/diffinery/docs/repositories/git.md+1−1View file
@@ -295,7 +295,7 @@
295295 | Packs sent to clients | Sent without deltas, so clones and fetches download more bytes than from GitHub. |
296296 | Signed pushes | Not supported. Signed commits push normally. |
297297 | `GITHUB_TOKEN` in workflows | Read-only; can't push. |
298-| Repository rename | The old URL doesn't redirect. Update remotes. |
298+| Repository rename or transfer | The old URL keeps working, as on GitHub: clones, fetches and pushes are served under the old name, and a push prints `This repository moved. Please use the new location:` with the new URL. Update remotes anyway. |
299299 | `refs/pull/*` | Same as GitHub: fetchable, not pushable, hidden from `push --mirror`. |
300300 | Ref names | Must start with `refs/` and follow Git's ref name rules, otherwise `invalid ref name`. |
301301
modifiedapps/diffinery/docs/repositories/overview.md+27−2View file
@@ -247,10 +247,35 @@
247247 -d '{"name": "site"}'
248248 ```
249249
250-> **Important:** Renaming changes the repository's URL and clone URL, and the old URL does not redirect (unlike GitHub). Update your remotes afterwards: `git remote set-url origin https://diffinery.com/acme/site.git`. Also update anything else that uses the old name, such as CI scripts, `uses: acme/website/...` references to reusable workflows and webhook consumers.
250+Renaming changes the repository's URL and clone URL. The old name keeps redirecting, as after a [transfer](#transfer-a-repository), until a repository is created with that name. Update your remotes anyway: `git remote set-url origin https://diffinery.com/acme/site.git`, and anything else that uses the old name, such as CI scripts and webhook consumers. A name the owner already uses is refused (`a repository with this name already exists`, HTTP 422).
251251
252-You can't move a repository to a different owner. To move one, create a repository under the new owner and push everything to it (see [Moving a repository with plain Git](/docs/migrate-from-github#move-with-plain-git)).
252+## Transfer a repository
253253
254+A transfer moves a repository to another owner, like GitHub's: your own account, or an organization where you are an owner or admin. You need admin access to the repository; a personal repository can only be transferred by its owner. Agents, workflow tokens (`GITHUB_TOKEN`) and sandbox tokens can't transfer repositories.
255+
256+- **Web UI**: **Settings → General → Danger zone → Transfer ownership**. Pick the new owner, optionally a new name, type the full name (for example `acme/website`) and click **Transfer**.
257+- **API**: `POST /api/v3/repos/acme/website/transfer` with `{"new_owner": "acme-labs"}` (and `"new_name"` to rename it too) returns `202` and the repository. It needs an `admin` token that isn't limited to repositories.
258+
259+```sh
260+curl -X POST https://diffinery.com/api/v3/repos/acme/website/transfer \
261+ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \
262+ -d '{"new_owner": "acme-labs"}'
263+```
264+
265+The repository keeps everything: Git history, issues, pull requests, releases, Actions runs, caches, secrets, variables, environments and webhooks. Container images linked to it move with it and keep following its access. Direct collaborators keep their roles; grants that came from the old organization (its members', including the admin grant of whoever created the repository, their agents' and the organization's own agents') are removed, and the new owner's base permission, organization secrets and organization variables apply instead. A transfer is refused (HTTP 422) when the new owner already has a repository with that name, or a container image with the name of one of the repository's images.
266+
267+The old owner/name keeps working for anyone who can still read the repository; everyone else gets a 404, so a redirect never shows that a private repository exists:
268+
269+| Where | What happens under the old name |
270+| --- | --- |
271+| Web pages | `301` to the same page under the new name, query string kept |
272+| REST API | `301` with the new URL in `Location` (and `url` in the body) for `GET` and `HEAD`, `307` for other methods, like GitHub |
273+| Git | Clones, fetches and pushes are served under the old name; a push prints `This repository moved. Please use the new location:` and the new URL |
274+| MCP tools | Served as if you had named the new owner and name |
275+| Container images | `diffinery.com/<old owner>/<image>` resolves to the moved image |
276+
277+Creating a repository with the old name ends the redirect for good. Transfers are recorded in the audit log as `repo.transfer` with `from` and `to`, and the admins of both owners get an email, unless they transferred it themselves.
278+
254279 ## Delete a repository
255280
256281 Deleting permanently removes the repository with its history, issues, pull requests, releases, runs, secrets, environments and webhooks. It can't be undone.
modifiedapps/diffinery/docs/security/audit-log.md+4−0View file
@@ -72,6 +72,7 @@
7272 |---|---|---|---|
7373 | `org.create` | An organization is created from the web UI | the organization's login | none |
7474 | `org.update` | An organization's settings are changed | the organization's login | `login` |
75+| `org.delete` | An organization is deleted (web or `DELETE /api/v3/orgs/{org}`) | the organization's login | none |
7576 | `org.invite` | Someone without an account is invited to an organization: an email address entered in **Add people** on the organization's **People** tab, or `POST /api/v3/orgs/{org}/invitations` | the organization's login | `email`, `role` |
7677 | `org.invite.revoke` | A pending organization invitation is revoked with **Revoke** | the organization's login | `id` (the invitation id) |
7778 | `org.member.set`, `org.member.remove` | Someone with an account is added with **Add people**, a member's role is changed, or a member is removed | the organization's login | `user`, `role` |
@@ -88,10 +89,13 @@
8889 | `repo.update` | **General** settings are saved | `name`, `visibility`, `defaultBranch` |
8990 | `repo.archive`, `repo.unarchive` | The repository is archived or unarchived | none |
9091 | `repo.delete` | The repository is deleted (web or `DELETE /api/v3/repos/{owner}/{repo}`) | none |
92+| `repo.transfer` | The repository is transferred to another owner (web or `POST /api/v3/repos/{owner}/{repo}/transfer`); the target is the new name | `from`, `to` |
9193 | `repo.collaborator.set`, `repo.collaborator.remove` | On **Collaborators**: a person or agent with an account is added with **Add people**, or a collaborator's role is changed, or a collaborator is removed | `login`, `role` |
9294 | `repo.invite` | An email address without an account is entered in **Add people**, which sends an invitation | `email`, `role` |
9395 | `repo.topics` | The topics are replaced with `PUT /api/v3/repos/{owner}/{repo}/topics` | `names` |
9496 | `repo.protection.set`, `repo.protection.delete` | A branch protection rule is saved or deleted on **Branch protection** | `pattern`, and `rules` when saved |
97+| `repo.protection.agent_auto_merge` | **Allow agents to land pull requests through auto-merge** (`allowAgentAutoMerge`) is turned on or off for a rule, from the web UI or the API | `pattern`, `enabled`, `requiredChecks` |
98+| `repo.protection.agent_approval_waiver` | **Let agents' auto-merges land without an approving review** (`agentAutoMergeWithoutApproval`) is turned on or off for a rule, from the web UI or the API | `pattern`, `enabled`, `requiredChecks` |
9599 | `repo.secret.set`, `repo.secret.delete` | An Actions secret is saved or deleted on **Secrets and variables** | `name` (never the value) |
96100 | `repo.variable.set`, `repo.variable.delete` | An Actions variable is saved or deleted | `name` |
97101 | `repo.webhook.create`, `repo.webhook.delete` | A webhook is added or deleted on **Webhooks** | `url` and `events`, or `id` |
addedapps/diffinery/migrations/0011_redirects.sql+34−0View file
@@ -1,0 +1,34 @@
1+-- Redirects left behind by repository transfers and renames, and by renamed accounts, like GitHub's.
2+-- The old owner/name keeps working (a 301 for pages and API reads, a 307 for other API calls, Git
3+-- served from the new location) for anyone who can read the repository, until a repository is
4+-- created at the old name again.
5+
6+-- An old owner login and repository name, pointing at the repository's id. The login is kept as
7+-- text so the redirect outlives a deleted or renamed owner.
8+CREATE TABLE repo_redirects (
9+ owner_login TEXT NOT NULL COLLATE NOCASE,
10+ name TEXT NOT NULL COLLATE NOCASE,
11+ repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
12+ created_at TEXT NOT NULL,
13+ PRIMARY KEY (owner_login, name)
14+);
15+CREATE INDEX repo_redirects_repo ON repo_redirects(repo_id);
16+
17+-- A renamed user's or organization's earlier logins. They apply only while no account has that login.
18+CREATE TABLE login_redirects (
19+ login TEXT PRIMARY KEY COLLATE NOCASE,
20+ account_id TEXT NOT NULL REFERENCES accounts(id),
21+ created_at TEXT NOT NULL
22+);
23+CREATE INDEX login_redirects_account ON login_redirects(account_id);
24+
25+-- Container images that moved to another owner with their repository: the old image name
26+-- (diffinery.com/<owner_login>/<name>) keeps resolving to the package.
27+CREATE TABLE package_redirects (
28+ owner_login TEXT NOT NULL COLLATE NOCASE,
29+ name TEXT NOT NULL COLLATE NOCASE,
30+ package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
31+ created_at TEXT NOT NULL,
32+ PRIMARY KEY (owner_login, name)
33+);
34+CREATE INDEX package_redirects_package ON package_redirects(package_id);
modifiedapps/diffinery/src/app.ts+1−1View file
@@ -91,7 +91,7 @@
9191 // Live updates: authorise in the Worker, then hand the WebSocket to the repository's Durable Object.
9292 app.get("/:owner/:repo/live", async (c) => {
9393 if (c.req.header("upgrade") !== "websocket") return c.text("expected websocket", 426);
94- const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"));
94+ const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "read", { follow: true });
9595 return stub.fetch(c.req.raw);
9696 });
9797
modifiedapps/diffinery/src/docs/api-meta.ts+37−1View file
@@ -36,6 +36,8 @@
3636 "POST /orgs/{org}/invitations": { scope: "admin" },
3737 "POST /invitations": { scope: "admin" },
3838 "PATCH /orgs/{org}": { scope: "admin" },
39+ "DELETE /orgs/{org}": { scope: "admin" },
40+ "POST /repos/{owner}/{repo}/transfer": { scope: "admin" },
3941 "GET /orgs/{org}/invitations": { scope: "read" },
4042 "DELETE /orgs/{org}/invitations/{id}": { scope: "admin" },
4143 "GET /invitations": { scope: "read" },
@@ -113,6 +115,13 @@
113115 body: { email: ["string", "Email address to invite.", true], role: ["string", "`member` (default), `admin` or `owner`."] },
114116 },
115117 "GET /orgs/{org}/invitations": { summary: "List an organization's pending invitations", description: "Owners and admins (or site admins). Each has an `id` to revoke it with.", access: "org-admin" },
118+ "DELETE /orgs/{org}": {
119+ summary: "Delete an organization",
120+ description:
121+ "Owners only (or site admins). Refused with 422 while the organization still owns repositories (transfer or delete them first), container images, or an import in progress. Its members, pending invitations, secrets and variables are removed, its agents disabled and their tokens revoked, and its login freed. Repositories transferred out of it keep redirecting from their old URLs. Audited.",
122+ access: "org-admin",
123+ status: 202,
124+ },
116125 "DELETE /orgs/{org}/invitations/{id}": {
117126 summary: "Revoke an organization invitation",
118127 description: "The link stops working and the address can be invited again. 404 when no pending invitation has this id. Audited.",
@@ -193,6 +202,7 @@
193202 "PATCH /repos/{owner}/{repo}": {
194203 summary: "Update a repository",
195204 access: "admin",
205+ description: "A new `name` renames the repository: its old name keeps redirecting (see the transfer endpoint), until a repository is created there again. 422 when the owner already has a repository with that name.",
196206 body: {
197207 name: ["string", "New name."],
198208 description: ["string", "New description."],
@@ -202,6 +212,14 @@
202212 },
203213 },
204214 "DELETE /repos/{owner}/{repo}": { summary: "Delete a repository", description: "People only; agents can't delete repositories. Audited.", access: "admin", status: 204 },
215+ "POST /repos/{owner}/{repo}/transfer": {
216+ summary: "Transfer a repository",
217+ description:
218+ "Moves the repository to `new_owner`: the caller's own account, or an organization where the caller is an owner or admin. Needs admin access to the repository (a personal repository: its owner) and a token with the admin scope; agents, job tokens (`GITHUB_TOKEN`) and sandbox tokens are refused. 422 when the new owner already has a repository (or a container image of a moved image's name) with that name. Git data, issues, pull requests, Actions runs, secrets, variables, environments, webhooks and caches move with it, and so do container images linked to it. Direct collaborators keep their access; grants that came from the old organization (its members, their agents, its agents) are dropped, and the new owner's organization secrets and variables apply. The old owner/name keeps working for anyone who can read the repository, as on GitHub: API calls get a 301 (reads) or 307 (anything else) to the new URL, web pages a 301, Git (with a `This repository moved` notice on push) and MCP are served under the old name, and image names resolve to the moved images. Creating a repository at the old name ends the redirect. Admins of both owners get an email. Audited as `repo.transfer`. Returns the repository.",
219+ access: "admin",
220+ status: 202,
221+ body: { new_owner: ["string", "Login of the user or organization to transfer to.", true], new_name: ["string", "A new name for the repository (default: keep its name)."] },
222+ },
205223 "GET /repos/{owner}/{repo}/collaborators": { summary: "List collaborators", description: "Each entry carries `role_name`.", access: "read" },
206224 "PUT /repos/{owner}/{repo}/collaborators/{login}": {
207225 summary: "Add or change a collaborator",
@@ -484,7 +502,8 @@
484502 "GET /repos/{owner}/{repo}/branches/{branch}/protection": { summary: "Get a protection rule", description: "`branch` is the rule's pattern.", access: "admin" },
485503 "PUT /repos/{owner}/{repo}/branches/{branch}/protection": {
486504 summary: "Create or replace a protection rule",
487- description: "`branch` is a branch name or pattern. People only; agents can't change protection. See the branch protection guide for every field.",
505+ description:
506+ "`branch` is a branch name or pattern. People only: agents and workflow tokens can't change protection. `allowAgentAutoMerge` (boolean) lets agents land pull requests through auto-merge or the merge queue; turning it on or off is audited as `repo.protection.agent_auto_merge`. `agentAutoMergeWithoutApproval` (boolean, needs `allowAgentAutoMerge`) lets those landings go through on green required checks without a person's approval, audited as `repo.protection.agent_approval_waiver`. See the branch protection guide for every field.",
488507 access: "admin",
489508 },
490509 "DELETE /repos/{owner}/{repo}/branches/{branch}/protection": { summary: "Delete a protection rule", access: "admin", status: 204 },
@@ -672,6 +691,23 @@
672691 body: { merge_method: ["string", "`merge`, `squash` or `rebase` (default: the queue's method)."], delete_branch: ["boolean", "Delete the head branch once merged."] },
673692 },
674693 "DELETE /repos/{owner}/{repo}/pulls/{number}/merge-queue": { summary: "Remove a pull request from the merge queue", access: "write", status: 204 },
694+ "PUT /repos/{owner}/{repo}/pulls/{number}/auto-merge": {
695+ summary: "Enable auto-merge",
696+ description:
697+ "Diffinery's REST form of GitHub's `enablePullRequestAutoMerge`. Diffinery merges the pull request by itself as soon as every rule of its base branch is met (required checks on the head, approvals from people, linear history), as the caller, never bypassing anything; into a branch with a merge queue it adds it to the queue instead. Returns `{ state, auto_merge, merge_commit_sha }`: `state` is `enabled` (waiting), `queued` or `merged` (it was ready). The pull request's `auto_merge` shows it until then. A push to the head branch by anyone else turns it off. Agents and workflow tokens may use it on the default branch or a protected one only when its rule sets `allowAgentAutoMerge` (else 403) and has at least one required check (else 422); a workflow token needs `contents: write`. Audited as `pull_request.auto_merge_enabled`, and the merge as `pull_request.auto_merged`.",
698+ access: "write",
699+ body: {
700+ merge_method: ["string", "`merge`, `squash` or `rebase` (default: the queue's method, `squash` where linear history is required, else `merge`)."],
701+ commit_title: ["string", "Title of the merge or squash commit."],
702+ commit_message: ["string", "Body of the merge or squash commit."],
703+ },
704+ },
705+ "DELETE /repos/{owner}/{repo}/pulls/{number}/auto-merge": {
706+ summary: "Disable auto-merge",
707+ description: "GitHub's `disablePullRequestAutoMerge`. Who turned it on, or anyone with write access, may turn it off. 204 even when it was off. Audited as `pull_request.auto_merge_disabled`.",
708+ access: "write",
709+ status: 204,
710+ },
675711 "GET /repos/{owner}/{repo}/pulls/{number}/reviews": { summary: "List reviews", access: "read" },
676712 "POST /repos/{owner}/{repo}/pulls/{number}/reviews": {
677713 summary: "Submit a review",
modifiedapps/diffinery/src/http/api.ts+47−4View file
@@ -15,7 +15,7 @@
1515 import { repoCreated } from "../mail/notify.js";
1616 import { actionsRest } from "./actions-runtime.js";
1717 import { compat } from "./compat.js";
18-import { baseOf, encPath, ghCheckRun, ghComment, ghCommit, ghGitCommit, ghJob, ghRef, ghRun, ghFile, ghLabel, ghReview, ghReviewComment, ghUser, isoSeconds, nodeId, pageOf, paginate, repoApi, repoHtml, setLinks } from "./gh.js";
18+import { baseOf, encPath, ghAutoMerge, ghCheckRun, ghComment, ghCommit, ghGitCommit, ghJob, ghRef, ghRun, ghFile, ghLabel, ghReview, ghReviewComment, ghUser, isoSeconds, nodeId, pageOf, paginate, repoApi, repoHtml, setLinks } from "./gh.js";
1919 import { decodePath, issueCtx, issuesOut, pullDetail, pullsOut, repoOut } from "./ghctx.js";
2020
2121 /**
@@ -100,6 +100,20 @@
100100 c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "org.update", target: org.login, detail: { name: b.name, base_role: b.default_repository_permission } }));
101101 return c.json(await orgJson(c, (await dir.account(org.id))!, a));
102102 });
103+// Owners only. Refused while the organization still owns repositories (see Directory.deleteOrg).
104+api.delete("/orgs/:org", async (c) => {
105+ requireAccountScope(c, "admin");
106+ const a = requireAccount(c);
107+ const dir = c.get("dir");
108+ const org = await dir.accountByLogin(c.req.param("org"));
109+ if (!org || org.kind !== "org") throw new HttpError(404, "not found");
110+ const role = await dir.orgRole(org.id, a.id);
111+ if (!role && !a.site_admin) throw new HttpError(404, "not found");
112+ if (role !== "owner" && !a.site_admin) throw new HttpError(403, "only organization owners can delete it");
113+ await dir.deleteOrg(org);
114+ c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "org.delete", target: org.login }));
115+ return c.json({}, 202);
116+});
103117 api.get("/orgs/:org/members", async (c) => {
104118 const a = requireAccount(c);
105119 const dir = c.get("dir");
@@ -244,6 +258,23 @@
244258 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: caller.id, actorLogin: caller.login, action: "repo.delete", target: `${repo.owner_login}/${repo.name}` }));
245259 return c.body(null, 204);
246260 });
261+// GitHub's transfer: to the caller's own account or an organization they own or administer, optionally
262+// under a new name. People only: never agents, job tokens (GITHUB_TOKEN) or sandbox tokens.
263+api.post("/repos/:owner/:repo/transfer", async (c) => {
264+ const { repo, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin", { whileArchived: true });
265+ const p = c.get("principal");
266+ if (p.type !== "account" || caller.kind !== "user" || caller.permissions) throw new HttpError(403, "agents and workflow tokens cannot transfer repositories");
267+ requireAccountScope(c, "admin");
268+ const b = await body<{ new_owner?: unknown; new_name?: unknown }>(c);
269+ if (typeof b.new_owner !== "string" || !b.new_owner.trim()) throw new HttpError(422, "new_owner is required", { errors: [{ resource: "Repository", code: "missing_field", field: "new_owner" }] });
270+ if (b.new_name !== undefined && b.new_name !== null && typeof b.new_name !== "string") throw new HttpError(422, "new_name must be a string", { errors: [{ resource: "Repository", code: "invalid", field: "new_name" }] });
271+ const dir = c.get("dir");
272+ const target = await dir.accountByLogin(b.new_owner.trim());
273+ if (!target) throw new HttpError(422, `no user or organization named ${b.new_owner.trim()}`, { errors: [{ resource: "Repository", code: "invalid", field: "new_owner" }] });
274+ const moved = await dir.transferRepo(repo, p.account, target, (b.new_name as string | null | undefined) ?? undefined);
275+ auditRepo(c, caller, moved, "repo.transfer", { from: `${repo.owner_login}/${repo.name}`, to: `${moved.owner_login}/${moved.name}` });
276+ return c.json(repoOut(c, moved, (await dir.roleFor(p.account, moved)) ?? "read"), 202);
277+});
247278 api.get("/repos/:owner/:repo/collaborators", async (c) => {
248279 const { repo } = await loadRepo(c, P(c).owner, P(c).repo);
249280 return c.json(paginate(c, (await c.get("dir").collaborators(repo.id)).map((x) => ({ ...userJson(c, x.account), permissions: permissionsOf(x.role), role_name: x.role }))));
@@ -460,14 +491,14 @@
460491 const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
461492 if (caller.kind === "agent") throw new HttpError(403, "agents cannot change branch protection");
462493 const rules = await body<ProtectionRules>(c);
463- await stub.setProtection(c.req.param("branch"), rules);
494+ await stub.setProtection(c.req.param("branch"), rules, caller);
464495 auditRepo(c, caller, repo, "repo.protection.set", { pattern: c.req.param("branch") });
465496 return c.json({ pattern: c.req.param("branch"), rules });
466497 });
467498 api.delete("/repos/:owner/:repo/branches/:branch/protection", async (c) => {
468499 const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
469500 if (caller.kind === "agent") throw new HttpError(403, "agents cannot change branch protection");
470- await stub.setProtection(c.req.param("branch"), null);
501+ await stub.setProtection(c.req.param("branch"), null, caller);
471502 auditRepo(c, caller, repo, "repo.protection.delete", { pattern: c.req.param("branch") });
472503 return c.body(null, 204);
473504 });
@@ -641,6 +672,18 @@
641672 await stub.dequeuePull(num(c), caller);
642673 return c.body(null, 204);
643674 });
675+// Auto-merge (GitHub's enablePullRequestAutoMerge and disablePullRequestAutoMerge, which it exposes through GraphQL only).
676+api.put("/repos/:owner/:repo/pulls/:number/auto-merge", async (c) => {
677+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
678+ const b = await body<{ merge_method?: string; commit_title?: string; commit_message?: string }>(c).catch(() => ({}) as Record<string, undefined>);
679+ const r = await stub.enableAutoMerge(num(c), { method: b.merge_method, commitTitle: b.commit_title, commitMessage: b.commit_message }, caller);
680+ return c.json({ state: r.state, auto_merge: ghAutoMerge(baseOf(c), r.autoMerge), merge_commit_sha: r.mergeCommit ?? null });
681+});
682+api.delete("/repos/:owner/:repo/pulls/:number/auto-merge", async (c) => {
683+ const { stub, caller } = await loadRepo(c, P(c).owner, P(c).repo, "write");
684+ await stub.disableAutoMerge(num(c), caller);
685+ return c.body(null, 204);
686+});
644687 api.get("/repos/:owner/:repo/pulls/:number/reviews", async (c) => {
645688 const { stub, repo } = await loadRepo(c, P(c).owner, P(c).repo);
646689 const n = num(c);
@@ -881,7 +924,7 @@
881924 repoIds = [];
882925 for (const full of b.repositories) {
883926 const [o, n] = full.split("/");
884- const r = o && n ? await c.get("dir").repo(o, n) : null;
927+ const r = o && n ? ((await c.get("dir").repo(o, n)) ?? (await c.get("dir").movedRepo(o, n))) : null;
885928 // Repositories the account can't see are "unknown", so this doesn't reveal private ones.
886929 if (!r || !(await callerFor(c, r))) throw new HttpError(422, `unknown repository ${full}`);
887930 repoIds.push(r.id);
modifiedapps/diffinery/src/http/common.ts+31−3View file
@@ -2,12 +2,23 @@
22 import type { Repo } from "../lib/directory.js";
33 import { denied, readOnly, requestPermission, type JobPermissions } from "../lib/job-permissions.js";
44 import { HttpError } from "../lib/util.js";
5+import { movedPath, movedStatus } from "../lib/redirects.js";
56 import { GITHUB_DOCS } from "./gh.js";
67 import { callerFor, requireScope, type AppContext } from "../auth/principal.js";
78 import { atLeast, type Caller, type Role } from "../repo/types.js";
89
910 export const repoStub = (env: Env, repoId: string) => env.REPO.get(env.REPO.idFromName(repoId));
1011
12+/** A repository that was transferred or renamed: answered with a redirect to `location` (see loadRepo). */
13+export class RepoMoved extends HttpError {
14+ constructor(
15+ status: 301 | 307,
16+ readonly location: string,
17+ ) {
18+ super(status, "Moved Permanently");
19+ }
20+}
21+
1122 /**
1223 * Load a repository and the caller's role, enforcing a minimum role (404 hides
1324 * private repos). Anonymous callers get read access to public repositories but
@@ -17,9 +28,19 @@
1728 * Archived repositories refuse every write, whatever role the route needs,
1829 * except the few routes that pass `whileArchived` (unarchiving, deleting,
1930 * watching), as on GitHub.
31+ *
32+ * An owner/name that a repository was transferred or renamed away from leads
33+ * to it, after the same visibility check, so callers who can't read it still
34+ * get a 404. Where the old name is in the request path, the answer is a
35+ * redirect (RepoMoved: 301 for reads, 307 otherwise, as GitHub does);
36+ * `follow` (Git, MCP) serves the repository under the old name instead, and
37+ * `moved` says it was reached that way.
2038 */
21-export async function loadRepo(c: AppContext, owner: string, name: string, min: Role = "read", opts: { readOnlyPost?: boolean; whileArchived?: boolean } = {}): Promise<{ repo: Repo; caller: Caller; stub: ReturnType<typeof repoStub> }> {
22- const repo = await c.get("dir").repo(owner, name.replace(/\.git$/, ""));
39+export async function loadRepo(c: AppContext, owner: string, name: string, min: Role = "read", opts: { readOnlyPost?: boolean; whileArchived?: boolean; follow?: boolean } = {}): Promise<{ repo: Repo; caller: Caller; stub: ReturnType<typeof repoStub>; moved: boolean }> {
40+ const dir = c.get("dir");
41+ let repo = await dir.repo(owner, name.replace(/\.git$/, ""));
42+ const moved = !repo;
43+ repo ??= await dir.movedRepo(owner, name);
2344 if (!repo) throw new HttpError(404, "repository not found");
2445 const anonymous = c.get("principal").type === "anonymous";
2546 const reading = c.req.method === "GET" || c.req.method === "HEAD" || (opts.readOnlyPost && c.req.method === "POST") || c.get("readOnly") === true;
@@ -29,6 +50,11 @@
2950 if (anonymous) throw new HttpError(401, "authentication required");
3051 throw new HttpError(404, "repository not found");
3152 }
53+ if (moved && !opts.follow) {
54+ const url = new URL(c.req.url);
55+ const path = movedPath(url.pathname, { owner, name }, { owner: repo.owner_login, name: repo.name });
56+ if (path) throw new RepoMoved(movedStatus(c.req.method), new URL(path + url.search, url).toString());
57+ }
3258 // After the visibility check, so a read token's write to a private repository it can't see is still a 404.
3359 if (!reading) requireScope(c, "write");
3460 if (!atLeast(caller.role, min)) throw new HttpError(403, `${min} access required`);
@@ -40,7 +66,7 @@
4066 if (reason && !(repo.visibility === "public" && readOnly(req))) throw new HttpError(403, reason);
4167 }
4268 if (repo.archived && !reading && !opts.whileArchived) throw new HttpError(403, "repository is archived (read-only)");
43- return { repo, caller, stub: repoStub(c.env, repo.id) };
69+ return { repo, caller, stub: repoStub(c.env, repo.id), moved };
4470 }
4571
4672 /** Record a repository settings change made through the API, like the web settings pages do. */
@@ -67,6 +93,8 @@
6793 if (e instanceof HttpError) {
6894 const headers: Record<string, string> = { "content-type": "application/json" };
6995 if (e.status === 401) headers["www-authenticate"] = 'Basic realm="Diffinery", charset="UTF-8"';
96+ // GitHub's answer for a renamed or transferred repository: the new URL in Location and in the body.
97+ if (e instanceof RepoMoved) return new Response(JSON.stringify({ message: e.detail, url: e.location, documentation_url: GITHUB_DOCS }), { status: e.status, headers: { ...headers, location: e.location } });
7098 return new Response(JSON.stringify({ message: e.detail, documentation_url: GITHUB_DOCS, ...e.extra }), { status: e.status, headers });
7199 }
72100 // Errors thrown inside Durable Objects arrive as plain Errors; recover HttpError-like status when present.
modifiedapps/diffinery/src/http/gh.ts+10−1View file
@@ -246,9 +246,18 @@
246246 mergeCommit: string | null;
247247 requestedReviewers: string[];
248248 intent: string | null;
249+ autoMerge?: AutoMergeLike | null;
249250 };
250251 }
251252
253+/** Auto-merge as `../repo/auto-merge.ts` reports it. */
254+export type AutoMergeLike = { method: string; commitTitle: string | null; commitMessage: string | null; enabledBy: Who };
255+
256+/** GitHub's `auto_merge` object of a pull request (null while auto-merge is off). */
257+export function ghAutoMerge(base: string, a: AutoMergeLike | null | undefined) {
258+ return a ? { enabled_by: ghUser(base, a.enabledBy), merge_method: a.method, commit_title: a.commitTitle, commit_message: a.commitMessage } : null;
259+}
260+
252261 /** Per-request lookups the issue builders need (labels by name, people by login, reactions by subject). */
253262 export interface IssueCtx {
254263 base: string;
@@ -368,7 +377,7 @@
368377 statuses: { href: `${api}/statuses/${p.headSha}` },
369378 },
370379 author_association: issue.author_association,
371- auto_merge: null,
380+ auto_merge: ghAutoMerge(x.base, p.autoMerge),
372381 draft: p.draft,
373382 merged: p.merged,
374383 merged_by: p.mergedBy ? ghUser(x.base, personByLogin(x, p.mergedBy)) : null,
modifiedapps/diffinery/src/http/git.ts+12−7View file
@@ -8,6 +8,11 @@
88 /**
99 * Git smart HTTP (protocol v2 for fetch/clone, v0 for push) and the Git LFS
1010 * batch API, at https://diffinery.com/<owner>/<repo>.git
11+ *
12+ * A transferred or renamed repository is served under its old name too (`follow`),
13+ * as GitHub does, and a push there says where it moved. A redirect would not do:
14+ * git follows one only on info/refs (never on its POSTs), and then rebuilds its
15+ * credentials from the new URL, which drops a token embedded in the remote URL.
1116 */
1217 export const git = new Hono<{ Bindings: Env; Variables: AppVars }>();
1318
@@ -18,7 +23,7 @@
1823 if (service !== "git-upload-pack" && service !== "git-receive-pack") throw new HttpError(403, "dumb HTTP protocol is not supported; update git");
1924 // Git only offers credentials after a 401, so an anonymous push must be asked for them (as GitHub does), even to a public repository.
2025 if (service === "git-receive-pack" && c.get("principal").type === "anonymous") throw new HttpError(401, "authentication required");
21- const { stub, repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), service === "git-receive-pack" ? "write" : "read");
26+ const { stub, repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), service === "git-receive-pack" ? "write" : "read", { follow: true });
2227 if (service === "git-receive-pack" && repo.archived) throw new HttpError(403, "repository is archived (read-only)");
2328 if (service === "git-upload-pack" && !/version=2/.test(c.req.header("git-protocol") ?? "")) {
2429 throw new HttpError(400, "Diffinery requires Git protocol v2 (git 2.26 or newer, or run: git config --global protocol.version 2)");
@@ -28,19 +33,19 @@
2833 });
2934
3035 git.post("/:owner/:repo/git-upload-pack", async (c) => {
31- const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "read", { readOnlyPost: true });
36+ const { stub } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "read", { readOnlyPost: true, follow: true });
3237 const body = await readBody(c.req.raw);
3338 const out = await stub.gitUploadPack(body);
3439 return new Response(out, { headers: { "content-type": "application/x-git-upload-pack-result", ...noCache } });
3540 });
3641
3742 git.post("/:owner/:repo/git-receive-pack", async (c) => {
38- const { stub, caller, repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "write");
43+ const { stub, caller, repo, moved } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "write", { follow: true });
3944 // A GitHub import's own token, while that import is running and filling this repository, may push in import mode.
4045 const p = c.get("principal");
4146 if (p.type === "account" && p.importId && p.tokenId && (await importPushAllowed(c.env, p.importId, repo.id, p.tokenId))) caller.importId = p.importId;
4247 const body = await readBody(c.req.raw);
43- const out = await stub.gitReceivePack(body, caller);
48+ const out = await stub.gitReceivePack(body, caller, { moved });
4449 c.executionCtx.waitUntil(c.get("dir").touchPushed(repo.id));
4550 return new Response(out, { headers: { "content-type": "application/x-git-receive-pack-result", ...noCache } });
4651 });
@@ -59,7 +64,7 @@
5964
6065 git.post("/:owner/:repo/info/lfs/objects/batch", async (c) => {
6166 const req = (await c.req.json()) as { operation: "upload" | "download"; objects: Array<{ oid: string; size: number }> };
62- const { repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), req.operation === "upload" ? "write" : "read", { readOnlyPost: req.operation !== "upload" });
67+ const { repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), req.operation === "upload" ? "write" : "read", { readOnlyPost: req.operation !== "upload", follow: true });
6368 const base = `${c.env.PUBLIC_URL}/${c.req.param("owner")}/${c.req.param("repo")}/info/lfs/objects`;
6469 const auth = c.req.header("authorization");
6570 const objects = [];
@@ -80,14 +85,14 @@
8085 });
8186
8287 git.get("/:owner/:repo/info/lfs/objects/:oid", async (c) => {
83- const { repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "read");
88+ const { repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "read", { follow: true });
8489 const obj = await c.env.BUCKET.get(lfsKey(repo.id, c.req.param("oid")));
8590 if (!obj) throw new HttpError(404, "object not found");
8691 return new Response(obj.body, { headers: { "content-type": "application/octet-stream", "content-length": String(obj.size) } });
8792 });
8893
8994 git.put("/:owner/:repo/info/lfs/objects/:oid", async (c) => {
90- const { repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "write");
95+ const { repo } = await loadRepo(c, c.req.param("owner"), c.req.param("repo"), "write", { follow: true });
9196 const oid = c.req.param("oid");
9297 if (!/^[0-9a-f]{64}$/.test(oid)) throw new HttpError(422, "invalid oid");
9398 // Verify the content hash while storing: R2 checks the SHA-256 we pass.
modifiedapps/diffinery/src/lib/directory.ts+166−6View file
@@ -4,6 +4,7 @@
44 import { HttpError, RESERVED_LOGINS, isValidLogin, isValidName, newId, now, randomToken, sha256 } from "./util.js";
55 import { mailForAudit, tokenCreated } from "../mail/notify.js";
66 import { enqueueMail } from "../mail/send.js";
7+import { archivePrefix, deletePrefixes } from "./storage-keys.js";
78
89 export interface Account {
910 id: string;
@@ -130,14 +131,25 @@
130131 }
131132 // A blank display name clears it, so pages fall back to the login.
132133 const name = patch.name === undefined ? undefined : (patch.name as string | null)?.trim() || null;
134+ const before = patch.login ? await this.account(id) : null;
133135 await this.db
134136 .prepare("UPDATE accounts SET login = COALESCE(?, login), name = CASE WHEN ? THEN ? ELSE name END, base_role = CASE WHEN kind = 'org' THEN COALESCE(?, base_role) ELSE base_role END WHERE id = ?")
135137 .bind(patch.login ?? null, name === undefined ? 0 : 1, name ?? null, (patch.baseRole as string | undefined) ?? null, id)
136138 .run();
139+ if (patch.login && before && before.login.toLowerCase() !== patch.login.toLowerCase()) {
140+ // The old login keeps leading here (repository URLs, the profile) until another account takes it.
141+ await this.db.batch([
142+ this.db.prepare("DELETE FROM login_redirects WHERE login = ?").bind(patch.login),
143+ this.db.prepare("INSERT OR REPLACE INTO login_redirects (login, account_id, created_at) VALUES (?, ?, ?)").bind(before.login, id, now()),
144+ ]);
145+ }
137146 if (patch.login) {
138147 // Repos keep their ids; their Durable Objects cache the owner login, so refresh them.
139148 const repos = await this.db.prepare("SELECT id FROM repos WHERE owner_id = ?").bind(id).all<{ id: string }>();
140- for (const r of repos.results) await this.env.REPO.get(this.env.REPO.idFromName(r.id)).updateMeta({ owner: patch.login });
149+ for (const r of repos.results) {
150+ await this.env.REPO.get(this.env.REPO.idFromName(r.id)).updateMeta({ owner: patch.login });
151+ await this.forgetArchives(r.id);
152+ }
141153 }
142154 }
143155
@@ -162,6 +174,34 @@
162174 return (await this.account(id))!;
163175 }
164176
177+ /**
178+ * Delete an organization. Refused while it still owns repositories (transfer or delete them first),
179+ * container images or an import in progress. Its agents are disabled (their tokens revoked), its
180+ * members, pending invitations, secrets and variables removed, and its login freed. The account row
181+ * stays, disabled under a placeholder login, because the audit log, import history and its agents
182+ * still refer to it. Repositories transferred out of it keep redirecting from their old URLs.
183+ */
184+ async deleteOrg(org: Account): Promise<void> {
185+ if (org.kind !== "org") throw new HttpError(404, "not an organization");
186+ const count = async (q: string) => (await this.db.prepare(q).bind(org.id).first<{ n: number }>())?.n ?? 0;
187+ const repos = await count("SELECT count(*) AS n FROM repos WHERE owner_id = ?");
188+ if (repos) throw new HttpError(422, `${org.login} still owns ${repos} ${repos === 1 ? "repository" : "repositories"}; transfer or delete ${repos === 1 ? "it" : "them"} before deleting the organization`);
189+ const images = await count("SELECT count(*) AS n FROM packages WHERE owner_id = ?");
190+ if (images) throw new HttpError(422, `${org.login} still owns ${images} container ${images === 1 ? "image" : "images"}; delete ${images === 1 ? "it" : "them"} before deleting the organization`);
191+ const imports = await count("SELECT count(*) AS n FROM imports WHERE target_owner_id = ? AND status IN ('draft', 'queued', 'running')");
192+ if (imports) throw new HttpError(422, `an import into ${org.login} is still in progress; wait for it to finish or cancel it first`);
193+ const agents = await this.db.prepare("SELECT id FROM accounts WHERE kind = 'agent' AND owner_id = ? AND status != 'disabled'").bind(org.id).all<{ id: string }>();
194+ for (const a of agents.results) await this.setStatus(a.id, "disabled");
195+ await this.db.batch([
196+ this.db.prepare("DELETE FROM org_members WHERE org_id = ?").bind(org.id),
197+ this.db.prepare("DELETE FROM invites WHERE org_id = ? AND accepted_at IS NULL").bind(org.id),
198+ this.db.prepare("DELETE FROM org_secrets WHERE org_id = ?").bind(org.id),
199+ this.db.prepare("DELETE FROM org_variables WHERE org_id = ?").bind(org.id),
200+ this.db.prepare("DELETE FROM login_redirects WHERE account_id = ?").bind(org.id),
201+ this.db.prepare("UPDATE accounts SET login = ?, status = 'disabled' WHERE id = ?").bind(`deleted-${org.id}`, org.id),
202+ ]);
203+ }
204+
165205 /** Whether an account sees an organization's settings such as its base permission: members, the
166206 * organization's own agents, agents of members, and site admins. */
167207 async inOrg(orgId: string, account: Account): Promise<boolean> {
@@ -517,6 +557,8 @@
517557 .prepare("INSERT INTO repos (id, owner_id, name, description, visibility, default_branch, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
518558 .bind(id, owner.id, input.name, input.description ?? "", repoVisibility(input.visibility) ?? "private", branch, t, t),
519559 ...(creatorGrant ? [this.db.prepare("INSERT INTO repo_access (repo_id, account_id, role) VALUES (?, ?, 'admin')").bind(id, creatorGrant)] : []),
560+ // A new repository at a moved one's old name takes it over for good, as on GitHub.
561+ ...this.claimName(owner.login, input.name),
520562 ]);
521563 const meta: RepoMeta = { repoId: id, ownerId: owner.id, owner: owner.login, name: input.name, defaultBranch: branch, ownerKind: owner.kind === "org" ? "org" : "user" };
522564 const stub = this.env.REPO.get(this.env.REPO.idFromName(id));
@@ -530,10 +572,17 @@
530572
531573 async updateRepo(repo: Repo, patch: { name?: string; description?: string; visibility?: string; defaultBranch?: string; archived?: boolean; homepage?: string; topics?: string[] }): Promise<Repo> {
532574 if (patch.name && !isValidName(patch.name)) throw new HttpError(422, "invalid repository name");
533- await this.db
534- .prepare("UPDATE repos SET name = COALESCE(?, name), description = COALESCE(?, description), visibility = COALESCE(?, visibility), default_branch = COALESCE(?, default_branch), archived = COALESCE(?, archived), updated_at = ? WHERE id = ?")
535- .bind(patch.name ?? null, patch.description ?? null, repoVisibility(patch.visibility) ?? null, patch.defaultBranch ?? null, patch.archived === undefined ? null : patch.archived ? 1 : 0, now(), repo.id)
536- .run();
575+ const renamed = !!patch.name && patch.name !== repo.name;
576+ if (renamed && patch.name!.toLowerCase() !== repo.name.toLowerCase() && (await this.repo(repo.owner_login, patch.name!))) {
577+ throw new HttpError(422, "a repository with this name already exists", { errors: [{ resource: "Repository", code: "custom", field: "name", message: "name already exists on this account" }] });
578+ }
579+ await this.db.batch([
580+ this.db
581+ .prepare("UPDATE repos SET name = COALESCE(?, name), description = COALESCE(?, description), visibility = COALESCE(?, visibility), default_branch = COALESCE(?, default_branch), archived = COALESCE(?, archived), updated_at = ? WHERE id = ?")
582+ .bind(patch.name ?? null, patch.description ?? null, repoVisibility(patch.visibility) ?? null, patch.defaultBranch ?? null, patch.archived === undefined ? null : patch.archived ? 1 : 0, now(), repo.id),
583+ // A rename leaves a redirect from the old name, like a transfer.
584+ ...(renamed ? [this.leaveRedirect(repo), ...this.claimName(repo.owner_login, patch.name!)] : []),
585+ ]);
537586 if (patch.homepage !== undefined || patch.topics !== undefined) {
538587 await this.db
539588 .prepare("UPDATE repos SET homepage = COALESCE(?, homepage), topics = COALESCE(?, topics) WHERE id = ?")
@@ -541,15 +590,126 @@
541590 .run();
542591 }
543592 if (patch.name || patch.defaultBranch) await this.env.REPO.get(this.env.REPO.idFromName(repo.id)).updateMeta({ name: patch.name ?? repo.name, defaultBranch: patch.defaultBranch ?? repo.default_branch });
593+ if (renamed) await this.forgetArchives(repo.id);
544594 return (await this.repoById(repo.id))!;
545595 }
546596
547597 async deleteRepo(repo: Repo): Promise<void> {
548- await this.db.batch([this.db.prepare("DELETE FROM repo_access WHERE repo_id = ?").bind(repo.id), this.db.prepare("DELETE FROM schedules WHERE repo_id = ?").bind(repo.id), this.db.prepare("DELETE FROM repos WHERE id = ?").bind(repo.id)]);
598+ await this.db.batch([
599+ this.db.prepare("DELETE FROM repo_access WHERE repo_id = ?").bind(repo.id),
600+ this.db.prepare("DELETE FROM schedules WHERE repo_id = ?").bind(repo.id),
601+ // A deleted repository's old names stop redirecting.
602+ this.db.prepare("DELETE FROM repo_redirects WHERE repo_id = ?").bind(repo.id),
603+ this.db.prepare("DELETE FROM repos WHERE id = ?").bind(repo.id),
604+ ]);
549605 // R2 objects under repos/<id>/ are removed by the cleanup job; the DO's storage is wiped here.
550606 await this.env.REPO.get(this.env.REPO.idFromName(repo.id)).destroy();
551607 }
552608
609+ /**
610+ * Move a repository to another owner, like GitHub's transfer, optionally renaming it. The repository
611+ * keeps its id, so its Durable Object (git data, issues, pull requests, runs, secrets, variables,
612+ * environments, webhooks, deploy keys, caches) and its R2 objects stay where they are; its packages
613+ * move with it, and the old owner/name redirects to the new one. The caller has checked that `by`
614+ * administers the repository; this checks the rest: only people transfer, a personal repository only
615+ * by its owner, and only to `by`'s own account or an organization `by` owns or administers.
616+ */
617+ async transferRepo(repo: Repo, by: Account, target: Account, newName?: string): Promise<Repo> {
618+ if (by.kind !== "user" || by.status !== "active") throw new HttpError(403, "only people can transfer repositories");
619+ if (repo.owner_kind !== "org" && repo.owner_id !== by.id && !by.site_admin) throw new HttpError(403, "only the owner can transfer a personal repository");
620+ if (target.kind === "agent" || target.status !== "active") throw new HttpError(422, "repositories can only be transferred to a person or an organization", { errors: [{ resource: "Repository", code: "invalid", field: "new_owner" }] });
621+ const targetRole = target.kind === "org" ? await this.orgRole(target.id, by.id) : null;
622+ if (target.kind === "user" ? target.id !== by.id : targetRole !== "owner" && targetRole !== "admin") {
623+ throw new HttpError(403, "you can transfer repositories only to your own account or to an organization you own or administer");
624+ }
625+ if (target.id === repo.owner_id) throw new HttpError(422, `${repo.owner_login}/${repo.name} already belongs to ${target.login}`, { errors: [{ resource: "Repository", code: "invalid", field: "new_owner" }] });
626+ const name = newName?.trim() || repo.name;
627+ if (!isValidName(name)) throw new HttpError(422, "invalid repository name: use letters, numbers, '.', '-' and '_'", { errors: [{ resource: "Repository", code: "invalid", field: "new_name" }] });
628+ if (await this.repo(target.login, name)) throw new HttpError(422, `${target.login} already has a repository named ${name}`, { errors: [{ resource: "Repository", code: "custom", field: "name", message: "name already exists on this account" }] });
629+ // Packages linked to the repository move with it; an image of the same name under the target would collide.
630+ const clash = await this.db
631+ .prepare("SELECT p.name FROM packages p WHERE p.owner_id = ? AND p.name IN (SELECT name FROM packages WHERE repo_id = ?) LIMIT 1")
632+ .bind(target.id, repo.id)
633+ .first<{ name: string }>();
634+ if (clash) throw new HttpError(422, `${target.login} already has a container image named ${clash.name}; this repository's image of that name would replace it`);
635+ // An import filling the repository works by its owner and name; let it finish first.
636+ const stub = this.env.REPO.get(this.env.REPO.idFromName(repo.id));
637+ if (await stub.importLockHolder()) throw new HttpError(423, "repository is locked by an import in progress; transfer it once the import finishes");
638+ const t = now();
639+ await this.db.batch([
640+ this.db.prepare("UPDATE repos SET owner_id = ?, name = ?, updated_at = ? WHERE id = ?").bind(target.id, name, t, repo.id),
641+ this.leaveRedirect(repo),
642+ ...this.claimName(target.login, name),
643+ this.db.prepare("INSERT OR REPLACE INTO package_redirects (owner_login, name, package_id, created_at) SELECT ?, name, id, ? FROM packages WHERE repo_id = ?").bind(repo.owner_login, t, repo.id),
644+ this.db.prepare("DELETE FROM package_redirects WHERE owner_login = ? AND name IN (SELECT name FROM packages WHERE repo_id = ?)").bind(target.login, repo.id),
645+ this.db.prepare("UPDATE packages SET owner_id = ?, updated_at = ? WHERE repo_id = ?").bind(target.id, t, repo.id),
646+ // Direct collaborators stay, and a new personal owner needs no grant of its own. Grants that came
647+ // with the old organization go, as when someone leaves it: its members' (including the admin grant
648+ // of whoever created the repository), their agents' and the organization's own agents'.
649+ this.db.prepare("DELETE FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repo.id, target.id),
650+ ...(repo.owner_kind === "org"
651+ ? [
652+ this.db
653+ .prepare("DELETE FROM repo_access WHERE repo_id = ? AND account_id IN (SELECT user_id FROM org_members WHERE org_id = ? UNION SELECT id FROM accounts WHERE kind = 'agent' AND (owner_id = ? OR owner_id IN (SELECT user_id FROM org_members WHERE org_id = ?)))")
654+ .bind(repo.id, repo.owner_id, repo.owner_id, repo.owner_id),
655+ ]
656+ : []),
657+ ]);
658+ // The Durable Object keeps its id; refresh what it knows about its owner, and swap in the new owner's organization variables.
659+ await stub.updateMeta({ owner: target.login, ownerId: target.id, ownerKind: target.kind === "org" ? "org" : "user", name });
660+ const vars = target.kind === "org" ? await this.orgVariables(target.id) : [];
661+ await stub.setOrgVariables(Object.fromEntries(vars.map((v) => [v.name, v.value])));
662+ await this.forgetArchives(repo.id);
663+ return (await this.repoById(repo.id))!;
664+ }
665+
666+ /** Cached zipballs and tarballs are named after the owner and repository; drop them when either changes (they are rebuilt on the next download). */
667+ private async forgetArchives(repoId: string): Promise<void> {
668+ await deletePrefixes(this.env.BUCKET, [archivePrefix(repoId)]).catch((e) => console.error("could not drop cached archives", repoId, e));
669+ }
670+
671+ // ---------------------------------------------------------------- redirects (transfers and renames)
672+
673+ /** Remember a repository's current owner/name before it moves, so the old URLs lead to it. */
674+ private leaveRedirect(repo: Repo): D1PreparedStatement {
675+ return this.db.prepare("INSERT OR REPLACE INTO repo_redirects (owner_login, name, repo_id, created_at) VALUES (?, ?, ?, ?)").bind(repo.owner_login, repo.name, repo.id, now());
676+ }
677+
678+ /**
679+ * A repository now lives at owner/name: an older redirect from there stops applying, and so do
680+ * redirects of images named after it (<owner>/<name> and <owner>/<name>/...), which its own
681+ * workflows push to.
682+ */
683+ private claimName(ownerLogin: string, name: string): D1PreparedStatement[] {
684+ return [
685+ this.db.prepare("DELETE FROM repo_redirects WHERE owner_login = ? AND name = ?").bind(ownerLogin, name),
686+ this.db.prepare("DELETE FROM package_redirects WHERE owner_login = ? AND (name = ? OR substr(name, 1, length(?) + 1) = lower(?) || '/')").bind(ownerLogin, name.toLowerCase(), name, name),
687+ ];
688+ }
689+
690+ /**
691+ * Where an owner/name that holds no repository now leads: a transferred or renamed repository, or one
692+ * under a renamed owner. Callers must apply the same visibility check as for a direct load, so a
693+ * redirect never shows that a private repository exists.
694+ */
695+ async movedRepo(owner: string, name: string): Promise<Repo | null> {
696+ const bare = name.replace(/\.git$/, "");
697+ const direct = await this.db.prepare("SELECT repo_id FROM repo_redirects WHERE owner_login = ? AND name = ?").bind(owner, bare).first<{ repo_id: string }>();
698+ if (direct) return this.repoById(direct.repo_id);
699+ const account = await this.renamedAccount(owner);
700+ if (!account) return null;
701+ const moved = await this.repo(account.login, bare);
702+ if (moved) return moved;
703+ const again = await this.db.prepare("SELECT repo_id FROM repo_redirects WHERE owner_login = ? AND name = ?").bind(account.login, bare).first<{ repo_id: string }>();
704+ return again ? this.repoById(again.repo_id) : null;
705+ }
706+
707+ /** The account that used to have this login, while no account has it now. */
708+ async renamedAccount(login: string): Promise<Account | null> {
709+ if (await this.accountByLogin(login)) return null;
710+ return this.db.prepare("SELECT a.* FROM login_redirects l JOIN accounts a ON a.id = l.account_id WHERE l.login = ? AND a.status = 'active'").bind(login).first<Account>();
711+ }
712+
553713 async setTopics(repoId: string, topics: string[]): Promise<void> {
554714 await this.db.prepare("UPDATE repos SET topics = ?, updated_at = ? WHERE id = ?").bind(JSON.stringify(topics), now(), repoId).run();
555715 }
modifiedapps/diffinery/src/lib/job-permissions.ts+2−1View file
@@ -158,7 +158,8 @@
158158 (first === "branches" && segs.slice(2).includes("protection"));
159159 if (adminOnly) return { forbidden: "this endpoint is not available to a workflow's GITHUB_TOKEN" };
160160 if (first === "issues" && segs.length === 1) return { scopes: ["issues"], access };
161- if (first === "pulls" && /^(merge|update-branch)$/.test(segs[2] ?? "")) return { scopes: ["contents"], access };
161+ // Merging, auto-merge (which merges later) and updating the branch write code, as on GitHub.
162+ if (first === "pulls" && /^(merge|update-branch|auto-merge)$/.test(segs[2] ?? "")) return { scopes: ["contents"], access };
162163 if (first === "commits" && /^(statuses|status)$/.test(segs[2] ?? "")) return { scopes: ["statuses"], access };
163164 if (first === "commits" && /^(check-runs|check-suites)$/.test(segs[2] ?? "")) return { scopes: ["checks"], access };
164165 if (BY_SEGMENT[first]) return { scopes: BY_SEGMENT[first]!, access };
addedapps/diffinery/src/lib/redirects.ts+47−0View file
@@ -1,0 +1,47 @@
1+/**
2+ * Where an old repository URL now points, after a transfer or a rename (see migrations/0011_redirects.sql).
3+ * Pure functions, so the rewriting rules are unit-tested on their own.
4+ */
5+
6+export interface RepoPath {
7+ owner: string;
8+ name: string;
9+}
10+
11+const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();
12+
13+/**
14+ * Rewrite the first `/<owner>/<name>` pair of a path (the name may end in `.git`, which is kept) to the
15+ * repository's new owner and name, keeping the rest of the path. Null when the path doesn't name the
16+ * old repository, for callers that took the name from elsewhere (a query, a JSON body, MCP arguments).
17+ */
18+export function movedPath(path: string, from: RepoPath, to: RepoPath): string | null {
19+ const segs = path.split("/");
20+ for (let i = 1; i + 1 < segs.length; i++) {
21+ let owner: string;
22+ let name: string;
23+ try {
24+ owner = decodeURIComponent(segs[i]!);
25+ name = decodeURIComponent(segs[i + 1]!);
26+ } catch {
27+ return null;
28+ }
29+ const git = name.toLowerCase().endsWith(".git");
30+ if (!same(owner, from.owner) || !same(git ? name.slice(0, -4) : name, from.name.replace(/\.git$/i, ""))) continue;
31+ segs[i] = encodeURIComponent(to.owner);
32+ segs[i + 1] = encodeURIComponent(to.name) + (git ? name.slice(-4) : "");
33+ return segs.join("/");
34+ }
35+ return null;
36+}
37+
38+/** Rewrite a path whose first segment is a renamed account's old login (its profile and repositories). */
39+export function movedLoginPath(path: string, from: string, to: string): string | null {
40+ const segs = path.split("/");
41+ if (segs.length < 2 || !same(segs[1]!, from)) return null;
42+ segs[1] = encodeURIComponent(to);
43+ return segs.join("/");
44+}
45+
46+/** GitHub's answer for a moved repository: 301 for reads, 307 for anything else so the method and body are kept. */
47+export const movedStatus = (method: string): 301 | 307 => (method === "GET" || method === "HEAD" ? 301 : 307);
modifiedapps/diffinery/src/lib/storage-keys.ts+5−2View file
@@ -33,12 +33,15 @@
3333
3434 /** A cached source archive of a commit (zipball or tarball). */
3535 export function archiveKey(repo: string, sha: string, format: string): string {
36- return `archives/${repo}/${sha}.${format}`;
36+ return `${archivePrefix(repo)}${sha}.${format}`;
3737 }
3838
39+/** A repository's cached source archives, which carry `<owner>-<repo>-<sha7>/` and so go stale when it moves. */
40+export const archivePrefix = (repo: string): string => `archives/${repo}/`;
41+
3942 /** A repository's prefixes outside repos/: Actions data and caches. */
4043 export function transientPrefixes(repo: string): string[] {
41- return [`actions-cache/${repo}/`, `artifacts/${repo}/`, `logs/${repo}/`, `${STAGING_ROOT}${repo}/`, `archives/${repo}/`];
44+ return [`actions-cache/${repo}/`, `artifacts/${repo}/`, `logs/${repo}/`, `${STAGING_ROOT}${repo}/`, archivePrefix(repo)];
4245 }
4346
4447 type ListDelete = {
modifiedapps/diffinery/src/mail/notify.ts+15−1View file
@@ -72,7 +72,7 @@
7272 }
7373
7474 async function threadEvent(env: Env, dir: Directory, ev: ThreadEvent): Promise<void> {
75- if (ev.type === "pull_request" && (ev.action === "enqueued" || ev.action === "dequeued" || ev.action === "edited")) return;
75+ if (ev.type === "pull_request" && (ev.action === "enqueued" || ev.action === "dequeued" || ev.action === "edited" || ev.action === "auto_merge_enabled" || ev.action === "auto_merge_disabled")) return;
7676 if (ev.type === "issues" && ev.action === "edited") return;
7777 const repo = await dir.repoById(ev.repoId);
7878 if (!repo) return;
@@ -316,6 +316,20 @@
316316 jobs = to(ids, { kind: "repo_deleted", data: { repo: `${owner}/${name}`, actor: e.actorLogin ?? "someone" } }, "admin");
317317 break;
318318 }
319+ case "repo.transfer": {
320+ // The admins of both owners: the old one's lose the repository, the new one's gain it.
321+ const from = s("from");
322+ const dest = s("to");
323+ if (!from || !dest) return;
324+ const admins = async (login: string) => {
325+ const acc = await dir.accountByLogin(login);
326+ if (!acc) return [];
327+ return acc.kind === "org" ? (await dir.orgMembers(acc.id)).filter((m) => m.role === "owner" || m.role === "admin").map((m) => m.id) : acc.kind === "user" ? [acc.id] : [];
328+ };
329+ const ids = [...new Set([...(await admins(from.split("/")[0]!)), ...(await admins(dest.split("/")[0]!))])].filter((id) => id !== e.actorId);
330+ jobs = to(ids, { kind: "repo_transferred", data: { from, to: dest, actor: e.actorLogin ?? "someone" } }, "admin");
331+ break;
332+ }
319333 default:
320334 return;
321335 }
modifiedapps/diffinery/src/mail/samples.ts+1−0View file
@@ -78,6 +78,7 @@
7878 item("repo-access", "Repository access", { kind: "repo_access", reason: "account", data: { repo, role: "write", previous: null } }),
7979 item("repo-created", "Repository created", { kind: "repo_created", reason: "your_activity", data: { repo, creator: me, visibility: "private", defaultBranch: "main", description: "Marketing site" } }),
8080 item("repo-deleted", "Repository deleted", { kind: "repo_deleted", reason: "admin", data: { repo, actor: "kim" } }),
81+ item("repo-transferred", "Repository transferred", { kind: "repo_transferred", reason: "admin", data: { from: `${me}/${repo.split("/")[1]}`, to: repo, actor: "kim" } }),
8182 item("test", "Test email", { kind: "test", reason: "account", data: { by: me } }),
8283 ];
8384 }
modifiedapps/diffinery/src/mail/templates.ts+18−0View file
@@ -626,6 +626,24 @@
626626 title: [user(job.data.actor), " deleted ", { b: job.data.repo }],
627627 blocks: [{ p: ["The repository, its issues, pull requests, workflow runs and settings are gone."] }, { kv: [["Repository", job.data.repo], ["Deleted by", user(job.data.actor)], ["When", at(job.at)]] }],
628628 };
629+ case "repo_transferred": {
630+ const d = job.data;
631+ const url = `${pub}/${d.to}`;
632+ return {
633+ subject: `[${d.to}] Repository transferred from ${d.from}`,
634+ preheader: `@${d.actor} moved ${d.from} to ${d.to}.`,
635+ eyebrow: { text: "Repository transferred", tone: "blue" },
636+ title: [user(d.actor), " transferred ", { b: d.from }, " to ", { b: d.to }],
637+ blocks: [
638+ { p: ["Its history, issues, pull requests, workflow runs, secrets and settings moved with it. Links, clones and API calls that use the old name keep working for people who can still read it; update your remotes when you can:"] },
639+ { code: `git remote set-url origin ${url}.git` },
640+ { kv: [["From", d.from], ["To", [{ a: d.to, href: url }]], ["Transferred by", user(d.actor)], ["When", at(job.at)]] },
641+ { button: { label: "Open repository", url } },
642+ ],
643+ context: repoContext(pub, d.to),
644+ url,
645+ };
646+ }
629647 case "test":
630648 return {
631649 subject: "[Diffinery] Test email",
modifiedapps/diffinery/src/mail/types.ts+2−0View file
@@ -88,6 +88,7 @@
8888 | { kind: "repo_access"; data: { repo: string; role: string | null; previous: string | null } }
8989 | { kind: "repo_created"; data: { repo: string; creator: string; visibility: string; defaultBranch: string; description: string } }
9090 | { kind: "repo_deleted"; data: { repo: string; actor: string } }
91+ | { kind: "repo_transferred"; data: { from: string; to: string; actor: string } }
9192 | { kind: "thread"; data: ThreadData }
9293 | { kind: "run"; data: RunData }
9394 | { kind: "test"; data: { by: string } };
@@ -128,6 +129,7 @@
128129 case "repo_access":
129130 case "repo_created":
130131 case "repo_deleted":
132+ case "repo_transferred":
131133 return "repos";
132134 case "run":
133135 return "actions";
modifiedapps/diffinery/src/mcp/releases.ts+2−1View file
@@ -33,7 +33,8 @@
3333 if (!Number.isInteger(n) || n < 1) throw new HttpError(422, `${k} must be a positive integer`);
3434 return n;
3535 };
36-const repoOf = (c: AppContext, a: Args, min: Role = "read") => loadRepo(c, str(a, "owner"), str(a, "repo"), min);
36+// A transferred or renamed repository is served under its old owner/name too.
37+const repoOf = (c: AppContext, a: Args, min: Role = "read") => loadRepo(c, str(a, "owner"), str(a, "repo"), min, { follow: true });
3738 const OWNER_REPO = {
3839 owner: { type: "string", description: "Repository owner (user or org login)" },
3940 repo: { type: "string", description: "Repository name" },
modifiedapps/diffinery/src/mcp/server.ts+29−2View file
@@ -45,7 +45,8 @@
4545 if (!Number.isInteger(n) || n < 1) throw new HttpError(422, `${k} must be a positive integer`);
4646 return n;
4747 };
48-const repoOf = (c: AppContext, a: Args, min: Role = "read") => loadRepo(c, str(a, "owner"), str(a, "repo"), min);
48+// A transferred or renamed repository is served under its old owner/name too.
49+const repoOf = (c: AppContext, a: Args, min: Role = "read") => loadRepo(c, str(a, "owner"), str(a, "repo"), min, { follow: true });
4950 const OWNER_REPO = {
5051 owner: { type: "string", description: "Repository owner (user or org login)" },
5152 repo: { type: "string", description: "Repository name" },
@@ -211,7 +212,7 @@
211212 },
212213 merge_pull_request: {
213214 description:
214- "Merge a pull request when it is mergeable (checks, approvals and branch protection are enforced). When the base branch uses a merge queue, this adds the pull request to the queue instead; it merges once its merge group passes the required checks.",
215+ "Merge a pull request when it is mergeable (checks, approvals and branch protection are enforced). When the base branch uses a merge queue, this adds the pull request to the queue instead; it merges once its merge group passes the required checks. Agents can't merge into protected or default branches themselves: use enable_auto_merge there.",
215216 input: { ...OWNER_REPO, number: { type: "integer" }, method: { type: "string", enum: ["merge", "squash", "rebase"] }, expected_head_sha: { type: "string" }, delete_branch: { type: "boolean" } },
216217 required: ["owner", "repo", "number"],
217218 run: async (c, a) => {
@@ -225,6 +226,32 @@
225226 return stub.mergePull(number, { method: (optStr(a, "method") as "merge") ?? "merge", expectedHeadSha: optStr(a, "expected_head_sha"), deleteBranch: !!a.delete_branch }, caller);
226227 },
227228 },
229+ enable_auto_merge: {
230+ description:
231+ "Turn on auto-merge: Diffinery merges the pull request by itself as soon as every branch rule is met (required checks green on the head, approvals from people, linear history), or adds it to the merge queue when the base branch uses one. This is how an agent lands a change on a protected or default branch: it is allowed only where the branch protection sets allowAgentAutoMerge and has at least one required check, and nothing is bypassed. A push to the branch by anyone else turns it off.",
232+ input: {
233+ ...OWNER_REPO,
234+ number: { type: "integer" },
235+ merge_method: { type: "string", enum: ["merge", "squash", "rebase"], description: "Default: the merge queue's method, squash where linear history is required, else merge." },
236+ commit_title: { type: "string" },
237+ commit_message: { type: "string" },
238+ },
239+ required: ["owner", "repo", "number"],
240+ run: async (c, a) => {
241+ const { stub, caller } = await repoOf(c, a, "write");
242+ return stub.enableAutoMerge(int(a, "number"), { method: optStr(a, "merge_method"), commitTitle: optStr(a, "commit_title"), commitMessage: optStr(a, "commit_message") }, caller);
243+ },
244+ },
245+ disable_auto_merge: {
246+ description: "Turn off auto-merge on a pull request.",
247+ input: { ...OWNER_REPO, number: { type: "integer" } },
248+ required: ["owner", "repo", "number"],
249+ run: async (c, a) => {
250+ const { stub, caller } = await repoOf(c, a, "write");
251+ await stub.disableAutoMerge(int(a, "number"), caller);
252+ return { auto_merge: null };
253+ },
254+ },
228255 dequeue_pull_request: {
229256 description: "Remove a pull request from its base branch's merge queue.",
230257 input: { ...OWNER_REPO, number: { type: "integer" } },
modifiedapps/diffinery/src/registry/http.ts+21−4View file
@@ -109,21 +109,38 @@
109109 async function target(c: AppContext, full: string, need: PackageRole): Promise<Target> {
110110 const lower = full.toLowerCase();
111111 const slash = lower.indexOf("/");
112- const name = slash > 0 ? lower.slice(slash + 1) : "";
112+ let name = slash > 0 ? lower.slice(slash + 1) : "";
113113 if (slash <= 0 || !NAME_RE.test(name) || lower.length > 255) {
114114 throw new RegistryError(400, "NAME_INVALID", `invalid image name "${full}": use ${new URL(c.env.PUBLIC_URL).host}/<owner>/<name> with lowercase letters, digits, '.', '_', '-' and '/'`);
115115 }
116116 const store = new RegistryStore(c.env);
117- const owner = await c.get("dir").accountByLogin(lower.slice(0, slash));
117+ const dir = c.get("dir");
118+ const ownerLogin = lower.slice(0, slash);
119+ let owner = await dir.accountByLogin(ownerLogin);
120+ let pkg = owner && owner.kind !== "agent" ? await store.package(owner.id, name) : null;
121+ // An image that moved with its repository to another owner, or one under a renamed owner, answers
122+ // under its old name too. Access is the moved package's, so the old name never shows a private one.
123+ let moved = false;
124+ if (!pkg) {
125+ const found = await store.movedPackage(ownerLogin, name);
126+ const renamed = found ? null : await dir.renamedAccount(ownerLogin);
127+ const to = found ? await dir.account(found.owner_id) : renamed;
128+ if (to) {
129+ moved = true;
130+ owner = to;
131+ pkg = found ?? (await store.package(to.id, name));
132+ }
133+ }
118134 if (!owner || owner.kind === "agent") {
119135 if (c.get("principal").type === "anonymous") throw new RegistryError(401, "UNAUTHORIZED", "authentication required");
120- throw new RegistryError(404, "NAME_UNKNOWN", `no user or organization named ${lower.slice(0, slash)}`);
136+ throw new RegistryError(404, "NAME_UNKNOWN", `no user or organization named ${ownerLogin}`);
121137 }
122- const pkg = await store.package(owner.id, name);
138+ if (moved && pkg) name = pkg.name;
123139 const access = await packageAccess(c, owner, name, pkg);
124140 if (!allows(access.role, need)) {
125141 const anon = c.get("principal").type === "anonymous";
126142 if (anon) throw new RegistryError(401, "UNAUTHORIZED", "authentication required");
143+ if (moved && !allows(access.role, "pull")) throw new RegistryError(404, "NAME_UNKNOWN", `repository ${lower} not found`);
127144 if (!allows(access.role, "pull")) {
128145 if (need === "pull" || pkg) throw new RegistryError(404, "NAME_UNKNOWN", `repository ${owner.login}/${name} not found${access.hint ? ` (${access.hint})` : ""}`);
129146 throw new RegistryError(403, "DENIED", `you can't create ${owner.login}/${name}${access.hint ? `: ${access.hint}` : ""}`);
modifiedapps/diffinery/src/registry/store.ts+6−0View file
@@ -82,6 +82,12 @@
8282 return this.db.prepare(`${PKG_SELECT} WHERE p.id = ?`).bind(id).first<PackageRow>();
8383 }
8484
85+ /** A package that moved to another owner with its repository, by its old <owner>/<name> (see migrations/0011_redirects.sql). */
86+ async movedPackage(ownerLogin: string, name: string): Promise<PackageRow | null> {
87+ const r = await this.db.prepare("SELECT package_id FROM package_redirects WHERE owner_login = ? AND name = ?").bind(ownerLogin, name).first<{ package_id: string }>();
88+ return r ? this.packageById(r.package_id) : null;
89+ }
90+
8591 /** Creates the package unless a concurrent push just did; returns it either way. */
8692 async createPackage(ownerId: string, name: string, repoId: string | null, createdBy: string): Promise<PackageRow> {
8793 const t = now();
modifiedapps/diffinery/src/repo/RepoDO.ts+62−6View file
@@ -41,6 +41,7 @@
4141 import { HttpError, isValidRefName, now } from "../lib/util.js";
4242 import { deletePrefixes, transientPrefixes } from "../lib/storage-keys.js";
4343 import { Actions } from "./actions.js";
44+import { AutoMerge, isPerson } from "./auto-merge.js";
4445 import { Collab } from "./collab.js";
4546 import { Deployments } from "./deployments.js";
4647 import { triggerWorkflows } from "./extra-triggers.js";
@@ -89,6 +90,7 @@
8990 readonly collab: Collab;
9091 readonly actions: Actions;
9192 readonly queue: MergeQueue;
93+ readonly autoMerge: AutoMerge;
9294 readonly runtime: Runtime;
9395 readonly policy: TokenPolicy;
9496 private metaCache: RepoMeta | null = null;
@@ -103,6 +105,7 @@
103105 this.collab = new Collab(this);
104106 this.actions = new Actions(this, env);
105107 this.queue = new MergeQueue(this);
108+ this.autoMerge = new AutoMerge(this, env);
106109 this.runtime = new Runtime(this, env);
107110 this.policy = new TokenPolicy(this);
108111 }
@@ -243,7 +246,8 @@
243246 }
244247 }
245248
246- async gitReceivePack(body: Uint8Array, caller: Caller): Promise<Uint8Array> {
249+ /** `moved`: the push came in under an old name (a transfer or rename), so say where the repository lives now, like GitHub. */
250+ async gitReceivePack(body: Uint8Array, caller: Caller, o: { moved?: boolean } = {}): Promise<Uint8Array> {
247251 if (body.length > MAX_PUSH_BYTES) {
248252 const req = { commands: [], capabilities: new Set<string>(), pushOptions: [], pack: null };
249253 return reportStatus(req, "push exceeds 100 MB; split it into smaller pushes", []);
@@ -259,7 +263,7 @@
259263 const denied = req.commands.map((c) => ({ ref: c.ref, ok: false as const, reason: "repository is locked by an import in progress" }));
260264 return reportStatus(req, "repository is locked by an import in progress", denied);
261265 }
262- const messages: string[] = [];
266+ const messages: string[] = o.moved ? ["This repository moved. Please use the new location:", ` ${this.env.PUBLIC_URL}/${this.meta.owner}/${this.meta.name}.git`] : [];
263267 let unpackError: string | null = null;
264268 // Like git's quarantine: nothing from the pack is stored until push protection passes,
265269 // so a blocked push leaves no objects behind for a retry to build on.
@@ -427,8 +431,10 @@
427431 }
428432 }
429433 if (newOid !== ZERO_OID) await this.actions.onPush(ev, caller).catch((e) => messages.push(`Workflow trigger error: ${(e as Error).message}`));
430- // A base branch that moved (outside the queue, or by it) needs its merge groups checked.
434+ // A base branch that moved (outside the queue, or by it) needs its merge groups checked,
435+ // and pull requests into it waiting to auto-merge may now be up to date.
431436 if (ref.startsWith("refs/heads/") && this.queue.hasEntries(ref.slice(11))) this.queue.schedule(ref.slice(11));
437+ if (ref.startsWith("refs/heads/") && newOid !== ZERO_OID) this.autoMerge.schedule({ base: ref.slice(11) });
432438 }
433439
434440 // ---------------------------------------------------------------- browsing
@@ -628,13 +634,52 @@
628634 return this.sql.exec<{ pattern: string; rules: string }>("SELECT pattern, rules FROM protections ORDER BY pattern").toArray().map((r) => ({ pattern: r.pattern, rules: JSON.parse(r.rules) }));
629635 }
630636
631- setProtection(pattern: string, rules: ProtectionRules | null): void {
632- if (rules?.mergeQueue) rules = { ...rules, mergeQueue: normalizeMergeQueue(rules.mergeQueue) };
637+ /**
638+ * Create, replace or (with null) delete a rule. Only people who administer the repository change
639+ * branch protection: never agents, never a workflow's token. Turning `allowAgentAutoMerge` or
640+ * `agentAutoMergeWithoutApproval` on or off is audited on its own, since they let agents land changes.
641+ */
642+ setProtection(pattern: string, rules: ProtectionRules | null, caller: Caller): void {
643+ if (!isPerson(caller) || caller.role !== "admin") throw new HttpError(403, caller.kind === "agent" ? "agents cannot change branch protection" : "only people who administer the repository can change branch protection");
644+ if (rules?.mergeQueue) {
645+ const asked = typeof rules.mergeQueue === "object" ? rules.mergeQueue.method : undefined;
646+ const mergeQueue = normalizeMergeQueue(rules.mergeQueue);
647+ // A queue that makes merge commits could never merge into a branch that refuses them.
648+ if (rules.requireLinearHistory && mergeQueue.method === "merge") {
649+ if (asked !== undefined) throw new HttpError(422, "this branch requires linear history, so its merge queue must squash or rebase (mergeQueue.method), not create merge commits");
650+ mergeQueue.method = "squash";
651+ }
652+ rules = { ...rules, mergeQueue };
653+ }
633654 if (rules?.requiredCheckSources) rules = { ...rules, requiredCheckSources: normalizeCheckSources(rules.requiredCheckSources) };
655+ if (rules && rules.allowAgentAutoMerge !== undefined && typeof rules.allowAgentAutoMerge !== "boolean") throw new HttpError(422, "allowAgentAutoMerge must be a boolean");
656+ if (rules && rules.agentAutoMergeWithoutApproval !== undefined && typeof rules.agentAutoMergeWithoutApproval !== "boolean") throw new HttpError(422, "agentAutoMergeWithoutApproval must be a boolean");
657+ if (rules?.agentAutoMergeWithoutApproval && !rules.allowAgentAutoMerge) throw new HttpError(422, "agentAutoMergeWithoutApproval needs allowAgentAutoMerge: it only applies to what Diffinery lands for agents");
658+ if (rules && !rules.allowAgentAutoMerge) {
659+ const { allowAgentAutoMerge: _, agentAutoMergeWithoutApproval: _w, ...rest } = rules;
660+ rules = rest;
661+ }
662+ if (rules && !rules.agentAutoMergeWithoutApproval) {
663+ const { agentAutoMergeWithoutApproval: _, ...rest } = rules;
664+ rules = rest;
665+ }
666+ const before = this.sql.exec<{ rules: string }>("SELECT rules FROM protections WHERE pattern = ?", pattern).toArray()[0];
667+ const old = before ? (JSON.parse(before.rules) as ProtectionRules) : null;
668+ const was = !!old?.allowAgentAutoMerge;
669+ const waived = !!old?.agentAutoMergeWithoutApproval;
634670 if (rules) this.sql.exec("INSERT INTO protections (pattern, rules) VALUES (?, ?) ON CONFLICT(pattern) DO UPDATE SET rules = excluded.rules", pattern, JSON.stringify(rules));
635671 else this.sql.exec("DELETE FROM protections WHERE pattern = ?", pattern);
636- // Turning a queue off (or changing its checks) affects what is queued.
672+ if (was !== !!rules?.allowAgentAutoMerge) {
673+ const m = this.meta;
674+ this.emit({ type: "audit", action: "repo.protection.agent_auto_merge", actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, target: `${m.owner}/${m.name}`, detail: { pattern, enabled: !!rules?.allowAgentAutoMerge, requiredChecks: rules?.requiredChecks ?? [] }, at: now() });
675+ }
676+ if (waived !== !!rules?.agentAutoMergeWithoutApproval) {
677+ const m = this.meta;
678+ this.emit({ type: "audit", action: "repo.protection.agent_approval_waiver", actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, target: `${m.owner}/${m.name}`, detail: { pattern, enabled: !!rules?.agentAutoMergeWithoutApproval, requiredChecks: rules?.requiredChecks ?? [] }, at: now() });
679+ }
680+ // Turning a queue off (or changing its checks) affects what is queued, and what may auto-merge.
637681 this.queue.schedule();
682+ this.autoMerge.schedule();
638683 }
639684
640685 // ---------------------------------------------------------------- RPC passthroughs (collab + actions)
@@ -722,6 +767,12 @@
722767 mergeQueueSettings(...a: Parameters<MergeQueue["settings"]>) {
723768 return this.queue.settings(...a);
724769 }
770+ enableAutoMerge(...a: Parameters<AutoMerge["enable"]>) {
771+ return this.autoMerge.enable(...a);
772+ }
773+ disableAutoMerge(...a: Parameters<AutoMerge["disable"]>) {
774+ return this.autoMerge.disable(...a);
775+ }
725776
726777 runs(...a: Parameters<Actions["listRuns"]>) {
727778 return this.actions.listRuns(...a);
@@ -1322,6 +1373,11 @@
13221373 }
13231374
13241375 importProtection(pattern: string, rules: ProtectionRules | null, importId: string) {
1376+ // What agents may do is a person's decision here, never something an import brings in.
1377+ if (rules) {
1378+ const { allowAgentAutoMerge: _a, agentAutoMergeWithoutApproval: _w, agentsMayPush: _p, agentsMayMerge: _m, ...rest } = rules;
1379+ rules = rest;
1380+ }
13251381 if (rules?.mergeQueue) rules = { ...rules, mergeQueue: normalizeMergeQueue(rules.mergeQueue) };
13261382 if (rules?.requiredCheckSources) rules = { ...rules, requiredCheckSources: normalizeCheckSources(rules.requiredCheckSources) };
13271383 if (rules) this.importsModule.protection(pattern, JSON.stringify(rules), importId);
modifiedapps/diffinery/src/repo/actions.ts+10−4View file
@@ -27,6 +27,7 @@
2727 import type { RepoDO } from "./RepoDO.js";
2828 import { completePayload } from "./event-payloads.js";
2929 import { atLeast, type Caller } from "./types.js";
30+import { isJobCaller } from "./token-policy.js";
3031 import { ReusableError, expandReusable, localKey, parseCallRef, type Expanded, type Source } from "./reusable.js";
3132 import { ancestors, combineResults, jobStatus, runnerLabels, type JobNode } from "./job-graph.js";
3233
@@ -465,15 +466,16 @@
465466 headRef?: string;
466467 baseRef?: string;
467468 title: string;
468- /** A re-run of a run whose code came from an agent. */
469+ /** Started by a run whose code came from an agent (a re-run, or a workflow_run follower). */
469470 agentOrigin?: boolean;
470471 /** Pull requests whose code the run includes (a merge group's). */
471472 prNumbers?: number[];
472473 },
473474 ): Promise<RunInfo | null> {
474475 // Like GitHub, what a job does with its GITHUB_TOKEN (pushes, pull requests) starts no new
475- // runs, so workflows cannot trigger themselves in a loop; workflow_dispatch is the exception.
476- if (o.caller.id.startsWith("job:") && o.event !== "workflow_dispatch") return null;
476+ // runs, so workflows cannot trigger themselves in a loop; workflow_dispatch and
477+ // repository_dispatch are the exceptions.
478+ if (isJobCaller(o.caller) && o.event !== "workflow_dispatch" && o.event !== "repository_dispatch") return null;
477479 const id = newId();
478480 const t = now();
479481 const run: RunRow = {
@@ -669,7 +671,9 @@
669671 if (ref.kind === "local") repoId = from.repoId!;
670672 else if (ref.owner.toLowerCase() === m.owner.toLowerCase() && ref.repo.toLowerCase() === m.name.toLowerCase()) repoId = m.repoId;
671673 else {
672- const target = await new Directory(this.env).repo(ref.owner, ref.repo);
674+ const dir = new Directory(this.env);
675+ // A transferred or renamed repository's workflows are found under the old name too.
676+ const target = (await dir.repo(ref.owner, ref.repo)) ?? (await dir.movedRepo(ref.owner, ref.repo));
673677 // Like GitHub: public workflows can be shared; private ones only within the same owner.
674678 if (!target || (target.visibility === "private" && target.owner_id !== m.ownerId)) throw new Error(`repository ${ref.owner}/${ref.repo} not found, or its workflows are not shared with ${m.owner}/${m.name}`);
675679 repoId = target.id;
@@ -1034,6 +1038,8 @@
10341038 payload: { action, workflow_run: workflowRun, workflow: { name: run.workflow_name, path: run.workflow_path }, repository: await this.repositoryPayload(), sender: { login: actor.login } },
10351039 caller: actor,
10361040 title: w.workflow!.name ?? w.path,
1041+ // A follower acts on what the run it follows produced (artifacts, outputs), so it stays an agent's.
1042+ agentOrigin: !!run.agent_origin,
10371043 });
10381044 }
10391045 } catch (e) {
addedapps/diffinery/src/repo/auto-merge.ts+290−0View file
@@ -1,0 +1,290 @@
1+import type { Actor } from "@diffinery/shared";
2+import type { Env } from "../env.js";
3+import { Directory } from "../lib/directory.js";
4+import { HttpError, now } from "../lib/util.js";
5+import type { RepoDO } from "./RepoDO.js";
6+import { isJobCaller } from "./token-policy.js";
7+import { ROLE_LEVEL, atLeast, type Caller, type ProtectionRules, type Role } from "./types.js";
8+
9+/**
10+ * Auto-merge, like GitHub's: Diffinery merges a pull request by itself as soon as every
11+ * rule of its base branch is met (required checks green on the head, approvals from people,
12+ * linear history and an up-to-date branch where required). Into a branch that merges
13+ * through a merge queue it adds the pull request to the queue instead.
14+ *
15+ * It is also how agents land changes on protected branches without a bypass: they never
16+ * merge there themselves, but a person who administers the repository can let them have
17+ * Diffinery do it (`allowAgentAutoMerge`), on a branch whose required checks gate it.
18+ */
19+
20+export type MergeMethod = "merge" | "squash" | "rebase";
21+
22+/** Stored on the pull request (pulls.auto_merge) while auto-merge is on. */
23+export type AutoMergeState = {
24+ method: MergeMethod;
25+ commitTitle: string | null;
26+ commitMessage: string | null;
27+ enabledBy: Actor;
28+ enabledAt: string;
29+ /** The role (and a workflow token's permissions) it was enabled with: the merge never acts with more. */
30+ role: Role;
31+ permissions?: Caller["permissions"];
32+};
33+
34+/** What the API and the web show: who turned it on, and how it will merge. */
35+export type AutoMergeInfo = Pick<AutoMergeState, "method" | "commitTitle" | "commitMessage" | "enabledBy" | "enabledAt">;
36+
37+export type AutoMergeResult = { state: "enabled" | "queued" | "merged"; autoMerge: AutoMergeInfo | null; mergeCommit?: string | null };
38+
39+/** Comments about auto-merge the server wrote on its own (it was turned off because ...). */
40+export const AUTO_MERGE_BOT: Caller = { id: "system:auto-merge", login: "diffinery-auto-merge[bot]", kind: "system", role: "admin" };
41+
42+const METHODS: readonly MergeMethod[] = ["merge", "squash", "rebase"];
43+
44+/** A person: not an agent, not a workflow's GITHUB_TOKEN, not the system. */
45+export const isPerson = (c: Pick<Caller, "id" | "kind">): boolean => c.kind === "user" && !isJobCaller(c);
46+
47+/**
48+ * Whether `caller` may have Diffinery land a pull request into `base` without a person merging
49+ * it: by auto-merge, or through the merge queue. Null when it may, else why not.
50+ *
51+ * - People may (branch protection still decides when the merge happens).
52+ * - Agents and workflow tokens may on a branch that is neither the default branch nor protected.
53+ * - An agent may where `agentsMayMerge` already lets it merge by itself.
54+ * - Otherwise only where a person turned on `allowAgentAutoMerge`, and the branch has at least
55+ * one required check, so CI gates what lands. Workflow tokens are held to the same rule even
56+ * when the run's code didn't come from an agent.
57+ */
58+export function automaticLandingRefusal(caller: Pick<Caller, "id" | "kind">, rules: ProtectionRules | null, isDefault: boolean, base: string): { status: 403 | 422; message: string } | null {
59+ if (isPerson(caller)) return null;
60+ if (!rules && !isDefault) return null;
61+ if (caller.kind === "agent" && rules?.agentsMayMerge) return null;
62+ const who = isJobCaller(caller) ? "workflow tokens" : "agents";
63+ if (!rules?.allowAgentAutoMerge) {
64+ return { status: 403, message: `${who} may not have pull requests merged into ${base} automatically unless its branch protection allows it (allowAgentAutoMerge, set by a repository admin); a person must merge` };
65+ }
66+ if (!rules.requiredChecks?.length) {
67+ return { status: 422, message: `${base} has no required status checks: allowAgentAutoMerge needs at least one, so that CI gates what ${who} land` };
68+ }
69+ return null;
70+}
71+
72+/** Whether an automatic landing by `caller` relies on `allowAgentAutoMerge` (rather than being a person's, or allowed anyway). */
73+export function landsByAgentRule(caller: Pick<Caller, "id" | "kind">, rules: ProtectionRules | null, isDefault: boolean): boolean {
74+ return !isPerson(caller) && (!!rules || isDefault) && !(caller.kind === "agent" && rules?.agentsMayMerge);
75+}
76+
77+const minRole = (a: Role, b: Role): Role => (ROLE_LEVEL[a] <= ROLE_LEVEL[b] ? a : b);
78+
79+type Filter = { number?: number; sha?: string; base?: string };
80+
81+export class AutoMerge {
82+ private lock: Promise<unknown> = Promise.resolve();
83+
84+ constructor(
85+ private repo: RepoDO,
86+ private env: Env,
87+ ) {}
88+
89+ private get sql() {
90+ return this.repo.sql;
91+ }
92+
93+ state(number: number): AutoMergeState | null {
94+ const r = this.sql.exec<{ auto_merge: string | null }>("SELECT auto_merge FROM pulls WHERE number = ?", number).toArray()[0];
95+ return r?.auto_merge ? (JSON.parse(r.auto_merge) as AutoMergeState) : null;
96+ }
97+
98+ static info(s: AutoMergeState | null): AutoMergeInfo | null {
99+ return s ? { method: s.method, commitTitle: s.commitTitle, commitMessage: s.commitMessage, enabledBy: s.enabledBy, enabledAt: s.enabledAt } : null;
100+ }
101+
102+ /** Turn auto-merge on (or change how it merges), then merge or enqueue right away if the pull request is ready. */
103+ async enable(number: number, input: { method?: string; commitTitle?: string | null; commitMessage?: string | null }, caller: Caller): Promise<AutoMergeResult> {
104+ const { issue } = this.repo.collab.get(number);
105+ const pull = issue.pull;
106+ if (!pull) throw new HttpError(404, "not a pull request");
107+ if (pull.merged) throw new HttpError(422, "pull request is already merged");
108+ if (issue.state !== "open") throw new HttpError(422, "pull request is closed");
109+ if (pull.draft) throw new HttpError(422, "pull request is a draft; mark it ready for review first");
110+ if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to enable auto-merge");
111+ const rules = this.repo.protectionFor(pull.base);
112+ const refusal = automaticLandingRefusal(caller, rules, pull.base === this.repo.meta.defaultBranch, pull.base);
113+ if (refusal) throw new HttpError(refusal.status, refusal.message);
114+ if (landsByAgentRule(caller, rules, pull.base === this.repo.meta.defaultBranch)) {
115+ const path = await this.repo.collab.changedWorkflow(number);
116+ if (path) throw new HttpError(422, `the pull request changes workflow file \`${path}\`; CI can't gate a change to itself, so a person must merge it`);
117+ }
118+ if (input.method !== undefined && !METHODS.includes(input.method as MergeMethod)) throw new HttpError(422, "merge_method must be merge, squash or rebase");
119+ const method = (input.method as MergeMethod | undefined) ?? rules?.mergeQueue?.method ?? (rules?.requireLinearHistory ? "squash" : "merge");
120+ if (method === "merge" && rules?.requireLinearHistory) throw new HttpError(422, `${pull.base} requires linear history; use squash or rebase`);
121+ if (this.repo.queue.isQueued(number)) throw new HttpError(422, "pull request is already in the merge queue");
122+ const state: AutoMergeState = {
123+ method,
124+ commitTitle: typeof input.commitTitle === "string" && input.commitTitle ? input.commitTitle : null,
125+ commitMessage: typeof input.commitMessage === "string" && input.commitMessage ? input.commitMessage : null,
126+ enabledBy: { id: caller.id, login: caller.login, kind: caller.kind, ...(caller.onBehalfOf ? { onBehalfOf: caller.onBehalfOf } : {}) },
127+ enabledAt: now(),
128+ role: caller.role,
129+ ...(caller.permissions ? { permissions: caller.permissions } : {}),
130+ };
131+ this.sql.exec("UPDATE pulls SET auto_merge = ? WHERE number = ?", JSON.stringify(state), number);
132+ this.repo.social.record(number, "auto_merge_enabled", caller, { merge_method: method });
133+ this.event(number, "auto_merge_enabled", caller);
134+ this.audit("pull_request.auto_merge_enabled", caller, number, { base: pull.base, method, viaAgentRule: landsByAgentRule(caller, rules, pull.base === this.repo.meta.defaultBranch) });
135+ this.repo.broadcast(`pr:${number}`, { action: "auto_merge" });
136+ await this.process({ number });
137+ return this.result(number);
138+ }
139+
140+ private result(number: number): AutoMergeResult {
141+ const p = this.sql.exec<{ merged: number; merge_commit: string | null }>("SELECT merged, merge_commit FROM pulls WHERE number = ?", number).one();
142+ if (p.merged) return { state: "merged", autoMerge: null, mergeCommit: p.merge_commit };
143+ if (this.repo.queue.isQueued(number)) return { state: "queued", autoMerge: null };
144+ return { state: "enabled", autoMerge: AutoMerge.info(this.state(number)) };
145+ }
146+
147+ /** Turn auto-merge off at someone's request: who turned it on, or anyone with write access. */
148+ disable(number: number, caller: Caller): void {
149+ const s = this.state(number);
150+ if (!s) return;
151+ if (s.enabledBy.id !== caller.id && !atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to disable auto-merge");
152+ this.cancel(number, caller, null);
153+ }
154+
155+ /** A push to the head branch by anyone but who turned auto-merge on cancels it, as on GitHub. */
156+ headPushed(number: number, pusher: Caller): void {
157+ const s = this.state(number);
158+ if (s && s.enabledBy.id !== pusher.id) this.cancel(number, pusher, `${pusher.login} pushed new commits to the branch`);
159+ }
160+
161+ /** The pull request closed or merged: auto-merge is over, nothing to announce. */
162+ clear(number: number): void {
163+ this.sql.exec("UPDATE pulls SET auto_merge = NULL WHERE number = ? AND auto_merge IS NOT NULL", number);
164+ }
165+
166+ /** Turn auto-merge off; with a reason, the server did (it is said in a comment). */
167+ cancel(number: number, by: Caller, reason: string | null): void {
168+ const s = this.state(number);
169+ if (!s) return;
170+ this.clear(number);
171+ this.repo.social.record(number, "auto_merge_disabled", by, reason ? { reason } : {});
172+ this.event(number, "auto_merge_disabled", by);
173+ this.audit("pull_request.auto_merge_disabled", by, number, { enabledBy: s.enabledBy.login, ...(reason ? { reason } : {}) });
174+ if (reason) {
175+ try {
176+ this.repo.collab.addComment(number, `Auto-merge was turned off: ${reason}.`, AUTO_MERGE_BOT);
177+ } catch (e) {
178+ console.warn("auto-merge comment failed", e);
179+ }
180+ }
181+ this.repo.broadcast(`pr:${number}`, { action: "auto_merge" });
182+ }
183+
184+ /** Re-evaluate in the background: after checks complete, reviews, protection changes and pushes. */
185+ schedule(filter: Filter = {}): void {
186+ if (!this.pending(filter).length) return;
187+ this.repo.background(this.process(filter));
188+ }
189+
190+ private pending(filter: Filter): number[] {
191+ const where = ["p.auto_merge IS NOT NULL", "i.state = 'open'"];
192+ const args: unknown[] = [];
193+ if (filter.number !== undefined) {
194+ where.push("p.number = ?");
195+ args.push(filter.number);
196+ }
197+ if (filter.sha !== undefined) {
198+ where.push("p.head_sha = ?");
199+ args.push(filter.sha);
200+ }
201+ if (filter.base !== undefined) {
202+ where.push("p.base_ref = ?");
203+ args.push(filter.base);
204+ }
205+ return this.sql
206+ .exec<{ number: number }>(`SELECT p.number FROM pulls p JOIN issues i ON i.number = p.number WHERE ${where.join(" AND ")} ORDER BY p.number`, ...args)
207+ .toArray()
208+ .map((r) => r.number);
209+ }
210+
211+ process(filter: Filter = {}): Promise<void> {
212+ return this.exclusive(async () => {
213+ for (const n of this.pending(filter)) {
214+ try {
215+ await this.step(n);
216+ } catch (e) {
217+ console.error(`auto-merge of #${n} failed`, e);
218+ }
219+ }
220+ });
221+ }
222+
223+ private exclusive<T>(fn: () => Promise<T>): Promise<T> {
224+ const run = this.lock.then(fn, fn);
225+ this.lock = run.catch(() => undefined);
226+ return run;
227+ }
228+
229+ /** Merge (or enqueue) one pull request if it is ready now; otherwise leave auto-merge on, or turn it off when it can never go through. */
230+ private async step(number: number): Promise<void> {
231+ const s = this.state(number);
232+ if (!s) return;
233+ const { issue } = this.repo.collab.get(number);
234+ const pull = issue.pull!;
235+ if (pull.merged || issue.state !== "open") return this.clear(number);
236+ if (this.repo.queue.isQueued(number)) return this.clear(number);
237+ const caller = await this.callerOf(s);
238+ if (!caller || !atLeast(caller.role, "write")) return this.cancel(number, AUTO_MERGE_BOT, `${s.enabledBy.login} no longer has write access`);
239+ // The rules may have changed since it was turned on: an agent's auto-merge needs them to still allow it.
240+ const rules = this.repo.protectionFor(pull.base);
241+ const refusal = automaticLandingRefusal(caller, rules, pull.base === this.repo.meta.defaultBranch, pull.base);
242+ if (refusal) return this.cancel(number, AUTO_MERGE_BOT, refusal.message);
243+ if (s.method === "merge" && rules?.requireLinearHistory) return this.cancel(number, AUTO_MERGE_BOT, `${pull.base} requires linear history, and auto-merge was set to create a merge commit`);
244+ const m = await this.repo.collab.mergeability(number, caller, { automatic: true });
245+ if (!m.mergeable) return;
246+ if (rules?.mergeQueue) {
247+ const r = await this.repo.queue.enqueue(number, { method: s.method }, caller);
248+ this.clear(number);
249+ this.audit("pull_request.auto_merge_enqueued", caller, number, { base: pull.base, state: r.state });
250+ return;
251+ }
252+ try {
253+ const r = await this.repo.collab.merge(number, { method: s.method, title: s.commitTitle ?? undefined, message: s.commitMessage ?? undefined, expectedHeadSha: pull.headSha }, caller, { automatic: true });
254+ this.audit("pull_request.auto_merged", caller, number, { base: pull.base, method: r.method, sha: r.sha });
255+ } catch (e) {
256+ // The head or base moved under us: the next event looks again. Anything else won't fix itself.
257+ if (e instanceof HttpError && e.status === 409) return;
258+ this.cancel(number, AUTO_MERGE_BOT, e instanceof HttpError ? e.detail : "the merge failed");
259+ }
260+ }
261+
262+ /**
263+ * Who merges: the one who turned auto-merge on, with their access as it is now (never more than
264+ * when they turned it on). A workflow token's job has ended by then, so it keeps what it had.
265+ */
266+ private async callerOf(s: AutoMergeState): Promise<Caller | null> {
267+ const by = s.enabledBy;
268+ if (isJobCaller(by)) return { ...by, role: s.role, permissions: s.permissions };
269+ const dir = new Directory(this.env);
270+ const [account, repo] = await Promise.all([dir.account(by.id), dir.repoById(this.repo.meta.repoId)]);
271+ if (!account || !repo) return null;
272+ const role = await dir.roleFor(account, repo);
273+ if (!role) return null;
274+ const kind = account.kind === "agent" ? "agent" : "user";
275+ const onBehalfOf = kind === "agent" && account.owner_id ? (await dir.account(account.owner_id))?.login : undefined;
276+ return { id: account.id, login: account.login, kind, ...(onBehalfOf ? { onBehalfOf } : {}), role: minRole(role, s.role) };
277+ }
278+
279+ private event(number: number, action: "auto_merge_enabled" | "auto_merge_disabled", by: Caller): void {
280+ const p = this.sql.exec<{ head_ref: string; head_sha: string; base_ref: string }>("SELECT head_ref, head_sha, base_ref FROM pulls WHERE number = ?", number).toArray()[0];
281+ if (!p) return;
282+ const m = this.repo.meta;
283+ this.repo.emit({ type: "pull_request", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: { id: by.id, login: by.login, kind: by.kind, onBehalfOf: by.onBehalfOf }, at: now() });
284+ }
285+
286+ private audit(action: string, by: Caller, number: number, detail: Record<string, string | number | boolean | null>): void {
287+ const m = this.repo.meta;
288+ this.repo.emit({ type: "audit", action, actor: { id: by.id, login: by.login, kind: by.kind, onBehalfOf: by.onBehalfOf }, target: `${m.owner}/${m.name}#${number}`, detail, at: now() });
289+ }
290+}
modifiedapps/diffinery/src/repo/collab.ts+71−15View file
@@ -6,6 +6,7 @@
66 import { atLeast, type Caller } from "./types.js";
77 import { COMMENT_SELECT } from "./social.js";
88 import { requiredCheck } from "./required.js";
9+import { AutoMerge, automaticLandingRefusal, isPerson, landsByAgentRule, type AutoMergeInfo } from "./auto-merge.js";
910
1011 type Sig = { name: string; email: string; timestamp: number; timezone: string };
1112
@@ -53,6 +54,7 @@
5354 merge_commit: string | null;
5455 requested_reviewers: string;
5556 intent: string | null;
57+ auto_merge: string | null;
5658 };
5759
5860 export type Issue = {
@@ -82,6 +84,8 @@
8284 mergeCommit: string | null;
8385 requestedReviewers: string[];
8486 intent: string | null;
87+ /** Set while auto-merge is on: it merges (or enqueues) by itself once every rule is met. */
88+ autoMerge: AutoMergeInfo | null;
8589 };
8690 };
8791
@@ -213,6 +217,7 @@
213217 mergeCommit: p.merge_commit,
214218 requestedReviewers: JSON.parse(p.requested_reviewers),
215219 intent: p.intent,
220+ autoMerge: p.auto_merge ? AutoMerge.info(JSON.parse(p.auto_merge)) : null,
216221 };
217222 }
218223 return issue;
@@ -402,6 +407,7 @@
402407 this.repo.emit({ type: "pull_request", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: actorOf(caller), at: t });
403408 if (!p.merged) this.repo.background(this.repo.actions.onPullRequest(action, number, caller).catch((e) => console.error("pr workflow trigger", e)));
404409 if (action === "closed" && this.repo.queue.isQueued(number)) this.repo.queue.schedule(p.base_ref);
410+ if (action === "closed") this.repo.autoMerge.clear(number);
405411 } else this.repo.emit({ type: "issues", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, actor: actorOf(caller), at: t });
406412 }
407413 const before = new Set((JSON.parse(r.assignees) as string[]).map((x) => x.toLowerCase()));
@@ -433,6 +439,9 @@
433439 const m = this.repo.meta;
434440 this.repo.emit({ type: "pull_request", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: actorOf(caller), at: t });
435441 this.repo.background(this.repo.actions.onPullRequest(action, number, caller).catch((e) => console.error("pr workflow trigger", e)));
442+ // As on GitHub, a draft can't auto-merge; one marked ready may now be mergeable.
443+ if (draft) this.repo.autoMerge.cancel(number, caller, "the pull request was converted to a draft");
444+ else this.repo.autoMerge.schedule({ number });
436445 this.repo.broadcast(`pr:${number}`, { action: "updated" });
437446 return this.toIssue(this.row(number));
438447 }
@@ -567,9 +576,11 @@
567576 this.sql.exec("UPDATE issues SET updated_at = ? WHERE number = ?", now(), p.number);
568577 const prefs = this.repo.protectionFor(p.base_ref);
569578 if (prefs?.dismissStaleReviews) this.sql.exec("UPDATE reviews SET state = 'DISMISSED' WHERE number = ? AND state = 'APPROVED' AND commit_id != ?", p.number, sha);
579+ this.repo.autoMerge.headPushed(p.number, caller);
570580 this.repo.emit({ type: "pull_request", action: "synchronize", repoId: m.repoId, owner: m.owner, repo: m.name, number: p.number, headRef: branch, headSha: sha, baseRef: p.base_ref, actor: actorOf(caller), at: now() });
571581 await this.repo.actions.onPullRequest("synchronize", p.number, caller).catch((e) => console.error("pr workflow trigger", e));
572582 if (this.repo.queue.isQueued(p.number)) this.repo.queue.schedule(p.base_ref);
583+ this.repo.autoMerge.schedule({ number: p.number });
573584 this.repo.broadcast(`pr:${p.number}`, { action: "synchronize", sha });
574585 out.push(p.number);
575586 }
@@ -584,6 +595,14 @@
584595 return { mergeBase: mb, headSha: head, ...(await this.repo.diff(mb, head)) };
585596 }
586597
598+ /** The first workflow file the pull request adds, changes or deletes, compared with where it branched off its base. */
599+ async changedWorkflow(number: number): Promise<string | null> {
600+ const p = this.pullRow(number);
601+ const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`);
602+ const mb = baseSha ? await mergeBase(this.repo.store, baseSha, p.head_sha) : null;
603+ return this.repo.policy.changedWorkflow(mb ?? ZERO_OID, p.head_sha);
604+ }
605+
587606 async pullCommits(number: number) {
588607 const p = this.pullRow(number);
589608 const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`);
@@ -647,6 +666,7 @@
647666 const m = this.repo.meta;
648667 this.repo.emit({ type: "pull_request_review", action: "submitted", repoId: m.repoId, owner: m.owner, repo: m.name, number, state, actor: actorOf(caller), at: t });
649668 this.repo.broadcast(`pr:${number}`, { action: "review", state });
669+ this.repo.autoMerge.schedule({ number });
650670 return this.reviews(number).find((x) => x.id === id)!;
651671 }
652672
@@ -708,16 +728,25 @@
708728 seq,
709729 );
710730 for (const p of this.sql.exec<{ number: number }>("SELECT number FROM pulls WHERE head_sha = ?", input.sha).toArray()) this.repo.broadcast(`pr:${p.number}`, { action: "checks" });
711- if (input.status === "completed") {
712- const queueBase = this.repo.queue.baseForGroup(input.sha);
713- if (queueBase) this.repo.queue.schedule(queueBase);
714- }
731+ if (input.status === "completed") this.checkCompleted(input.sha);
715732 return this.checks(input.sha).find((c) => c.id === id)!;
716733 }
717734
735+ /** A check run or commit status finished on `sha`: a merge group may pass, a pull request may be ready to auto-merge. */
736+ checkCompleted(sha: string): void {
737+ const queueBase = this.repo.queue.baseForGroup(sha);
738+ if (queueBase) this.repo.queue.schedule(queueBase);
739+ this.repo.autoMerge.schedule({ sha });
740+ }
741+
718742 // ---------------------------------------------------------------- merging
719743
720- async mergeability(number: number, caller?: Caller): Promise<Mergeability> {
744+ /**
745+ * Whether the pull request can merge now, and if not, why. `automatic`: Diffinery itself would
746+ * land it for `caller` (auto-merge or the merge queue), which an agent may do only where the
747+ * branch allows it (./auto-merge.ts); every other rule applies all the same.
748+ */
749+ async mergeability(number: number, caller?: Caller, opts: { automatic?: boolean } = {}): Promise<Mergeability> {
721750 const r = this.row(number);
722751 const p = this.pullRow(number);
723752 const result: Mergeability = { mergeable: false, state: "unknown", conflicts: [], approvals: 0, requiredApprovals: 0, changesRequested: [], checks: [], blockers: [], mergeBase: null, mergeQueue: null };
@@ -735,25 +764,34 @@
735764 result.linearHistory = !!rules.requireLinearHistory;
736765 result.enforceAdmins = !!rules.enforceAdmins;
737766 result.requiredApprovals = rules.requiredApprovals ?? 0;
767+ // When Diffinery lands it for an agent (./auto-merge.ts), an approval counts only for the commit it
768+ // approved: nobody merges by hand to notice that the agent pushed again after a person approved.
769+ const isDefaultBase = p.base_ref === this.repo.meta.defaultBranch;
770+ const agentLanding = !!opts.automatic && !!caller && landsByAgentRule(caller, this.repo.protectionFor(p.base_ref), isDefaultBase);
771+ const currentOnly = !!rules.dismissStaleReviews || agentLanding;
738772 const latest = new Map<string, Review>();
739773 // Imported reviews are history, not votes, whoever authored them; and so are plain comments.
740774 for (const rv of this.reviews(number)) if (rv.state !== "COMMENTED" && rv.author.kind !== "github" && !rv.imported) latest.set(rv.author.id, rv);
741775 for (const rv of latest.values()) {
742776 // Only people's approvals count toward requirements: agents' reviews are advisory, and a
743777 // workflow's token (kind "system", or "agent" for code an agent wrote) never approves its own way in.
744- if (rv.state === "APPROVED" && rv.author.kind === "user" && (!rules.dismissStaleReviews || rv.commitId === p.head_sha)) result.approvals++;
778+ if (rv.state === "APPROVED" && rv.author.kind === "user" && (!currentOnly || rv.commitId === p.head_sha)) result.approvals++;
745779 if (rv.state === "CHANGES_REQUESTED") result.changesRequested.push(rv.author.login);
746780 }
747781 // Commit statuses count as checks too: a required check matches a status context.
748782 const checks = [...this.checks(p.head_sha), ...this.repo.statuses.asChecks(p.head_sha)];
749783 result.checks = checks.map((c) => ({ name: c.name, state: c.status === "completed" ? (c.conclusion ?? "neutral") : c.status }));
750- if (r.author_kind === "agent" && result.requiredApprovals < 1) {
784+ if (agentLanding && rules.agentAutoMergeWithoutApproval) {
785+ // An admin waived approvals for what Diffinery lands for agents here: green required checks
786+ // are enough (allowAgentAutoMerge always has some). Requested changes still block it below.
787+ result.requiredApprovals = 0;
788+ } else if (r.author_kind === "agent" && result.requiredApprovals < 1) {
751789 // Agent-authored changes always need one human approval unless a rule says otherwise.
752790 result.requiredApprovals = 1;
753791 }
754792 if (p.draft) result.blockers.push("pull request is a draft");
755793 if (result.conflicts.length) result.blockers.push(`merge conflicts in ${result.conflicts.length} file(s)`);
756- if (result.approvals < result.requiredApprovals) result.blockers.push(`needs ${result.requiredApprovals - result.approvals} more approving review(s)`);
794+ if (result.approvals < result.requiredApprovals) result.blockers.push(`needs ${result.requiredApprovals - result.approvals} more approving review(s)${agentLanding ? " of its latest commit" : ""}`);
757795 if (result.changesRequested.length) result.blockers.push(`changes requested by ${result.changesRequested.join(", ")}`);
758796 for (const name of rules.requiredChecks ?? []) {
759797 const c = requiredCheck(name, checks, rules.requiredCheckSources?.[name]);
@@ -767,21 +805,37 @@
767805 const workflowEdit = await this.repo.policy.refuseWorkflowEdit(caller, mb ?? ZERO_OID, p.head_sha);
768806 if (workflowEdit) result.blockers.push(workflowEdit);
769807 const isDefault = p.base_ref === this.repo.meta.defaultBranch;
770- if (caller.kind === "agent" && (isDefault || Object.keys(rules).length) && !rules.agentsMayMerge) result.blockers.push("agents may not merge into this branch; a human must merge");
808+ const protection = this.repo.protectionFor(p.base_ref);
809+ if (opts.automatic && !isPerson(caller)) {
810+ // Agents and workflow tokens never merge here themselves; a rule may let Diffinery land it for them.
811+ const refusal = automaticLandingRefusal(caller, protection, isDefault, p.base_ref);
812+ if (refusal) result.blockers.push(refusal.message);
813+ else if (landsByAgentRule(caller, protection, isDefault)) {
814+ // CI gates what lands, so an agent's change to the workflows that run it needs a person.
815+ const path = await this.repo.policy.changedWorkflow(mb ?? ZERO_OID, p.head_sha);
816+ if (path) result.blockers.push(`the pull request changes workflow file \`${path}\`; a person must merge it`);
817+ }
818+ } else if (caller.kind === "agent" && (isDefault || Object.keys(rules).length) && !rules.agentsMayMerge) result.blockers.push("agents may not merge into this branch; a human must merge");
771819 }
772820 result.mergeable = result.blockers.length === 0;
773821 result.state = p.draft ? "draft" : result.conflicts.length ? "conflicting" : result.mergeable ? "clean" : "blocked";
774822 return result;
775823 }
776824
777- async merge(number: number, input: { method?: "merge" | "squash" | "rebase"; title?: string; message?: string; expectedHeadSha?: string; deleteBranch?: boolean; bypassMergeQueue?: boolean; bypassRules?: boolean }, caller: Caller) {
825+ /** Merge now. `automatic`: auto-merge doing it for `caller` (./auto-merge.ts), which never bypasses anything. */
826+ async merge(
827+ number: number,
828+ input: { method?: "merge" | "squash" | "rebase"; title?: string; message?: string; expectedHeadSha?: string; deleteBranch?: boolean; bypassMergeQueue?: boolean; bypassRules?: boolean },
829+ caller: Caller,
830+ opts: { automatic?: boolean } = {},
831+ ) {
778832 const p = this.pullRow(number);
779833 const r = this.row(number);
780834 if (input.expectedHeadSha && input.expectedHeadSha !== p.head_sha) throw new HttpError(409, "head branch was modified; review and try again");
781- const m = await this.mergeability(number, caller);
835+ const m = await this.mergeability(number, caller, opts);
782836 const rules = this.repo.protectionFor(p.base_ref) ?? {};
783- // Admins (people, not agents) may bypass unmet rules, but only when they ask to.
784- const mayBypass = caller.role === "admin" && !rules.enforceAdmins && caller.kind === "user" && m.conflicts.length === 0 && !p.draft;
837+ // Admins (people, not agents) may bypass unmet rules, but only when they ask to, and never through auto-merge.
838+ const mayBypass = !opts.automatic && caller.role === "admin" && !rules.enforceAdmins && caller.kind === "user" && m.conflicts.length === 0 && !p.draft;
785839 // Each bypass covers only what it names: bypass_merge_queue skips the queue, bypass_rules skips unmet rules.
786840 const bypassQueue = mayBypass && !!input.bypassMergeQueue;
787841 const adminBypass = mayBypass && !!input.bypassRules;
@@ -814,9 +868,11 @@
814868 const title = input.title ?? (method === "squash" ? `${r.title} (#${number})` : `Merge pull request #${number} from ${p.head_ref}`);
815869 const body = input.message ?? (method === "squash" ? (await this.pullCommits(number)).map((c) => `* ${c.message.split("\n")[0]}`).join("\n") : r.title);
816870 const author = method === "squash" ? squashSig({ login: r.author_login, kind: r.author_kind }, sig) : sig;
871+ // Like every commit an agent makes, its merge commit says who it acts for.
872+ const extraHeaders: [string, string][] = caller.kind === "agent" ? [["diffinery-agent", `${caller.login}${caller.onBehalfOf ? ` on-behalf-of ${caller.onBehalfOf}` : ""}`]] : [];
817873 newSha = await store.write(
818874 "commit",
819- serializeCommit({ tree: merged.tree, parents: method === "squash" ? [baseSha] : [baseSha, p.head_sha], author, committer: sig, message: `${title}\n\n${body}\n` }),
875+ serializeCommit({ tree: merged.tree, parents: method === "squash" ? [baseSha] : [baseSha, p.head_sha], author, committer: sig, message: `${title}\n\n${body}\n`, extraHeaders }),
820876 );
821877 }
822878 if (!this.repo.casRef(baseRef, baseSha, newSha)) throw new HttpError(409, "base branch moved during merge; try again");
@@ -830,7 +886,7 @@
830886 const r = this.row(number);
831887 const baseRef = `refs/heads/${p.base_ref}`;
832888 const t = now();
833- this.sql.exec("UPDATE pulls SET merged = 1, merged_at = ?, merged_by = ?, merge_commit = ? WHERE number = ?", t, caller.login, newSha, number);
889+ this.sql.exec("UPDATE pulls SET merged = 1, merged_at = ?, merged_by = ?, merge_commit = ?, auto_merge = NULL WHERE number = ?", t, caller.login, newSha, number);
834890 if (this.repo.queue.isQueued(number)) this.repo.queue.schedule(p.base_ref);
835891 this.sql.exec("UPDATE issues SET state = 'closed', state_reason = 'merged', closed_at = ?, updated_at = ? WHERE number = ?", t, t, number);
836892 this.repo.social.record(number, "merged", caller, { commit_id: newSha });
modifiedapps/diffinery/src/repo/extra-triggers.ts+2−1View file
@@ -1,5 +1,6 @@
11 import { matchesTrigger } from "@diffinery/workflow-yaml";
22 import type { RepoDO } from "./RepoDO.js";
3+import { isJobCaller } from "./token-policy.js";
34 import type { Caller } from "./types.js";
45
56 /**
@@ -14,7 +15,7 @@
1415 ev: { name: string; action?: string; ref: string; sha: string; payload: Record<string, unknown>; title: string },
1516 caller: Caller,
1617 ): Promise<number> {
17- if (caller.kind === "system" && caller.id.startsWith("job:") && ev.name !== "repository_dispatch") return 0;
18+ if (isJobCaller(caller) && ev.name !== "repository_dispatch") return 0;
1819 let started = 0;
1920 let payload: Record<string, unknown> | undefined;
2021 try {
modifiedapps/diffinery/src/repo/gitdata.ts+1−0View file
@@ -588,6 +588,7 @@
588588 c.id,
589589 );
590590 for (const p of this.sql.exec<{ number: number }>("SELECT number FROM pulls WHERE head_sha = ?", c.sha).toArray()) this.repo.broadcast(`pr:${p.number}`, { action: "checks" });
591+ if (status === "completed") this.repo.collab.checkCompleted(c.sha);
591592 return this.check(c.id);
592593 }
593594
modifiedapps/diffinery/src/repo/queue.ts+5−1View file
@@ -1,5 +1,6 @@
11 import { ZERO_OID, mergeBase, mergeTrees, readCommit, serializeCommit } from "@diffinery/git-core";
22 import { squashSig } from "./collab.js";
3+import { automaticLandingRefusal } from "./auto-merge.js";
34 import { requiredCheck } from "./required.js";
45 import type { Actor } from "@diffinery/shared";
56 import { HttpError, now } from "../lib/util.js";
@@ -157,7 +158,10 @@
157158 const settings = this.settings(pull.base);
158159 if (!settings) throw new HttpError(422, `${pull.base} does not use a merge queue; merge the pull request directly`);
159160 if (this.row(number)) return this.result(number);
160- const m = await this.repo.collab.mergeability(number, caller);
161+ // The queue lands the pull request for whoever enqueued it: agents and workflow tokens only where the branch allows it.
162+ const refusal = automaticLandingRefusal(caller, this.repo.protectionFor(pull.base), pull.base === this.repo.meta.defaultBranch, pull.base);
163+ if (refusal) throw new HttpError(refusal.status, refusal.message);
164+ const m = await this.repo.collab.mergeability(number, caller, { automatic: true });
161165 if (!m.mergeable) throw new HttpError(405, `pull request cannot be added to the merge queue: ${m.blockers.join("; ")}`, { blockers: m.blockers });
162166 const method = input.method ?? settings.method ?? "merge";
163167 if (method === "merge" && this.repo.protectionFor(pull.base)?.requireLinearHistory) throw new HttpError(422, `${pull.base} requires linear history; use squash or rebase`);
modifiedapps/diffinery/src/repo/schema.ts+2−0View file
@@ -180,6 +180,8 @@
180180 ["labels", "imported", "TEXT"],
181181 ["milestones", "imported", "TEXT"],
182182 ["protections", "imported", "TEXT"],
183+ // Auto-merge while it is on (repo/auto-merge.ts AutoMergeState as JSON); null when off.
184+ ["pulls", "auto_merge", "TEXT"],
183185 ];
184186
185187 export function migrateRepoSchema(sql: SqlStorage): void {
modifiedapps/diffinery/src/repo/social.ts+2−0View file
@@ -354,6 +354,8 @@
354354 this.sql.exec("UPDATE reviews SET state = 'DISMISSED' WHERE id = ?", r.id);
355355 this.record(number, "review_dismissed", caller, { dismissed_review: { state: r.state.toLowerCase(), review_id: r.seq ?? r.id, dismissal_message: message } });
356356 this.repo.broadcast(`pr:${number}`, { action: "review", state: "DISMISSED" });
357+ // A dismissed change request may have been the last thing holding back auto-merge.
358+ this.repo.autoMerge.schedule({ number });
357359 return this.review(number, r.id);
358360 }
359361
modifiedapps/diffinery/src/repo/statuses.ts+1−4View file
@@ -101,10 +101,7 @@
101101 at: t,
102102 });
103103 for (const p of this.sql.exec<{ number: number }>("SELECT number FROM pulls WHERE head_sha = ?", sha).toArray()) this.repo.broadcast(`pr:${p.number}`, { action: "checks" });
104- if (state !== "pending") {
105- const queueBase = this.repo.queue.baseForGroup(sha);
106- if (queueBase) this.repo.queue.schedule(queueBase);
107- }
104+ if (state !== "pending") this.repo.collab.checkCompleted(sha);
108105 return status;
109106 }
110107
modifiedapps/diffinery/src/repo/types.ts+13−0View file
@@ -39,6 +39,19 @@
3939 agentsMayPush?: boolean;
4040 /** Agents may merge pull requests into matching branches. Default false. */
4141 agentsMayMerge?: boolean;
42+ /**
43+ * Agents (and workflow tokens) may have Diffinery land a pull request here for them, through
44+ * auto-merge or the merge queue, once every rule is met. Needs at least one required check.
45+ * Only people who administer the repository change it. Default false.
46+ */
47+ allowAgentAutoMerge?: boolean;
48+ /**
49+ * With `allowAgentAutoMerge`: what Diffinery lands for an agent through auto-merge or the merge
50+ * queue needs no approving review from a person; green required checks are enough. A person's
51+ * requested changes still block it, and a change to a workflow file still needs a person to merge.
52+ * Only people who administer the repository change it, and every change is audited. Default false.
53+ */
54+ agentAutoMergeWithoutApproval?: boolean;
4255 /** Pull requests merge through a merge queue (like GitHub's "Require merge queue"). */
4356 mergeQueue?: MergeQueueSettings;
4457 }
modifiedapps/diffinery/src/web/account.tsx+42−2View file
@@ -4,6 +4,7 @@
44 import { requireAccount, type AppContext } from "../auth/principal.js";
55 import { baseRole, type Account, type Repo, type TokenScope } from "../lib/directory.js";
66 import { loadRepo } from "../http/common.js";
7+import { movedLoginPath } from "../lib/redirects.js";
78 import { HttpError } from "../lib/util.js";
89 import type { Role } from "../repo/types.js";
910 import { ICONS } from "./assets.js";
@@ -1565,7 +1566,7 @@
15651566 const me = requireAccount(c);
15661567 const dir = c.get("dir");
15671568 const org = await dir.accountByLogin(prm(c, "owner"));
1568- if (!org) throw new HttpError(404, "not found");
1569+ if (!org || (org.kind === "org" && org.status === "disabled")) throw new HttpError(404, "not found");
15691570 if (org.kind !== "org") throw new HttpError(404, "not an organization");
15701571 const role = await dir.orgRole(org.id, me.id);
15711572 const isAdmin = !!me.site_admin || role === "owner" || role === "admin";
@@ -1578,7 +1579,15 @@
15781579 const dir = c.get("dir");
15791580 const t = csrfToken(c);
15801581 const acct = await dir.accountByLogin(prm(c, "owner"));
1581- if (!acct) throw new HttpError(404, "not found");
1582+ if (!acct) {
1583+ // A renamed person's or organization's old login leads to the new one.
1584+ const renamed = await dir.renamedAccount(prm(c, "owner"));
1585+ const url = new URL(c.req.url);
1586+ if (renamed) return c.redirect(`${movedLoginPath(url.pathname, prm(c, "owner"), renamed.login) ?? `/${renamed.login}`}${url.search}`, 301);
1587+ throw new HttpError(404, "not found");
1588+ }
1589+ // A deleted organization keeps its account row for the audit log (Directory.deleteOrg); its page is gone.
1590+ if (acct.kind === "org" && acct.status === "disabled") throw new HttpError(404, "not found");
15821591 if (acct.login !== prm(c, "owner")) return c.redirect(`/${acct.login}${new URL(c.req.url).search}`, 301);
15831592
15841593 if (acct.kind === "agent") {
@@ -1832,6 +1841,21 @@
18321841 <button class="btn primary">Save</button>
18331842 </div>
18341843 </Form>
1844+ <div class="box" style="border-color:color-mix(in srgb,var(--red) 45%,transparent)">
1845+ <div class="head" style="background:var(--red-soft)">
1846+ <h2 class="bad">Danger zone</h2>
1847+ </div>
1848+ <Form action={`${base}/-/org/delete`} t={t} match={acct.login} class="body">
1849+ <b>Delete this organization</b>
1850+ <p class="muted small">
1851+ Transfer or delete its repositories and container images first. Its members, invitations, secrets and variables are removed and its agents disabled; repositories transferred out of it keep their old URLs working. Type <b class="mono">{acct.login}</b> to confirm.
1852+ </p>
1853+ <div class="row">
1854+ <input name="confirm" type="text" required autocomplete="off" style="max-width:320px" aria-label="Confirm organization name" />
1855+ <button class="btn danger">Delete this organization</button>
1856+ </div>
1857+ </Form>
1858+ </div>
18351859 </>
18361860 );
18371861 }
@@ -1915,6 +1939,22 @@
19151939 }, "Organization updated.");
19161940 });
19171941
1942+ post(web, "/:owner/-/org/delete", async (c, f) => {
1943+ const { me, org, isOwner, dir } = await orgContext(c);
1944+ return act(
1945+ c,
1946+ `/${org.login}?tab=settings`,
1947+ async () => {
1948+ if (!isOwner) throw new HttpError(403, "organization owners only");
1949+ if (f.s("confirm").trim() !== org.login) throw new HttpError(422, `type ${org.login} to confirm deletion`);
1950+ await dir.deleteOrg(org);
1951+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.delete", target: org.login }));
1952+ return "/";
1953+ },
1954+ `Deleted ${org.login}.`,
1955+ );
1956+ });
1957+
19181958 avatarSizeGuard(web, "/:owner/-/org/avatar", (c) => `/${c.req.param("owner")}?tab=settings`);
19191959 post(web, "/:owner/-/org/avatar", async (c, f) => {
19201960 const { me, org, isOwner } = await orgContext(c);
modifiedapps/diffinery/src/web/collab.tsx+69−3View file
@@ -776,6 +776,7 @@
776776 </ul>
777777 ) : null}
778778 </div>
779+ <AutoMergeRow x={x} canAct={isAccount && atLeast(caller.role, "write") && !x.l.repo.archived} queue={!!m.mergeQueue} />
779780 {m.mergeQueue ? (
780781 <QueueBox x={x} m={m} canAct={isAccount && atLeast(caller.role, "write") && !x.l.repo.archived} adminBypass={caller.role === "admin" && caller.kind === "user" && !m.enforceAdmins && m.conflicts.length === 0 && !p.draft} />
781782 ) : isAccount && atLeast(caller.role, "write") && !x.l.repo.archived ? (
@@ -813,12 +814,54 @@
813814 </button>
814815 )}
815816 </Form>
817+ {!m.mergeable && !p.autoMerge && m.conflicts.length === 0 && !p.draft ? <EnableAutoMerge x={x} linearHistory={!!m.linearHistory} /> : null}
816818 </div>
817819 ) : null}
818820 </div>
819821 );
820822 };
821823
824+/** While auto-merge is on: who turned it on, how it will merge, and the button to turn it off. */
825+const AutoMergeRow: FC<{ x: ConvCtx; canAct: boolean; queue: boolean }> = ({ x, canAct, queue }) => {
826+ const a = x.issue.pull?.autoMerge;
827+ if (!a) return null;
828+ const by = a.enabledBy;
829+ const text = (
830+ <>
831+ <Status status="in_progress" />
832+ <span class="grow">
833+ <b>Auto-merge is on</b>
834+ <span class="muted">
835+ {" "}
836+ · turned on by <Actor login={by.login} kind={by.kind} onBehalfOf={by.onBehalfOf} avatar={false} /> · {queue ? `joins the merge queue (${METHOD_LABELS[a.method].toLowerCase()}) once every requirement is met` : `${METHOD_LABELS[a.method].toLowerCase()} once every requirement is met`}
837+ </span>
838+ </span>
839+ </>
840+ );
841+ return canAct ? (
842+ <Form action={`${repoUrl(x.l.repo)}/pull/${x.issue.number}/auto-merge/disable`} t={csrfToken(x.c)} class="row">
843+ {text}
844+ <button class="btn">Disable auto-merge</button>
845+ </Form>
846+ ) : (
847+ <div class="row">{text}</div>
848+ );
849+};
850+
851+/** Auto-merge: merge (or join the merge queue) by itself once checks and reviews allow it. */
852+const EnableAutoMerge: FC<{ x: ConvCtx; linearHistory: boolean; queueMethod?: "merge" | "squash" | "rebase" }> = ({ x, linearHistory, queueMethod }) => (
853+ <Form action={`${repoUrl(x.l.repo)}/pull/${x.issue.number}/auto-merge`} t={csrfToken(x.c)} class="row" style="margin-top:8px">
854+ <span class="muted small grow">{queueMethod ? "Join the merge queue by itself once every requirement is met." : "Merge by itself once every requirement is met."}</span>
855+ <KSelect
856+ name="method"
857+ label="Auto-merge method"
858+ value={queueMethod ?? (linearHistory ? "squash" : "merge")}
859+ options={(["merge", "squash", "rebase"] as const).filter((v) => !(linearHistory && v === "merge")).map((v) => ({ value: v, label: METHOD_LABELS[v] }))}
860+ />
861+ <button class="btn">Enable auto-merge</button>
862+ </Form>
863+);
864+
822865 const METHOD_LABELS = { merge: "Create a merge commit", squash: "Squash and merge", rebase: "Rebase and merge" } as const;
823866
824867 /** The merge box's footer when the base branch merges through a merge queue. */
@@ -890,16 +933,24 @@
890933 </button>
891934 )}
892935 </Form>
936+ {!m.mergeable && !p.autoMerge && m.conflicts.length === 0 && !p.draft ? <EnableAutoMerge x={x} linearHistory={!!m.linearHistory} queueMethod={q.method} /> : null}
893937 <div class="row muted small" style="margin-top:6px">
894938 <span class="grow">
895939 {p.base} merges through a <a href={queueUrl}>merge queue</a>: the pull request is tested together with the ones ahead of it before it merges.
896940 </span>
897941 {adminBypass ? (
898- <Form action={`${base}/pull/${i.number}/merge`} t={csrfToken(x.c)} confirm="Merge directly, bypassing the merge queue?">
942+ // Skipping the queue alone merges only a pull request that meets the rules; otherwise the button
943+ // says it also bypasses them, and asks for that (audited) explicitly.
944+ <Form
945+ action={`${base}/pull/${i.number}/merge`}
946+ t={csrfToken(x.c)}
947+ confirm={m.mergeable ? "Merge directly, bypassing the merge queue?" : "Merge directly, bypassing the merge queue and the unmet requirements?"}
948+ >
899949 <input type="hidden" name="expected_head" value={p.headSha} />
900- <input type="hidden" name="method" value={q.method} />
950+ <input type="hidden" name="method" value={m.linearHistory && q.method === "merge" ? "squash" : q.method} />
901951 <input type="hidden" name="bypass_queue" value="1" />
902- <button class="btn sm danger">Merge now (bypass the queue)</button>
952+ {m.mergeable ? null : <input type="hidden" name="bypass_rules" value="1" />}
953+ <button class="btn sm danger">{m.mergeable ? "Merge now (bypass the queue)" : "Merge now (bypass the queue and the unmet requirements)"}</button>
903954 </Form>
904955 ) : null}
905956 </div>
@@ -1345,6 +1396,21 @@
13451396 });
13461397 });
13471398
1399+ post(web, "/:owner/:repo/pull/:n/auto-merge", async (c, f) => {
1400+ const l = await forAction(c, "write");
1401+ const n = issueNumber(c);
1402+ return act(c, `${repoUrl(l.repo)}/pull/${n}`, async () => {
1403+ const r = await l.stub.enableAutoMerge(n, { method: f.s("method") || undefined }, l.caller);
1404+ flash(c, "notice", r.state === "merged" ? "Pull request merged." : r.state === "queued" ? "Added to the merge queue." : "Auto-merge is on: it merges once every requirement is met.");
1405+ });
1406+ });
1407+
1408+ post(web, "/:owner/:repo/pull/:n/auto-merge/disable", async (c) => {
1409+ const l = await forAction(c, "write");
1410+ const n = issueNumber(c);
1411+ return act(c, `${repoUrl(l.repo)}/pull/${n}`, () => l.stub.disableAutoMerge(n, l.caller), "Auto-merge is off.");
1412+ });
1413+
13481414 post(web, "/:owner/:repo/pull/:n/dequeue", async (c) => {
13491415 const l = await forAction(c, "write");
13501416 const n = issueNumber(c);
modifiedapps/diffinery/src/web/repo-settings.tsx+78−5View file
@@ -6,7 +6,7 @@
66 import { HttpError } from "../lib/util.js";
77 import type { ProtectionRules, Role } from "../repo/types.js";
88 import { ICONS } from "./assets.js";
9-import { KCheckbox, KRadio, KSelect } from "./kumo.js";
9+import { KCheckbox, KRadio, KSelect, type KOption } from "./kumo.js";
1010 import { csrfToken, type FormData } from "./csrf.js";
1111 import { Actor, CopyButton, Form, PendingInvites, ROLES, Time, cap, act, ago, flash, post, refNames, render, repoUrl, statusOf, type WebApp, prm } from "./ui.js";
1212
@@ -82,6 +82,16 @@
8282 ["enforceAdmins", "Do not allow administrators to bypass these rules", ""],
8383 ["agentsMayPush", "Allow agents to push directly", "By default agents must open pull requests against protected and default branches."],
8484 ["agentsMayMerge", "Allow agents to merge pull requests", "By default a human must merge into protected and default branches."],
85+ [
86+ "allowAgentAutoMerge",
87+ "Allow agents to land pull requests through auto-merge",
88+ "Agents may turn on auto-merge or add pull requests to the merge queue; Diffinery merges once every rule is met, with no bypass. Needs at least one required status check.",
89+ ],
90+ [
91+ "agentAutoMergeWithoutApproval",
92+ "Let agents' auto-merges land without an approving review",
93+ "With the setting above: green required checks are enough for what Diffinery lands for agents. Requested changes still block it, and a change to a workflow file still needs a person.",
94+ ],
8595 ];
8696
8797 const RuleForm: FC<{ c: AppContext; base: string; pattern?: string; rules?: ProtectionRules }> = ({ c, base, pattern, rules = {} }) => (
@@ -184,11 +194,22 @@
184194
185195 // ---------------------------------------------------------------- pages
186196
197+/** Where the signed-in person may transfer a repository: their own account and the organizations they own or administer. */
198+async function transferTargets(c: AppContext, l: Loaded): Promise<KOption[]> {
199+ const p = c.get("principal");
200+ if (p.type !== "account" || p.account.kind !== "user" || l.caller.kind !== "user") return [];
201+ const me = p.account;
202+ // A personal repository is transferred by its owner only.
203+ if (l.repo.owner_kind !== "org" && l.repo.owner_id !== me.id && !me.site_admin) return [];
204+ const orgs = (await c.get("dir").orgsFor(me.id)).filter((o) => o.role === "owner" || o.role === "admin");
205+ return [{ value: me.login, label: `${me.login} (your account)` }, ...orgs.map((o) => ({ value: o.login, label: o.login }))].filter((o) => o.value.toLowerCase() !== l.repo.owner_login.toLowerCase());
206+}
207+
187208 async function generalPage(c: AppContext, l: Loaded) {
188209 const { repo } = l;
189210 const base = repoUrl(repo);
190211 const t = csrfToken(c);
191- const names = await refNames(l.stub);
212+ const [names, targets] = await Promise.all([refNames(l.stub), transferTargets(c, l)]);
192213 const full = `${repo.owner_login}/${repo.name}`;
193214 return page(
194215 c,
@@ -243,6 +264,38 @@
243264 <button class="btn danger">{repo.archived ? "Unarchive" : "Archive"}</button>
244265 </Form>
245266 <hr />
267+ <Form action={`${base}/settings/transfer`} t={t} match={full}>
268+ <b>Transfer ownership</b>
269+ <p class="muted small">
270+ Move this repository to your account or an organization you own or administer. Its history, issues, pull requests, runs, secrets and settings move with it, and its old URLs redirect to the new ones. People who only had access through {repo.owner_login} lose it.
271+ </p>
272+ {targets.length ? (
273+ <>
274+ <div class="formrow">
275+ <div class="field">
276+ <label for="new_owner">New owner</label>
277+ <KSelect id="new_owner" name="new_owner" label="New owner" placeholder="Choose an owner" options={targets} />
278+ </div>
279+ <div class="field grow">
280+ <label for="new_name">
281+ New name <span class="faint">(optional)</span>
282+ </label>
283+ <input id="new_name" name="new_name" type="text" pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,99}" placeholder={repo.name} autocomplete="off" />
284+ </div>
285+ </div>
286+ <p class="muted small">
287+ Type <b class="mono">{full}</b> to confirm.
288+ </p>
289+ <div class="row">
290+ <input name="confirm" type="text" required autocomplete="off" style="max-width:320px" aria-label="Confirm repository name" />
291+ <button class="btn danger">Transfer</button>
292+ </div>
293+ </>
294+ ) : (
295+ <p class="muted small m0">{repo.owner_kind !== "org" && l.caller.id !== repo.owner_id ? `Only ${repo.owner_login} can transfer this repository.` : "You don't own or administer another account or organization to transfer it to."}</p>
296+ )}
297+ </Form>
298+ <hr />
246299 <Form action={`${base}/settings/delete`} t={t} match={full}>
247300 <b>Delete this repository</b>
248301 <p class="muted small">
@@ -694,6 +747,26 @@
694747 );
695748 });
696749
750+ post(web, "/:owner/:repo/settings/transfer", async (c, f) => {
751+ const l = await load(c, { whileArchived: true });
752+ const me = requireAccount(c);
753+ const full = `${l.repo.owner_login}/${l.repo.name}`;
754+ return act(
755+ c,
756+ `${repoUrl(l.repo)}/settings`,
757+ async () => {
758+ if (f.s("confirm").trim() !== full) throw new HttpError(422, `type ${full} to confirm the transfer`);
759+ const dir = c.get("dir");
760+ const target = await dir.accountByLogin(f.s("new_owner").trim());
761+ if (!target) throw new HttpError(422, "choose the new owner");
762+ const moved = await dir.transferRepo(l.repo, me, target, f.s("new_name").trim() || undefined);
763+ audit(c, { ...l, repo: moved }, "repo.transfer", { from: full, to: `${moved.owner_login}/${moved.name}` });
764+ flash(c, "notice", `Transferred ${full} to ${moved.owner_login}/${moved.name}.`);
765+ return `${repoUrl(moved)}/settings`;
766+ },
767+ );
768+ });
769+
697770 post(web, "/:owner/:repo/settings/access", async (c, f) => {
698771 const l = await load(c);
699772 const dir = c.get("dir");
@@ -750,8 +823,8 @@
750823 const kept = Object.fromEntries(Object.entries(before).filter(([name]) => rules.requiredChecks!.includes(name)));
751824 if (Object.keys(kept).length) rules.requiredCheckSources = kept;
752825 }
753- if (original && original !== pattern) await l.stub.setProtection(original, null);
754- await l.stub.setProtection(pattern, rules);
826+ if (original && original !== pattern) await l.stub.setProtection(original, null, l.caller);
827+ await l.stub.setProtection(pattern, rules, l.caller);
755828 audit(c, l, "repo.protection.set", { pattern, rules });
756829 },
757830 "Branch protection saved.",
@@ -761,7 +834,7 @@
761834 post(web, "/:owner/:repo/settings/branches/delete", async (c, f) => {
762835 const l = await load(c);
763836 return act(c, `${repoUrl(l.repo)}/settings/branches`, async () => {
764- await l.stub.setProtection(f.s("pattern"), null);
837+ await l.stub.setProtection(f.s("pattern"), null, l.caller);
765838 audit(c, l, "repo.protection.delete", { pattern: f.s("pattern") });
766839 });
767840 });
modifiedapps/diffinery/src/web/ui.tsx+3−1View file
@@ -8,7 +8,7 @@
88 import type { AppContext, AppVars } from "../auth/principal.js";
99 import { HttpError, randomToken } from "../lib/util.js";
1010 import { atLeast, type Caller } from "../repo/types.js";
11-import type { repoStub } from "../http/common.js";
11+import { RepoMoved, type repoStub } from "../http/common.js";
1212 import { CSS, ICONS, JS, type IconName } from "./assets.js";
1313 import { CSRF_FIELD, csrfToken, verifyCsrf, type FormData } from "./csrf.js";
1414 import { fenceLangs, renderMarkdown, type MarkdownBase } from "./markdown.js";
@@ -513,6 +513,8 @@
513513
514514 /** Friendly error page for HttpErrors (and DO errors carrying a status prefix). */
515515 export function errorPage(c: AppContext, e: unknown): Response | Promise<Response> {
516+ // A transferred or renamed repository: its old URLs lead to the new ones.
517+ if (e instanceof RepoMoved) return c.redirect(e.location, e.status as 301 | 307);
516518 const s = statusOf(e);
517519 if (s.status >= 500) console.error(e);
518520 // Signed-out visitors who open a page go straight to sign-in, and come back afterwards.
modifiedapps/diffinery/test/e2e/e2e.test.mjs+113−0View file
@@ -653,6 +653,119 @@
653653 assert.equal((await runs()).length, 2, "the follower does not trigger itself");
654654 });
655655
656+test("triggers from workflows: a job's repository_dispatch starts runs, a follower of agent code stays an agent's", { timeout: 600_000 }, async () => {
657+ const owner = (await ok("GET", "/user")).login;
658+ await ok("POST", "/user/repos", { name: "relay" });
659+ const bot = (await ok("POST", "/diffinery/agents", { login: "relay-bot" })).token.token;
660+ const src = join(work, "relay");
661+ mkdirSync(join(src, ".github/workflows"), { recursive: true });
662+ writeFileSync(
663+ join(src, ".github/workflows/send.yml"),
664+ `name: Send
665+on: workflow_dispatch
666+jobs:
667+ send:
668+ runs-on: [self-hosted, linux]
669+ permissions: { contents: write }
670+ steps:
671+ - env: { GH_TOKEN: "\${{ github.token }}" }
672+ run: |
673+ code=$(curl -s -o out.txt -w '%{http_code}' -X POST -H "authorization: Bearer $GH_TOKEN" -H 'content-type: application/json' -d '{"event_type":"ping","client_payload":{"from":"a job"}}' "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/dispatches")
674+ echo "dispatch: $code $(cat out.txt)"
675+`,
676+ );
677+ writeFileSync(
678+ join(src, ".github/workflows/pinged.yml"),
679+ `name: Pinged
680+on:
681+ repository_dispatch:
682+ types: [ping]
683+jobs:
684+ pinged:
685+ runs-on: [self-hosted, linux]
686+ steps:
687+ - run: echo "pinged from \${{ github.event.client_payload.from }}"
688+`,
689+ );
690+ writeFileSync(join(src, ".github/workflows/ci.yml"), `name: CI\non: pull_request\njobs:\n build:\n runs-on: [self-hosted, linux]\n steps:\n - run: echo built\n`);
691+ writeFileSync(
692+ join(src, ".github/workflows/after.yml"),
693+ `name: After CI
694+on:
695+ workflow_run:
696+ workflows: [CI]
697+ types: [completed]
698+jobs:
699+ follow:
700+ runs-on: [self-hosted, linux]
701+ permissions: { contents: write }
702+ steps:
703+ - uses: actions/checkout@v4
704+ with: { ref: main }
705+ - env:
706+ GIT_AUTHOR_NAME: bot
707+ GIT_AUTHOR_EMAIL: bot@example.com
708+ GIT_COMMITTER_NAME: bot
709+ GIT_COMMITTER_EMAIL: bot@example.com
710+ run: |
711+ echo "after \${{ github.event.workflow_run.head_branch }}" >> followed.txt
712+ git add followed.txt && git commit -qm followed
713+ if git push -q origin HEAD:main 2>err.txt; then echo "main push: ok"; else echo "main push: refused $(tr '\\n' ' ' < err.txt)"; fi
714+`,
715+ );
716+ git(src, ["init", "-q", "-b", "main"]);
717+ git(src, ["add", "."]);
718+ git(src, ["commit", "-q", "-m", "relay"]);
719+ git(src, ["remote", "add", "origin", `${base}/${owner}/relay.git`]);
720+ git(src, ["push", "-q", "origin", "main"]);
721+
722+ const runs = async () => (await ok("GET", `/repos/${owner}/relay/actions/runs`)).workflow_runs;
723+ const logOf = async (claim) => (await (await fetch(`${base}/api/v3/repos/${owner}/relay/actions/jobs/${claim.job_id}/logs`, { headers: { authorization: `Bearer ${token}` } })).text()).replace(/^\S+Z /gm, "");
724+
725+ // Like GitHub, a job's GITHUB_TOKEN may send repository_dispatch, and that starts runs.
726+ await ok("POST", `/repos/${owner}/relay/actions/workflows/send.yml/dispatches`, { ref: "main" });
727+ const send = await runOneJob(owner, "relay");
728+ assert.equal(send.claim.name, "send");
729+ assert.match(await logOf(send.claim), /dispatch: 204/);
730+ const pingedRun = await until("repository_dispatch run", async () => (await runs()).find((r) => r.event === "repository_dispatch"));
731+ assert.equal(pingedRun.name, "Pinged");
732+ const pinged = await runOneJob(owner, "relay");
733+ assert.equal(pinged.claim.name, "pinged");
734+ assert.match(await logOf(pinged.claim), /pinged from a job/);
735+
736+ /** Opens a pull request from `branch` as a person, runs its CI, then the follower; returns the follower's log. */
737+ const follow = async (branch) => {
738+ const seen = new Set((await runs()).map((r) => r.id));
739+ await ok("POST", `/repos/${owner}/relay/pulls`, { title: branch, head: branch, base: "main" });
740+ await until("CI run", async () => (await runs()).find((r) => !seen.has(r.id) && r.name === "CI"));
741+ const build = await runOneJob(owner, "relay");
742+ assert.equal(build.claim.name, "build");
743+ await until("follower run", async () => (await runs()).find((r) => !seen.has(r.id) && r.event === "workflow_run"));
744+ const f = await runOneJob(owner, "relay");
745+ assert.equal(f.claim.name, "follow");
746+ return logOf(f.claim);
747+ };
748+
749+ // A person's pull request: the follower writes to main with the token it was granted.
750+ git(src, ["checkout", "-q", "-b", "person-work"]);
751+ writeFileSync(join(src, "person.txt"), "from a person\n");
752+ git(src, ["add", "person.txt"]);
753+ git(src, ["commit", "-q", "-m", "person change"]);
754+ git(src, ["push", "-q", "origin", "person-work"]);
755+ assert.match(await follow("person-work"), /main push: ok/);
756+
757+ // A person opens a pull request from a branch an agent pushed. CI runs the agent's code, so
758+ // the follower, which acts on what CI produced, is an agent's too and may not push to main.
759+ const agentClone = join(work, "relay-agent");
760+ git(work, ["clone", "-q", `${base}/${owner}/relay.git`, agentClone], bot);
761+ git(agentClone, ["checkout", "-q", "-b", "agent-work"], bot);
762+ writeFileSync(join(agentClone, "agent.txt"), "from the agent\n");
763+ git(agentClone, ["add", "agent.txt"], bot);
764+ git(agentClone, ["commit", "-q", "-m", "agent change"], bot);
765+ git(agentClone, ["push", "-q", "origin", "agent-work"], bot);
766+ assert.match(await follow("agent-work"), /main push: refused .*agents may not push to protected or default branches/);
767+});
768+
656769 test("pull requests: test merge commits, pull_request_target and the merge queue", async () => {
657770 const owner = (await ok("GET", "/user")).login;
658771 await ok("POST", "/user/repos", { name: "queue" });
modifiedapps/diffinery/test/e2e/security.test.mjs+513−3View file
@@ -15,8 +15,12 @@
1515 // be marked ready; the exact branch protection rule wins over a pattern.
1616 // Push protection also covers commits made without git (web editor, REST, MCP).
1717 // Repository-limited tokens can't mint wider ones; settings changed through the
18-// API are audited; readers' labels and assignees are dropped.
19-import { spawn, execFileSync } from "node:child_process";
18+// API are audited; readers' labels and assignees are dropped. Repository
19+// transfers keep a repository's data and leave redirects (web, REST, git, MCP,
20+// images) that only readers can follow; emptied organizations can be deleted.
21+// Agents land changes on protected branches only through auto-merge (or the
22+// merge queue) where a person allowed it and a required check gates it, never by bypass.
23+import { spawn, spawnSync, execFileSync } from "node:child_process";
2024 import { createHash, randomBytes } from "node:crypto";
2125 import { mkdtempSync, rmSync } from "node:fs";
2226 import { tmpdir } from "node:os";
@@ -34,7 +38,7 @@
3438
3539 const hash = (s) => createHash("sha256").update(s).digest("hex");
3640 const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`;
37-const T = { olive: tok("olive"), bot: tok("bot"), rita: tok("rita"), dora: tok("dora"), orphan: tok("orphan"), reader: tok("reader"), ritaAdmin: tok("ritaAdmin"), milo: tok("milo"), sam: tok("sam") };
41+const T = { olive: tok("olive"), bot: tok("bot"), rita: tok("rita"), dora: tok("dora"), orphan: tok("orphan"), reader: tok("reader"), ritaAdmin: tok("ritaAdmin"), milo: tok("milo"), sam: tok("sam"), helper: tok("helper") };
3842 const S = { penny: `penny-session-${randomBytes(12).toString("hex")}`, dora: `dora-session-${randomBytes(12).toString("hex")}`, olive: `olive-session-${randomBytes(12).toString("hex")}` };
3943 const OWNER = "olive";
4044
@@ -126,6 +130,8 @@
126130 account("o-shop", "shop", "org", "active"),
127131 account("u-sam", "sam", "user", "active"),
128132 token("t9", "u-sam", T.sam, ["admin"]),
133+ account("a-helper", "olive-helper", "agent", "active", "u-olive"),
134+ token("t10", "a-helper", T.helper, ["write"]),
129135 `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-shop', 'u-olive', 'owner', '${at}'), ('o-shop', 'u-sam', 'member', '${at}')`,
130136 ].join("; "),
131137 );
@@ -675,3 +681,507 @@
675681 await until("the secret's audit entry", async () => auditLog("org.secret.delete").some((e) => JSON.parse(e.detail).name === "DEPLOY_KEY"));
676682 assert.ok(!auditLog("org.secret.delete").some((e) => JSON.parse(e.detail).name === "NEVER_SET"));
677683 });
684+
685+/** A request that doesn't follow redirects: as a token, or as olive's browser session (`{ cookie: true }`). */
686+async function manual(method, url, { as = T.olive, body } = {}) {
687+ const headers = { "content-type": "application/json" };
688+ if (typeof as === "string") headers.authorization = `Bearer ${as}`;
689+ else if (as?.cookie) headers.cookie = `__Host-dfy_session=${S.olive}`;
690+ const res = await fetch(url.startsWith("http") ? url : `${base}${url}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: "manual" });
691+ const text = await res.text();
692+ let json = null;
693+ try {
694+ json = text ? JSON.parse(text) : null;
695+ } catch {}
696+ return { status: res.status, location: res.headers.get("location"), json, text };
697+}
698+
699+/** Runs git, returning its stderr; fails the test when git fails. */
700+function gitRun(args, cwd) {
701+ const r = spawnSync("git", ["-c", "protocol.version=2", "-c", "user.name=e2e", "-c", "user.email=e2e@example.com", ...args], { cwd, encoding: "utf8" });
702+ assert.equal(r.status, 0, `git ${args.join(" ")} failed:\n${r.stderr}`);
703+ return r.stderr;
704+}
705+
706+/** Uploads a blob, then a one-layer manifest tagged `tag`, through the registry API. */
707+async function pushTinyImage(name, tag, as) {
708+ const auth = { authorization: `Basic ${Buffer.from(`anyone:${as}`).toString("base64")}` };
709+ const digest = (b) => `sha256:${createHash("sha256").update(b).digest("hex")}`;
710+ const blob = async (bytes) => {
711+ const r = await fetch(`${base}/v2/${name}/blobs/uploads/?digest=${digest(bytes)}`, { method: "POST", body: bytes, headers: { ...auth, "content-type": "application/octet-stream" } });
712+ assert.equal(r.status, 201, await r.text());
713+ return { digest: digest(bytes), size: bytes.length };
714+ };
715+ const layer = randomBytes(256);
716+ const config = Buffer.from(JSON.stringify({ architecture: "amd64", os: "linux", rootfs: { type: "layers", diff_ids: [digest(layer)] } }));
717+ const manifest = JSON.stringify({ schemaVersion: 2, mediaType: "application/vnd.oci.image.manifest.v1+json", config: { mediaType: "application/vnd.oci.image.config.v1+json", ...(await blob(config)) }, layers: [{ mediaType: "application/vnd.oci.image.layer.v1.tar+gzip", ...(await blob(layer)) }] });
718+ const r = await fetch(`${base}/v2/${name}/manifests/${tag}`, { method: "PUT", body: manifest, headers: { ...auth, "content-type": "application/vnd.oci.image.manifest.v1+json" } });
719+ assert.equal(r.status, 201, await r.text());
720+}
721+const pullManifest = (name, tag, as) => fetch(`${base}/v2/${name}/manifests/${tag}`, { headers: { authorization: `Basic ${Buffer.from(`anyone:${as}`).toString("base64")}`, accept: "application/vnd.oci.image.manifest.v1+json" } }).then(async (r) => ({ status: r.status, json: await r.json().catch(() => null) }));
722+
723+test("security: a transferred repository keeps its data, and its old name redirects only for readers", async () => {
724+ // Two organizations olive owns, like moving everything from codician-team to codician. sam is a
725+ // member of the old one, which gives members no access by default.
726+ await ok("POST", "/user/orgs", { login: "team-old" });
727+ await ok("POST", "/user/orgs", { login: "team-new" });
728+ await ok("PATCH", "/orgs/team-old", { default_repository_permission: "none" });
729+ assert.equal((await oliveSubmits("/team-old?tab=people", "/team-old/-/org/people", { who: "sam", role: "member" })).status, 303);
730+
731+ // ------------------------------------------------------------ a private repository with history, an issue, a secret, a variable and an image
732+ await repoWithReadme("moving");
733+ const R = `/repos/${OWNER}/moving`;
734+ const issue = await ok("POST", `${R}/issues`, { title: "survives the move" });
735+ await status(204, "PUT", `${R}/actions/secrets/DEPLOY_KEY`, { value: "s3cret" });
736+ await status(204, "PUT", `${R}/actions/variables/REGION`, { value: "eu" });
737+ await status(204, "PUT", `${R}/collaborators/rita`, { permission: "read" });
738+ await status(204, "PUT", `${R}/collaborators/olive-bot`, { permission: "admin" });
739+ const repoId = (await ok("GET", R)).id;
740+ await pushTinyImage(`${OWNER}/moving`, "v1", T.olive);
741+
742+ // ------------------------------------------------------------ who may transfer
743+ // Agents never, whatever their role; readers and read-scoped tokens need admin; a missing body field is a 422.
744+ assert.match((await status(403, "POST", `${R}/transfer`, { new_owner: "team-old" }, T.bot)).message, /agents/);
745+ await status(403, "POST", `${R}/transfer`, { new_owner: "team-old" }, T.rita);
746+ await status(403, "POST", `${R}/transfer`, { new_owner: "team-old" }, T.reader);
747+ await status(404, "POST", `${R}/transfer`, { new_owner: "sam" }, T.sam);
748+ await status(422, "POST", `${R}/transfer`, {});
749+ await status(422, "POST", `${R}/transfer`, { new_owner: "nobody-here" });
750+ // Only to your own account or an organization you own or administer.
751+ assert.match((await status(403, "POST", `${R}/transfer`, { new_owner: "sam" })).message, /your own account/);
752+ await ok("POST", "/user/repos", { name: "scratch" }, T.sam);
753+ await status(403, "POST", "/repos/sam/scratch/transfer", { new_owner: "guild" }, T.sam);
754+ // Not onto a name the target already uses.
755+ await ok("POST", "/orgs/team-old/repos", { name: "clash", private: true });
756+ assert.match((await status(422, "POST", `${R}/transfer`, { new_owner: "team-old", new_name: "clash" })).message, /already has a repository named clash/);
757+
758+ // ------------------------------------------------------------ user to organization
759+ const moved = await status(202, "POST", `${R}/transfer`, { new_owner: "team-old" });
760+ assert.equal(moved.full_name, "team-old/moving");
761+ assert.equal(moved.owner.login, "team-old");
762+ assert.equal(moved.id, repoId);
763+ const N = "/repos/team-old/moving";
764+
765+ // Everything stayed: issues, secrets, variables and direct collaborators.
766+ assert.equal((await ok("GET", `${N}/issues/${issue.number}`)).title, "survives the move");
767+ assert.ok((await ok("GET", `${N}/actions/secrets`)).secrets.some((s) => s.name === "DEPLOY_KEY"));
768+ assert.ok((await ok("GET", `${N}/actions/variables`)).variables.some((v) => v.name === "REGION" && v.value === "eu"));
769+ assert.ok((await ok("GET", `${N}/collaborators`)).some((u) => u.login === "rita"), "a user-to-org transfer keeps direct collaborators");
770+ assert.equal((await ok("GET", N, undefined, T.rita)).permissions.pull, true);
771+
772+ // ------------------------------------------------------------ REST: 301 for reads, 307 for the rest, like GitHub
773+ const get = await manual("GET", `/api/v3${R}/issues?state=all`);
774+ assert.equal(get.status, 301);
775+ assert.equal(get.location, `${base}/api/v3${N}/issues?state=all`);
776+ assert.equal(get.json.url, get.location);
777+ const post = await manual("POST", `/api/v3${R}/issues`, { body: { title: "via the old name" } });
778+ assert.equal(post.status, 307);
779+ assert.equal(post.location, `${base}/api/v3${N}/issues`);
780+ // A client that follows the redirect (fetch, Octokit) keeps working, writes included.
781+ assert.equal((await status(201, "POST", `${R}/issues`, { title: "followed" })).title, "followed");
782+ assert.equal((await manual("GET", `/api/v3${R}`, { as: T.rita })).location, `${base}/api/v3${N}`);
783+ // Someone who can't read the repository learns nothing: the same 404 (or 401) as for a name that never existed.
784+ await status(404, "GET", R, undefined, T.sam);
785+ await status(404, "GET", `${R}/issues`, undefined, T.sam);
786+ await status(404, "POST", `${R}/issues`, { title: "probe" }, T.sam);
787+ await status(401, "GET", R, undefined, null);
788+ assert.deepEqual(await api("GET", R, undefined, T.sam), await api("GET", `/repos/${OWNER}/never-existed`, undefined, T.sam));
789+
790+ // ------------------------------------------------------------ web pages: 301 to the same page, query kept
791+ const page = await manual("GET", `/${OWNER}/moving/issues?state=closed`, { as: { cookie: true } });
792+ assert.equal(page.status, 301);
793+ assert.equal(page.location, `${base}/team-old/moving/issues?state=closed`);
794+ assert.equal((await manual("GET", `/${OWNER}/moving/blob/main/README.md`, { as: { cookie: true } })).location, `${base}/team-old/moving/blob/main/README.md`);
795+
796+ // ------------------------------------------------------------ git: clone and push through the old URL
797+ const authed = (path) => base.replace("://", `://x:${T.olive}@`) + path;
798+ const clone = join(work, "moving-old-url");
799+ // Served under the old name, as GitHub does (a redirect would make git drop the token in the URL).
800+ gitRun(["clone", "-q", authed(`/${OWNER}/moving.git`), clone]);
801+ gitRun(["commit", "-q", "--allow-empty", "-m", "pushed through the old URL"], clone);
802+ const pushed = gitRun(["push", "origin", "HEAD:main"], clone);
803+ assert.match(pushed, /This repository moved\. Please use the new location:\s+remote:\s+\S+\/team-old\/moving\.git/);
804+ const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: clone, encoding: "utf8" }).trim();
805+ assert.equal((await ok("GET", `${N}/branches/main`)).commit.sha, head);
806+ gitRun(["fetch", "-q", "origin"], clone);
807+ const reader = spawnSync("git", ["-c", "protocol.version=2", "ls-remote", base.replace("://", `://x:${T.sam}@`) + `/${OWNER}/moving.git`], { encoding: "utf8" });
808+ assert.notEqual(reader.status, 0, "someone without access can't clone through the old URL");
809+ assert.doesNotMatch(reader.stderr, /team-old/);
810+
811+ // ------------------------------------------------------------ MCP tools take the old owner/name
812+ const file = await mcpTool(T.olive, "get_file", { owner: OWNER, repo: "moving", path: "README.md" });
813+ assert.equal(file.isError, false, file.text);
814+ assert.match(file.data.content, /# moving/);
815+ const hidden = await mcpTool(T.sam, "get_file", { owner: OWNER, repo: "moving", path: "README.md" });
816+ assert.equal(hidden.isError, true);
817+ assert.doesNotMatch(hidden.text, /team-old/);
818+
819+ // ------------------------------------------------------------ the image moved with it, and its old name resolves
820+ assert.equal((await pullManifest("team-old/moving", "v1", T.olive)).status, 200);
821+ assert.equal((await pullManifest(`${OWNER}/moving`, "v1", T.olive)).status, 200);
822+ const probe = await pullManifest(`${OWNER}/moving`, "v1", T.sam);
823+ assert.equal(probe.status, 404);
824+ assert.doesNotMatch(JSON.stringify(probe.json), /team-old/);
825+
826+ // ------------------------------------------------------------ an organization that owns repositories can't be deleted
827+ await status(204, "PUT", `${N}/collaborators/sam`, { permission: "write" });
828+ assert.equal((await ok("GET", N, undefined, T.sam)).permissions.push, true);
829+ await status(204, "DELETE", "/repos/team-old/clash");
830+ const refused = await status(422, "DELETE", "/orgs/team-old");
831+ assert.match(refused.message, /team-old still owns 1 repository; transfer or delete it/);
832+ await status(403, "DELETE", "/orgs/team-old", undefined, T.sam);
833+
834+ // ------------------------------------------------------------ organization to organization, renamed on the way
835+ const again = await status(202, "POST", `${N}/transfer`, { new_owner: "team-new", new_name: "moved" });
836+ assert.equal(again.full_name, "team-new/moved");
837+ const M = "/repos/team-new/moved";
838+ // Grants that came with team-old go (sam is its member); outside collaborators stay.
839+ const logins = (await ok("GET", `${M}/collaborators`)).map((u) => u.login);
840+ assert.ok(logins.includes("rita") && !logins.includes("sam"), JSON.stringify(logins));
841+ await status(404, "GET", M, undefined, T.sam);
842+ await status(404, "GET", N, undefined, T.sam);
843+ // Both old names lead here.
844+ assert.equal((await manual("GET", `/api/v3${R}`)).location, `${base}/api/v3${M}`);
845+ assert.equal((await manual("GET", `/api/v3${N}/pulls`)).location, `${base}/api/v3${M}/pulls`);
846+ assert.equal((await pullManifest("team-old/moving", "v1", T.olive)).status, 200);
847+
848+ // ------------------------------------------------------------ a new repository at an old name takes over for good
849+ await ok("POST", "/user/repos", { name: "moving" });
850+ const fresh = await ok("GET", R);
851+ assert.notEqual(fresh.id, repoId);
852+ assert.equal(fresh.full_name, `${OWNER}/moving`);
853+ // ...and so does its own image name: the moved image no longer answers there.
854+ assert.equal((await pullManifest(`${OWNER}/moving`, "v1", T.olive)).status, 404);
855+ assert.equal((await pullManifest("team-new/moving", "v1", T.olive)).status, 200);
856+ // Deleting it doesn't bring the redirect back.
857+ await status(204, "DELETE", R);
858+ await status(404, "GET", R);
859+
860+ // ------------------------------------------------------------ the emptied organization can now be deleted; its old URLs keep working
861+ await status(202, "DELETE", "/orgs/team-old");
862+ await status(404, "GET", "/orgs/team-old");
863+ assert.equal((await manual("GET", `/api/v3${N}`)).location, `${base}/api/v3${M}`);
864+ assert.equal((await manual("GET", "/team-old/moving", { as: { cookie: true } })).location, `${base}/team-new/moved`);
865+ assert.equal((await pullManifest("team-old/moving", "v1", T.olive)).status, 200);
866+
867+ // ------------------------------------------------------------ renames leave redirects too, and refuse a taken name
868+ await ok("POST", "/orgs/team-new/repos", { name: "taken", private: true });
869+ await status(422, "PATCH", M, { name: "taken" });
870+ assert.equal((await ok("PATCH", M, { name: "settled" })).full_name, "team-new/settled");
871+ assert.equal((await manual("GET", `/api/v3${M}`)).location, `${base}/api/v3/repos/team-new/settled`);
872+
873+ // ------------------------------------------------------------ the web form: a Kumo picker, a typed confirmation, CSRF
874+ const settings = await fetch(`${base}/team-new/settled/settings`, { headers: { cookie: `__Host-dfy_session=${S.olive}` } }).then((r) => r.text());
875+ assert.match(settings, /Transfer ownership/);
876+ assert.match(settings, /data-k="select"[^>]*new_owner/);
877+ assert.doesNotMatch(settings, /<select/);
878+ const wrong = await oliveSubmits("/team-new/settled/settings", "/team-new/settled/settings/transfer", { new_owner: "olive", new_name: "web-moved", confirm: "settled" });
879+ assert.match(wrong.flash, /type team-new\/settled to confirm/);
880+ const viaWeb = await oliveSubmits("/team-new/settled/settings", "/team-new/settled/settings/transfer", { new_owner: "olive", new_name: "web-moved", confirm: "team-new/settled" });
881+ assert.equal(viaWeb.status, 303, viaWeb.flash);
882+ assert.match(viaWeb.flash, /Transferred team-new\/settled to olive\/web-moved/);
883+ assert.equal((await ok("GET", `/repos/${OWNER}/web-moved`)).id, repoId);
884+ assert.equal((await ok("GET", `/repos/${OWNER}/web-moved/issues/${issue.number}`)).title, "survives the move");
885+
886+ // ------------------------------------------------------------ audited, with from and to
887+ await until("transfer audit entries", async () => auditLog("repo.transfer").length >= 3);
888+ const trail = auditLog("repo.transfer").map((e) => JSON.parse(e.detail));
889+ assert.ok(trail.some((d) => d.from === `${OWNER}/moving` && d.to === "team-old/moving"), JSON.stringify(trail));
890+ assert.ok(trail.some((d) => d.from === "team-old/moving" && d.to === "team-new/moved"));
891+ assert.ok(trail.some((d) => d.from === "team-new/settled" && d.to === `${OWNER}/web-moved`));
892+});
893+
894+test("security: agents land changes on protected branches only through auto-merge a person allowed, gated by CI", async () => {
895+ const R = `/repos/${OWNER}/landing`;
896+ await repoWithReadme("landing");
897+ // A workflow on pushes to main shows who the merge's own runs act as (no runner ever takes its job).
898+ const workflow = "on:\n push:\n branches: [main]\njobs:\n note:\n runs-on: [self-hosted, nobody]\n steps:\n - run: echo merged\n";
899+ const mainSha = (await ok("PUT", `${R}/contents/.github/workflows/after-merge.yml`, { message: "after-merge workflow", content: b64(workflow) })).commit.sha;
900+ await status(204, "PUT", `${R}/collaborators/olive-bot`, { permission: "write" });
901+ await status(204, "PUT", `${R}/collaborators/olive-helper`, { permission: "write" });
902+ const pull = (n) => ok("GET", `${R}/pulls/${n}`);
903+ /** A fresh clone of the repository, to read raw commits. */
904+ const clone = () => {
905+ const dir = mkdtempSync(join(work, "landing-"));
906+ execFileSync("git", ["-c", `http.extraHeader=Authorization: Bearer ${T.olive}`, "clone", "-q", `${base}/${OWNER}/landing.git`, dir], { stdio: "pipe" });
907+ return dir;
908+ };
909+ /** A pull request the agent opens from a new branch off `from`. */
910+ const agentPull = async (branch, base = "main", from = mainSha, path = `${branch}.txt`) => {
911+ await ok("POST", `${R}/git/refs`, { ref: `refs/heads/${branch}`, sha: from }, T.bot);
912+ const put = await ok("PUT", `${R}/contents/${path}`, { message: `agent: ${branch}`, content: b64(`${branch}\n`), branch }, T.bot);
913+ const pr = await ok("POST", `${R}/pulls`, { title: `Agent ${branch}`, head: branch, base }, T.bot);
914+ return { number: pr.number, head: put.commit.sha };
915+ };
916+
917+ // ------------------------------------------------------------ refused unless a person allowed it, and CI gates it
918+ await ok("PUT", `${R}/branches/main/protection`, { requirePullRequest: true, requiredChecks: ["ci"] });
919+ const one = await agentPull("agent-one");
920+ const off = await status(403, "PUT", `${R}/pulls/${one.number}/auto-merge`, { merge_method: "squash" }, T.bot);
921+ assert.match(off.message, /allowAgentAutoMerge/);
922+ // The agent can't turn the setting on for itself, nor can its helper.
923+ for (const as of [T.bot, T.helper]) await status(403, "PUT", `${R}/branches/main/protection`, { requirePullRequest: true, requiredChecks: ["ci"], allowAgentAutoMerge: true }, as);
924+ await ok("PUT", `${R}/branches/main/protection`, { requirePullRequest: true, allowAgentAutoMerge: true });
925+ const unchecked = await status(422, "PUT", `${R}/pulls/${one.number}/auto-merge`, { merge_method: "squash" }, T.bot);
926+ assert.match(unchecked.message, /required status checks/);
927+ await status(422, "PUT", `${R}/branches/main/protection`, { requiredChecks: ["ci"], allowAgentAutoMerge: "yes" });
928+ await ok("PUT", `${R}/branches/main/protection`, { requirePullRequest: true, requiredChecks: ["ci"], allowAgentAutoMerge: true });
929+ // A change to the workflows that gate it needs a person.
930+ const wf = await agentPull("agent-workflow", "main", mainSha, ".github/workflows/ci.yml");
931+ assert.match((await status(422, "PUT", `${R}/pulls/${wf.number}/auto-merge`, {}, T.bot)).message, /workflow file/);
932+
933+ // ------------------------------------------------------------ with both, it merges by itself once the check passes, not before
934+ const enabled = await ok("PUT", `${R}/pulls/${one.number}/auto-merge`, { merge_method: "squash" }, T.bot);
935+ assert.equal(enabled.state, "enabled");
936+ assert.deepEqual([enabled.auto_merge.enabled_by.login, enabled.auto_merge.merge_method], ["olive-bot", "squash"]);
937+ assert.equal((await pull(one.number)).auto_merge.enabled_by.login, "olive-bot");
938+ // A direct merge is still the agent's to ask for and be refused, bypass flags or not.
939+ for (const extra of [{}, { bypass_rules: true }, { bypass_merge_queue: true }]) {
940+ assert.match((await status(405, "PUT", `${R}/pulls/${one.number}/merge`, { merge_method: "squash", ...extra }, T.bot)).message, /agents may not merge/);
941+ }
942+ // Approvals from agents don't count.
943+ await ok("POST", `${R}/pulls/${one.number}/reviews`, { event: "APPROVE", body: "lgtm" }, T.helper);
944+ const run = await ok("POST", `${R}/check-runs`, { name: "ci", head_sha: one.head, status: "in_progress" });
945+ let p = await pull(one.number);
946+ assert.equal(p.approvals, 0);
947+ assert.ok(p.merge_blockers.some((b) => /needs 1 more approving review/.test(b)), JSON.stringify(p.merge_blockers));
948+ // A person approves; the required check is still running, so nothing merges yet.
949+ await ok("POST", `${R}/pulls/${one.number}/reviews`, { event: "APPROVE", body: "ship it" });
950+ await sleep(1500);
951+ p = await pull(one.number);
952+ assert.equal(p.merged, false);
953+ assert.deepEqual(p.merge_blockers, ["required check 'ci' is in_progress"]);
954+ assert.ok(p.auto_merge);
955+ // The check passes: Diffinery merges, as the agent, and main moves to that commit.
956+ await ok("PATCH", `${R}/check-runs/${run.id}`, { status: "completed", conclusion: "success" });
957+ const merged = await until("auto-merge", async () => {
958+ const x = await pull(one.number);
959+ return x.merged ? x : null;
960+ });
961+ assert.equal(merged.merged_by.login, "olive-bot");
962+ assert.equal(merged.auto_merge, null);
963+ assert.equal((await ok("GET", `${R}/branches/main`)).commit.sha, merged.merge_commit_sha);
964+ // The merge commit says who it acts for, and the workflows it triggers run as the agent's.
965+ assert.match(execFileSync("git", ["cat-file", "commit", merged.merge_commit_sha], { cwd: clone(), encoding: "utf8" }), /^diffinery-agent olive-bot on-behalf-of olive$/m);
966+ const after = await until("the merge's push run", async () => (await ok("GET", `${R}/actions/runs?event=push`)).workflow_runs.find((r) => r.head_sha === merged.merge_commit_sha));
967+ assert.equal(after.actor.login, "olive-bot");
968+
969+ // ------------------------------------------------------------ an approval counts only for the commit it approved
970+ const three = await agentPull("agent-three", "main", merged.merge_commit_sha);
971+ await ok("POST", `${R}/pulls/${three.number}/reviews`, { event: "APPROVE" });
972+ const later = await ok("PUT", `${R}/contents/agent-three-later.txt`, { message: "after the approval", content: b64("later\n"), branch: "agent-three" }, T.bot);
973+ await ok("POST", `${R}/check-runs`, { name: "ci", head_sha: later.commit.sha, status: "completed", conclusion: "success" });
974+ assert.equal((await ok("PUT", `${R}/pulls/${three.number}/auto-merge`, {}, T.bot)).state, "enabled");
975+ await sleep(1500);
976+ assert.equal((await pull(three.number)).merged, false, "the person approved an older commit");
977+ await ok("POST", `${R}/pulls/${three.number}/reviews`, { event: "APPROVE", body: "the new commit too" });
978+ await until("merged after the fresh approval", async () => (await pull(three.number)).merged);
979+
980+ // ------------------------------------------------------------ a push by someone else cancels it; the agent's own doesn't
981+ const two = await agentPull("agent-two");
982+ await ok("POST", `${R}/pulls/${two.number}/reviews`, { event: "APPROVE" });
983+ assert.equal((await ok("PUT", `${R}/pulls/${two.number}/auto-merge`, {}, T.bot)).state, "enabled");
984+ await ok("PUT", `${R}/contents/agent-two-more.txt`, { message: "more", content: b64("more\n"), branch: "agent-two" }, T.bot);
985+ assert.ok((await pull(two.number)).auto_merge, "the agent's own push keeps auto-merge on");
986+ await ok("PUT", `${R}/contents/olive.txt`, { message: "olive's fix", content: b64("fix\n"), branch: "agent-two" });
987+ assert.equal((await pull(two.number)).auto_merge, null);
988+ const said = await ok("GET", `${R}/issues/${two.number}/comments`);
989+ assert.ok(said.some((c) => /Auto-merge was turned off: olive pushed new commits/.test(c.body)), JSON.stringify(said));
990+ // The pull request page turns it on and off for people, and the settings page has the switch.
991+ const cookie = `__Host-dfy_session=${S.olive}`;
992+ const web = async (path, form) => {
993+ const page = await fetch(`${base}/${OWNER}/landing/pull/${two.number}`, { headers: { cookie } });
994+ const html = await page.text();
995+ if (!form) return html;
996+ const csrf = /name="_csrf" value="([^"]+)"/.exec(html)?.[1];
997+ const csrfCookie = /dfy_csrf=([^;]+)/.exec(page.headers.get("set-cookie") ?? "")?.[1];
998+ const res = await fetch(`${base}/${OWNER}/landing${path}`, { method: "POST", body: new URLSearchParams({ _csrf: csrf, ...form }), redirect: "manual", headers: { cookie: `${cookie}; dfy_csrf=${csrfCookie}`, "sec-fetch-site": "same-origin" } });
999+ assert.equal(res.status, 303, `POST ${path}`);
1000+ return html;
1001+ };
1002+ assert.match(await web(`/pull/${two.number}/auto-merge`, { method: "squash" }), /Enable auto-merge/);
1003+ assert.equal((await pull(two.number)).auto_merge.enabled_by.login, OWNER);
1004+ assert.match(await web(`/pull/${two.number}/auto-merge/disable`, {}), /Auto-merge is on[\s\S]*Disable auto-merge/);
1005+ assert.equal((await pull(two.number)).auto_merge, null);
1006+ const settings = await (await fetch(`${base}/${OWNER}/landing/settings/branches`, { headers: { cookie } })).text();
1007+ assert.match(settings, /Allow agents to land pull requests through auto-merge/);
1008+ assert.doesNotMatch(settings, /<select|type="checkbox"/);
1009+ // Turned on again, then off on request.
1010+ await ok("PUT", `${R}/pulls/${two.number}/auto-merge`, {}, T.bot);
1011+ await status(204, "DELETE", `${R}/pulls/${two.number}/auto-merge`, undefined, T.bot);
1012+ assert.equal((await pull(two.number)).auto_merge, null);
1013+ // Taking the setting away turns an agent's waiting auto-merge off.
1014+ await ok("PUT", `${R}/pulls/${two.number}/auto-merge`, {}, T.bot);
1015+ await ok("PUT", `${R}/branches/main/protection`, { requirePullRequest: true, requiredChecks: ["ci"] });
1016+ await until("auto-merge turned off", async () => (await pull(two.number)).auto_merge === null);
1017+
1018+ // ------------------------------------------------------------ the merge queue path, for agents with the setting
1019+ await ok("POST", `${R}/git/refs`, { ref: "refs/heads/train", sha: mainSha });
1020+ await ok("PUT", `${R}/branches/train/protection`, { requiredChecks: ["ci"], mergeQueue: { method: "squash" } });
1021+ const q = await agentPull("agent-queue", "train");
1022+ await ok("POST", `${R}/pulls/${q.number}/reviews`, { event: "APPROVE" });
1023+ await status(403, "PUT", `${R}/pulls/${q.number}/merge-queue`, {}, T.bot);
1024+ await status(403, "PUT", `${R}/pulls/${q.number}/auto-merge`, {}, T.bot);
1025+ await ok("PUT", `${R}/branches/train/protection`, { requiredChecks: ["ci"], mergeQueue: { method: "squash" }, allowAgentAutoMerge: true });
1026+ // Auto-merge waits for the head's check, then joins the queue; the merge group's check gates the merge.
1027+ const queued = await ok("PUT", `${R}/pulls/${q.number}/auto-merge`, {}, T.bot);
1028+ assert.deepEqual([queued.state, queued.auto_merge.merge_method], ["enabled", "squash"]);
1029+ await ok("POST", `${R}/check-runs`, { name: "ci", head_sha: q.head, status: "completed", conclusion: "success" });
1030+ const entry = await until("queued by auto-merge", async () => (await ok("GET", `${R}/merge-queue/train`)).entries.find((e) => e.number === q.number && e.merge_group));
1031+ assert.equal(entry.enqueued_by.login, "olive-bot");
1032+ assert.equal((await pull(q.number)).merged, false);
1033+ await ok("POST", `${R}/check-runs`, { name: "ci", head_sha: entry.merge_group.head_sha, status: "completed", conclusion: "success" });
1034+ assert.equal((await until("merged through the queue", async () => ((await pull(q.number)).merged ? pull(q.number) : null))).merged_by.login, "olive-bot");
1035+ // An agent may also add a ready pull request to the queue itself.
1036+ const q2 = await agentPull("agent-queue-2", "train", (await ok("GET", `${R}/branches/train`)).commit.sha);
1037+ await ok("POST", `${R}/pulls/${q2.number}/reviews`, { event: "APPROVE" });
1038+ await ok("POST", `${R}/check-runs`, { name: "ci", head_sha: q2.head, status: "completed", conclusion: "success" });
1039+ const direct = await status(201, "PUT", `${R}/pulls/${q2.number}/merge-queue`, {}, T.bot);
1040+ assert.equal(direct.state, "queued");
1041+ await ok("POST", `${R}/check-runs`, { name: "ci", head_sha: direct.entry.merge_group.head_sha, status: "completed", conclusion: "success" });
1042+ await until("second queue merge", async () => (await pull(q2.number)).merged);
1043+
1044+ // ------------------------------------------------------------ all of it is on the record
1045+ await until("auto-merge audit entries", async () => {
1046+ const setting = auditLog("repo.protection.agent_auto_merge").filter((e) => e.target === `${OWNER}/landing`);
1047+ const on = auditLog("pull_request.auto_merge_enabled").filter((e) => e.target.startsWith(`${OWNER}/landing#`));
1048+ const offs = auditLog("pull_request.auto_merge_disabled").filter((e) => e.target === `${OWNER}/landing#${two.number}`);
1049+ const done = auditLog("pull_request.auto_merged").find((e) => e.target === `${OWNER}/landing#${one.number}`);
1050+ return setting.length >= 3 && setting.every((e) => e.actor_login === OWNER) && on.some((e) => e.actor_login === "olive-bot") && on.some((e) => e.actor_login === OWNER) && offs.length >= 2 && done?.actor_login === "olive-bot";
1051+ });
1052+});
1053+
1054+test("security: an admin can let agents' auto-merges land on green checks without an approval, and nothing more", async () => {
1055+ const R = `/repos/${OWNER}/waived`;
1056+ const mainSha = await repoWithReadme("waived");
1057+ await status(204, "PUT", `${R}/collaborators/olive-bot`, { permission: "write" });
1058+ const pull = (n) => ok("GET", `${R}/pulls/${n}`);
1059+ /** A pull request `as` opens from a new branch off `from`, writing `path`. */
1060+ const opened = async (branch, as, from = mainSha, path = `${branch}.txt`) => {
1061+ await ok("POST", `${R}/git/refs`, { ref: `refs/heads/${branch}`, sha: from }, as);
1062+ const put = await ok("PUT", `${R}/contents/${path}`, { message: branch, content: b64(`${branch}\n`), branch }, as);
1063+ const pr = await ok("POST", `${R}/pulls`, { title: branch, head: branch, base: "main" }, as);
1064+ return { number: pr.number, head: put.commit.sha };
1065+ };
1066+ const green = (head) => ok("POST", `${R}/check-runs`, { name: "ci", head_sha: head, status: "completed", conclusion: "success" });
1067+ const rule = { requirePullRequest: true, requiredChecks: ["ci"], requiredApprovals: 1, allowAgentAutoMerge: true };
1068+
1069+ // ------------------------------------------------------------ only an admin sets it, only alongside allowAgentAutoMerge
1070+ await status(422, "PUT", `${R}/branches/main/protection`, { requirePullRequest: true, requiredChecks: ["ci"], agentAutoMergeWithoutApproval: true });
1071+ await status(422, "PUT", `${R}/branches/main/protection`, { ...rule, agentAutoMergeWithoutApproval: "yes" });
1072+ await status(403, "PUT", `${R}/branches/main/protection`, { ...rule, agentAutoMergeWithoutApproval: true }, T.bot);
1073+ await ok("PUT", `${R}/branches/main/protection`, { ...rule, agentAutoMergeWithoutApproval: true });
1074+ await until("waiver audit", async () => auditLog("repo.protection.agent_approval_waiver").some((e) => JSON.parse(e.detail).enabled === true && e.actor_login === OWNER));
1075+
1076+ // ------------------------------------------------------------ an agent's pull request lands on the green check alone
1077+ const one = await opened("agent-one", T.bot);
1078+ assert.equal((await ok("PUT", `${R}/pulls/${one.number}/auto-merge`, { merge_method: "squash" }, T.bot)).state, "enabled");
1079+ await sleep(1000);
1080+ assert.equal((await pull(one.number)).merged, false, "the required check still gates it");
1081+ await green(one.head);
1082+ const merged = await until("waived auto-merge", async () => {
1083+ const x = await pull(one.number);
1084+ return x.merged ? x : null;
1085+ });
1086+ assert.equal(merged.merged_by.login, "olive-bot");
1087+ assert.equal(merged.approvals, 0);
1088+ // A direct merge by the agent is still refused.
1089+ const direct = await opened("agent-direct", T.bot, merged.merge_commit_sha);
1090+ await green(direct.head);
1091+ assert.match((await status(405, "PUT", `${R}/pulls/${direct.number}/merge`, { merge_method: "squash" }, T.bot)).message, /agents may not merge/);
1092+
1093+ // ------------------------------------------------------------ a person's requested changes and workflow edits still need a person
1094+ const objected = await opened("agent-objected", T.bot, merged.merge_commit_sha);
1095+ await ok("POST", `${R}/pulls/${objected.number}/reviews`, { event: "REQUEST_CHANGES", body: "not like this" });
1096+ await green(objected.head);
1097+ assert.equal((await ok("PUT", `${R}/pulls/${objected.number}/auto-merge`, {}, T.bot)).state, "enabled");
1098+ await sleep(1500);
1099+ const held = await pull(objected.number);
1100+ assert.equal(held.merged, false);
1101+ assert.ok(held.merge_blockers.some((b) => /changes requested by/.test(b)), JSON.stringify(held.merge_blockers));
1102+ const wf = await opened("agent-workflow", T.bot, merged.merge_commit_sha, ".github/workflows/ci.yml");
1103+ await green(wf.head);
1104+ assert.match((await status(422, "PUT", `${R}/pulls/${wf.number}/auto-merge`, {}, T.bot)).message, /workflow file/);
1105+
1106+ // ------------------------------------------------------------ people's own pull requests keep the rule's approvals
1107+ const mine = await opened("person", undefined, merged.merge_commit_sha);
1108+ await green(mine.head);
1109+ assert.equal((await ok("PUT", `${R}/pulls/${mine.number}/auto-merge`, {})).state, "enabled");
1110+ await sleep(1500);
1111+ const waiting = await pull(mine.number);
1112+ assert.equal(waiting.merged, false);
1113+ assert.ok(waiting.merge_blockers.some((b) => /needs 1 more approving review/.test(b)), JSON.stringify(waiting.merge_blockers));
1114+
1115+ // ------------------------------------------------------------ the settings page shows it; turning allowAgentAutoMerge off ends it
1116+ const settings = await (await fetch(`${base}/${OWNER}/waived/settings/branches`, { headers: { cookie: `__Host-dfy_session=${S.olive}` } })).text();
1117+ assert.match(settings, /Let agents&#39; auto-merges land without an approving review|Let agents' auto-merges land without an approving review/);
1118+ assert.doesNotMatch(settings, /<select|type="checkbox"/);
1119+ await ok("PUT", `${R}/branches/main/protection`, { requirePullRequest: true, requiredChecks: ["ci"], requiredApprovals: 1 });
1120+ await until("waiver off audit", async () => auditLog("repo.protection.agent_approval_waiver").some((e) => JSON.parse(e.detail).enabled === false));
1121+ await ok("PUT", `${R}/branches/main/protection`, rule);
1122+ const after = await opened("agent-after", T.bot, merged.merge_commit_sha);
1123+ await green(after.head);
1124+ assert.equal((await ok("PUT", `${R}/pulls/${after.number}/auto-merge`, {}, T.bot)).state, "enabled");
1125+ await sleep(1500);
1126+ const again = await pull(after.number);
1127+ assert.equal(again.merged, false, "the waiver does not come back with allowAgentAutoMerge");
1128+ // A person's approval lands it, as before the waiver.
1129+ await ok("POST", `${R}/pulls/${after.number}/reviews`, { event: "APPROVE" });
1130+ await until("merged after the approval", async () => (await pull(after.number)).merged);
1131+});
1132+
1133+test("security: the queue's bypass button does what it says, and a merge queue can't contradict linear history", async () => {
1134+ const R = `/repos/${OWNER}/bypass`;
1135+ const mainSha = await repoWithReadme("bypass");
1136+ // ------------------------------------------------------------ a queue that would make merge commits on a linear branch is refused
1137+ const contradiction = await status(422, "PUT", `${R}/branches/main/protection`, { requireLinearHistory: true, mergeQueue: { method: "merge" } });
1138+ assert.match(contradiction.message, /linear history/);
1139+ await ok("PUT", `${R}/branches/main/protection`, { requireLinearHistory: true, mergeQueue: {} });
1140+ assert.equal((await ok("GET", `${R}/branches/main/protection`)).rules.mergeQueue.method, "squash", "without a method, a linear branch's queue squashes");
1141+
1142+ // ------------------------------------------------------------ the web button sends what its label promises
1143+ const cookie = `__Host-dfy_session=${S.olive}`;
1144+ const opened = async (branch) => {
1145+ await ok("POST", `${R}/git/refs`, { ref: `refs/heads/${branch}`, sha: mainSha });
1146+ await ok("PUT", `${R}/contents/${branch}.txt`, { message: branch, content: b64(`${branch}\n`), branch });
1147+ return (await ok("POST", `${R}/pulls`, { title: branch, head: branch, base: "main" })).number;
1148+ };
1149+ /** The pull request page's form that merges directly, its button label, and the result of submitting it. */
1150+ const bypassForm = async (n) => {
1151+ const page = await fetch(`${base}/${OWNER}/bypass/pull/${n}`, { headers: { cookie } });
1152+ const html = await page.text();
1153+ const form = [...html.matchAll(/<form[^>]*action="([^"]+)"[^>]*>([\s\S]*?)<\/form>/g)].find((m) => m[1].endsWith(`/pull/${n}/merge`));
1154+ if (!form) return null;
1155+ const fields = Object.fromEntries([...form[2].matchAll(/<input type="hidden" name="([^"]+)" value="([^"]*)"/g)].map((m) => [m[1], m[2]]));
1156+ const label = />([^<]*)<\/button>/.exec(form[2])?.[1];
1157+ const csrfCookie = /dfy_csrf=([^;]+)/.exec(page.headers.get("set-cookie") ?? "")?.[1];
1158+ const submit = () => fetch(`${base}${form[1]}`, { method: "POST", body: new URLSearchParams(fields), redirect: "manual", headers: { cookie: `${cookie}; dfy_csrf=${csrfCookie}`, "sec-fetch-site": "same-origin" } });
1159+ return { fields, label, submit };
1160+ };
1161+ const merged = async (n) => (await ok("GET", `${R}/pulls/${n}`)).merged;
1162+
1163+ // Rules met: the button skips only the queue.
1164+ await ok("PUT", `${R}/branches/main/protection`, { mergeQueue: { method: "squash" } });
1165+ const ready = await opened("ready");
1166+ const plain = await bypassForm(ready);
1167+ assert.equal(plain.fields.bypass_queue, "1");
1168+ assert.equal(plain.fields.bypass_rules, undefined);
1169+ assert.match(plain.label, /bypass the queue/);
1170+ assert.equal((await plain.submit()).status, 303);
1171+ assert.equal(await merged(ready), true);
1172+
1173+ // Rules unmet: an admin who may bypass them gets a button that says so, sends bypass_rules, and is audited.
1174+ await ok("PUT", `${R}/branches/main/protection`, { requiredApprovals: 1, mergeQueue: { method: "squash" } });
1175+ const unreviewed = await opened("unreviewed");
1176+ const both = await bypassForm(unreviewed);
1177+ assert.deepEqual([both.fields.bypass_queue, both.fields.bypass_rules], ["1", "1"]);
1178+ assert.match(both.label, /bypass the queue and the unmet requirements/);
1179+ assert.equal((await both.submit()).status, 303);
1180+ assert.equal(await merged(unreviewed), true);
1181+ await until("bypass audit", async () => auditLog("pull_request.merge_bypassed").some((e) => e.target === `${OWNER}/bypass#${unreviewed}` && /approving review/.test(e.detail) && /merge queue/.test(e.detail)));
1182+
1183+ // Admins held to the rules see no button at all, and a reader neither.
1184+ await ok("PUT", `${R}/branches/main/protection`, { requiredApprovals: 1, enforceAdmins: true, mergeQueue: { method: "squash" } });
1185+ const held = await opened("held");
1186+ assert.equal(await bypassForm(held), null);
1187+});
addedapps/diffinery/test/unit/auto-merge.test.ts+53−0View file
@@ -1,0 +1,53 @@
1+import { describe, expect, it } from "vitest";
2+import { automaticLandingRefusal, isPerson, landsByAgentRule } from "../../src/repo/auto-merge.js";
3+import type { ProtectionRules } from "../../src/repo/types.js";
4+
5+const person = { id: "u1", kind: "user" as const };
6+const agent = { id: "a1", kind: "agent" as const };
7+const job = { id: "job:j1", kind: "system" as const };
8+const agentJob = { id: "job:j2", kind: "agent" as const };
9+const refuse = (c: { id: string; kind: "user" | "agent" | "system" }, rules: ProtectionRules | null, isDefault = true) => automaticLandingRefusal(c, rules, isDefault, "main");
10+
11+describe("who may have Diffinery land a pull request for them", () => {
12+ it("tells people from agents and workflow tokens", () => {
13+ expect(isPerson(person)).toBe(true);
14+ for (const c of [agent, job, agentJob]) expect(isPerson(c)).toBe(false);
15+ // A job token is never a person, whatever kind it carries.
16+ expect(isPerson({ id: "job:x", kind: "user" })).toBe(false);
17+ });
18+
19+ it("always lets people", () => {
20+ expect(refuse(person, null)).toBeNull();
21+ expect(refuse(person, { requiredChecks: [] })).toBeNull();
22+ });
23+
24+ it("lets agents and tokens on a branch that is neither default nor protected", () => {
25+ for (const c of [agent, job, agentJob]) expect(refuse(c, null, false)).toBeNull();
26+ });
27+
28+ it("refuses agents and tokens on the default branch or a protected one without allowAgentAutoMerge", () => {
29+ for (const c of [agent, job, agentJob]) {
30+ expect(refuse(c, null)?.status).toBe(403);
31+ expect(refuse(c, { requiredChecks: ["ci"] }, false)?.status).toBe(403);
32+ expect(refuse(c, { requiredChecks: ["ci"], allowAgentAutoMerge: false })?.message).toMatch(/allowAgentAutoMerge/);
33+ }
34+ });
35+
36+ it("needs at least one required check with the setting", () => {
37+ for (const c of [agent, job]) {
38+ expect(refuse(c, { allowAgentAutoMerge: true })?.status).toBe(422);
39+ expect(refuse(c, { allowAgentAutoMerge: true, requiredChecks: [] })?.message).toMatch(/required status checks/);
40+ expect(refuse(c, { allowAgentAutoMerge: true, requiredChecks: ["ci"] })).toBeNull();
41+ }
42+ });
43+
44+ it("keeps agentsMayMerge for agents only: workflow tokens are never looser than agents", () => {
45+ expect(refuse(agent, { agentsMayMerge: true })).toBeNull();
46+ expect(refuse(agentJob, { agentsMayMerge: true })).toBeNull();
47+ expect(refuse(job, { agentsMayMerge: true })?.status).toBe(403);
48+ expect(landsByAgentRule(agent, { agentsMayMerge: true, allowAgentAutoMerge: true, requiredChecks: ["ci"] }, true)).toBe(false);
49+ expect(landsByAgentRule(agent, { allowAgentAutoMerge: true, requiredChecks: ["ci"] }, true)).toBe(true);
50+ expect(landsByAgentRule(person, { allowAgentAutoMerge: true, requiredChecks: ["ci"] }, true)).toBe(false);
51+ expect(landsByAgentRule(agent, null, false)).toBe(false);
52+ });
53+});
modifiedapps/diffinery/test/unit/email.test.ts+1−1View file
@@ -15,7 +15,7 @@
1515 describe("email templates", () => {
1616 it("has a sample for every kind of email", () => {
1717 const kinds = new Set(samples.map((s) => s.job.kind));
18- for (const k of ["signin_new", "passkey_added", "passkey_removed", "token_created", "token_expiring", "agent_created", "push_bypassed", "push_blocked", "account_status", "invite", "invite_accepted", "welcome", "signin_link", "access_request", "org_membership", "repo_access", "repo_created", "repo_deleted", "thread", "run", "test"]) {
18+ for (const k of ["signin_new", "passkey_added", "passkey_removed", "token_created", "token_expiring", "agent_created", "push_bypassed", "push_blocked", "account_status", "invite", "invite_accepted", "welcome", "signin_link", "access_request", "org_membership", "repo_access", "repo_created", "repo_deleted", "repo_transferred", "thread", "run", "test"]) {
1919 expect(kinds.has(k as MailJob["kind"]), k).toBe(true);
2020 }
2121 });
modifiedapps/diffinery/test/unit/job-permissions.test.ts+2−0View file
@@ -113,6 +113,8 @@
113113 expect(check("POST", "/api/v3/repos/o/r/check-runs")).toMatch(/checks: write/);
114114 expect(check("POST", "/api/v3/repos/o/r/pulls")).toMatch(/pull-requests: write/);
115115 expect(check("PUT", "/api/v3/repos/o/r/pulls/1/merge")).toMatch(/contents: write/);
116+ expect(check("PUT", "/api/v3/repos/o/r/pulls/1/auto-merge")).toMatch(/contents: write/);
117+ expect(check("DELETE", "/api/v3/repos/o/r/pulls/1/auto-merge")).toMatch(/contents: write/);
116118 expect(check("GET", "/api/v3/repos/o/r/actions/runs")).toMatch(/actions: read/);
117119 expect(check("POST", "/api/v3/repos/o/r/releases")).toMatch(/contents: write/);
118120 expect(check("POST", "/api/uploads/repos/o/r/releases/1/assets")).toMatch(/contents: write/);
addedapps/diffinery/test/unit/redirects.test.ts+59−0View file
@@ -1,0 +1,59 @@
1+import { describe, expect, it } from "vitest";
2+import { movedLoginPath, movedPath, movedStatus } from "../../src/lib/redirects.js";
3+
4+const from = { owner: "codician-team", name: "diffinery" };
5+const to = { owner: "codician", name: "diffinery" };
6+
7+describe("moved repository paths", () => {
8+ it("rewrites web pages and keeps the rest of the path", () => {
9+ expect(movedPath("/codician-team/diffinery", from, to)).toBe("/codician/diffinery");
10+ expect(movedPath("/codician-team/diffinery/blob/main/README.md", from, to)).toBe("/codician/diffinery/blob/main/README.md");
11+ expect(movedPath("/codician-team/diffinery/issues/", from, to)).toBe("/codician/diffinery/issues/");
12+ });
13+
14+ it("rewrites REST paths", () => {
15+ expect(movedPath("/api/v3/repos/codician-team/diffinery/issues/3", from, to)).toBe("/api/v3/repos/codician/diffinery/issues/3");
16+ expect(movedPath("/api/v3/repos/codician-team/diffinery", from, { owner: "codician", name: "forge" })).toBe("/api/v3/repos/codician/forge");
17+ });
18+
19+ it("keeps a .git suffix on Git URLs", () => {
20+ expect(movedPath("/codician-team/diffinery.git/info/refs", from, to)).toBe("/codician/diffinery.git/info/refs");
21+ expect(movedPath("/codician-team/diffinery/info/refs", from, to)).toBe("/codician/diffinery/info/refs");
22+ });
23+
24+ it("matches owner and name without regard to case, like the directory", () => {
25+ expect(movedPath("/Codician-Team/Diffinery/pulls", from, to)).toBe("/codician/diffinery/pulls");
26+ });
27+
28+ it("only rewrites a whole owner/name pair", () => {
29+ expect(movedPath("/codician-team/diffinery-old/pulls", from, to)).toBeNull();
30+ expect(movedPath("/codician-team-2/diffinery", from, to)).toBeNull();
31+ expect(movedPath("/codician-team", from, to)).toBeNull();
32+ });
33+
34+ it("is null when the name came from somewhere other than the path", () => {
35+ expect(movedPath("/mcp", from, to)).toBeNull();
36+ expect(movedPath("/settings/files", from, to)).toBeNull();
37+ });
38+
39+ it("encodes the new names and survives malformed escapes", () => {
40+ expect(movedPath("/codician-team/diffinery/tree/a%2Fb", from, { owner: "x", name: "y" })).toBe("/x/y/tree/a%2Fb");
41+ expect(movedPath("/%E0%A4%A/diffinery", from, to)).toBeNull();
42+ });
43+});
44+
45+describe("renamed account paths", () => {
46+ it("rewrites the first segment only", () => {
47+ expect(movedLoginPath("/old-org", "old-org", "new-org")).toBe("/new-org");
48+ expect(movedLoginPath("/Old-Org/site/issues", "old-org", "new-org")).toBe("/new-org/site/issues");
49+ expect(movedLoginPath("/other/old-org", "old-org", "new-org")).toBeNull();
50+ });
51+});
52+
53+describe("moved status", () => {
54+ it("is 301 for reads and 307 for everything else, as on GitHub", () => {
55+ expect(movedStatus("GET")).toBe(301);
56+ expect(movedStatus("HEAD")).toBe(301);
57+ for (const m of ["POST", "PUT", "PATCH", "DELETE"]) expect(movedStatus(m)).toBe(307);
58+ });
59+});
modifiedpackages/shared/src/events.ts+1−1View file
@@ -2,7 +2,7 @@
22 /** Events flowing through the `events` queue. Payload shapes follow GitHub webhooks where practical. */
33 export type DiffineryEvent =
44 | { type: "push"; repoId: string; owner: string; repo: string; ref: string; before: string; after: string; actor: Actor; forced: boolean; created: boolean; deleted: boolean; at: string }
5- | { type: "pull_request"; action: "opened" | "synchronize" | "closed" | "reopened" | "edited" | "merged" | "enqueued" | "dequeued" | "assigned" | "review_requested" | "ready_for_review" | "converted_to_draft"; repoId: string; owner: string; repo: string; number: number; headRef: string; headSha: string; baseRef: string; actor: Actor; at: string; /** Logins newly assigned (assigned). */ assignees?: string[]; /** Logins newly asked to review (review_requested). */ reviewers?: string[] }
5+ | { type: "pull_request"; action: "opened" | "synchronize" | "closed" | "reopened" | "edited" | "merged" | "enqueued" | "dequeued" | "assigned" | "review_requested" | "ready_for_review" | "converted_to_draft" | "auto_merge_enabled" | "auto_merge_disabled"; repoId: string; owner: string; repo: string; number: number; headRef: string; headSha: string; baseRef: string; actor: Actor; at: string; /** Logins newly assigned (assigned). */ assignees?: string[]; /** Logins newly asked to review (review_requested). */ reviewers?: string[] }
66 | { type: "issues"; action: "opened" | "closed" | "reopened" | "edited" | "assigned"; repoId: string; owner: string; repo: string; number: number; actor: Actor; at: string; /** Logins newly assigned (assigned). */ assignees?: string[] }
77 | { type: "issue_comment"; action: "created"; repoId: string; owner: string; repo: string; number: number; commentId: string; actor: Actor; at: string }
88 | { type: "pull_request_review"; action: "submitted"; repoId: string; owner: string; repo: string; number: number; state: string; actor: Actor; at: string }