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)
- README.md+35−3
- apps/diffinery/docs/accounts/notifications.md+1−0
- apps/diffinery/docs/accounts/organizations.md+8−1
- apps/diffinery/docs/accounts/profile-and-preferences.md+3−3
- apps/diffinery/docs/admin/site-administration.md+1−1
- apps/diffinery/docs/agents/overview.md+4−3
- apps/diffinery/docs/collaboration/merge-queue.md+3−3
- apps/diffinery/docs/packages/container-registry.md+4−0
- apps/diffinery/docs/reference/rest-api.md+1−1
- apps/diffinery/docs/repositories/branch-protection.md+30−4
- apps/diffinery/docs/repositories/git.md+1−1
- apps/diffinery/docs/repositories/overview.md+27−2
- apps/diffinery/docs/security/audit-log.md+4−0
- apps/diffinery/migrations/0011_redirects.sql+34−0
- apps/diffinery/src/app.ts+1−1
- apps/diffinery/src/docs/api-meta.ts+37−1
- apps/diffinery/src/http/api.ts+47−4
- apps/diffinery/src/http/common.ts+31−3
- apps/diffinery/src/http/gh.ts+10−1
- apps/diffinery/src/http/git.ts+12−7
- apps/diffinery/src/lib/directory.ts+166−6
- apps/diffinery/src/lib/job-permissions.ts+2−1
- apps/diffinery/src/lib/redirects.ts+47−0
- apps/diffinery/src/lib/storage-keys.ts+5−2
- apps/diffinery/src/mail/notify.ts+15−1
- apps/diffinery/src/mail/samples.ts+1−0
- apps/diffinery/src/mail/templates.ts+18−0
- apps/diffinery/src/mail/types.ts+2−0
- apps/diffinery/src/mcp/releases.ts+2−1
- apps/diffinery/src/mcp/server.ts+29−2
- apps/diffinery/src/registry/http.ts+21−4
- apps/diffinery/src/registry/store.ts+6−0
- apps/diffinery/src/repo/RepoDO.ts+62−6
- apps/diffinery/src/repo/actions.ts+10−4
- apps/diffinery/src/repo/auto-merge.ts+290−0
- apps/diffinery/src/repo/collab.ts+71−15
- apps/diffinery/src/repo/extra-triggers.ts+2−1
- apps/diffinery/src/repo/gitdata.ts+1−0
- apps/diffinery/src/repo/queue.ts+5−1
- apps/diffinery/src/repo/schema.ts+2−0
- apps/diffinery/src/repo/social.ts+2−0
- apps/diffinery/src/repo/statuses.ts+1−4
- apps/diffinery/src/repo/types.ts+13−0
- apps/diffinery/src/web/account.tsx+42−2
- apps/diffinery/src/web/collab.tsx+69−3
- apps/diffinery/src/web/repo-settings.tsx+78−5
- apps/diffinery/src/web/ui.tsx+3−1
- apps/diffinery/test/e2e/e2e.test.mjs+113−0
- apps/diffinery/test/e2e/security.test.mjs+513−3
- apps/diffinery/test/unit/auto-merge.test.ts+53−0
- apps/diffinery/test/unit/email.test.ts+1−1
- apps/diffinery/test/unit/job-permissions.test.ts+2−0
- apps/diffinery/test/unit/redirects.test.ts+59−0
- packages/shared/src/events.ts+1−1
modifiedREADME.md+35−3View file
| @@ -207,7 +207,8 @@ | ||
| 207 | 207 | |
| 208 | 208 | Tools include `get_file`, `list_tree`, `commit_files` (no git client needed), |
| 209 | 209 | `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, | |
| 211 | 212 | `list_workflow_runs` and `get_job_logs`, `rerun_workflow`, releases |
| 212 | 213 | (`create_release`, `upload_release_asset`, `generate_release_notes`), |
| 213 | 214 | `sandbox_create`/`sandbox_exec` for running builds and tests, and |
| @@ -218,15 +219,37 @@ | ||
| 218 | 219 | |
| 219 | 220 | - An agent's role is capped at write, and never exceeds its owner's role. |
| 220 | 221 | - 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). | |
| 222 | 224 | - 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`). | |
| 224 | 227 | - Agents can't change branch protection or read or set secrets. |
| 225 | 228 | - Agents can't change or delete environments or their branch policies, and |
| 226 | 229 | can't dismiss reviews. |
| 227 | 230 | - Commit statuses and check runs that agents report never satisfy a required |
| 228 | 231 | check. |
| 229 | 232 | |
| 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 | + | |
| 230 | 253 | ## Workflows |
| 231 | 254 | |
| 232 | 255 | Put workflow files in `.github/workflows/` (or `.diffinery/workflows/`). |
| @@ -394,6 +417,15 @@ | ||
| 394 | 417 | with `sha`; a stale `sha` returns 409), compare, commits, `merges`, |
| 395 | 418 | `dispatches`, and `zipball`/`tarball` archives. `private: false` makes a |
| 396 | 419 | 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}`). | |
| 397 | 429 | - **Archives:** `zipball` and `tarball` are built on demand and cached in R2. |
| 398 | 430 | The cache can be evicted at any time; the archive is then built again. An |
| 399 | 431 | 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 @@ | ||
| 244 | 244 | | `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 | |
| 245 | 245 | | `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 | |
| 246 | 246 | | `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 | | |
| 247 | 248 | |
| 248 | 249 | Issue and pull request emails (`thread`) come in these variants: |
| 249 | 250 |
modifiedapps/diffinery/docs/accounts/organizations.md+8−1View file
| @@ -216,8 +216,14 @@ | ||
| 216 | 216 | git remote set-url origin https://diffinery.com/acme-inc/website.git |
| 217 | 217 | ``` |
| 218 | 218 | |
| 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. | |
| 220 | 220 | |
| 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 | + | |
| 221 | 227 | ## Organizations API |
| 222 | 228 | |
| 223 | 229 | | Method and path | Description | |
| @@ -226,6 +232,7 @@ | ||
| 226 | 232 | | `POST /api/v3/user/orgs` | Create an organization: `{"login": "acme", "name": "Acme Inc."}`. Needs an `admin` token. | |
| 227 | 233 | | `GET /api/v3/orgs/{org}` | The organization; members, their agents and site administrators also get `default_repository_permission` | |
| 228 | 234 | | `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. | | |
| 229 | 236 | | `GET /api/v3/orgs/{org}/members` | Members and their roles. Members and site administrators only; others get `404`. | |
| 230 | 237 | | `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). | |
| 231 | 238 | | `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 @@ | ||
| 44 | 44 | |
| 45 | 45 | 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. |
| 46 | 46 | |
| 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: | |
| 48 | 48 | |
| 49 | 49 | ```sh |
| 50 | 50 | git remote set-url origin https://diffinery.com/new-name/website.git |
| 51 | 51 | ``` |
| 52 | 52 | |
| 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. | |
| 54 | 54 | |
| 55 | 55 | | Message | What to do | |
| 56 | 56 | | --- | --- | |
| @@ -251,7 +251,7 @@ | ||
| 251 | 251 | - Profile pictures are uploaded to Diffinery. There is no Gravatar support (`gravatar_id` is always empty in API responses). |
| 252 | 252 | - Diffinery has an API to upload and remove profile pictures for users and organizations; GitHub doesn't. |
| 253 | 253 | - 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. | |
| 255 | 255 | |
| 256 | 256 | ## Troubleshooting |
| 257 | 257 |
modifiedapps/diffinery/docs/admin/site-administration.md+1−1View file
| @@ -140,7 +140,7 @@ | ||
| 140 | 140 | | `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 | |
| 141 | 141 | | `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` | |
| 142 | 142 | |
| 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`. | |
| 144 | 144 | |
| 145 | 145 | ## Administration from the CLI |
| 146 | 146 |
modifiedapps/diffinery/docs/agents/overview.md+4−3View file
| @@ -118,7 +118,7 @@ | ||
| 118 | 118 | | Rule | Details | How to relax it | |
| 119 | 119 | | --- | --- | --- | |
| 120 | 120 | | 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)). | | |
| 122 | 122 | | 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. | |
| 123 | 123 | | 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. | |
| 124 | 124 | | No branch protection changes | `agents cannot change branch protection` | None. | |
| @@ -147,7 +147,8 @@ | ||
| 147 | 147 | - the `commit_files` MCP tool and `POST /repos/{owner}/{repo}/diffinery/commits`; |
| 148 | 148 | - the contents API (`PUT` and `DELETE /repos/{owner}/{repo}/contents/{path}`); |
| 149 | 149 | - 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. | |
| 151 | 152 | |
| 152 | 153 | 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. |
| 153 | 154 | |
| @@ -255,7 +256,7 @@ | ||
| 255 | 256 | | Message | Cause and fix | |
| 256 | 257 | | --- | --- | |
| 257 | 258 | | `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. | | |
| 259 | 260 | | `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. | |
| 260 | 261 | | `only people can create agents` | You called the agents API with an agent's token. Use your own. | |
| 261 | 262 | | `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 @@ | ||
| 119 | 119 | |
| 120 | 120 | ## Add a pull request to the queue |
| 121 | 121 | |
| 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)). | |
| 123 | 123 | |
| 124 | 124 | ### Queue in the web UI |
| 125 | 125 | |
| @@ -203,7 +203,7 @@ | ||
| 203 | 203 | | `squash` | One commit on top of the previous group. Subject `<title> (#12)`, body a `* <subject>` line per commit. Author: the pull request's author. | |
| 204 | 204 | | `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. | |
| 205 | 205 | |
| 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. | |
| 207 | 207 | |
| 208 | 208 | ## When a pull request merges |
| 209 | 209 | |
| @@ -278,7 +278,7 @@ | ||
| 278 | 278 | |
| 279 | 279 | 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: |
| 280 | 280 | |
| 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`. | |
| 282 | 282 | - **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. |
| 283 | 283 | - **REST API:** `PUT /repos/{owner}/{repo}/pulls/{number}/merge` with `"bypass_merge_queue": true`. `"bypass_rules": true` alone doesn't skip the queue. |
| 284 | 284 |
modifiedapps/diffinery/docs/packages/container-registry.md+4−0View file
| @@ -156,6 +156,10 @@ | ||
| 156 | 156 | |
| 157 | 157 | 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. |
| 158 | 158 | |
| 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 | + | |
| 159 | 163 | ## Permissions |
| 160 | 164 | |
| 161 | 165 | Each caller gets one of four roles on a package: |
modifiedapps/diffinery/docs/reference/rest-api.md+1−1View file
| @@ -74,7 +74,7 @@ | ||
| 74 | 74 | | Release | `latest`, whether it is the repository's latest release | |
| 75 | 75 | | Pull request file | `binary` | |
| 76 | 76 | |
| 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`. | |
| 78 | 78 | |
| 79 | 79 | ### IDs |
| 80 | 80 |
modifiedapps/diffinery/docs/repositories/branch-protection.md+30−4View file
| @@ -65,6 +65,8 @@ | ||
| 65 | 65 | | `enforceAdmins` | Do not allow administrators to bypass these rules | boolean | `false` | Applies the rules to repository admins too. | |
| 66 | 66 | | `agentsMayPush` | Allow agents to push directly | boolean | `false` | Lets agents push to matching branches. | |
| 67 | 67 | | `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). | | |
| 68 | 70 | | `mergeQueue` | Require a merge queue | object | none | Pull requests merge through a merge queue. See [Merge queue settings](#merge-queue-settings). | |
| 69 | 71 | |
| 70 | 72 | ### Required checks |
| @@ -131,6 +133,29 @@ | ||
| 131 | 133 | |
| 132 | 134 | 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. |
| 133 | 135 | |
| 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 | + | |
| 134 | 159 | ### Merge queue settings |
| 135 | 160 | |
| 136 | 161 | `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 @@ | ||
| 141 | 166 | | `buildConcurrency` | Build concurrency | `5` | 1 to 20: how many queued pull requests are built and checked at once | |
| 142 | 167 | | `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 | |
| 143 | 168 | |
| 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. | |
| 145 | 170 | |
| 146 | 171 | ## Who can bypass the rules |
| 147 | 172 | |
| @@ -154,7 +179,7 @@ | ||
| 154 | 179 | | 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. | |
| 155 | 180 | | 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)`. | |
| 156 | 181 | | 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. | | |
| 158 | 183 | | Deleting the default branch | Never allowed. | |
| 159 | 184 | | Agent rules | Never bypassed by admin status for merges. | |
| 160 | 185 | |
| @@ -336,7 +361,8 @@ | ||
| 336 | 361 | | `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`. | |
| 337 | 362 | | `branch is behind its base; update it (linear history required)` | With `requireLinearHistory`, the head branch must contain the latest base commit. | |
| 338 | 363 | | `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. | | |
| 340 | 366 | |
| 341 | 367 | Example response from `GET /api/v3/repos/acme/website/pulls/42`, trimmed: |
| 342 | 368 | |
| @@ -388,7 +414,7 @@ | ||
| 388 | 414 | - 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)). |
| 389 | 415 | - 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. |
| 390 | 416 | - 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. | |
| 392 | 418 | - Required Actions check names include the workflow name: `ci / test`. |
| 393 | 419 | - Rules have no options for restricting pushes to specific users or teams, requiring signed commits, requiring conversation resolution or requiring code owner reviews. |
| 394 | 420 | - 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 @@ | ||
| 295 | 295 | | Packs sent to clients | Sent without deltas, so clones and fetches download more bytes than from GitHub. | |
| 296 | 296 | | Signed pushes | Not supported. Signed commits push normally. | |
| 297 | 297 | | `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. | | |
| 299 | 299 | | `refs/pull/*` | Same as GitHub: fetchable, not pushable, hidden from `push --mirror`. | |
| 300 | 300 | | Ref names | Must start with `refs/` and follow Git's ref name rules, otherwise `invalid ref name`. | |
| 301 | 301 |
modifiedapps/diffinery/docs/repositories/overview.md+27−2View file
| @@ -247,10 +247,35 @@ | ||
| 247 | 247 | -d '{"name": "site"}' |
| 248 | 248 | ``` |
| 249 | 249 | |
| 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). | |
| 251 | 251 | |
| 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 | |
| 253 | 253 | |
| 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 | + | |
| 254 | 279 | ## Delete a repository |
| 255 | 280 | |
| 256 | 281 | 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 @@ | ||
| 72 | 72 | |---|---|---|---| |
| 73 | 73 | | `org.create` | An organization is created from the web UI | the organization's login | none | |
| 74 | 74 | | `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 | | |
| 75 | 76 | | `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` | |
| 76 | 77 | | `org.invite.revoke` | A pending organization invitation is revoked with **Revoke** | the organization's login | `id` (the invitation id) | |
| 77 | 78 | | `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 @@ | ||
| 88 | 89 | | `repo.update` | **General** settings are saved | `name`, `visibility`, `defaultBranch` | |
| 89 | 90 | | `repo.archive`, `repo.unarchive` | The repository is archived or unarchived | none | |
| 90 | 91 | | `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` | | |
| 91 | 93 | | `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` | |
| 92 | 94 | | `repo.invite` | An email address without an account is entered in **Add people**, which sends an invitation | `email`, `role` | |
| 93 | 95 | | `repo.topics` | The topics are replaced with `PUT /api/v3/repos/{owner}/{repo}/topics` | `names` | |
| 94 | 96 | | `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` | | |
| 95 | 99 | | `repo.secret.set`, `repo.secret.delete` | An Actions secret is saved or deleted on **Secrets and variables** | `name` (never the value) | |
| 96 | 100 | | `repo.variable.set`, `repo.variable.delete` | An Actions variable is saved or deleted | `name` | |
| 97 | 101 | | `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 @@ | ||
| 91 | 91 | // Live updates: authorise in the Worker, then hand the WebSocket to the repository's Durable Object. |
| 92 | 92 | app.get("/:owner/:repo/live", async (c) => { |
| 93 | 93 | 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 }); | |
| 95 | 95 | return stub.fetch(c.req.raw); |
| 96 | 96 | }); |
| 97 | 97 |
modifiedapps/diffinery/src/docs/api-meta.ts+37−1View file
| @@ -36,6 +36,8 @@ | ||
| 36 | 36 | "POST /orgs/{org}/invitations": { scope: "admin" }, |
| 37 | 37 | "POST /invitations": { scope: "admin" }, |
| 38 | 38 | "PATCH /orgs/{org}": { scope: "admin" }, |
| 39 | + "DELETE /orgs/{org}": { scope: "admin" }, | |
| 40 | + "POST /repos/{owner}/{repo}/transfer": { scope: "admin" }, | |
| 39 | 41 | "GET /orgs/{org}/invitations": { scope: "read" }, |
| 40 | 42 | "DELETE /orgs/{org}/invitations/{id}": { scope: "admin" }, |
| 41 | 43 | "GET /invitations": { scope: "read" }, |
| @@ -113,6 +115,13 @@ | ||
| 113 | 115 | body: { email: ["string", "Email address to invite.", true], role: ["string", "`member` (default), `admin` or `owner`."] }, |
| 114 | 116 | }, |
| 115 | 117 | "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 | + }, | |
| 116 | 125 | "DELETE /orgs/{org}/invitations/{id}": { |
| 117 | 126 | summary: "Revoke an organization invitation", |
| 118 | 127 | 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 @@ | ||
| 193 | 202 | "PATCH /repos/{owner}/{repo}": { |
| 194 | 203 | summary: "Update a repository", |
| 195 | 204 | 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.", | |
| 196 | 206 | body: { |
| 197 | 207 | name: ["string", "New name."], |
| 198 | 208 | description: ["string", "New description."], |
| @@ -202,6 +212,14 @@ | ||
| 202 | 212 | }, |
| 203 | 213 | }, |
| 204 | 214 | "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 | + }, | |
| 205 | 223 | "GET /repos/{owner}/{repo}/collaborators": { summary: "List collaborators", description: "Each entry carries `role_name`.", access: "read" }, |
| 206 | 224 | "PUT /repos/{owner}/{repo}/collaborators/{login}": { |
| 207 | 225 | summary: "Add or change a collaborator", |
| @@ -484,7 +502,8 @@ | ||
| 484 | 502 | "GET /repos/{owner}/{repo}/branches/{branch}/protection": { summary: "Get a protection rule", description: "`branch` is the rule's pattern.", access: "admin" }, |
| 485 | 503 | "PUT /repos/{owner}/{repo}/branches/{branch}/protection": { |
| 486 | 504 | 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.", | |
| 488 | 507 | access: "admin", |
| 489 | 508 | }, |
| 490 | 509 | "DELETE /repos/{owner}/{repo}/branches/{branch}/protection": { summary: "Delete a protection rule", access: "admin", status: 204 }, |
| @@ -672,6 +691,23 @@ | ||
| 672 | 691 | body: { merge_method: ["string", "`merge`, `squash` or `rebase` (default: the queue's method)."], delete_branch: ["boolean", "Delete the head branch once merged."] }, |
| 673 | 692 | }, |
| 674 | 693 | "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 | + }, | |
| 675 | 711 | "GET /repos/{owner}/{repo}/pulls/{number}/reviews": { summary: "List reviews", access: "read" }, |
| 676 | 712 | "POST /repos/{owner}/{repo}/pulls/{number}/reviews": { |
| 677 | 713 | summary: "Submit a review", |
modifiedapps/diffinery/src/http/api.ts+47−4View file
| @@ -15,7 +15,7 @@ | ||
| 15 | 15 | import { repoCreated } from "../mail/notify.js"; |
| 16 | 16 | import { actionsRest } from "./actions-runtime.js"; |
| 17 | 17 | 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"; | |
| 19 | 19 | import { decodePath, issueCtx, issuesOut, pullDetail, pullsOut, repoOut } from "./ghctx.js"; |
| 20 | 20 | |
| 21 | 21 | /** |
| @@ -100,6 +100,20 @@ | ||
| 100 | 100 | 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 } })); |
| 101 | 101 | return c.json(await orgJson(c, (await dir.account(org.id))!, a)); |
| 102 | 102 | }); |
| 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 | +}); | |
| 103 | 117 | api.get("/orgs/:org/members", async (c) => { |
| 104 | 118 | const a = requireAccount(c); |
| 105 | 119 | const dir = c.get("dir"); |
| @@ -244,6 +258,23 @@ | ||
| 244 | 258 | c.executionCtx.waitUntil(c.get("dir").audit({ actorId: caller.id, actorLogin: caller.login, action: "repo.delete", target: `${repo.owner_login}/${repo.name}` })); |
| 245 | 259 | return c.body(null, 204); |
| 246 | 260 | }); |
| 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 | +}); | |
| 247 | 278 | api.get("/repos/:owner/:repo/collaborators", async (c) => { |
| 248 | 279 | const { repo } = await loadRepo(c, P(c).owner, P(c).repo); |
| 249 | 280 | 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 @@ | ||
| 460 | 491 | const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); |
| 461 | 492 | if (caller.kind === "agent") throw new HttpError(403, "agents cannot change branch protection"); |
| 462 | 493 | 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); | |
| 464 | 495 | auditRepo(c, caller, repo, "repo.protection.set", { pattern: c.req.param("branch") }); |
| 465 | 496 | return c.json({ pattern: c.req.param("branch"), rules }); |
| 466 | 497 | }); |
| 467 | 498 | api.delete("/repos/:owner/:repo/branches/:branch/protection", async (c) => { |
| 468 | 499 | const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); |
| 469 | 500 | 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); | |
| 471 | 502 | auditRepo(c, caller, repo, "repo.protection.delete", { pattern: c.req.param("branch") }); |
| 472 | 503 | return c.body(null, 204); |
| 473 | 504 | }); |
| @@ -641,6 +672,18 @@ | ||
| 641 | 672 | await stub.dequeuePull(num(c), caller); |
| 642 | 673 | return c.body(null, 204); |
| 643 | 674 | }); |
| 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 | +}); | |
| 644 | 687 | api.get("/repos/:owner/:repo/pulls/:number/reviews", async (c) => { |
| 645 | 688 | const { stub, repo } = await loadRepo(c, P(c).owner, P(c).repo); |
| 646 | 689 | const n = num(c); |
| @@ -881,7 +924,7 @@ | ||
| 881 | 924 | repoIds = []; |
| 882 | 925 | for (const full of b.repositories) { |
| 883 | 926 | 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; | |
| 885 | 928 | // Repositories the account can't see are "unknown", so this doesn't reveal private ones. |
| 886 | 929 | if (!r || !(await callerFor(c, r))) throw new HttpError(422, `unknown repository ${full}`); |
| 887 | 930 | repoIds.push(r.id); |
modifiedapps/diffinery/src/http/common.ts+31−3View file
| @@ -2,12 +2,23 @@ | ||
| 2 | 2 | import type { Repo } from "../lib/directory.js"; |
| 3 | 3 | import { denied, readOnly, requestPermission, type JobPermissions } from "../lib/job-permissions.js"; |
| 4 | 4 | import { HttpError } from "../lib/util.js"; |
| 5 | +import { movedPath, movedStatus } from "../lib/redirects.js"; | |
| 5 | 6 | import { GITHUB_DOCS } from "./gh.js"; |
| 6 | 7 | import { callerFor, requireScope, type AppContext } from "../auth/principal.js"; |
| 7 | 8 | import { atLeast, type Caller, type Role } from "../repo/types.js"; |
| 8 | 9 | |
| 9 | 10 | export const repoStub = (env: Env, repoId: string) => env.REPO.get(env.REPO.idFromName(repoId)); |
| 10 | 11 | |
| 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 | + | |
| 11 | 22 | /** |
| 12 | 23 | * Load a repository and the caller's role, enforcing a minimum role (404 hides |
| 13 | 24 | * private repos). Anonymous callers get read access to public repositories but |
| @@ -17,9 +28,19 @@ | ||
| 17 | 28 | * Archived repositories refuse every write, whatever role the route needs, |
| 18 | 29 | * except the few routes that pass `whileArchived` (unarchiving, deleting, |
| 19 | 30 | * 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. | |
| 20 | 38 | */ |
| 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); | |
| 23 | 44 | if (!repo) throw new HttpError(404, "repository not found"); |
| 24 | 45 | const anonymous = c.get("principal").type === "anonymous"; |
| 25 | 46 | const reading = c.req.method === "GET" || c.req.method === "HEAD" || (opts.readOnlyPost && c.req.method === "POST") || c.get("readOnly") === true; |
| @@ -29,6 +50,11 @@ | ||
| 29 | 50 | if (anonymous) throw new HttpError(401, "authentication required"); |
| 30 | 51 | throw new HttpError(404, "repository not found"); |
| 31 | 52 | } |
| 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 | + } | |
| 32 | 58 | // After the visibility check, so a read token's write to a private repository it can't see is still a 404. |
| 33 | 59 | if (!reading) requireScope(c, "write"); |
| 34 | 60 | if (!atLeast(caller.role, min)) throw new HttpError(403, `${min} access required`); |
| @@ -40,7 +66,7 @@ | ||
| 40 | 66 | if (reason && !(repo.visibility === "public" && readOnly(req))) throw new HttpError(403, reason); |
| 41 | 67 | } |
| 42 | 68 | 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 }; | |
| 44 | 70 | } |
| 45 | 71 | |
| 46 | 72 | /** Record a repository settings change made through the API, like the web settings pages do. */ |
| @@ -67,6 +93,8 @@ | ||
| 67 | 93 | if (e instanceof HttpError) { |
| 68 | 94 | const headers: Record<string, string> = { "content-type": "application/json" }; |
| 69 | 95 | 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 } }); | |
| 70 | 98 | return new Response(JSON.stringify({ message: e.detail, documentation_url: GITHUB_DOCS, ...e.extra }), { status: e.status, headers }); |
| 71 | 99 | } |
| 72 | 100 | // 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 @@ | ||
| 246 | 246 | mergeCommit: string | null; |
| 247 | 247 | requestedReviewers: string[]; |
| 248 | 248 | intent: string | null; |
| 249 | + autoMerge?: AutoMergeLike | null; | |
| 249 | 250 | }; |
| 250 | 251 | } |
| 251 | 252 | |
| 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 | + | |
| 252 | 261 | /** Per-request lookups the issue builders need (labels by name, people by login, reactions by subject). */ |
| 253 | 262 | export interface IssueCtx { |
| 254 | 263 | base: string; |
| @@ -368,7 +377,7 @@ | ||
| 368 | 377 | statuses: { href: `${api}/statuses/${p.headSha}` }, |
| 369 | 378 | }, |
| 370 | 379 | author_association: issue.author_association, |
| 371 | - auto_merge: null, | |
| 380 | + auto_merge: ghAutoMerge(x.base, p.autoMerge), | |
| 372 | 381 | draft: p.draft, |
| 373 | 382 | merged: p.merged, |
| 374 | 383 | 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 @@ | ||
| 8 | 8 | /** |
| 9 | 9 | * Git smart HTTP (protocol v2 for fetch/clone, v0 for push) and the Git LFS |
| 10 | 10 | * 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. | |
| 11 | 16 | */ |
| 12 | 17 | export const git = new Hono<{ Bindings: Env; Variables: AppVars }>(); |
| 13 | 18 | |
| @@ -18,7 +23,7 @@ | ||
| 18 | 23 | if (service !== "git-upload-pack" && service !== "git-receive-pack") throw new HttpError(403, "dumb HTTP protocol is not supported; update git"); |
| 19 | 24 | // Git only offers credentials after a 401, so an anonymous push must be asked for them (as GitHub does), even to a public repository. |
| 20 | 25 | 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 }); | |
| 22 | 27 | if (service === "git-receive-pack" && repo.archived) throw new HttpError(403, "repository is archived (read-only)"); |
| 23 | 28 | if (service === "git-upload-pack" && !/version=2/.test(c.req.header("git-protocol") ?? "")) { |
| 24 | 29 | 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 @@ | ||
| 28 | 33 | }); |
| 29 | 34 | |
| 30 | 35 | 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 }); | |
| 32 | 37 | const body = await readBody(c.req.raw); |
| 33 | 38 | const out = await stub.gitUploadPack(body); |
| 34 | 39 | return new Response(out, { headers: { "content-type": "application/x-git-upload-pack-result", ...noCache } }); |
| 35 | 40 | }); |
| 36 | 41 | |
| 37 | 42 | 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 }); | |
| 39 | 44 | // A GitHub import's own token, while that import is running and filling this repository, may push in import mode. |
| 40 | 45 | const p = c.get("principal"); |
| 41 | 46 | if (p.type === "account" && p.importId && p.tokenId && (await importPushAllowed(c.env, p.importId, repo.id, p.tokenId))) caller.importId = p.importId; |
| 42 | 47 | const body = await readBody(c.req.raw); |
| 43 | - const out = await stub.gitReceivePack(body, caller); | |
| 48 | + const out = await stub.gitReceivePack(body, caller, { moved }); | |
| 44 | 49 | c.executionCtx.waitUntil(c.get("dir").touchPushed(repo.id)); |
| 45 | 50 | return new Response(out, { headers: { "content-type": "application/x-git-receive-pack-result", ...noCache } }); |
| 46 | 51 | }); |
| @@ -59,7 +64,7 @@ | ||
| 59 | 64 | |
| 60 | 65 | git.post("/:owner/:repo/info/lfs/objects/batch", async (c) => { |
| 61 | 66 | 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 }); | |
| 63 | 68 | const base = `${c.env.PUBLIC_URL}/${c.req.param("owner")}/${c.req.param("repo")}/info/lfs/objects`; |
| 64 | 69 | const auth = c.req.header("authorization"); |
| 65 | 70 | const objects = []; |
| @@ -80,14 +85,14 @@ | ||
| 80 | 85 | }); |
| 81 | 86 | |
| 82 | 87 | 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 }); | |
| 84 | 89 | const obj = await c.env.BUCKET.get(lfsKey(repo.id, c.req.param("oid"))); |
| 85 | 90 | if (!obj) throw new HttpError(404, "object not found"); |
| 86 | 91 | return new Response(obj.body, { headers: { "content-type": "application/octet-stream", "content-length": String(obj.size) } }); |
| 87 | 92 | }); |
| 88 | 93 | |
| 89 | 94 | 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 }); | |
| 91 | 96 | const oid = c.req.param("oid"); |
| 92 | 97 | if (!/^[0-9a-f]{64}$/.test(oid)) throw new HttpError(422, "invalid oid"); |
| 93 | 98 | // 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 @@ | ||
| 4 | 4 | import { HttpError, RESERVED_LOGINS, isValidLogin, isValidName, newId, now, randomToken, sha256 } from "./util.js"; |
| 5 | 5 | import { mailForAudit, tokenCreated } from "../mail/notify.js"; |
| 6 | 6 | import { enqueueMail } from "../mail/send.js"; |
| 7 | +import { archivePrefix, deletePrefixes } from "./storage-keys.js"; | |
| 7 | 8 | |
| 8 | 9 | export interface Account { |
| 9 | 10 | id: string; |
| @@ -130,14 +131,25 @@ | ||
| 130 | 131 | } |
| 131 | 132 | // A blank display name clears it, so pages fall back to the login. |
| 132 | 133 | const name = patch.name === undefined ? undefined : (patch.name as string | null)?.trim() || null; |
| 134 | + const before = patch.login ? await this.account(id) : null; | |
| 133 | 135 | await this.db |
| 134 | 136 | .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 = ?") |
| 135 | 137 | .bind(patch.login ?? null, name === undefined ? 0 : 1, name ?? null, (patch.baseRole as string | undefined) ?? null, id) |
| 136 | 138 | .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 | + } | |
| 137 | 146 | if (patch.login) { |
| 138 | 147 | // Repos keep their ids; their Durable Objects cache the owner login, so refresh them. |
| 139 | 148 | 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 | + } | |
| 141 | 153 | } |
| 142 | 154 | } |
| 143 | 155 | |
| @@ -162,6 +174,34 @@ | ||
| 162 | 174 | return (await this.account(id))!; |
| 163 | 175 | } |
| 164 | 176 | |
| 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 | + | |
| 165 | 205 | /** Whether an account sees an organization's settings such as its base permission: members, the |
| 166 | 206 | * organization's own agents, agents of members, and site admins. */ |
| 167 | 207 | async inOrg(orgId: string, account: Account): Promise<boolean> { |
| @@ -517,6 +557,8 @@ | ||
| 517 | 557 | .prepare("INSERT INTO repos (id, owner_id, name, description, visibility, default_branch, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") |
| 518 | 558 | .bind(id, owner.id, input.name, input.description ?? "", repoVisibility(input.visibility) ?? "private", branch, t, t), |
| 519 | 559 | ...(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), | |
| 520 | 562 | ]); |
| 521 | 563 | const meta: RepoMeta = { repoId: id, ownerId: owner.id, owner: owner.login, name: input.name, defaultBranch: branch, ownerKind: owner.kind === "org" ? "org" : "user" }; |
| 522 | 564 | const stub = this.env.REPO.get(this.env.REPO.idFromName(id)); |
| @@ -530,10 +572,17 @@ | ||
| 530 | 572 | |
| 531 | 573 | async updateRepo(repo: Repo, patch: { name?: string; description?: string; visibility?: string; defaultBranch?: string; archived?: boolean; homepage?: string; topics?: string[] }): Promise<Repo> { |
| 532 | 574 | 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 | + ]); | |
| 537 | 586 | if (patch.homepage !== undefined || patch.topics !== undefined) { |
| 538 | 587 | await this.db |
| 539 | 588 | .prepare("UPDATE repos SET homepage = COALESCE(?, homepage), topics = COALESCE(?, topics) WHERE id = ?") |
| @@ -541,15 +590,126 @@ | ||
| 541 | 590 | .run(); |
| 542 | 591 | } |
| 543 | 592 | 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); | |
| 544 | 594 | return (await this.repoById(repo.id))!; |
| 545 | 595 | } |
| 546 | 596 | |
| 547 | 597 | 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 | + ]); | |
| 549 | 605 | // R2 objects under repos/<id>/ are removed by the cleanup job; the DO's storage is wiped here. |
| 550 | 606 | await this.env.REPO.get(this.env.REPO.idFromName(repo.id)).destroy(); |
| 551 | 607 | } |
| 552 | 608 | |
| 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 | + | |
| 553 | 713 | async setTopics(repoId: string, topics: string[]): Promise<void> { |
| 554 | 714 | await this.db.prepare("UPDATE repos SET topics = ?, updated_at = ? WHERE id = ?").bind(JSON.stringify(topics), now(), repoId).run(); |
| 555 | 715 | } |
modifiedapps/diffinery/src/lib/job-permissions.ts+2−1View file
| @@ -158,7 +158,8 @@ | ||
| 158 | 158 | (first === "branches" && segs.slice(2).includes("protection")); |
| 159 | 159 | if (adminOnly) return { forbidden: "this endpoint is not available to a workflow's GITHUB_TOKEN" }; |
| 160 | 160 | 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 }; | |
| 162 | 163 | if (first === "commits" && /^(statuses|status)$/.test(segs[2] ?? "")) return { scopes: ["statuses"], access }; |
| 163 | 164 | if (first === "commits" && /^(check-runs|check-suites)$/.test(segs[2] ?? "")) return { scopes: ["checks"], access }; |
| 164 | 165 | 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 @@ | ||
| 33 | 33 | |
| 34 | 34 | /** A cached source archive of a commit (zipball or tarball). */ |
| 35 | 35 | export function archiveKey(repo: string, sha: string, format: string): string { |
| 36 | - return `archives/${repo}/${sha}.${format}`; | |
| 36 | + return `${archivePrefix(repo)}${sha}.${format}`; | |
| 37 | 37 | } |
| 38 | 38 | |
| 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 | + | |
| 39 | 42 | /** A repository's prefixes outside repos/: Actions data and caches. */ |
| 40 | 43 | 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)]; | |
| 42 | 45 | } |
| 43 | 46 | |
| 44 | 47 | type ListDelete = { |
modifiedapps/diffinery/src/mail/notify.ts+15−1View file
| @@ -72,7 +72,7 @@ | ||
| 72 | 72 | } |
| 73 | 73 | |
| 74 | 74 | 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; | |
| 76 | 76 | if (ev.type === "issues" && ev.action === "edited") return; |
| 77 | 77 | const repo = await dir.repoById(ev.repoId); |
| 78 | 78 | if (!repo) return; |
| @@ -316,6 +316,20 @@ | ||
| 316 | 316 | jobs = to(ids, { kind: "repo_deleted", data: { repo: `${owner}/${name}`, actor: e.actorLogin ?? "someone" } }, "admin"); |
| 317 | 317 | break; |
| 318 | 318 | } |
| 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 | + } | |
| 319 | 333 | default: |
| 320 | 334 | return; |
| 321 | 335 | } |
modifiedapps/diffinery/src/mail/samples.ts+1−0View file
| @@ -78,6 +78,7 @@ | ||
| 78 | 78 | item("repo-access", "Repository access", { kind: "repo_access", reason: "account", data: { repo, role: "write", previous: null } }), |
| 79 | 79 | item("repo-created", "Repository created", { kind: "repo_created", reason: "your_activity", data: { repo, creator: me, visibility: "private", defaultBranch: "main", description: "Marketing site" } }), |
| 80 | 80 | 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" } }), | |
| 81 | 82 | item("test", "Test email", { kind: "test", reason: "account", data: { by: me } }), |
| 82 | 83 | ]; |
| 83 | 84 | } |
modifiedapps/diffinery/src/mail/templates.ts+18−0View file
| @@ -626,6 +626,24 @@ | ||
| 626 | 626 | title: [user(job.data.actor), " deleted ", { b: job.data.repo }], |
| 627 | 627 | 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)]] }], |
| 628 | 628 | }; |
| 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 | + } | |
| 629 | 647 | case "test": |
| 630 | 648 | return { |
| 631 | 649 | subject: "[Diffinery] Test email", |
modifiedapps/diffinery/src/mail/types.ts+2−0View file
| @@ -88,6 +88,7 @@ | ||
| 88 | 88 | | { kind: "repo_access"; data: { repo: string; role: string | null; previous: string | null } } |
| 89 | 89 | | { kind: "repo_created"; data: { repo: string; creator: string; visibility: string; defaultBranch: string; description: string } } |
| 90 | 90 | | { kind: "repo_deleted"; data: { repo: string; actor: string } } |
| 91 | + | { kind: "repo_transferred"; data: { from: string; to: string; actor: string } } | |
| 91 | 92 | | { kind: "thread"; data: ThreadData } |
| 92 | 93 | | { kind: "run"; data: RunData } |
| 93 | 94 | | { kind: "test"; data: { by: string } }; |
| @@ -128,6 +129,7 @@ | ||
| 128 | 129 | case "repo_access": |
| 129 | 130 | case "repo_created": |
| 130 | 131 | case "repo_deleted": |
| 132 | + case "repo_transferred": | |
| 131 | 133 | return "repos"; |
| 132 | 134 | case "run": |
| 133 | 135 | return "actions"; |
modifiedapps/diffinery/src/mcp/releases.ts+2−1View file
| @@ -33,7 +33,8 @@ | ||
| 33 | 33 | if (!Number.isInteger(n) || n < 1) throw new HttpError(422, `${k} must be a positive integer`); |
| 34 | 34 | return n; |
| 35 | 35 | }; |
| 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 }); | |
| 37 | 38 | const OWNER_REPO = { |
| 38 | 39 | owner: { type: "string", description: "Repository owner (user or org login)" }, |
| 39 | 40 | repo: { type: "string", description: "Repository name" }, |
modifiedapps/diffinery/src/mcp/server.ts+29−2View file
| @@ -45,7 +45,8 @@ | ||
| 45 | 45 | if (!Number.isInteger(n) || n < 1) throw new HttpError(422, `${k} must be a positive integer`); |
| 46 | 46 | return n; |
| 47 | 47 | }; |
| 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 }); | |
| 49 | 50 | const OWNER_REPO = { |
| 50 | 51 | owner: { type: "string", description: "Repository owner (user or org login)" }, |
| 51 | 52 | repo: { type: "string", description: "Repository name" }, |
| @@ -211,7 +212,7 @@ | ||
| 211 | 212 | }, |
| 212 | 213 | merge_pull_request: { |
| 213 | 214 | 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.", | |
| 215 | 216 | input: { ...OWNER_REPO, number: { type: "integer" }, method: { type: "string", enum: ["merge", "squash", "rebase"] }, expected_head_sha: { type: "string" }, delete_branch: { type: "boolean" } }, |
| 216 | 217 | required: ["owner", "repo", "number"], |
| 217 | 218 | run: async (c, a) => { |
| @@ -225,6 +226,32 @@ | ||
| 225 | 226 | return stub.mergePull(number, { method: (optStr(a, "method") as "merge") ?? "merge", expectedHeadSha: optStr(a, "expected_head_sha"), deleteBranch: !!a.delete_branch }, caller); |
| 226 | 227 | }, |
| 227 | 228 | }, |
| 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 | + }, | |
| 228 | 255 | dequeue_pull_request: { |
| 229 | 256 | description: "Remove a pull request from its base branch's merge queue.", |
| 230 | 257 | input: { ...OWNER_REPO, number: { type: "integer" } }, |
modifiedapps/diffinery/src/registry/http.ts+21−4View file
| @@ -109,21 +109,38 @@ | ||
| 109 | 109 | async function target(c: AppContext, full: string, need: PackageRole): Promise<Target> { |
| 110 | 110 | const lower = full.toLowerCase(); |
| 111 | 111 | const slash = lower.indexOf("/"); |
| 112 | - const name = slash > 0 ? lower.slice(slash + 1) : ""; | |
| 112 | + let name = slash > 0 ? lower.slice(slash + 1) : ""; | |
| 113 | 113 | if (slash <= 0 || !NAME_RE.test(name) || lower.length > 255) { |
| 114 | 114 | 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 '/'`); |
| 115 | 115 | } |
| 116 | 116 | 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 | + } | |
| 118 | 134 | if (!owner || owner.kind === "agent") { |
| 119 | 135 | 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}`); | |
| 121 | 137 | } |
| 122 | - const pkg = await store.package(owner.id, name); | |
| 138 | + if (moved && pkg) name = pkg.name; | |
| 123 | 139 | const access = await packageAccess(c, owner, name, pkg); |
| 124 | 140 | if (!allows(access.role, need)) { |
| 125 | 141 | const anon = c.get("principal").type === "anonymous"; |
| 126 | 142 | 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`); | |
| 127 | 144 | if (!allows(access.role, "pull")) { |
| 128 | 145 | if (need === "pull" || pkg) throw new RegistryError(404, "NAME_UNKNOWN", `repository ${owner.login}/${name} not found${access.hint ? ` (${access.hint})` : ""}`); |
| 129 | 146 | 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 @@ | ||
| 82 | 82 | return this.db.prepare(`${PKG_SELECT} WHERE p.id = ?`).bind(id).first<PackageRow>(); |
| 83 | 83 | } |
| 84 | 84 | |
| 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 | + | |
| 85 | 91 | /** Creates the package unless a concurrent push just did; returns it either way. */ |
| 86 | 92 | async createPackage(ownerId: string, name: string, repoId: string | null, createdBy: string): Promise<PackageRow> { |
| 87 | 93 | const t = now(); |
modifiedapps/diffinery/src/repo/RepoDO.ts+62−6View file
| @@ -41,6 +41,7 @@ | ||
| 41 | 41 | import { HttpError, isValidRefName, now } from "../lib/util.js"; |
| 42 | 42 | import { deletePrefixes, transientPrefixes } from "../lib/storage-keys.js"; |
| 43 | 43 | import { Actions } from "./actions.js"; |
| 44 | +import { AutoMerge, isPerson } from "./auto-merge.js"; | |
| 44 | 45 | import { Collab } from "./collab.js"; |
| 45 | 46 | import { Deployments } from "./deployments.js"; |
| 46 | 47 | import { triggerWorkflows } from "./extra-triggers.js"; |
| @@ -89,6 +90,7 @@ | ||
| 89 | 90 | readonly collab: Collab; |
| 90 | 91 | readonly actions: Actions; |
| 91 | 92 | readonly queue: MergeQueue; |
| 93 | + readonly autoMerge: AutoMerge; | |
| 92 | 94 | readonly runtime: Runtime; |
| 93 | 95 | readonly policy: TokenPolicy; |
| 94 | 96 | private metaCache: RepoMeta | null = null; |
| @@ -103,6 +105,7 @@ | ||
| 103 | 105 | this.collab = new Collab(this); |
| 104 | 106 | this.actions = new Actions(this, env); |
| 105 | 107 | this.queue = new MergeQueue(this); |
| 108 | + this.autoMerge = new AutoMerge(this, env); | |
| 106 | 109 | this.runtime = new Runtime(this, env); |
| 107 | 110 | this.policy = new TokenPolicy(this); |
| 108 | 111 | } |
| @@ -243,7 +246,8 @@ | ||
| 243 | 246 | } |
| 244 | 247 | } |
| 245 | 248 | |
| 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> { | |
| 247 | 251 | if (body.length > MAX_PUSH_BYTES) { |
| 248 | 252 | const req = { commands: [], capabilities: new Set<string>(), pushOptions: [], pack: null }; |
| 249 | 253 | return reportStatus(req, "push exceeds 100 MB; split it into smaller pushes", []); |
| @@ -259,7 +263,7 @@ | ||
| 259 | 263 | const denied = req.commands.map((c) => ({ ref: c.ref, ok: false as const, reason: "repository is locked by an import in progress" })); |
| 260 | 264 | return reportStatus(req, "repository is locked by an import in progress", denied); |
| 261 | 265 | } |
| 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`] : []; | |
| 263 | 267 | let unpackError: string | null = null; |
| 264 | 268 | // Like git's quarantine: nothing from the pack is stored until push protection passes, |
| 265 | 269 | // so a blocked push leaves no objects behind for a retry to build on. |
| @@ -427,8 +431,10 @@ | ||
| 427 | 431 | } |
| 428 | 432 | } |
| 429 | 433 | 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. | |
| 431 | 436 | 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) }); | |
| 432 | 438 | } |
| 433 | 439 | |
| 434 | 440 | // ---------------------------------------------------------------- browsing |
| @@ -628,13 +634,52 @@ | ||
| 628 | 634 | 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) })); |
| 629 | 635 | } |
| 630 | 636 | |
| 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 | + } | |
| 633 | 654 | 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; | |
| 634 | 670 | if (rules) this.sql.exec("INSERT INTO protections (pattern, rules) VALUES (?, ?) ON CONFLICT(pattern) DO UPDATE SET rules = excluded.rules", pattern, JSON.stringify(rules)); |
| 635 | 671 | 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. | |
| 637 | 681 | this.queue.schedule(); |
| 682 | + this.autoMerge.schedule(); | |
| 638 | 683 | } |
| 639 | 684 | |
| 640 | 685 | // ---------------------------------------------------------------- RPC passthroughs (collab + actions) |
| @@ -722,6 +767,12 @@ | ||
| 722 | 767 | mergeQueueSettings(...a: Parameters<MergeQueue["settings"]>) { |
| 723 | 768 | return this.queue.settings(...a); |
| 724 | 769 | } |
| 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 | + } | |
| 725 | 776 | |
| 726 | 777 | runs(...a: Parameters<Actions["listRuns"]>) { |
| 727 | 778 | return this.actions.listRuns(...a); |
| @@ -1322,6 +1373,11 @@ | ||
| 1322 | 1373 | } |
| 1323 | 1374 | |
| 1324 | 1375 | 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 | + } | |
| 1325 | 1381 | if (rules?.mergeQueue) rules = { ...rules, mergeQueue: normalizeMergeQueue(rules.mergeQueue) }; |
| 1326 | 1382 | if (rules?.requiredCheckSources) rules = { ...rules, requiredCheckSources: normalizeCheckSources(rules.requiredCheckSources) }; |
| 1327 | 1383 | if (rules) this.importsModule.protection(pattern, JSON.stringify(rules), importId); |
modifiedapps/diffinery/src/repo/actions.ts+10−4View file
| @@ -27,6 +27,7 @@ | ||
| 27 | 27 | import type { RepoDO } from "./RepoDO.js"; |
| 28 | 28 | import { completePayload } from "./event-payloads.js"; |
| 29 | 29 | import { atLeast, type Caller } from "./types.js"; |
| 30 | +import { isJobCaller } from "./token-policy.js"; | |
| 30 | 31 | import { ReusableError, expandReusable, localKey, parseCallRef, type Expanded, type Source } from "./reusable.js"; |
| 31 | 32 | import { ancestors, combineResults, jobStatus, runnerLabels, type JobNode } from "./job-graph.js"; |
| 32 | 33 | |
| @@ -465,15 +466,16 @@ | ||
| 465 | 466 | headRef?: string; |
| 466 | 467 | baseRef?: string; |
| 467 | 468 | 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). */ | |
| 469 | 470 | agentOrigin?: boolean; |
| 470 | 471 | /** Pull requests whose code the run includes (a merge group's). */ |
| 471 | 472 | prNumbers?: number[]; |
| 472 | 473 | }, |
| 473 | 474 | ): Promise<RunInfo | null> { |
| 474 | 475 | // 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; | |
| 477 | 479 | const id = newId(); |
| 478 | 480 | const t = now(); |
| 479 | 481 | const run: RunRow = { |
| @@ -669,7 +671,9 @@ | ||
| 669 | 671 | if (ref.kind === "local") repoId = from.repoId!; |
| 670 | 672 | else if (ref.owner.toLowerCase() === m.owner.toLowerCase() && ref.repo.toLowerCase() === m.name.toLowerCase()) repoId = m.repoId; |
| 671 | 673 | 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)); | |
| 673 | 677 | // Like GitHub: public workflows can be shared; private ones only within the same owner. |
| 674 | 678 | 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}`); |
| 675 | 679 | repoId = target.id; |
| @@ -1034,6 +1038,8 @@ | ||
| 1034 | 1038 | payload: { action, workflow_run: workflowRun, workflow: { name: run.workflow_name, path: run.workflow_path }, repository: await this.repositoryPayload(), sender: { login: actor.login } }, |
| 1035 | 1039 | caller: actor, |
| 1036 | 1040 | 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, | |
| 1037 | 1043 | }); |
| 1038 | 1044 | } |
| 1039 | 1045 | } 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 @@ | ||
| 6 | 6 | import { atLeast, type Caller } from "./types.js"; |
| 7 | 7 | import { COMMENT_SELECT } from "./social.js"; |
| 8 | 8 | import { requiredCheck } from "./required.js"; |
| 9 | +import { AutoMerge, automaticLandingRefusal, isPerson, landsByAgentRule, type AutoMergeInfo } from "./auto-merge.js"; | |
| 9 | 10 | |
| 10 | 11 | type Sig = { name: string; email: string; timestamp: number; timezone: string }; |
| 11 | 12 | |
| @@ -53,6 +54,7 @@ | ||
| 53 | 54 | merge_commit: string | null; |
| 54 | 55 | requested_reviewers: string; |
| 55 | 56 | intent: string | null; |
| 57 | + auto_merge: string | null; | |
| 56 | 58 | }; |
| 57 | 59 | |
| 58 | 60 | export type Issue = { |
| @@ -82,6 +84,8 @@ | ||
| 82 | 84 | mergeCommit: string | null; |
| 83 | 85 | requestedReviewers: string[]; |
| 84 | 86 | 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; | |
| 85 | 89 | }; |
| 86 | 90 | }; |
| 87 | 91 | |
| @@ -213,6 +217,7 @@ | ||
| 213 | 217 | mergeCommit: p.merge_commit, |
| 214 | 218 | requestedReviewers: JSON.parse(p.requested_reviewers), |
| 215 | 219 | intent: p.intent, |
| 220 | + autoMerge: p.auto_merge ? AutoMerge.info(JSON.parse(p.auto_merge)) : null, | |
| 216 | 221 | }; |
| 217 | 222 | } |
| 218 | 223 | return issue; |
| @@ -402,6 +407,7 @@ | ||
| 402 | 407 | 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 }); |
| 403 | 408 | if (!p.merged) this.repo.background(this.repo.actions.onPullRequest(action, number, caller).catch((e) => console.error("pr workflow trigger", e))); |
| 404 | 409 | if (action === "closed" && this.repo.queue.isQueued(number)) this.repo.queue.schedule(p.base_ref); |
| 410 | + if (action === "closed") this.repo.autoMerge.clear(number); | |
| 405 | 411 | } else this.repo.emit({ type: "issues", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, actor: actorOf(caller), at: t }); |
| 406 | 412 | } |
| 407 | 413 | const before = new Set((JSON.parse(r.assignees) as string[]).map((x) => x.toLowerCase())); |
| @@ -433,6 +439,9 @@ | ||
| 433 | 439 | const m = this.repo.meta; |
| 434 | 440 | 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 }); |
| 435 | 441 | 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 }); | |
| 436 | 445 | this.repo.broadcast(`pr:${number}`, { action: "updated" }); |
| 437 | 446 | return this.toIssue(this.row(number)); |
| 438 | 447 | } |
| @@ -567,9 +576,11 @@ | ||
| 567 | 576 | this.sql.exec("UPDATE issues SET updated_at = ? WHERE number = ?", now(), p.number); |
| 568 | 577 | const prefs = this.repo.protectionFor(p.base_ref); |
| 569 | 578 | 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); | |
| 570 | 580 | 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() }); |
| 571 | 581 | await this.repo.actions.onPullRequest("synchronize", p.number, caller).catch((e) => console.error("pr workflow trigger", e)); |
| 572 | 582 | if (this.repo.queue.isQueued(p.number)) this.repo.queue.schedule(p.base_ref); |
| 583 | + this.repo.autoMerge.schedule({ number: p.number }); | |
| 573 | 584 | this.repo.broadcast(`pr:${p.number}`, { action: "synchronize", sha }); |
| 574 | 585 | out.push(p.number); |
| 575 | 586 | } |
| @@ -584,6 +595,14 @@ | ||
| 584 | 595 | return { mergeBase: mb, headSha: head, ...(await this.repo.diff(mb, head)) }; |
| 585 | 596 | } |
| 586 | 597 | |
| 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 | + | |
| 587 | 606 | async pullCommits(number: number) { |
| 588 | 607 | const p = this.pullRow(number); |
| 589 | 608 | const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`); |
| @@ -647,6 +666,7 @@ | ||
| 647 | 666 | const m = this.repo.meta; |
| 648 | 667 | 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 }); |
| 649 | 668 | this.repo.broadcast(`pr:${number}`, { action: "review", state }); |
| 669 | + this.repo.autoMerge.schedule({ number }); | |
| 650 | 670 | return this.reviews(number).find((x) => x.id === id)!; |
| 651 | 671 | } |
| 652 | 672 | |
| @@ -708,16 +728,25 @@ | ||
| 708 | 728 | seq, |
| 709 | 729 | ); |
| 710 | 730 | 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); | |
| 715 | 732 | return this.checks(input.sha).find((c) => c.id === id)!; |
| 716 | 733 | } |
| 717 | 734 | |
| 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 | + | |
| 718 | 742 | // ---------------------------------------------------------------- merging |
| 719 | 743 | |
| 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> { | |
| 721 | 750 | const r = this.row(number); |
| 722 | 751 | const p = this.pullRow(number); |
| 723 | 752 | const result: Mergeability = { mergeable: false, state: "unknown", conflicts: [], approvals: 0, requiredApprovals: 0, changesRequested: [], checks: [], blockers: [], mergeBase: null, mergeQueue: null }; |
| @@ -735,25 +764,34 @@ | ||
| 735 | 764 | result.linearHistory = !!rules.requireLinearHistory; |
| 736 | 765 | result.enforceAdmins = !!rules.enforceAdmins; |
| 737 | 766 | 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; | |
| 738 | 772 | const latest = new Map<string, Review>(); |
| 739 | 773 | // Imported reviews are history, not votes, whoever authored them; and so are plain comments. |
| 740 | 774 | for (const rv of this.reviews(number)) if (rv.state !== "COMMENTED" && rv.author.kind !== "github" && !rv.imported) latest.set(rv.author.id, rv); |
| 741 | 775 | for (const rv of latest.values()) { |
| 742 | 776 | // Only people's approvals count toward requirements: agents' reviews are advisory, and a |
| 743 | 777 | // 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++; | |
| 745 | 779 | if (rv.state === "CHANGES_REQUESTED") result.changesRequested.push(rv.author.login); |
| 746 | 780 | } |
| 747 | 781 | // Commit statuses count as checks too: a required check matches a status context. |
| 748 | 782 | const checks = [...this.checks(p.head_sha), ...this.repo.statuses.asChecks(p.head_sha)]; |
| 749 | 783 | 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) { | |
| 751 | 789 | // Agent-authored changes always need one human approval unless a rule says otherwise. |
| 752 | 790 | result.requiredApprovals = 1; |
| 753 | 791 | } |
| 754 | 792 | if (p.draft) result.blockers.push("pull request is a draft"); |
| 755 | 793 | 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" : ""}`); | |
| 757 | 795 | if (result.changesRequested.length) result.blockers.push(`changes requested by ${result.changesRequested.join(", ")}`); |
| 758 | 796 | for (const name of rules.requiredChecks ?? []) { |
| 759 | 797 | const c = requiredCheck(name, checks, rules.requiredCheckSources?.[name]); |
| @@ -767,21 +805,37 @@ | ||
| 767 | 805 | const workflowEdit = await this.repo.policy.refuseWorkflowEdit(caller, mb ?? ZERO_OID, p.head_sha); |
| 768 | 806 | if (workflowEdit) result.blockers.push(workflowEdit); |
| 769 | 807 | 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"); | |
| 771 | 819 | } |
| 772 | 820 | result.mergeable = result.blockers.length === 0; |
| 773 | 821 | result.state = p.draft ? "draft" : result.conflicts.length ? "conflicting" : result.mergeable ? "clean" : "blocked"; |
| 774 | 822 | return result; |
| 775 | 823 | } |
| 776 | 824 | |
| 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 | + ) { | |
| 778 | 832 | const p = this.pullRow(number); |
| 779 | 833 | const r = this.row(number); |
| 780 | 834 | 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); | |
| 782 | 836 | 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; | |
| 785 | 839 | // Each bypass covers only what it names: bypass_merge_queue skips the queue, bypass_rules skips unmet rules. |
| 786 | 840 | const bypassQueue = mayBypass && !!input.bypassMergeQueue; |
| 787 | 841 | const adminBypass = mayBypass && !!input.bypassRules; |
| @@ -814,9 +868,11 @@ | ||
| 814 | 868 | const title = input.title ?? (method === "squash" ? `${r.title} (#${number})` : `Merge pull request #${number} from ${p.head_ref}`); |
| 815 | 869 | const body = input.message ?? (method === "squash" ? (await this.pullCommits(number)).map((c) => `* ${c.message.split("\n")[0]}`).join("\n") : r.title); |
| 816 | 870 | 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}` : ""}`]] : []; | |
| 817 | 873 | newSha = await store.write( |
| 818 | 874 | "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 }), | |
| 820 | 876 | ); |
| 821 | 877 | } |
| 822 | 878 | if (!this.repo.casRef(baseRef, baseSha, newSha)) throw new HttpError(409, "base branch moved during merge; try again"); |
| @@ -830,7 +886,7 @@ | ||
| 830 | 886 | const r = this.row(number); |
| 831 | 887 | const baseRef = `refs/heads/${p.base_ref}`; |
| 832 | 888 | 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); | |
| 834 | 890 | if (this.repo.queue.isQueued(number)) this.repo.queue.schedule(p.base_ref); |
| 835 | 891 | this.sql.exec("UPDATE issues SET state = 'closed', state_reason = 'merged', closed_at = ?, updated_at = ? WHERE number = ?", t, t, number); |
| 836 | 892 | this.repo.social.record(number, "merged", caller, { commit_id: newSha }); |
modifiedapps/diffinery/src/repo/extra-triggers.ts+2−1View file
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { matchesTrigger } from "@diffinery/workflow-yaml"; |
| 2 | 2 | import type { RepoDO } from "./RepoDO.js"; |
| 3 | +import { isJobCaller } from "./token-policy.js"; | |
| 3 | 4 | import type { Caller } from "./types.js"; |
| 4 | 5 | |
| 5 | 6 | /** |
| @@ -14,7 +15,7 @@ | ||
| 14 | 15 | ev: { name: string; action?: string; ref: string; sha: string; payload: Record<string, unknown>; title: string }, |
| 15 | 16 | caller: Caller, |
| 16 | 17 | ): 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; | |
| 18 | 19 | let started = 0; |
| 19 | 20 | let payload: Record<string, unknown> | undefined; |
| 20 | 21 | try { |
modifiedapps/diffinery/src/repo/gitdata.ts+1−0View file
| @@ -588,6 +588,7 @@ | ||
| 588 | 588 | c.id, |
| 589 | 589 | ); |
| 590 | 590 | 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); | |
| 591 | 592 | return this.check(c.id); |
| 592 | 593 | } |
| 593 | 594 |
modifiedapps/diffinery/src/repo/queue.ts+5−1View file
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { ZERO_OID, mergeBase, mergeTrees, readCommit, serializeCommit } from "@diffinery/git-core"; |
| 2 | 2 | import { squashSig } from "./collab.js"; |
| 3 | +import { automaticLandingRefusal } from "./auto-merge.js"; | |
| 3 | 4 | import { requiredCheck } from "./required.js"; |
| 4 | 5 | import type { Actor } from "@diffinery/shared"; |
| 5 | 6 | import { HttpError, now } from "../lib/util.js"; |
| @@ -157,7 +158,10 @@ | ||
| 157 | 158 | const settings = this.settings(pull.base); |
| 158 | 159 | if (!settings) throw new HttpError(422, `${pull.base} does not use a merge queue; merge the pull request directly`); |
| 159 | 160 | 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 }); | |
| 161 | 165 | if (!m.mergeable) throw new HttpError(405, `pull request cannot be added to the merge queue: ${m.blockers.join("; ")}`, { blockers: m.blockers }); |
| 162 | 166 | const method = input.method ?? settings.method ?? "merge"; |
| 163 | 167 | 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 @@ | ||
| 180 | 180 | ["labels", "imported", "TEXT"], |
| 181 | 181 | ["milestones", "imported", "TEXT"], |
| 182 | 182 | ["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"], | |
| 183 | 185 | ]; |
| 184 | 186 | |
| 185 | 187 | export function migrateRepoSchema(sql: SqlStorage): void { |
modifiedapps/diffinery/src/repo/social.ts+2−0View file
| @@ -354,6 +354,8 @@ | ||
| 354 | 354 | this.sql.exec("UPDATE reviews SET state = 'DISMISSED' WHERE id = ?", r.id); |
| 355 | 355 | this.record(number, "review_dismissed", caller, { dismissed_review: { state: r.state.toLowerCase(), review_id: r.seq ?? r.id, dismissal_message: message } }); |
| 356 | 356 | 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 }); | |
| 357 | 359 | return this.review(number, r.id); |
| 358 | 360 | } |
| 359 | 361 |
modifiedapps/diffinery/src/repo/statuses.ts+1−4View file
| @@ -101,10 +101,7 @@ | ||
| 101 | 101 | at: t, |
| 102 | 102 | }); |
| 103 | 103 | 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); | |
| 108 | 105 | return status; |
| 109 | 106 | } |
| 110 | 107 |
modifiedapps/diffinery/src/repo/types.ts+13−0View file
| @@ -39,6 +39,19 @@ | ||
| 39 | 39 | agentsMayPush?: boolean; |
| 40 | 40 | /** Agents may merge pull requests into matching branches. Default false. */ |
| 41 | 41 | 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; | |
| 42 | 55 | /** Pull requests merge through a merge queue (like GitHub's "Require merge queue"). */ |
| 43 | 56 | mergeQueue?: MergeQueueSettings; |
| 44 | 57 | } |
modifiedapps/diffinery/src/web/account.tsx+42−2View file
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | import { requireAccount, type AppContext } from "../auth/principal.js"; |
| 5 | 5 | import { baseRole, type Account, type Repo, type TokenScope } from "../lib/directory.js"; |
| 6 | 6 | import { loadRepo } from "../http/common.js"; |
| 7 | +import { movedLoginPath } from "../lib/redirects.js"; | |
| 7 | 8 | import { HttpError } from "../lib/util.js"; |
| 8 | 9 | import type { Role } from "../repo/types.js"; |
| 9 | 10 | import { ICONS } from "./assets.js"; |
| @@ -1565,7 +1566,7 @@ | ||
| 1565 | 1566 | const me = requireAccount(c); |
| 1566 | 1567 | const dir = c.get("dir"); |
| 1567 | 1568 | 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"); | |
| 1569 | 1570 | if (org.kind !== "org") throw new HttpError(404, "not an organization"); |
| 1570 | 1571 | const role = await dir.orgRole(org.id, me.id); |
| 1571 | 1572 | const isAdmin = !!me.site_admin || role === "owner" || role === "admin"; |
| @@ -1578,7 +1579,15 @@ | ||
| 1578 | 1579 | const dir = c.get("dir"); |
| 1579 | 1580 | const t = csrfToken(c); |
| 1580 | 1581 | 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"); | |
| 1582 | 1591 | if (acct.login !== prm(c, "owner")) return c.redirect(`/${acct.login}${new URL(c.req.url).search}`, 301); |
| 1583 | 1592 | |
| 1584 | 1593 | if (acct.kind === "agent") { |
| @@ -1832,6 +1841,21 @@ | ||
| 1832 | 1841 | <button class="btn primary">Save</button> |
| 1833 | 1842 | </div> |
| 1834 | 1843 | </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> | |
| 1835 | 1859 | </> |
| 1836 | 1860 | ); |
| 1837 | 1861 | } |
| @@ -1915,6 +1939,22 @@ | ||
| 1915 | 1939 | }, "Organization updated."); |
| 1916 | 1940 | }); |
| 1917 | 1941 | |
| 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 | + | |
| 1918 | 1958 | avatarSizeGuard(web, "/:owner/-/org/avatar", (c) => `/${c.req.param("owner")}?tab=settings`); |
| 1919 | 1959 | post(web, "/:owner/-/org/avatar", async (c, f) => { |
| 1920 | 1960 | const { me, org, isOwner } = await orgContext(c); |
modifiedapps/diffinery/src/web/collab.tsx+69−3View file
| @@ -776,6 +776,7 @@ | ||
| 776 | 776 | </ul> |
| 777 | 777 | ) : null} |
| 778 | 778 | </div> |
| 779 | + <AutoMergeRow x={x} canAct={isAccount && atLeast(caller.role, "write") && !x.l.repo.archived} queue={!!m.mergeQueue} /> | |
| 779 | 780 | {m.mergeQueue ? ( |
| 780 | 781 | <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} /> |
| 781 | 782 | ) : isAccount && atLeast(caller.role, "write") && !x.l.repo.archived ? ( |
| @@ -813,12 +814,54 @@ | ||
| 813 | 814 | </button> |
| 814 | 815 | )} |
| 815 | 816 | </Form> |
| 817 | + {!m.mergeable && !p.autoMerge && m.conflicts.length === 0 && !p.draft ? <EnableAutoMerge x={x} linearHistory={!!m.linearHistory} /> : null} | |
| 816 | 818 | </div> |
| 817 | 819 | ) : null} |
| 818 | 820 | </div> |
| 819 | 821 | ); |
| 820 | 822 | }; |
| 821 | 823 | |
| 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 | + | |
| 822 | 865 | const METHOD_LABELS = { merge: "Create a merge commit", squash: "Squash and merge", rebase: "Rebase and merge" } as const; |
| 823 | 866 | |
| 824 | 867 | /** The merge box's footer when the base branch merges through a merge queue. */ |
| @@ -890,16 +933,24 @@ | ||
| 890 | 933 | </button> |
| 891 | 934 | )} |
| 892 | 935 | </Form> |
| 936 | + {!m.mergeable && !p.autoMerge && m.conflicts.length === 0 && !p.draft ? <EnableAutoMerge x={x} linearHistory={!!m.linearHistory} queueMethod={q.method} /> : null} | |
| 893 | 937 | <div class="row muted small" style="margin-top:6px"> |
| 894 | 938 | <span class="grow"> |
| 895 | 939 | {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. |
| 896 | 940 | </span> |
| 897 | 941 | {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 | + > | |
| 899 | 949 | <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} /> | |
| 901 | 951 | <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> | |
| 903 | 954 | </Form> |
| 904 | 955 | ) : null} |
| 905 | 956 | </div> |
| @@ -1345,6 +1396,21 @@ | ||
| 1345 | 1396 | }); |
| 1346 | 1397 | }); |
| 1347 | 1398 | |
| 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 | + | |
| 1348 | 1414 | post(web, "/:owner/:repo/pull/:n/dequeue", async (c) => { |
| 1349 | 1415 | const l = await forAction(c, "write"); |
| 1350 | 1416 | const n = issueNumber(c); |
modifiedapps/diffinery/src/web/repo-settings.tsx+78−5View file
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | import { HttpError } from "../lib/util.js"; |
| 7 | 7 | import type { ProtectionRules, Role } from "../repo/types.js"; |
| 8 | 8 | import { ICONS } from "./assets.js"; |
| 9 | -import { KCheckbox, KRadio, KSelect } from "./kumo.js"; | |
| 9 | +import { KCheckbox, KRadio, KSelect, type KOption } from "./kumo.js"; | |
| 10 | 10 | import { csrfToken, type FormData } from "./csrf.js"; |
| 11 | 11 | import { Actor, CopyButton, Form, PendingInvites, ROLES, Time, cap, act, ago, flash, post, refNames, render, repoUrl, statusOf, type WebApp, prm } from "./ui.js"; |
| 12 | 12 | |
| @@ -82,6 +82,16 @@ | ||
| 82 | 82 | ["enforceAdmins", "Do not allow administrators to bypass these rules", ""], |
| 83 | 83 | ["agentsMayPush", "Allow agents to push directly", "By default agents must open pull requests against protected and default branches."], |
| 84 | 84 | ["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 | + ], | |
| 85 | 95 | ]; |
| 86 | 96 | |
| 87 | 97 | const RuleForm: FC<{ c: AppContext; base: string; pattern?: string; rules?: ProtectionRules }> = ({ c, base, pattern, rules = {} }) => ( |
| @@ -184,11 +194,22 @@ | ||
| 184 | 194 | |
| 185 | 195 | // ---------------------------------------------------------------- pages |
| 186 | 196 | |
| 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 | + | |
| 187 | 208 | async function generalPage(c: AppContext, l: Loaded) { |
| 188 | 209 | const { repo } = l; |
| 189 | 210 | const base = repoUrl(repo); |
| 190 | 211 | const t = csrfToken(c); |
| 191 | - const names = await refNames(l.stub); | |
| 212 | + const [names, targets] = await Promise.all([refNames(l.stub), transferTargets(c, l)]); | |
| 192 | 213 | const full = `${repo.owner_login}/${repo.name}`; |
| 193 | 214 | return page( |
| 194 | 215 | c, |
| @@ -243,6 +264,38 @@ | ||
| 243 | 264 | <button class="btn danger">{repo.archived ? "Unarchive" : "Archive"}</button> |
| 244 | 265 | </Form> |
| 245 | 266 | <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 /> | |
| 246 | 299 | <Form action={`${base}/settings/delete`} t={t} match={full}> |
| 247 | 300 | <b>Delete this repository</b> |
| 248 | 301 | <p class="muted small"> |
| @@ -694,6 +747,26 @@ | ||
| 694 | 747 | ); |
| 695 | 748 | }); |
| 696 | 749 | |
| 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 | + | |
| 697 | 770 | post(web, "/:owner/:repo/settings/access", async (c, f) => { |
| 698 | 771 | const l = await load(c); |
| 699 | 772 | const dir = c.get("dir"); |
| @@ -750,8 +823,8 @@ | ||
| 750 | 823 | const kept = Object.fromEntries(Object.entries(before).filter(([name]) => rules.requiredChecks!.includes(name))); |
| 751 | 824 | if (Object.keys(kept).length) rules.requiredCheckSources = kept; |
| 752 | 825 | } |
| 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); | |
| 755 | 828 | audit(c, l, "repo.protection.set", { pattern, rules }); |
| 756 | 829 | }, |
| 757 | 830 | "Branch protection saved.", |
| @@ -761,7 +834,7 @@ | ||
| 761 | 834 | post(web, "/:owner/:repo/settings/branches/delete", async (c, f) => { |
| 762 | 835 | const l = await load(c); |
| 763 | 836 | 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); | |
| 765 | 838 | audit(c, l, "repo.protection.delete", { pattern: f.s("pattern") }); |
| 766 | 839 | }); |
| 767 | 840 | }); |
modifiedapps/diffinery/src/web/ui.tsx+3−1View file
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | import type { AppContext, AppVars } from "../auth/principal.js"; |
| 9 | 9 | import { HttpError, randomToken } from "../lib/util.js"; |
| 10 | 10 | 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"; | |
| 12 | 12 | import { CSS, ICONS, JS, type IconName } from "./assets.js"; |
| 13 | 13 | import { CSRF_FIELD, csrfToken, verifyCsrf, type FormData } from "./csrf.js"; |
| 14 | 14 | import { fenceLangs, renderMarkdown, type MarkdownBase } from "./markdown.js"; |
| @@ -513,6 +513,8 @@ | ||
| 513 | 513 | |
| 514 | 514 | /** Friendly error page for HttpErrors (and DO errors carrying a status prefix). */ |
| 515 | 515 | 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); | |
| 516 | 518 | const s = statusOf(e); |
| 517 | 519 | if (s.status >= 500) console.error(e); |
| 518 | 520 | // 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 @@ | ||
| 653 | 653 | assert.equal((await runs()).length, 2, "the follower does not trigger itself"); |
| 654 | 654 | }); |
| 655 | 655 | |
| 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 | + | |
| 656 | 769 | test("pull requests: test merge commits, pull_request_target and the merge queue", async () => { |
| 657 | 770 | const owner = (await ok("GET", "/user")).login; |
| 658 | 771 | await ok("POST", "/user/repos", { name: "queue" }); |
modifiedapps/diffinery/test/e2e/security.test.mjs+513−3View file
| @@ -15,8 +15,12 @@ | ||
| 15 | 15 | // be marked ready; the exact branch protection rule wins over a pattern. |
| 16 | 16 | // Push protection also covers commits made without git (web editor, REST, MCP). |
| 17 | 17 | // 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"; | |
| 20 | 24 | import { createHash, randomBytes } from "node:crypto"; |
| 21 | 25 | import { mkdtempSync, rmSync } from "node:fs"; |
| 22 | 26 | import { tmpdir } from "node:os"; |
| @@ -34,7 +38,7 @@ | ||
| 34 | 38 | |
| 35 | 39 | const hash = (s) => createHash("sha256").update(s).digest("hex"); |
| 36 | 40 | 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") }; | |
| 38 | 42 | const S = { penny: `penny-session-${randomBytes(12).toString("hex")}`, dora: `dora-session-${randomBytes(12).toString("hex")}`, olive: `olive-session-${randomBytes(12).toString("hex")}` }; |
| 39 | 43 | const OWNER = "olive"; |
| 40 | 44 | |
| @@ -126,6 +130,8 @@ | ||
| 126 | 130 | account("o-shop", "shop", "org", "active"), |
| 127 | 131 | account("u-sam", "sam", "user", "active"), |
| 128 | 132 | 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"]), | |
| 129 | 135 | `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-shop', 'u-olive', 'owner', '${at}'), ('o-shop', 'u-sam', 'member', '${at}')`, |
| 130 | 136 | ].join("; "), |
| 131 | 137 | ); |
| @@ -675,3 +681,507 @@ | ||
| 675 | 681 | await until("the secret's audit entry", async () => auditLog("org.secret.delete").some((e) => JSON.parse(e.detail).name === "DEPLOY_KEY")); |
| 676 | 682 | assert.ok(!auditLog("org.secret.delete").some((e) => JSON.parse(e.detail).name === "NEVER_SET")); |
| 677 | 683 | }); |
| 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' 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 @@ | ||
| 15 | 15 | describe("email templates", () => { |
| 16 | 16 | it("has a sample for every kind of email", () => { |
| 17 | 17 | 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"]) { | |
| 19 | 19 | expect(kinds.has(k as MailJob["kind"]), k).toBe(true); |
| 20 | 20 | } |
| 21 | 21 | }); |
modifiedapps/diffinery/test/unit/job-permissions.test.ts+2−0View file
| @@ -113,6 +113,8 @@ | ||
| 113 | 113 | expect(check("POST", "/api/v3/repos/o/r/check-runs")).toMatch(/checks: write/); |
| 114 | 114 | expect(check("POST", "/api/v3/repos/o/r/pulls")).toMatch(/pull-requests: write/); |
| 115 | 115 | 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/); | |
| 116 | 118 | expect(check("GET", "/api/v3/repos/o/r/actions/runs")).toMatch(/actions: read/); |
| 117 | 119 | expect(check("POST", "/api/v3/repos/o/r/releases")).toMatch(/contents: write/); |
| 118 | 120 | 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 | +}); |