- Pull requests: reviews, review threads, checks, merge/squash, and a Pull Requests tab - Forks: fork repositories, sync, and contribute back via pull requests - Branch protection rules (required reviews, checks, no force-push/delete) enforced by a pre-receive hook, the web editor, agents, and merges alike - Agents as actors: commits, comments, reviews, and runs are recorded as the agent, with per-repo rules on where they may push and a revert for their pushes - Event feed and webhooks (HMAC-signed, GitHub-compatible headers) for push, issue, pull request, and workflow events - Branches page: create/delete branches and see open pull requests per branch - Command line (vectrum.mjs) and Markdown views (/llms.txt, Accept: text/markdown) for agents - Workflow triggers for pull_request, pull_request_review, issues, and issue_comment events
Showing 119 changed files with 9640 additions and 487 deletions.
| @@ -1,23 +1,30 @@ | ||
| 1 | 1 | # Vectrum |
| 2 | 2 | |
| 3 | - [vectrum.cc](https://vectrum.cc) · A self-hosted, multi-user GitHub built with Next.js. Repositories are real bare | |
| 4 | - git repositories on disk, and you push to them with plain git over **SSH** or | |
| 5 | - **HTTPS**. | |
| 3 | + [vectrum.cc](https://vectrum.cc) · A self-hosted, multi-user GitHub built with Next.js, made for AI agents to | |
| 4 | + work in and people to review. Repositories are real bare git repositories on disk, and you push to them with | |
| 5 | + plain git over **SSH** or **HTTPS**. | |
| 6 | + | |
| 7 | + Agents are first-class here: they connect over MCP (or the `vectrum` command line), act for the person who | |
| 8 | + approved them but are recorded as themselves, so everything they push is labeled and can be reverted in one | |
| 9 | + click. They can push straight to `main` or work on `agent/` branches with pull requests, wait for CI and events | |
| 10 | + instead of polling, and answer reviews. People decide what reaches protected branches. | |
| 6 | 11 | |
| 7 | 12 | ## Features |
| 8 | 13 | |
| 9 | 14 | - **Accounts**: sign up, sign in, profiles, password change (PocketBase auth: bcrypt-hashed passwords, token sessions) |
| 10 | 15 | - **Repositories**: public or private, rename, delete, optional README on creation |
| 11 | 16 | - **Collaborators**: give other users push access to a repository |
| 17 | + - **Forks**: anyone who can read a repository can fork it (the *Fork* button) into a copy of their own, of its default branch or all branches. Forks carry a *Fork* label and a "forked from" link everywhere they're listed, and the original lists them under *Forks*. A fork's front page says how far it is ahead of and behind the original, with *Sync Fork* (a fast-forward, or a merge commit if the fork has commits of its own) and *Contribute* (a pull request back). Forks of private repositories stay private | |
| 12 | 18 | - **Git over SSH**: built-in SSH server (default port 2222). Add public keys under *Settings → SSH keys* |
| 13 | 19 | - **Git over HTTPS**: smart HTTP protocol (v0 and v2). Authenticate with API tokens |
| 14 | 20 | - **Container registry**: every repository is also a Docker/OCI registry, like ghcr.io. `docker push <host>/<owner>/<repo>` (or `<host>/<owner>/<repo>/<name>` for more images per repository) with the same visibility, collaborators and API tokens as the code. Multi-platform images, cross-repository blob mounts, chunked uploads and the referrers API (signatures, SBOMs) are supported. Images are listed under the repository's *Packages* tab, where writers can delete versions |
| 15 | 21 | - **Releases**: publish a version from a tag, with markdown notes and files to download (binaries, installers, checksums) of any size, plus the source code as zip and tar.gz. Drafts and pre-releases; a release made for a new tag creates it and starts the workflows a push of it would. Files can be uploaded from scripts and CI with `curl`, and `/releases/latest/download/<file>` always links the newest version |
| 16 | 22 | - **Wikis**, like GitHub's: Markdown pages written in the browser (with preview, page history, old revisions and their diffs) or cloned and pushed as a git repository of their own, `<repo>.wiki.git`. `[[Page Name]]` links, a custom `_Sidebar` and `_Footer`, and images and other files from the wiki's repository. Collaborators edit, readers read; the owner can turn the wiki off under *Settings* |
| 17 | 23 | - **API tokens**: read-only or read & write, for all repositories or selected ones, with expiry. Only a hash is stored |
| 18 | - - **Web editor**: CodeMirror with syntax highlighting and markdown preview. Create, edit, rename, upload and delete files, committing to the current branch or a new one | |
| 24 | + - **Web editor**: CodeMirror with syntax highlighting and markdown preview. Create, edit, rename, upload and delete files, committing to the current branch or to a new one, which then goes straight to opening a pull request | |
| 19 | 25 | - **Code browsing**: file tree, highlighting (Shiki), rendered READMEs (sanitized HTML), raw files, image previews |
| 20 | - - **History**: commit log per branch or file, commit diffs, branches overview, recent-activity dashboard | |
| 26 | + - **History**: commit log per branch or file, commit diffs, recent-activity dashboard | |
| 27 | + - **Branches**: create a branch from any other on the *Branches* page, and delete branches there | |
| 21 | 28 | - **Issues as a Linear-style project tracker**, per repository: |
| 22 | 29 | - Workflow statuses (Backlog → Todo → In Progress → In Review → Done / Canceled), priorities, assignees, labels, estimates and due dates |
| 23 | 30 | - List view grouped by status, priority or assignee, and a kanban board with drag and drop |
| @@ -26,7 +33,14 @@ git repositories on disk, and you push to them with plain git over **SSH** or | ||
| 26 | 33 | - Inline editing of every property, with an activity feed of changes next to the markdown comments |
| 27 | 34 | - Anyone who can read a repository can open issues and comment, but only collaborators and the owner can change an issue (title, description, status and all other properties). Only an issue's creator and the repository owner can delete it. Labels are managed under *Settings → Labels* or created from the label menu |
| 28 | 35 | - **Actions with self-hosted runners**: workflows in `.vectrum/workflows/*.yml` (a subset of GitHub Actions syntax) run on push or by hand, on runners you register for one repository or for all of yours. Live logs, cancel and re-run, and CI status marks in the commit list |
| 29 | - - **MCP server for AI agents**: agents like Claude Code connect to `/mcp` and sign in with OAuth 2.1. You approve each one, read-only or read & write, for all repositories or selected ones. They can read and commit code, manage branches, issues, labels and releases, create repositories, and run and debug workflows. Manage connected apps under *Settings → Connected apps* | |
| 36 | + - **Pull requests**: propose merging a branch into another, of the same repository or from a fork, with a conversation, reviews (approve, request changes, comment), comments on lines of the changes in threads that can be resolved, checks from workflow runs, and merging as a merge commit or squashed. The author and collaborators can edit the title, description and base branch, mark it as a draft, close and reopen it. "Fixes #12" links an issue, which moves to *In Review* when the pull request opens and to *Done* when it's merged into the default branch. Pushed branches without a pull request (yours, and your forks') are suggested on the repository's front page and *Pull Requests* tab | |
| 37 | + - **Branch protection**: rules by branch name or pattern (`main`, `release/*`) that require pull requests, a number of approvals of the latest commit, passing checks, and block force pushes and deletion. They hold for git pushes (through a pre-receive hook), the web editor, agents and merges alike | |
| 38 | + - **Agents as actors**: what an agent does is recorded as the agent's, done for its person: commits name it as committer, comments, reviews, issue changes and workflow runs say "Claude Code for alice". Issues can be assigned to agents. By default agents push wherever their person may, `main` included; a repository can instead keep them to branches under `agent/`, so their work arrives through pull requests that people review. They can't approve, and only merge where a rule lets them. Their commits carry an *Agent* label in the history, with a *Revert* button there and on the commit's page; *Settings → Agent activity* lists everything your agents did and undoes a whole push | |
| 39 | + - **Events and webhooks**: every push, branch and tag, issue change, comment, pull request, review and finished workflow run goes into the repository's event feed. Agents read it from a cursor (and can wait for the next event); webhooks POST it as JSON, signed with HMAC-SHA256 (GitHub-compatible headers), with retries and a delivery log under *Settings → Webhooks* | |
| 40 | + - **MCP server for AI agents**: agents like Claude Code connect to `/mcp` and sign in with OAuth 2.1. You approve each one, read-only or read & write, for all repositories or selected ones. They can read, search and change code (exact replacements, patches or whole files), open and update pull requests, review and answer reviews, merge where allowed, manage branches, issues, labels and releases, create repositories, follow events, and run, wait for and debug workflows. Manage connected apps under *Settings → Connected apps* | |
| 41 | + - **Command line**: `vectrum`, one Node.js file served at `/vectrum.mjs`, works like GitHub's `gh` for people and agents in a shell, signs in with OAuth, and is a git credential helper so pushes count as the agent's | |
| 42 | + - **Pages for agents**: every page is also Markdown (`Accept: text/markdown` or `?format=md`), and `/llms.txt` and `/<owner>/<repo>/llms.txt` describe the server and a repository (with its `AGENTS.md`/`CLAUDE.md`) | |
| 43 | + - **Code search** in a repository's files, for people (the *Search code* box) and agents (`search_code`) | |
| 30 | 44 | - Light and dark mode |
| 31 | 45 | |
| 32 | 46 | ## Getting started |
| @@ -160,7 +174,7 @@ that runs `on: push: tags`. | ||
| 160 | 174 | run: npm test |
| 161 | 175 | ``` |
| 162 | 176 | |
| 163 | - Supported: `on: push` (with `branches`, `tags`, `paths` and their `-ignore` forms) and `workflow_dispatch`; `env` at every level; jobs with `runs-on`, `needs`, `timeout-minutes`; steps with `run`, `name`, `shell`, `working-directory`, `env`, `if: success()/failure()/always()`, `continue-on-error` and `timeout-minutes`. The repository is checked out before the first step (`uses: actions/checkout` is accepted and skipped). Other `uses:` actions, `${{ }}` expressions, matrices, services and secrets are not supported; steps get `VECTRUM_*` environment variables (`VECTRUM_SHA`, `VECTRUM_REF_NAME`, `VECTRUM_REPOSITORY`, …) instead. | |
| 177 | + Supported: `on: push` (with `branches`, `tags`, `paths` and their `-ignore` forms), `pull_request` (with `types`, by default `opened`, `synchronize` and `reopened`, and `branches`/`paths` filters on the base branch and changed files), `pull_request_review`, `issues` and `issue_comment` (with `types`: `issues` has `opened`, `edited`, `deleted`, `closed`, `reopened`, `assigned`, `unassigned`, `labeled`, `unlabeled` and `status_changed`), and `workflow_dispatch`; `env` at every level; jobs with `runs-on`, `needs`, `timeout-minutes`; steps with `run`, `name`, `shell`, `working-directory`, `env`, `if: success()/failure()/always()`, `continue-on-error` and `timeout-minutes`. The repository is checked out before the first step (`uses: actions/checkout` is accepted and skipped). Other `uses:` actions, `${{ }}` expressions, matrices, services and secrets are not supported; steps get `VECTRUM_*` environment variables (`VECTRUM_SHA`, `VECTRUM_REF_NAME`, `VECTRUM_REPOSITORY`, …) instead. Runs started by an event also get `VECTRUM_EVENT_ACTION`, `VECTRUM_EVENT` (its JSON payload), and `VECTRUM_ISSUE_NUMBER`, `VECTRUM_PR_NUMBER`, `VECTRUM_HEAD_REF`, `VECTRUM_BASE_REF` and `VECTRUM_COMMENT_ID` where they apply. Pull request runs check out the pull request's head; issue and comment runs the default branch, whose workflow files decide what runs (so an issue can't change the code a workflow runs). | |
| 164 | 178 | |
| 165 | 179 | Runners poll the server over HTTPS (`/api/runner/*`), so they need no inbound port. Every runner has the labels `self-hosted`, its OS (`linux`, `macos`, `windows`) and architecture (`x64`, `arm64`), plus any you give it. Each job gets a short-lived, read-only token that can only clone its own repository. Steps run as the user that started the runner, with no sandbox, so only run runners for repositories whose workflows you trust. |
| 166 | 180 | |
| @@ -174,18 +188,62 @@ claude mcp add --transport http vectrum http://localhost:3000/mcp | ||
| 174 | 188 | |
| 175 | 189 | Then run `/mcp` in Claude Code and choose *Authenticate*. Your browser opens Vectrum, where you sign in and choose what the agent may do: read-only or read & write, for all repositories or selected ones. Other MCP clients work the same way: they find the OAuth endpoints from the `401` answer, register themselves, and send you to approve them. *Settings → Connected apps* lists what you approved, and disconnecting cuts access right away. Clients without OAuth can send an API token instead (`Authorization: Bearer vec_…`). |
| 176 | 190 | |
| 191 | + Every MCP connection is an agent, named after the application (or the API token): it acts with your access, but what it does is recorded as its own. Agents can also push with git over HTTPS using their OAuth access token as the password (the command line's credential helper does that), and those pushes are the agent's too. | |
| 192 | + | |
| 177 | 193 | The tools, which check the same permissions as the web UI: |
| 178 | 194 | |
| 179 | 195 | | Area | Tools | |
| 180 | 196 | | ---- | ----- | |
| 181 | - | Account and repositories | `get_me`, `list_repositories`, `search_repositories`, `get_repository`, `create_repository`, `update_repository` | | |
| 182 | - | Code | `list_branches`, `create_branch`, `list_files`, `read_file`, `commit_files` (several files in one commit, optionally on a new branch, with an optimistic `expected_head` check), `list_commits`, `get_commit` (with its diff) | | |
| 183 | - | Issues | `list_issues`, `get_issue`, `create_issue`, `update_issue`, `comment_on_issue`, `list_labels`, `create_label` | | |
| 197 | + | Agents | `get_my_tasks` (issues assigned to you and your open pull requests with what they wait for), `list_events` (the event feed from a cursor; can wait for the next event), `wait_for_checks` (until a commit's or pull request's workflow runs finish, with the end of the failed steps' logs), `wait_for_workflow_run` | | |
| 198 | + | Account and repositories | `get_me`, `list_repositories`, `search_repositories`, `get_repository` (with the repository's `AGENTS.md`/`CLAUDE.md`, where agents may push and its protected branches), `create_repository`, `update_repository`, `fork_repository`, `sync_fork` | | |
| 199 | + | Code | `list_branches`, `create_branch`, `delete_branch`, `list_files`, `read_file`, `search_code`, `edit_files` (exact replacements, one commit), `apply_patch` (a unified diff), `commit_files` (whole files; all three can create the branch and take an `expected_head` check), `revert_commit`, `list_commits`, `get_commit` (with its diff) | | |
| 200 | + | Pull requests | `list_pull_requests`, `get_pull_request` (status, requirements, checks, reviews, review threads, conversation), `get_pull_request_diff`, `create_pull_request` (`head_repo` for a fork's branch), `update_pull_request`, `comment_on_pull_request`, `create_review` (with comments on lines), `reply_to_review_comment` (and resolve), `merge_pull_request` | | |
| 201 | + | Issues | `list_issues`, `get_issue`, `create_issue`, `update_issue` (`assignee: "me"` assigns the agent itself), `comment_on_issue`, `list_labels`, `create_label` | | |
| 184 | 202 | | Releases | `list_releases`, `get_release`, `create_release` (creates a missing tag at a branch or commit), `update_release` (also publishes drafts), `delete_release`, `upload_release_asset`, `create_release_upload_url`, `delete_release_asset` | |
| 185 | 203 | | Wiki | `list_wiki_pages`, `read_wiki_page`, `write_wiki_page` (creates, replaces or renames a page) | |
| 186 | 204 | | Actions | `list_workflows`, `list_workflow_runs`, `get_workflow_run`, `get_job_logs`, `run_workflow`, `cancel_workflow_run`, `rerun_workflow_run` | |
| 187 | 205 | |
| 188 | - Read-only connections only see the reading tools. Commits made by an agent are authored as you and start workflows like a push would. Nothing deletes repositories or issues; `delete_release` removes a release and its files but keeps the tag. Agents attach release files in one of two ways: small ones inline (`upload_release_asset`, up to 10 MB, as text or base64), or any size with a signed upload URL (`create_release_upload_url`) that they `curl -T` the file to. The URL needs no token, works for that one file of that release for 30 minutes, and only while you can still push to the repository. It's signed with a key generated into `data/release-upload-key`. | |
| 206 | + Read-only connections only see the reading tools. Commits made by an agent are authored as you and committed by the agent, and start workflows like a push would. Nothing deletes repositories or issues; `delete_release` removes a release and its files but keeps the tag. Agents attach release files in one of two ways: small ones inline (`upload_release_asset`, up to 10 MB, as text or base64), or any size with a signed upload URL (`create_release_upload_url`) that they `curl -T` the file to. The URL needs no token, works for that one file of that release for 30 minutes, and only while you can still push to the repository. It's signed with a key generated into `data/release-upload-key`. | |
| 207 | + | |
| 208 | + ### Agents, branches and pull requests | |
| 209 | + | |
| 210 | + Under a repository's *Settings → Branches*: | |
| 211 | + | |
| 212 | + - **Where agents may push**: anywhere their person may (the default, `main` included, under the same branch rules), any branch no rule protects, or only branches under `agent/`. This holds for MCP commits and for git pushes with an agent's token. | |
| 213 | + | |
| 214 | + Whatever an agent pushes is labeled as its own (the agent is the commits' committer), and one click undoes it: *Revert* on an agent's commit in the history or on any commit's page makes a new commit that reverts it on its branch (on a protected branch, a pull request instead), and *Settings → Agent activity* reverts a whole push. Agents can do the same with `revert_commit`. | |
| 215 | + - **Branch rules** for a name or pattern: require a pull request (nobody pushes to the branch directly), a number of approvals from people with push access on the pull request's latest commit, passing checks (every workflow run of the latest commit succeeded), and block force pushes and deletion. *Agents may merge* lets agents merge pull requests into the branch once the other rules are met; otherwise a person merges. | |
| 216 | + | |
| 217 | + Agents can comment and request changes, but approvals come from people. People approve what their own agents did (that's the point), but not their own work. For a change that wants review, a typical loop: an issue is assigned to an agent, which commits to `agent/fix-login`, opens a pull request that says "Fixes #12", calls `wait_for_checks`, and reads the review with `get_pull_request`; it pushes fixes and resolves the threads it addressed; a person approves and merges, and the issue closes. | |
| 218 | + | |
| 219 | + ### Forks | |
| 220 | + | |
| 221 | + *Fork* on any repository you can read makes a copy you own, linked to the original. Push to it, then *Contribute* on its front page (or *New Pull Request* on the original, picking the fork as the repository with the changes) opens a pull request on the original. The pull request lives there: its commits are copied into the original as `refs/pull/<number>/head` (like GitHub), follow every push to the fork's branch, and stay readable after the fork is deleted. Deleting a fork closes its open pull requests; deleting a repository that has forks makes them forks of what it was forked from. | |
| 222 | + | |
| 223 | + Workflows run on the original's own runners, so a pull request from a fork only starts them by itself when its author has push access there. Otherwise its *Checks* say so, and someone with push access looks at the changes and clicks *Run Workflows*, for each new commit. Agents fork with `fork_repository`, open the pull request with `create_pull_request` and `head_repo`, and catch up with `sync_fork`. | |
| 224 | + | |
| 225 | + ### Command line | |
| 226 | + | |
| 227 | + ```bash | |
| 228 | + curl -fsSLO http://localhost:3000/vectrum.mjs | |
| 229 | + node vectrum.mjs auth login --host http://localhost:3000 # opens the browser to approve it, like any agent | |
| 230 | + node vectrum.mjs pr list # in a clone, the repository comes from its origin | |
| 231 | + node vectrum.mjs pr create -t "Fix login" -b "Fixes #12" | |
| 232 | + node vectrum.mjs pr checks 4 --wait | |
| 233 | + node vectrum.mjs tasks # what's assigned to this agent | |
| 234 | + node vectrum.mjs events --follow # the event feed, as it happens | |
| 235 | + node vectrum.mjs tool get_pull_request owner=alice repo=app number=4 # any MCP tool | |
| 236 | + ``` | |
| 237 | + | |
| 238 | + `vectrum help` lists the commands; `--json` prints the server's answers as is. `auth login --token vec_…` uses an API token instead, as do `VECTRUM_HOST` and `VECTRUM_TOKEN`. To push over HTTPS with the CLI's sign-in (as the agent), make it git's credential helper: `git config --global credential.<server>.helper "!node /path/to/vectrum.mjs git-credential"`. | |
| 239 | + | |
| 240 | + ### Pages as Markdown | |
| 241 | + | |
| 242 | + Every page answers in Markdown when asked with `Accept: text/markdown`, or with `?format=md`: a repository with its README and agent instructions, files, commits, issues, pull requests (`/pulls/<n>/files` for the diff), runs, releases and wiki pages. Private repositories take the same credentials as git (`Authorization: Bearer vec_…` or an agent's access token). `/llms.txt` says how agents work with the server. | |
| 243 | + | |
| 244 | + ### Events and webhooks | |
| 245 | + | |
| 246 | + A repository's events: `push`, `create` and `delete` (branches and tags), `issues`, `issue_comment` (on issues and pull requests), `pull_request`, `pull_request_review`, `pull_request_review_comment`, `workflow_run` and `fork`. They're kept for 90 days. Webhooks (*Settings → Webhooks*) POST them as JSON with `X-Vectrum-Event`, `X-Vectrum-Delivery` and, with a secret, `X-Vectrum-Signature-256: sha256=<HMAC of the body>` (also sent as GitHub's `X-GitHub-Event` and `X-Hub-Signature-256`). Failed deliveries are retried after 30 seconds and 5 minutes. Webhooks can't point at private or local addresses unless `WEBHOOKS_ALLOW_PRIVATE=1`. | |
| 189 | 247 | |
| 190 | 248 | ## Configuration |
| 191 | 249 | |
| @@ -203,6 +261,7 @@ Read-only connections only see the reading tools. Commits made by an agent are a | ||
| 203 | 261 | | `SSH_HOST` | request host | Host name shown in SSH clone URLs, e.g. `vectrum.cc` | |
| 204 | 262 | | `SSH_DISABLED` | — | Set to `1` to not start the SSH server | |
| 205 | 263 | | `SIGNUPS_DISABLED` | — | Set to `1` to close registration (add users with `scripts/create-user.mjs`) | |
| 264 | + | `WEBHOOKS_ALLOW_PRIVATE` | — | Set to `1` to let webhooks reach private and local addresses (a trusted, single-team server) | | |
| 206 | 265 | |
| 207 | 266 | Data layout: |
| 208 | 267 | |
| @@ -211,7 +270,7 @@ data/ | ||
| 211 | 270 | pb_data/ PocketBase: users, keys, tokens, repos, issues, releases, workflow runs and logs |
| 212 | 271 | pocketbase-superuser.json the generated PocketBase superuser login |
| 213 | 272 | ssh_host_ed25519_key generated on first start |
| 214 | - hooks/ server-side git hooks for every push, rewritten on start | |
| 273 | + hooks/ server-side git hooks for every push, rewritten on start, and the socket they ask the server on | |
| 215 | 274 | repos/<owner>/<repo>.git bare git repositories |
| 216 | 275 | wikis/<repo>.git each repository's wiki, named by its record id |
| 217 | 276 | registry.db container image manifests, tags and blob records (SQLite) |
| @@ -228,6 +287,8 @@ kept so pushes in progress don't lose their layers). | ||
| 228 | 287 | ## Security notes |
| 229 | 288 | |
| 230 | 289 | - Serve the web UI over HTTPS if it's reachable beyond localhost, and set `PUBLIC_URL`. `Host` and `X-Forwarded-*` headers are client-controlled, so they never decide cookie security, and only stand in for `PUBLIC_URL` in development. |
| 290 | + - Branch rules are checked for every way a branch changes: a pre-receive hook asks the server (over a local socket, with a token per push) about every pushed ref, and the web editor, agents' tools and merges check the same rules. The hook fails closed. | |
| 291 | + - Webhooks refuse loopback, private, link-local and similar addresses (checked again on every delivery, and redirects aren't followed), unless the admin allows them. | |
| 231 | 292 | - Ref names are never passed to git where they could be read as options: pushes of refs with a component starting with `-` are refused by a pre-receive hook, such refs are ignored when reading, and every revision argument follows `--end-of-options`. |
| 232 | 293 | - Private repositories show a 404 page to anyone without access, so their existence isn't revealed. |
| 233 | 294 | - OAuth for agents follows the MCP authorization spec: public clients only, PKCE (S256) required, exact redirect URIs (any port for `localhost`), one-time codes that expire in 5 minutes, access tokens that last an hour, and refresh tokens that rotate on every use (presenting an old one revokes the grant). Only hashes of codes and tokens are stored. The consent page can't be framed, and it shows where it will send you, because anyone can register an application under any name. |
| @@ -242,6 +303,11 @@ kept so pushes in progress don't lose their layers). | ||
| 242 | 303 | - `lib/issues.ts`, `lib/issue-meta.ts`: issue tracker data and its shared vocabulary (statuses, priorities); UI in `components/issues/` |
| 243 | 304 | - `lib/keys.ts`, `lib/tokens.ts`: SSH keys and API tokens |
| 244 | 305 | - `lib/oauth.ts`, `lib/oauth-rules.ts`: the OAuth 2.1 authorization server for agents (`app/oauth/*`, `app/.well-known/*`); `lib/mcp/*`: the MCP endpoint (`app/mcp`) and its tools |
| 306 | + - `lib/actor.ts`, `lib/agents.ts`: who acts (a person, or an agent for one), and agents that issues can be assigned to | |
| 307 | + - `lib/pulls.ts`: pull requests, reviews, threads and merging; UI in `app/[owner]/[repo]/pulls/`, `compare/` and `components/pulls/` | |
| 308 | + - `lib/branch-rules.ts`, `lib/protection.ts`, `lib/push-hooks.ts`, `lib/hook-script.ts`: branch protection, its rules, and the pre-receive hook that checks pushes | |
| 309 | + - `lib/push.ts`: what follows every change of refs (events, workflows, pull requests); `lib/events.ts`, `lib/webhooks.ts`: the event feed and webhooks; `lib/revert.ts`: undoing an agent's push | |
| 310 | + - `lib/markdown-views.ts`: pages as Markdown and `llms.txt`; `public/vectrum.mjs`: the command line | |
| 245 | 311 | - `lib/git.ts`, `lib/git-write.ts`: reading from and committing to bare repositories |
| 246 | 312 | - `lib/git-http.ts`, `lib/ssh-server.ts`: git transports (started from `instrumentation.ts`) |
| 247 | 313 | - `lib/registry.ts`, `lib/registry-http.ts`: container registry storage and the OCI distribution API, served under `/v2` |
| @@ -128,7 +128,7 @@ export default async function RunPage({ params, searchParams }: PageProps<"/[own | ||
| 128 | 128 | <Link |
| 129 | 129 | href={`${runHref}?job=${encodeURIComponent(j.key)}`} |
| 130 | 130 | aria-current={j.id === job.id ? "page" : undefined} |
| 131 | - className="flex h-10 items-center gap-2.5 rounded-row px-3 outline-none focus-visible:focus-ring" | |
| 131 | + className="flex h-10 items-center gap-2.5 rounded-plate px-3 outline-none focus-visible:focus-ring" | |
| 132 | 132 | > |
| 133 | 133 | <RunStatusIcon status={j.status} size={15} /> |
| 134 | 134 | <span className="min-w-0 flex-1 truncate type-ui text-ink">{j.name}</span> |
| @@ -1,10 +1,15 @@ | ||
| 1 | 1 | import Link from "next/link"; |
| 2 | 2 | import type { Metadata } from "next"; |
| 3 | - import { BranchIcon, HistoryIcon } from "@/components/icons"; | |
| 3 | + import { createBranchAction, deleteBranchAction } from "@/app/actions/branches"; | |
| 4 | + import { ActionButton } from "@/components/action-button"; | |
| 5 | + import { NewBranchForm } from "@/components/branches/new-branch-form"; | |
| 6 | + import { BranchIcon, HistoryIcon, PullRequestIcon, TrashIcon } from "@/components/icons"; | |
| 7 | + import { PullStateIcon } from "@/components/pulls/pull-icons"; | |
| 4 | 8 | import { liftRowClass, plateHeadingClass } from "@/components/repo-rows"; |
| 5 | 9 | import { Blankslate, Box, ButtonLink, Label, PageTitle, RefChip } from "@/components/ui"; |
| 6 | 10 | import { listBranches } from "@/lib/git"; |
| 7 | 11 | import { encodePath, timeAgo } from "@/lib/format"; |
| 12 | + import { listPulls } from "@/lib/pulls"; | |
| 8 | 13 | import { getRepo } from "@/lib/repo"; |
| 9 | 14 | |
| 10 | 15 | export const metadata: Metadata = { title: "Branches" }; |
| @@ -12,18 +17,23 @@ export const metadata: Metadata = { title: "Branches" }; | ||
| 12 | 17 | export default async function BranchesPage({ params }: PageProps<"/[owner]/[repo]/branches">) { |
| 13 | 18 | const { owner, repo: repoParam } = await params; |
| 14 | 19 | const repo = await getRepo(owner, repoParam); |
| 15 | - const branches = await listBranches(repo.dir); | |
| 20 | + const [branches, { pulls }] = await Promise.all([listBranches(repo.dir), listPulls(repo.id, { state: "open", perPage: 200 })]); | |
| 21 | + // Pull requests from this repository's branches (not from forks' branches of the same name). | |
| 22 | + const pullFor = new Map(pulls.filter((p) => !p.headRepo).map((p) => [p.head, p])); | |
| 16 | 23 | |
| 17 | 24 | return ( |
| 18 | 25 | <div> |
| 19 | 26 | <PageTitle title="Branches" /> |
| 27 | + {repo.canWrite && branches.length > 0 && ( | |
| 28 | + <NewBranchForm action={createBranchAction.bind(null, repo.owner, repo.name)} branches={branches.map((b) => b.name)} from={repo.defaultBranch ?? branches[0].name} /> | |
| 29 | + )} | |
| 20 | 30 | <Box className="p-1.5"> |
| 21 | 31 | <h2 className={plateHeadingClass}> |
| 22 | 32 | All branches <span className="type-readout text-ink3 [text-shadow:none]">{branches.length}</span> |
| 23 | 33 | </h2> |
| 24 | 34 | {branches.length === 0 && ( |
| 25 | 35 | <Blankslate icon={<BranchIcon />} title="No branches yet"> |
| 26 | - Push to this repository to create one. | |
| 36 | + {repo.canWrite ? "Push to this repository, or add a file, to create one." : "Nothing has been pushed yet."} | |
| 27 | 37 | </Blankslate> |
| 28 | 38 | )} |
| 29 | 39 | <ul> |
| @@ -37,9 +47,19 @@ export default async function BranchesPage({ params }: PageProps<"/[owner]/[repo | ||
| 37 | 47 | </Link> |
| 38 | 48 | {b.name === repo.defaultBranch && <Label tone="accent">default</Label>} |
| 39 | 49 | <span className="min-w-0 truncate type-ui text-ink2">{b.subject}</span> |
| 50 | + {pullFor.has(b.name) && ( | |
| 51 | + <Link href={`${repo.base}/pulls/${pullFor.get(b.name)!.number}`} className="flex shrink-0 items-center gap-1 type-ui text-ink2 hover:text-ink"> | |
| 52 | + <PullStateIcon state="open" draft={pullFor.get(b.name)!.draft} size={14} />#{pullFor.get(b.name)!.number} | |
| 53 | + </Link> | |
| 54 | + )} | |
| 40 | 55 | <span className="ml-auto hidden shrink-0 type-meta text-ink3 sm:inline"> |
| 41 | 56 | Updated <time dateTime={new Date(b.date).toISOString()} className="type-readout">{timeAgo(b.date)}</time> |
| 42 | 57 | </span> |
| 58 | + {repo.canWrite && b.name !== repo.defaultBranch && !pullFor.has(b.name) && ( | |
| 59 | + <ButtonLink href={`${repo.base}/compare?head=${encodeURIComponent(b.name)}`} variant="invisible" icon={<PullRequestIcon width={14} height={14} />} className="shrink-0"> | |
| 60 | + New Pull Request | |
| 61 | + </ButtonLink> | |
| 62 | + )} | |
| 43 | 63 | <ButtonLink |
| 44 | 64 | href={`${repo.base}/commits/${encodePath(b.name)}`} |
| 45 | 65 | variant="invisible" |
| @@ -48,6 +68,24 @@ export default async function BranchesPage({ params }: PageProps<"/[owner]/[repo | ||
| 48 | 68 | > |
| 49 | 69 | Commits |
| 50 | 70 | </ButtonLink> |
| 71 | + {repo.canWrite && b.name !== repo.defaultBranch && ( | |
| 72 | + <ActionButton | |
| 73 | + action={deleteBranchAction.bind(null, repo.owner, repo.name, b.name, b.sha)} | |
| 74 | + variant="invisible" | |
| 75 | + icon={<TrashIcon width={14} height={14} />} | |
| 76 | + className="shrink-0" | |
| 77 | + confirm={{ | |
| 78 | + title: `Delete ${b.name}?`, | |
| 79 | + description: pullFor.has(b.name) | |
| 80 | + ? `#${pullFor.get(b.name)!.number} comes from it and can't be merged while it's gone. Pushing the branch again brings it back.` | |
| 81 | + : "Its commits stay reachable from any branch that has them. Pushing the branch again brings it back.", | |
| 82 | + label: "Delete Branch", | |
| 83 | + destructive: true, | |
| 84 | + }} | |
| 85 | + > | |
| 86 | + <span className="sr-only">Delete {b.name}</span> | |
| 87 | + </ActionButton> | |
| 88 | + )} | |
| 51 | 89 | </li> |
| 52 | 90 | ))} |
| 53 | 91 | </ul> |
| @@ -1,9 +1,14 @@ | ||
| 1 | + import Link from "next/link"; | |
| 1 | 2 | import { notFound } from "next/navigation"; |
| 3 | + import { revertCommitAction } from "@/app/actions/activity"; | |
| 4 | + import { ConfirmButton } from "@/components/confirm-button"; | |
| 2 | 5 | import { ShaChip } from "@/components/file-table"; |
| 3 | 6 | import { diffBudgets, FileDiffView } from "@/components/file-diff"; |
| 4 | - import { CodeIcon } from "@/components/icons"; | |
| 5 | - import { Avatar, Box, ButtonLink, RefChip } from "@/components/ui"; | |
| 6 | - import { diffCommit, getCommit } from "@/lib/git"; | |
| 7 | + import { ActorAvatar } from "@/components/agent"; | |
| 8 | + import { CodeIcon, SparkleIcon, UndoIcon } from "@/components/icons"; | |
| 9 | + import { Box, ButtonLink, Label, RefChip } from "@/components/ui"; | |
| 10 | + import { agentKeyFromEmail } from "@/lib/actor"; | |
| 11 | + import { branchesContaining, diffCommit, findRevertOf, getCommit } from "@/lib/git"; | |
| 7 | 12 | import { encodePath, shortSha, timeAgo } from "@/lib/format"; |
| 8 | 13 | import { getRepo } from "@/lib/repo"; |
| 9 | 14 | |
| @@ -13,6 +18,13 @@ export default async function CommitPage({ params }: PageProps<"/[owner]/[repo]/ | ||
| 13 | 18 | const commit = await getCommit(repo.dir, sha); |
| 14 | 19 | if (!commit) notFound(); |
| 15 | 20 | const files = await diffCommit(repo.dir, commit); |
| 21 | + // Commits an agent made name it as their committer (see lib/actor.ts). | |
| 22 | + const agent = agentKeyFromEmail(commit.committerEmail) ? commit.committerName : null; | |
| 23 | + // Where it can be reverted: the default branch if it has the commit, else another branch that does. | |
| 24 | + const branches = await branchesContaining(repo.dir, commit.sha); | |
| 25 | + const branch = repo.defaultBranch && branches.includes(repo.defaultBranch) ? repo.defaultBranch : (branches[0] ?? null); | |
| 26 | + const revertedIn = branch ? await findRevertOf(repo.dir, `refs/heads/${branch}`, commit.sha) : null; | |
| 27 | + const canRevert = repo.canWrite && !!branch && !revertedIn && commit.parents.length > 0; | |
| 16 | 28 | const additions = files.reduce((n, f) => n + f.additions, 0); |
| 17 | 29 | const deletions = files.reduce((n, f) => n + f.deletions, 0); |
| 18 | 30 | |
| @@ -26,15 +38,39 @@ export default async function CommitPage({ params }: PageProps<"/[owner]/[repo]/ | ||
| 26 | 38 | <h1 className="text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">{commit.subject}</h1> |
| 27 | 39 | {commit.body && <pre className="mt-3 font-mono text-[11.5px] leading-relaxed whitespace-pre-wrap text-ink2">{commit.body}</pre>} |
| 28 | 40 | </div> |
| 29 | - <ButtonLink href={`${repo.base}/tree/${commit.sha}`} icon={<CodeIcon />}> | |
| 30 | - Browse Files | |
| 31 | - </ButtonLink> | |
| 41 | + <div className="flex flex-wrap items-center gap-2"> | |
| 42 | + {revertedIn && ( | |
| 43 | + <Link href={`${repo.base}/commit/${revertedIn}`} className="transition-opacity hover:opacity-80"> | |
| 44 | + <Label tone="attention">Reverted in {shortSha(revertedIn)}</Label> | |
| 45 | + </Link> | |
| 46 | + )} | |
| 47 | + {canRevert && ( | |
| 48 | + <ConfirmButton | |
| 49 | + action={revertCommitAction.bind(null, repo.owner, repo.name, commit.sha)} | |
| 50 | + message={`Revert this commit on ${branch}? A new commit undoes its changes (or a pull request does, if ${branch} is protected).`} | |
| 51 | + variant="default" | |
| 52 | + confirmLabel="Revert" | |
| 53 | + > | |
| 54 | + <UndoIcon width={14} height={14} /> Revert | |
| 55 | + </ConfirmButton> | |
| 56 | + )} | |
| 57 | + <ButtonLink href={`${repo.base}/tree/${commit.sha}`} icon={<CodeIcon />}> | |
| 58 | + Browse Files | |
| 59 | + </ButtonLink> | |
| 60 | + </div> | |
| 32 | 61 | </div> |
| 33 | 62 | <div className="rule-t flex flex-wrap items-center gap-2 px-6 py-3 type-ui"> |
| 34 | - <Avatar name={commit.authorName} size={22} /> | |
| 63 | + <ActorAvatar name={commit.authorName} agent={agent} size={22} /> | |
| 35 | 64 | <span className="font-medium text-ink" title={commit.authorEmail}> |
| 36 | 65 | {commit.authorName} |
| 37 | 66 | </span> |
| 67 | + {agent && ( | |
| 68 | + <span className="inline-flex items-center gap-1 text-ink2"> | |
| 69 | + with <SparkleIcon width={11} height={11} className="text-ink3" /> | |
| 70 | + <span className="font-medium text-ink">{agent}</span> | |
| 71 | + <Label tone="accent">Agent</Label> | |
| 72 | + </span> | |
| 73 | + )} | |
| 38 | 74 | <span className="text-ink2"> |
| 39 | 75 | committed <time dateTime={new Date(commit.date).toISOString()}>{timeAgo(commit.date)}</time> |
| 40 | 76 | </span> |
| @@ -4,10 +4,14 @@ import { BranchSelect } from "@/components/branch-select"; | ||
| 4 | 4 | import { RunStatusIcon } from "@/components/ci/run-status"; |
| 5 | 5 | import { CopyButton } from "@/components/clone-button"; |
| 6 | 6 | import { ShaChip } from "@/components/file-table"; |
| 7 | - import { ChevronLeftIcon, ChevronRightIcon, CodeIcon, CommitIcon, HistoryIcon } from "@/components/icons"; | |
| 7 | + import { ChevronLeftIcon, ChevronRightIcon, CodeIcon, CommitIcon, HistoryIcon, UndoIcon } from "@/components/icons"; | |
| 8 | 8 | import { ghostIconLinkClass, liftRowClass } from "@/components/repo-rows"; |
| 9 | - import { Avatar, Blankslate, Box, ButtonLink, RefChip } from "@/components/ui"; | |
| 10 | - import { listBranches, log, type Commit } from "@/lib/git"; | |
| 9 | + import { ActorAvatar } from "@/components/agent"; | |
| 10 | + import { Blankslate, Box, ButtonLink, Label, RefChip } from "@/components/ui"; | |
| 11 | + import { agentKeyFromEmail } from "@/lib/actor"; | |
| 12 | + import { findRevertOf, listBranches, log, type Commit } from "@/lib/git"; | |
| 13 | + import { revertCommitAction } from "@/app/actions/activity"; | |
| 14 | + import { ConfirmButton } from "@/components/confirm-button"; | |
| 11 | 15 | import { encodePath, formatDate, timeAgo } from "@/lib/format"; |
| 12 | 16 | import { getRefAndPath, getRepo } from "@/lib/repo"; |
| 13 | 17 | import { commitStatuses } from "@/lib/runs"; |
| @@ -35,6 +39,16 @@ export default async function CommitsPage({ params, searchParams }: PageProps<"/ | ||
| 35 | 39 | ]); |
| 36 | 40 | const hasNext = commits.length > PAGE_SIZE; |
| 37 | 41 | const statuses = await commitStatuses(repo.id, commits.map((c) => c.sha)); |
| 42 | + // Agents' commits on a branch get a Revert button, or a mark once they were reverted. | |
| 43 | + const onBranch = branches.some((b) => b.name === ref); | |
| 44 | + const reverted = new Map<string, string>(); | |
| 45 | + if (onBranch) { | |
| 46 | + for (const c of commits.slice(0, PAGE_SIZE)) { | |
| 47 | + if (!agentKeyFromEmail(c.committerEmail)) continue; | |
| 48 | + const by = await findRevertOf(repo.dir, `refs/heads/${ref}`, c.sha); | |
| 49 | + if (by) reverted.set(c.sha, by); | |
| 50 | + } | |
| 51 | + } | |
| 38 | 52 | const selfHref = `${repo.base}/commits/${encodePath(ref)}${path ? `/${encodePath(path)}` : ""}`; |
| 39 | 53 | |
| 40 | 54 | return ( |
| @@ -66,7 +80,7 @@ export default async function CommitsPage({ params, searchParams }: PageProps<"/ | ||
| 66 | 80 | <ul> |
| 67 | 81 | {dayCommits.map((c) => ( |
| 68 | 82 | <li key={c.sha} className={`${liftRowClass} flex items-center gap-3.5 px-3 py-2.5`}> |
| 69 | - <Avatar name={c.authorName} size={32} className="hidden sm:inline-flex" /> | |
| 83 | + <ActorAvatar name={c.authorName} agent={agentKeyFromEmail(c.committerEmail) ? c.committerName : null} size={32} className="hidden sm:inline-flex" /> | |
| 70 | 84 | <div className="min-w-0 flex-1"> |
| 71 | 85 | <Link |
| 72 | 86 | href={`${repo.base}/commit/${c.sha}`} |
| @@ -75,7 +89,11 @@ export default async function CommitsPage({ params, searchParams }: PageProps<"/ | ||
| 75 | 89 | {c.subject} |
| 76 | 90 | </Link> |
| 77 | 91 | <div className="mt-0.5 flex items-center gap-1 type-meta text-ink3"> |
| 78 | - <span className="font-medium text-ink2">{c.authorName}</span> committed{" "} | |
| 92 | + <span className="font-medium text-ink2"> | |
| 93 | + {agentKeyFromEmail(c.committerEmail) ? `${c.committerName} for ${c.authorName}` : c.authorName} | |
| 94 | + </span> | |
| 95 | + {agentKeyFromEmail(c.committerEmail) && <Label tone="accent">Agent</Label>}{" "} | |
| 96 | + committed{" "} | |
| 79 | 97 | <time dateTime={new Date(c.date).toISOString()}>{timeAgo(c.date)}</time> |
| 80 | 98 | {statuses.has(c.sha) && ( |
| 81 | 99 | <Link href={`${repo.base}/actions/runs/${statuses.get(c.sha)!.runNumber}`} className="ml-1 transition-opacity hover:opacity-80"> |
| @@ -85,6 +103,25 @@ export default async function CommitsPage({ params, searchParams }: PageProps<"/ | ||
| 85 | 103 | </div> |
| 86 | 104 | </div> |
| 87 | 105 | <div className="flex shrink-0 items-center gap-1"> |
| 106 | + {reverted.has(c.sha) ? ( | |
| 107 | + <Link href={`${repo.base}/commit/${reverted.get(c.sha)}`} className="mr-1 transition-opacity hover:opacity-80"> | |
| 108 | + <Label tone="attention">Reverted</Label> | |
| 109 | + </Link> | |
| 110 | + ) : ( | |
| 111 | + onBranch && | |
| 112 | + repo.canWrite && | |
| 113 | + agentKeyFromEmail(c.committerEmail) && | |
| 114 | + c.parents.length > 0 && ( | |
| 115 | + <ConfirmButton | |
| 116 | + action={revertCommitAction.bind(null, repo.owner, repo.name, c.sha)} | |
| 117 | + message={`Revert “${c.subject}”? A new commit on ${ref} undoes its changes (or a pull request does, if ${ref} is protected).`} | |
| 118 | + variant="invisible" | |
| 119 | + confirmLabel="Revert" | |
| 120 | + > | |
| 121 | + <UndoIcon width={14} height={14} /> Revert | |
| 122 | + </ConfirmButton> | |
| 123 | + ) | |
| 124 | + )} | |
| 88 | 125 | <ShaChip sha={c.sha} href={`${repo.base}/commit/${c.sha}`} className="mr-1" /> |
| 89 | 126 | <CopyButton text={c.sha} label="Copy full SHA" /> |
| 90 | 127 | <Tooltip label="Browse files"> |
| @@ -0,0 +1,169 @@ | ||
| 1 | + import Link from "next/link"; | |
| 2 | + import type { Metadata } from "next"; | |
| 3 | + import { createPullAction } from "@/app/actions/pulls"; | |
| 4 | + import { ActorAvatar } from "@/components/agent"; | |
| 5 | + import { diffBudgets, FileDiffView } from "@/components/file-diff"; | |
| 6 | + import { ShaChip } from "@/components/file-table"; | |
| 7 | + import { BranchIcon, CommitIcon, PullRequestIcon } from "@/components/icons"; | |
| 8 | + import { CompareSelect } from "@/components/pulls/compare-select"; | |
| 9 | + import { CreatePullForm } from "@/components/pulls/create-pull-form"; | |
| 10 | + import { liftRowClass, plateHeadingClass } from "@/components/repo-rows"; | |
| 11 | + import { PlateSection } from "@/components/settings-plate"; | |
| 12 | + import { Alert, Blankslate, Box, ButtonLink, PageTitle } from "@/components/ui"; | |
| 13 | + import { agentKeyFromEmail } from "@/lib/actor"; | |
| 14 | + import { encodePath, timeAgo } from "@/lib/format"; | |
| 15 | + import { commitsBetween, defaultBranch, diffRange, fetchBranch, listBranches, mergeBase, mergeTrees, revParse } from "@/lib/git"; | |
| 16 | + import { openPullsFor, openPullsFromFork } from "@/lib/pulls"; | |
| 17 | + import { getRepo } from "@/lib/repo"; | |
| 18 | + import { forkNetwork } from "@/lib/repos"; | |
| 19 | + | |
| 20 | + export const metadata: Metadata = { title: "Compare changes" }; | |
| 21 | + | |
| 22 | + /** "agent/fix-login" → "Fix login". */ | |
| 23 | + const titleFromBranch = (branch: string) => { | |
| 24 | + const words = (branch.split("/").pop() ?? branch).replace(/[-_]+/g, " ").trim(); | |
| 25 | + return words ? words[0].toUpperCase() + words.slice(1) : branch; | |
| 26 | + }; | |
| 27 | + | |
| 28 | + export default async function ComparePage({ params, searchParams }: PageProps<"/[owner]/[repo]/compare">) { | |
| 29 | + const { owner, repo: repoParam } = await params; | |
| 30 | + const query = await searchParams; | |
| 31 | + const repo = await getRepo(owner, repoParam); | |
| 32 | + const fullName = (r: { owner: string; name: string }) => `${r.owner}/${r.name}`; | |
| 33 | + // The changes can come from a fork (head_repo=owner/name), or go from the original to a fork. | |
| 34 | + const network = repo.forkOf || (await forkNetwork(repo, repo.viewer?.id ?? null, 2)).length > 1 ? await forkNetwork(repo, repo.viewer?.id ?? null) : [repo]; | |
| 35 | + const wanted = typeof query.head_repo === "string" ? query.head_repo.toLowerCase() : ""; | |
| 36 | + const headRepo = network.find((r) => r.id !== repo.id && fullName(r).toLowerCase() === wanted) ?? repo; | |
| 37 | + const fork = headRepo.id !== repo.id ? headRepo : null; | |
| 38 | + const headBase = `/${fullName(headRepo)}`; | |
| 39 | + | |
| 40 | + const branches = (await listBranches(repo.dir)).map((b) => b.name); | |
| 41 | + const headBranches = fork ? (await listBranches(fork.dir)).map((b) => b.name) : branches; | |
| 42 | + const base = typeof query.base === "string" && branches.includes(query.base) ? query.base : (repo.defaultBranch ?? branches[0] ?? ""); | |
| 43 | + const head = typeof query.head === "string" && headBranches.includes(query.head) ? query.head : fork ? await defaultBranch(fork.dir) : null; | |
| 44 | + const label = fork ? `${fork.owner}:${head}` : head; | |
| 45 | + | |
| 46 | + const header = ( | |
| 47 | + <> | |
| 48 | + <PageTitle | |
| 49 | + title="Compare changes" | |
| 50 | + description={ | |
| 51 | + network.length > 1 | |
| 52 | + ? "Choose the branch with the changes, on this repository or a fork of it, and the branch to merge them into. Then open a pull request to discuss and review them." | |
| 53 | + : "Choose the branch with the changes and the branch to merge them into, then open a pull request to discuss and review them." | |
| 54 | + } | |
| 55 | + /> | |
| 56 | + <Box className="mb-6 px-4 py-3"> | |
| 57 | + <CompareSelect | |
| 58 | + branches={branches} | |
| 59 | + headBranches={headBranches} | |
| 60 | + repos={network.map(fullName)} | |
| 61 | + current={{ baseRepo: fullName(repo), base, headRepo: fullName(headRepo), head }} | |
| 62 | + /> | |
| 63 | + </Box> | |
| 64 | + </> | |
| 65 | + ); | |
| 66 | + | |
| 67 | + if (!head || (!fork && head === base)) { | |
| 68 | + return ( | |
| 69 | + <div className="mx-auto max-w-6xl"> | |
| 70 | + {header} | |
| 71 | + <Box> | |
| 72 | + <Blankslate icon={<BranchIcon />} title={head ? "These are the same branch" : "Choose a branch to compare"}> | |
| 73 | + Pick the branch whose changes you want to merge into {base}. | |
| 74 | + {!repo.canWrite && repo.viewer && network.length === 1 && " To propose changes here, fork the repository and push them to your fork."} | |
| 75 | + </Blankslate> | |
| 76 | + </Box> | |
| 77 | + </div> | |
| 78 | + ); | |
| 79 | + } | |
| 80 | + | |
| 81 | + // A fork's commits are copied here to compare them (opening the pull request keeps them). | |
| 82 | + const [baseSha, headSha] = await Promise.all([ | |
| 83 | + revParse(repo.dir, `refs/heads/${base}`), | |
| 84 | + fork ? fetchBranch(repo.dir, fork.dir, head).catch(() => null) : revParse(repo.dir, `refs/heads/${head}`), | |
| 85 | + ]); | |
| 86 | + const from = baseSha && headSha ? await mergeBase(repo.dir, baseSha, headSha) : null; | |
| 87 | + const [commits, files, existing, merge] = await Promise.all([ | |
| 88 | + from && headSha ? commitsBetween(repo.dir, baseSha!, headSha) : Promise.resolve([]), | |
| 89 | + from && headSha ? diffRange(repo.dir, from, headSha) : Promise.resolve([]), | |
| 90 | + fork ? openPullsFromFork(fork.id, head).then((pulls) => pulls.filter((p) => p.repoId === repo.id)) : openPullsFor(repo.id, head, "head"), | |
| 91 | + baseSha && headSha ? mergeTrees(repo.dir, baseSha, headSha) : Promise.resolve(null), | |
| 92 | + ]); | |
| 93 | + const open = existing.find((p) => p.base === base); | |
| 94 | + const canOpen = !!repo.viewer && (fork ? true : repo.canWrite); | |
| 95 | + const budgets = diffBudgets(files); | |
| 96 | + const ordered = [...commits].reverse(); | |
| 97 | + | |
| 98 | + return ( | |
| 99 | + <div className="mx-auto max-w-6xl"> | |
| 100 | + {header} | |
| 101 | + <div className="flex flex-col gap-6"> | |
| 102 | + {!from ? ( | |
| 103 | + <Alert>{label} and {base} have no history in common, so they can't be compared.</Alert> | |
| 104 | + ) : commits.length === 0 ? ( | |
| 105 | + <Box> | |
| 106 | + <Blankslate icon={<CommitIcon />} title="Nothing to compare"> | |
| 107 | + {base} already has everything {label} has. | |
| 108 | + </Blankslate> | |
| 109 | + </Box> | |
| 110 | + ) : open ? ( | |
| 111 | + <Box className="flex flex-wrap items-center gap-3 px-5 py-4"> | |
| 112 | + <PullRequestIcon className="text-green-deep" /> | |
| 113 | + <span className="type-ui text-ink"> | |
| 114 | + There's already a pull request for these branches: <strong>{open.title}</strong> | |
| 115 | + </span> | |
| 116 | + <ButtonLink href={`${repo.base}/pulls/${open.number}`} className="ml-auto"> | |
| 117 | + View #{open.number} | |
| 118 | + </ButtonLink> | |
| 119 | + </Box> | |
| 120 | + ) : canOpen ? ( | |
| 121 | + <PlateSection title="Open a pull request"> | |
| 122 | + {merge && !merge.clean && ( | |
| 123 | + <div className="px-6 pt-5"> | |
| 124 | + <Alert kind="info">These branches conflict ({merge.conflicts.slice(0, 5).join(", ")}): you can still open the pull request and resolve them on {label}.</Alert> | |
| 125 | + </div> | |
| 126 | + )} | |
| 127 | + <CreatePullForm | |
| 128 | + action={createPullAction.bind(null, repo.owner, repo.name)} | |
| 129 | + head={head} | |
| 130 | + headRepo={fork ? fullName(fork) : null} | |
| 131 | + base={base} | |
| 132 | + title={commits.length === 1 ? commits[0].subject : titleFromBranch(head)} | |
| 133 | + body={commits.length === 1 ? commits[0].body : ordered.map((c) => `- ${c.subject}`).join("\n")} | |
| 134 | + /> | |
| 135 | + </PlateSection> | |
| 136 | + ) : null} | |
| 137 | + | |
| 138 | + {commits.length > 0 && ( | |
| 139 | + <Box className="p-1.5"> | |
| 140 | + <h2 className={plateHeadingClass}> | |
| 141 | + {commits.length} commit{commits.length === 1 ? "" : "s"} | |
| 142 | + </h2> | |
| 143 | + <ul> | |
| 144 | + {ordered.map((c) => { | |
| 145 | + const agent = agentKeyFromEmail(c.committerEmail) ? c.committerName : null; | |
| 146 | + return ( | |
| 147 | + <li key={c.sha} className={`${liftRowClass} flex items-center gap-3 px-3 py-2`}> | |
| 148 | + <ActorAvatar name={c.authorName} agent={agent} size={20} /> | |
| 149 | + <Link href={`${headBase}/commit/${c.sha}`} className="min-w-0 flex-1 truncate type-ui text-ink hover:underline"> | |
| 150 | + {c.subject} | |
| 151 | + </Link> | |
| 152 | + <span className="hidden type-meta text-ink3 sm:inline"> | |
| 153 | + {agent ? `${agent} for ${c.authorName}` : c.authorName} · {timeAgo(c.date)} | |
| 154 | + </span> | |
| 155 | + <ShaChip sha={c.sha} href={`${headBase}/commit/${c.sha}`} /> | |
| 156 | + </li> | |
| 157 | + ); | |
| 158 | + })} | |
| 159 | + </ul> | |
| 160 | + </Box> | |
| 161 | + )} | |
| 162 | + | |
| 163 | + {files.map((file, i) => ( | |
| 164 | + <FileDiffView key={file.newPath} file={file} viewHref={`${headBase}/blob/${encodePath(head)}/${encodePath(file.newPath)}`} budget={budgets[i]} /> | |
| 165 | + ))} | |
| 166 | + </div> | |
| 167 | + </div> | |
| 168 | + ); | |
| 169 | + } | |
| @@ -0,0 +1,81 @@ | ||
| 1 | + "use client"; | |
| 2 | + | |
| 3 | + import { useActionState } from "react"; | |
| 4 | + import { Checkbox, Rule } from "@unlocalhosted/metalui"; | |
| 5 | + import type { FormState } from "@/app/actions/types"; | |
| 6 | + import { BranchIcon, ForkIcon } from "@/components/icons"; | |
| 7 | + import { TextField } from "@/components/settings-fields"; | |
| 8 | + import { PlateBody, PlateFooter } from "@/components/settings-plate"; | |
| 9 | + import { Alert, Avatar, Box, Button } from "@/components/ui"; | |
| 10 | + | |
| 11 | + export function ForkForm({ | |
| 12 | + action, | |
| 13 | + owner, | |
| 14 | + name, | |
| 15 | + description, | |
| 16 | + defaultBranch, | |
| 17 | + }: { | |
| 18 | + action: (prev: FormState, formData: FormData) => Promise<FormState>; | |
| 19 | + owner: string; | |
| 20 | + name: string; | |
| 21 | + description: string; | |
| 22 | + defaultBranch: string | null; | |
| 23 | + }) { | |
| 24 | + const [state, formAction, pending] = useActionState(action, undefined); | |
| 25 | + return ( | |
| 26 | + <form action={formAction}> | |
| 27 | + <Box> | |
| 28 | + <PlateBody> | |
| 29 | + <div className="flex flex-wrap items-start gap-2"> | |
| 30 | + <div className="flex flex-col gap-1.5"> | |
| 31 | + <span className="type-ui text-ink">Owner</span> | |
| 32 | + <span className="flex h-8 items-center gap-2 rounded-[11px] recipe-well-field pr-3.5 pl-2 type-ui text-ink2"> | |
| 33 | + <Avatar name={owner} size={20} /> {owner} | |
| 34 | + </span> | |
| 35 | + </div> | |
| 36 | + <span aria-hidden className="mt-[22px] flex h-8 items-center type-content text-ink3"> | |
| 37 | + / | |
| 38 | + </span> | |
| 39 | + <TextField | |
| 40 | + className="min-w-60 flex-1" | |
| 41 | + label="Repository name" | |
| 42 | + description="The fork keeps a link to the repository it came from, whatever you call it." | |
| 43 | + name="name" | |
| 44 | + required | |
| 45 | + autoFocus | |
| 46 | + defaultValue={name} | |
| 47 | + pattern="[A-Za-z0-9][A-Za-z0-9._\-]*" | |
| 48 | + /> | |
| 49 | + </div> | |
| 50 | + <TextField label="Description" optional name="description" maxLength={350} defaultValue={description} /> | |
| 51 | + </PlateBody> | |
| 52 | + | |
| 53 | + {defaultBranch && ( | |
| 54 | + <> | |
| 55 | + <Rule className="mx-6" /> | |
| 56 | + <PlateBody> | |
| 57 | + <label className="flex cursor-pointer items-start gap-3"> | |
| 58 | + <Checkbox name="defaultBranchOnly" defaultChecked className="mt-px" /> | |
| 59 | + <span className="flex flex-col gap-0.5"> | |
| 60 | + <span className="flex items-center gap-2 type-title text-ink"> | |
| 61 | + <BranchIcon width={14} height={14} className="text-ink2" /> Copy the {defaultBranch} branch only | |
| 62 | + </span> | |
| 63 | + <span className="type-body text-ink2">Leave it unchecked to copy every branch. Tags come along either way.</span> | |
| 64 | + </span> | |
| 65 | + </label> | |
| 66 | + </PlateBody> | |
| 67 | + </> | |
| 68 | + )} | |
| 69 | + | |
| 70 | + <PlateBody> | |
| 71 | + {state?.error && <Alert>{state.error}</Alert>} | |
| 72 | + </PlateBody> | |
| 73 | + <PlateFooter> | |
| 74 | + <Button variant="primary" icon={<ForkIcon />} disabled={pending}> | |
| 75 | + {pending ? "Forking…" : "Create Fork"} | |
| 76 | + </Button> | |
| 77 | + </PlateFooter> | |
| 78 | + </Box> | |
| 79 | + </form> | |
| 80 | + ); | |
| 81 | + } | |
| @@ -0,0 +1,72 @@ | ||
| 1 | + import Link from "next/link"; | |
| 2 | + import type { Metadata } from "next"; | |
| 3 | + import { forkRepoAction } from "@/app/actions/forks"; | |
| 4 | + import { ForkIcon } from "@/components/icons"; | |
| 5 | + import { Alert } from "@/components/ui"; | |
| 6 | + import { requireViewer } from "@/lib/auth"; | |
| 7 | + import { getRepo } from "@/lib/repo"; | |
| 8 | + import { findRepo, listForks } from "@/lib/repos"; | |
| 9 | + import { ForkForm } from "./form"; | |
| 10 | + | |
| 11 | + export const metadata: Metadata = { title: "Fork" }; | |
| 12 | + | |
| 13 | + /** A name for the fork that's free in the viewer's repositories: the original's, or with -fork after it. */ | |
| 14 | + async function freeName(owner: string, name: string) { | |
| 15 | + for (const candidate of [name, `${name}-fork`, ...[2, 3, 4, 5].map((n) => `${name}-fork-${n}`)]) { | |
| 16 | + if (!(await findRepo(owner, candidate))) return candidate; | |
| 17 | + } | |
| 18 | + return `${name}-fork`; | |
| 19 | + } | |
| 20 | + | |
| 21 | + export default async function ForkPage({ params }: PageProps<"/[owner]/[repo]/fork">) { | |
| 22 | + const { owner, repo: repoParam } = await params; | |
| 23 | + const repo = await getRepo(owner, repoParam); | |
| 24 | + const viewer = await requireViewer(`${repo.base}/fork`); | |
| 25 | + const mine = (await listForks(repo.id, viewer.id)).filter((f) => f.ownerId === viewer.id); | |
| 26 | + | |
| 27 | + return ( | |
| 28 | + <div className="mx-auto max-w-2xl py-6"> | |
| 29 | + <div className="mb-8 flex items-center gap-4"> | |
| 30 | + <span className="icon-tile size-12 shrink-0 rounded-plate [&_svg]:size-[22px]"> | |
| 31 | + <ForkIcon /> | |
| 32 | + </span> | |
| 33 | + <div> | |
| 34 | + <h1 className="text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink"> | |
| 35 | + Fork {repo.owner}/{repo.name} | |
| 36 | + </h1> | |
| 37 | + <p className="mt-1 type-lead text-ink2"> | |
| 38 | + A copy of its code that you own. Change it freely, keep it in sync, and propose your changes back with a pull request. | |
| 39 | + </p> | |
| 40 | + </div> | |
| 41 | + </div> | |
| 42 | + {mine.length > 0 && ( | |
| 43 | + <div className="mb-5"> | |
| 44 | + <Alert kind="info"> | |
| 45 | + You already have {mine.length === 1 ? "a fork" : "forks"} of this repository:{" "} | |
| 46 | + {mine.map((f, i) => ( | |
| 47 | + <span key={f.id}> | |
| 48 | + {i > 0 && ", "} | |
| 49 | + <Link href={`/${f.owner}/${f.name}`} className="font-medium underline underline-offset-2"> | |
| 50 | + {f.owner}/{f.name} | |
| 51 | + </Link> | |
| 52 | + </span> | |
| 53 | + ))} | |
| 54 | + . | |
| 55 | + </Alert> | |
| 56 | + </div> | |
| 57 | + )} | |
| 58 | + <ForkForm | |
| 59 | + action={forkRepoAction.bind(null, repo.owner, repo.name)} | |
| 60 | + owner={viewer.username} | |
| 61 | + name={await freeName(viewer.username, repo.name)} | |
| 62 | + description={repo.description} | |
| 63 | + defaultBranch={repo.defaultBranch} | |
| 64 | + /> | |
| 65 | + <p className="mt-4 type-meta text-ink3"> | |
| 66 | + {repo.visibility === "private" | |
| 67 | + ? "This repository is private, so the fork is private too. Issues, pull requests, releases and the wiki stay here." | |
| 68 | + : "The fork is public like this repository; you can make it private in its settings. Issues, pull requests, releases and the wiki stay here."} | |
| 69 | + </p> | |
| 70 | + </div> | |
| 71 | + ); | |
| 72 | + } | |
| @@ -0,0 +1,39 @@ | ||
| 1 | + import type { Metadata } from "next"; | |
| 2 | + import { ForkIcon } from "@/components/icons"; | |
| 3 | + import { RepoList } from "@/components/repo-list"; | |
| 4 | + import { Blankslate, Box, ButtonLink, PageTitle } from "@/components/ui"; | |
| 5 | + import { getRepo } from "@/lib/repo"; | |
| 6 | + import { withActivity } from "@/lib/listing"; | |
| 7 | + import { listForks } from "@/lib/repos"; | |
| 8 | + | |
| 9 | + export const metadata: Metadata = { title: "Forks" }; | |
| 10 | + | |
| 11 | + export default async function ForksPage({ params }: PageProps<"/[owner]/[repo]/forks">) { | |
| 12 | + const { owner, repo: repoParam } = await params; | |
| 13 | + const repo = await getRepo(owner, repoParam); | |
| 14 | + const forks = await listForks(repo.id, repo.viewer?.id ?? null); | |
| 15 | + const items = await withActivity(forks, repo.viewer?.id); | |
| 16 | + | |
| 17 | + return ( | |
| 18 | + <div className="mx-auto max-w-6xl"> | |
| 19 | + <PageTitle | |
| 20 | + title="Forks" | |
| 21 | + description={`Copies of ${repo.owner}/${repo.name} that people made to change on their own and propose back.`} | |
| 22 | + actions={ | |
| 23 | + <ButtonLink href={`${repo.base}/fork`} variant="primary" icon={<ForkIcon />}> | |
| 24 | + Fork | |
| 25 | + </ButtonLink> | |
| 26 | + } | |
| 27 | + /> | |
| 28 | + {items.length === 0 ? ( | |
| 29 | + <Box> | |
| 30 | + <Blankslate icon={<ForkIcon />} title="No forks yet"> | |
| 31 | + Fork this repository to work on a copy of your own. | |
| 32 | + </Blankslate> | |
| 33 | + </Box> | |
| 34 | + ) : ( | |
| 35 | + <RepoList repos={items} /> | |
| 36 | + )} | |
| 37 | + </div> | |
| 38 | + ); | |
| 39 | + } | |
| @@ -4,15 +4,19 @@ import type { ReactNode } from "react"; | ||
| 4 | 4 | import { commentAction, deleteIssueAction } from "@/app/actions/issues"; |
| 5 | 5 | import { Surface, Textarea } from "@unlocalhosted/metalui"; |
| 6 | 6 | import { ConfirmButton } from "@/components/confirm-button"; |
| 7 | - import { ArrowRightIcon, CommentIcon, TrashIcon } from "@/components/icons"; | |
| 7 | + import { ActorAvatar, ActorName } from "@/components/agent"; | |
| 8 | + import { AuthorBadge, CommentBox, TimelineDot } from "@/components/conversation"; | |
| 9 | + import { ArrowRightIcon, CommentIcon, PullRequestIcon, TrashIcon } from "@/components/icons"; | |
| 10 | + import { PullStateIcon } from "@/components/pulls/pull-icons"; | |
| 8 | 11 | import { IssueDetail } from "@/components/issues/issue-detail"; |
| 9 | 12 | import { PriorityIcon, StatusIcon } from "@/components/issues/issue-icons"; |
| 10 | 13 | import { TrackerProvider } from "@/components/issues/tracker-context"; |
| 11 | 14 | import { Markdown } from "@/components/markdown"; |
| 12 | - import { Avatar, Button, ButtonLink, Label } from "@/components/ui"; | |
| 15 | + import { Avatar, Button, ButtonLink } from "@/components/ui"; | |
| 13 | 16 | import { formatDate, timeAgo } from "@/lib/format"; |
| 14 | 17 | import { STATUS_IDS, formatDay, priorityLabel, statusLabel, type Status } from "@/lib/issue-meta"; |
| 15 | 18 | import { getIssue, listComments, listEvents, type IssueEvent } from "@/lib/issues"; |
| 19 | + import { pullsLinkingIssue } from "@/lib/pulls"; | |
| 16 | 20 | import { getRepo } from "@/lib/repo"; |
| 17 | 21 | import { trackerProps } from "@/lib/tracker"; |
| 18 | 22 | |
| @@ -23,43 +27,6 @@ export async function generateMetadata({ params }: PageProps<"/[owner]/[repo]/is | ||
| 23 | 27 | return { title: issue ? `${issue.title} · Issue #${issue.number}` : "Issue" }; |
| 24 | 28 | } |
| 25 | 29 | |
| 26 | - function CommentBox({ author, createdAt, body, isAuthor }: { author: string; createdAt: number; body: string; isAuthor?: boolean }) { | |
| 27 | - return ( | |
| 28 | - <div className="flex gap-3"> | |
| 29 | - <Link href={`/${author}`} className="shrink-0"> | |
| 30 | - <Avatar name={author} size={32} /> | |
| 31 | - </Link> | |
| 32 | - <Surface material="raise-lite" radius="plate" className="min-w-0 flex-1"> | |
| 33 | - <div className="rule-b flex h-10 items-center gap-2 px-4"> | |
| 34 | - <Link href={`/${author}`} className="type-ui text-ink hover:underline"> | |
| 35 | - {author} | |
| 36 | - </Link> | |
| 37 | - <span className="type-label engraved" title={formatDate(createdAt)}> | |
| 38 | - commented {timeAgo(createdAt)} | |
| 39 | - </span> | |
| 40 | - {isAuthor && ( | |
| 41 | - <span className="ml-auto"> | |
| 42 | - <Label tone="accent">Author</Label> | |
| 43 | - </span> | |
| 44 | - )} | |
| 45 | - </div> | |
| 46 | - <div className="px-5 py-4"> | |
| 47 | - <Markdown>{body}</Markdown> | |
| 48 | - </div> | |
| 49 | - </Surface> | |
| 50 | - </div> | |
| 51 | - ); | |
| 52 | - } | |
| 53 | - | |
| 54 | - /** A point on the activity rail: a small sunk well on the page, holding the event's glyph or an unlit dot. */ | |
| 55 | - function TimelineDot({ children }: { children?: ReactNode }) { | |
| 56 | - return ( | |
| 57 | - <span className="well flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full -ml-[3px]"> | |
| 58 | - {children ?? <span className="h-1.5 w-1.5 rounded-full bg-ink3" />} | |
| 59 | - </span> | |
| 60 | - ); | |
| 61 | - } | |
| 62 | - | |
| 63 | 30 | const isStatus = (value: string | null): value is Status => !!value && (STATUS_IDS as string[]).includes(value); |
| 64 | 31 | |
| 65 | 32 | function StatusName({ value }: { value: string | null }) { |
| @@ -112,6 +79,8 @@ function describe(e: IssueEvent): { icon: ReactNode; text: ReactNode } { | ||
| 112 | 79 | return { icon: null, text: e.newValue ? <>set the due date to {strong(formatDay(e.newValue, true))}</> : <>removed the due date</> }; |
| 113 | 80 | case "title": |
| 114 | 81 | return { icon: null, text: <>renamed this from {strong(`“${e.oldValue}”`)}</> }; |
| 82 | + case "pull_request": | |
| 83 | + return { icon: <PullRequestIcon width={11} height={11} />, text: <>linked pull request {strong(e.newValue)}</> }; | |
| 115 | 84 | default: |
| 116 | 85 | return { icon: null, text: <>changed {e.field}</> }; |
| 117 | 86 | } |
| @@ -125,6 +94,7 @@ export default async function IssuePage({ params }: PageProps<"/[owner]/[repo]/i | ||
| 125 | 94 | const viewer = repo.viewer; |
| 126 | 95 | const action = commentAction.bind(null, repo.owner, repo.name, issue.number); |
| 127 | 96 | |
| 97 | + const pulls = await pullsLinkingIssue(repo.id, issue.number); | |
| 128 | 98 | // Comments and property changes, oldest first. |
| 129 | 99 | const activity = [ |
| 130 | 100 | ...(await listComments(issue.id)).map((c) => ({ kind: "comment" as const, at: c.createdAt, comment: c })), |
| @@ -140,13 +110,22 @@ export default async function IssuePage({ params }: PageProps<"/[owner]/[repo]/i | ||
| 140 | 110 | sidebarFooter={ |
| 141 | 111 | <div className="space-y-2"> |
| 142 | 112 | <p className="flex items-center gap-1.5"> |
| 143 | - <Avatar name={issue.author} size={16} /> | |
| 144 | - <Link href={`/${issue.author}`} className="font-medium text-ink hover:underline"> | |
| 145 | - {issue.author} | |
| 146 | - </Link> | |
| 147 | - opened {timeAgo(issue.createdAt)} | |
| 113 | + <ActorAvatar name={issue.author} agent={issue.authorAgent} size={16} /> | |
| 114 | + <ActorName person={issue.author} agent={issue.authorAgent} /> opened {timeAgo(issue.createdAt)} | |
| 148 | 115 | </p> |
| 149 | 116 | <p title={formatDate(issue.updatedAt)}>Updated {timeAgo(issue.updatedAt)}</p> |
| 117 | + {pulls.length > 0 && ( | |
| 118 | + <div className="space-y-1 pt-1"> | |
| 119 | + <p className="type-label engraved">Pull requests</p> | |
| 120 | + {pulls.map((p) => ( | |
| 121 | + <Link key={p.id} href={`${repo.base}/pulls/${p.number}`} className="flex items-center gap-1.5 text-ink hover:underline"> | |
| 122 | + <PullStateIcon state={p.state} draft={p.draft} size={13} /> | |
| 123 | + <span className="truncate">{p.title}</span> | |
| 124 | + <span className="ml-auto text-ink3">#{p.number}</span> | |
| 125 | + </Link> | |
| 126 | + ))} | |
| 127 | + </div> | |
| 128 | + )} | |
| 150 | 129 | {(repo.isAdmin || viewer?.id === issue.authorId) && ( |
| 151 | 130 | <div className="pt-1.5"> |
| 152 | 131 | <ConfirmButton |
| @@ -164,14 +143,14 @@ export default async function IssuePage({ params }: PageProps<"/[owner]/[repo]/i | ||
| 164 | 143 | <div className="relative space-y-4 before:absolute before:top-2 before:bottom-2 before:left-4 before:w-px before:bg-line"> |
| 165 | 144 | <p className="relative flex items-center gap-2 pl-2.5 type-body text-ink2"> |
| 166 | 145 | <TimelineDot /> |
| 167 | - <strong className="font-medium text-ink">{issue.author}</strong> created the issue · {timeAgo(issue.createdAt)} | |
| 146 | + <ActorName person={issue.author} agent={issue.authorAgent} /> created the issue · {timeAgo(issue.createdAt)} | |
| 168 | 147 | </p> |
| 169 | 148 | {activity.map((item) => { |
| 170 | 149 | if (item.kind === "comment") { |
| 171 | 150 | const c = item.comment; |
| 172 | 151 | return ( |
| 173 | 152 | <div key={`c${c.id}`} className="relative"> |
| 174 | - <CommentBox author={c.author} createdAt={c.createdAt} body={c.body} isAuthor={c.author === issue.author} /> | |
| 153 | + <CommentBox author={c.author} agent={c.agent} createdAt={c.createdAt} body={c.body} badge={c.author === issue.author && !c.agent ? <AuthorBadge /> : undefined} /> | |
| 175 | 154 | </div> |
| 176 | 155 | ); |
| 177 | 156 | } |
| @@ -179,7 +158,7 @@ export default async function IssuePage({ params }: PageProps<"/[owner]/[repo]/i | ||
| 179 | 158 | return ( |
| 180 | 159 | <p key={`e${item.event.id}`} className="relative flex flex-wrap items-center gap-x-1.5 gap-y-1 pl-2.5 type-body text-ink2"> |
| 181 | 160 | <TimelineDot>{icon}</TimelineDot> |
| 182 | - <strong className="font-medium text-ink">{item.event.actor}</strong> {text} · {timeAgo(item.event.createdAt)} | |
| 161 | + <ActorName person={item.event.actor} agent={item.event.agent} /> {text} · {timeAgo(item.event.createdAt)} | |
| 183 | 162 | </p> |
| 184 | 163 | ); |
| 185 | 164 | })} |
| @@ -1,9 +1,13 @@ | ||
| 1 | 1 | import Link from "next/link"; |
| 2 | + import { ForkedFrom, ForkLabel } from "@/components/forks"; | |
| 3 | + import { ForkIcon } from "@/components/icons"; | |
| 2 | 4 | import { RepoTabs } from "@/components/repo-tabs"; |
| 3 | - import { Label, RepoAvatar, VisibilityLabel } from "@/components/ui"; | |
| 5 | + import { ButtonLink, Label, RepoAvatar, VisibilityLabel } from "@/components/ui"; | |
| 4 | 6 | import { openIssueCount } from "@/lib/issues"; |
| 7 | + import { countOpenPulls } from "@/lib/pulls"; | |
| 5 | 8 | import { countImages } from "@/lib/registry"; |
| 6 | 9 | import { getRepo } from "@/lib/repo"; |
| 10 | + import { countForks, visibleParent } from "@/lib/repos"; | |
| 7 | 11 | import { wikiHead } from "@/lib/wiki"; |
| 8 | 12 | |
| 9 | 13 | export async function generateMetadata({ params }: LayoutProps<"/[owner]/[repo]">) { |
| @@ -20,11 +24,12 @@ export default async function RepoLayout({ params, children }: LayoutProps<"/[ow | ||
| 20 | 24 | const repo = await getRepo(owner, repoParam); |
| 21 | 25 | // Like GitHub, readers only see the tab once the wiki has a page. |
| 22 | 26 | const showWiki = repo.wikiEnabled && (repo.canWrite || (await wikiHead(repo)).head !== null); |
| 27 | + const [parent, forks] = await Promise.all([visibleParent(repo, repo.viewer?.id), countForks(repo.id, repo.viewer?.id ?? null)]); | |
| 23 | 28 | |
| 24 | 29 | return ( |
| 25 | 30 | <> |
| 26 | 31 | <div className="rule-b"> |
| 27 | - <div className="flex min-h-12 flex-wrap items-center gap-x-6 gap-y-1.5 px-4 py-1.5 md:px-5 xl:grid xl:grid-cols-[1fr_auto_1fr]"> | |
| 32 | + <div className="flex min-h-12 flex-wrap items-center gap-x-6 gap-y-1.5 px-4 py-1.5 md:px-5 2xl:grid 2xl:grid-cols-[1fr_auto_1fr]"> | |
| 28 | 33 | <div className="flex min-w-0 items-center gap-2"> |
| 29 | 34 | <Link href={repo.base} aria-label={`${repo.owner}/${repo.name}`} className="shrink-0 rounded-key transition-opacity hover:opacity-80 focus-visible:focus-ring"> |
| 30 | 35 | <RepoAvatar repo={repo} size={22} /> |
| @@ -39,10 +44,25 @@ export default async function RepoLayout({ params, children }: LayoutProps<"/[ow | ||
| 39 | 44 | {repo.name} |
| 40 | 45 | </Link> |
| 41 | 46 | <VisibilityLabel visibility={repo.visibility} /> |
| 47 | + {repo.forkOf && <ForkLabel />} | |
| 42 | 48 | {repo.access === "write" && <Label tone="accent">Collaborator</Label>} |
| 49 | + {repo.forkOf && <ForkedFrom parent={parent} className="ml-1 hidden lg:inline-flex" />} | |
| 50 | + </div> | |
| 51 | + <RepoTabs base={repo.base} openIssues={await openIssueCount(repo.id)} openPulls={await countOpenPulls(repo.id)} images={countImages(repo.id)} showWiki={showWiki} showSettings={repo.isAdmin} /> | |
| 52 | + <div className="ml-auto flex items-center gap-2 2xl:ml-0 2xl:justify-self-end"> | |
| 53 | + <ButtonLink href={`${repo.base}/fork`} icon={<ForkIcon />}> | |
| 54 | + Fork | |
| 55 | + </ButtonLink> | |
| 56 | + <Link href={`${repo.base}/forks`} className="rounded-key px-1 type-readout text-ink2 transition-colors hover:text-ink focus-visible:focus-ring"> | |
| 57 | + {forks} {forks === 1 ? "fork" : "forks"} | |
| 58 | + </Link> | |
| 43 | 59 | </div> |
| 44 | - <RepoTabs base={repo.base} openIssues={await openIssueCount(repo.id)} images={countImages(repo.id)} showWiki={showWiki} showSettings={repo.isAdmin} /> | |
| 45 | 60 | </div> |
| 61 | + {repo.forkOf && ( | |
| 62 | + <div className="px-4 pb-2 md:px-5 lg:hidden"> | |
| 63 | + <ForkedFrom parent={parent} /> | |
| 64 | + </div> | |
| 65 | + )} | |
| 46 | 66 | </div> |
| 47 | 67 | <div className="mx-auto w-full max-w-[1600px] flex-1 px-4 py-4 md:px-5">{children}</div> |
| 48 | 68 | </> |
| @@ -0,0 +1,11 @@ | ||
| 1 | + import { markdownViewer, renderMarkdown } from "@/lib/markdown-views"; | |
| 2 | + import { serverOrigin } from "@/lib/request-origin"; | |
| 3 | + | |
| 4 | + /** A repository for agents: what it is, its files, its agent instructions and README. */ | |
| 5 | + export async function GET(request: Request, { params }: RouteContext<"/[owner]/[repo]/llms.txt">) { | |
| 6 | + const { owner, repo } = await params; | |
| 7 | + const viewer = await markdownViewer(request); | |
| 8 | + const body = viewer === "invalid" ? null : await renderMarkdown(viewer, await serverOrigin(), [owner, repo]); | |
| 9 | + if (body === null) return new Response("Not found.\n", { status: 404, headers: { "Content-Type": "text/plain" } }); | |
| 10 | + return new Response(body, { headers: { "Content-Type": "text/markdown; charset=utf-8", "Cache-Control": "no-store" } }); | |
| 11 | + } | |
| @@ -1,26 +1,33 @@ | ||
| 1 | 1 | import Link from "next/link"; |
| 2 | 2 | import { AvatarGroup } from "@unlocalhosted/metalui"; |
| 3 | 3 | import { CloneUrlTabs, CopyButton } from "@/components/clone-button"; |
| 4 | + import { ForkStatusBar } from "@/components/fork-status"; | |
| 4 | 5 | import { |
| 5 | 6 | BranchIcon, |
| 6 | 7 | CalendarIcon, |
| 8 | + ForkIcon, | |
| 7 | 9 | HistoryIcon, |
| 8 | 10 | IssueOpenedIcon, |
| 9 | 11 | PeopleIcon, |
| 10 | 12 | PlusIcon, |
| 13 | + PullRequestIcon, | |
| 11 | 14 | SparkleIcon, |
| 12 | 15 | TagIcon, |
| 13 | 16 | UploadIcon, |
| 14 | 17 | } from "@/components/icons"; |
| 18 | + import { RecentPushes } from "@/components/pulls/recent-pushes"; | |
| 15 | 19 | import { TreeView } from "@/components/tree-view"; |
| 16 | - import { Avatar, Blankslate, Box, ButtonLink, Label } from "@/components/ui"; | |
| 20 | + import { Avatar, Blankslate, Box, ButtonLink, inputClass, Label } from "@/components/ui"; | |
| 21 | + import { countOpenPulls } from "@/lib/pulls"; | |
| 17 | 22 | import { encodePath, formatDate, timeAgo } from "@/lib/format"; |
| 18 | 23 | import { commitCount, listBranches, listContributors, listFilesWithSizes, listTags } from "@/lib/git"; |
| 19 | 24 | import { openIssueCount } from "@/lib/issues"; |
| 20 | 25 | import { languageBreakdown, type LanguageShare } from "@/lib/languages"; |
| 21 | 26 | import { countReleases, latestRelease, releaseTitle } from "@/lib/releases"; |
| 22 | 27 | import { cloneUrls, getRepo, type Repo } from "@/lib/repo"; |
| 23 | - import { listCollaborators } from "@/lib/repos"; | |
| 28 | + import { forkStatus } from "@/lib/forks"; | |
| 29 | + import { recentPushes } from "@/lib/recent-pushes"; | |
| 30 | + import { countForks, listCollaborators } from "@/lib/repos"; | |
| 24 | 31 | |
| 25 | 32 | function CommandBlock({ title, commands }: { title: string; commands: string }) { |
| 26 | 33 | return ( |
| @@ -146,16 +153,31 @@ export default async function RepoHome({ params }: PageProps<"/[owner]/[repo]">) | ||
| 146 | 153 | ref ? listFilesWithSizes(repo.dir, ref) : [], |
| 147 | 154 | ref ? listContributors(repo.dir, ref) : [], |
| 148 | 155 | ]); |
| 149 | - const [collaborators, releases, latest] = await Promise.all([listCollaborators(repo.id), countReleases(repo.id), latestRelease(repo.id)]); | |
| 156 | + const viewerId = repo.viewer?.id ?? null; | |
| 157 | + const [collaborators, releases, latest, forks, fork, recent] = await Promise.all([ | |
| 158 | + listCollaborators(repo.id), | |
| 159 | + countReleases(repo.id), | |
| 160 | + latestRelease(repo.id), | |
| 161 | + countForks(repo.id, viewerId), | |
| 162 | + repo.forkOf ? forkStatus(repo, viewerId) : null, | |
| 163 | + recentPushes(repo, viewerId, repo.canWrite, 2), | |
| 164 | + ]); | |
| 150 | 165 | const languages = languageBreakdown(files); |
| 151 | 166 | const historyHref = ref ? `${repo.base}/commits/${encodePath(ref)}` : `${repo.base}/commits`; |
| 152 | 167 | |
| 153 | 168 | return ( |
| 154 | 169 | <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_260px] xl:gap-10"> |
| 155 | 170 | <div className="min-w-0"> |
| 171 | + {fork && <ForkStatusBar fork={repo} status={fork} canWrite={repo.canWrite} />} | |
| 172 | + <RecentPushes pushes={recent} /> | |
| 156 | 173 | {ref ? <TreeView repo={repo} gitRef={ref} path="" /> : <QuickSetup repo={repo} />} |
| 157 | 174 | </div> |
| 158 | 175 | <aside className="flex flex-col gap-7"> |
| 176 | + {ref && ( | |
| 177 | + <form action={`${repo.base}/search`} role="search"> | |
| 178 | + <input name="q" aria-label="Search this repository's code" placeholder="Search code…" className={inputClass} /> | |
| 179 | + </form> | |
| 180 | + )} | |
| 159 | 181 | <section> |
| 160 | 182 | <SectionLabel>About</SectionLabel> |
| 161 | 183 | <p className={`type-body ${repo.description ? "text-ink" : "text-ink3 italic"}`}> |
| @@ -173,6 +195,8 @@ export default async function RepoHome({ params }: PageProps<"/[owner]/[repo]">) | ||
| 173 | 195 | <Stat href={`${repo.base}/branches`} icon={BranchIcon} label="Branches" value={branches.length} /> |
| 174 | 196 | <Stat href={`${repo.base}/releases`} icon={TagIcon} label="Tags" value={tags.length} /> |
| 175 | 197 | <Stat href={`${repo.base}/issues`} icon={IssueOpenedIcon} label="Open issues" value={await openIssueCount(repo.id)} /> |
| 198 | + <Stat href={`${repo.base}/pulls`} icon={PullRequestIcon} label="Open pull requests" value={await countOpenPulls(repo.id)} /> | |
| 199 | + <Stat href={`${repo.base}/forks`} icon={ForkIcon} label="Forks" value={forks} /> | |
| 176 | 200 | <Stat |
| 177 | 201 | href={repo.isAdmin ? `${repo.base}/settings/access` : `/${repo.owner}`} |
| 178 | 202 | icon={PeopleIcon} |
| @@ -0,0 +1,54 @@ | ||
| 1 | + import Link from "next/link"; | |
| 2 | + import { ActorAvatar } from "@/components/agent"; | |
| 3 | + import { RunStatusIcon } from "@/components/ci/run-status"; | |
| 4 | + import { ShaChip } from "@/components/file-table"; | |
| 5 | + import { CommitIcon } from "@/components/icons"; | |
| 6 | + import { liftRowClass } from "@/components/repo-rows"; | |
| 7 | + import { Blankslate, Box } from "@/components/ui"; | |
| 8 | + import { agentKeyFromEmail } from "@/lib/actor"; | |
| 9 | + import { timeAgo } from "@/lib/format"; | |
| 10 | + import { getPullCommits, getPullForPage } from "@/lib/pull-page"; | |
| 11 | + import { commitStatuses } from "@/lib/runs"; | |
| 12 | + | |
| 13 | + export default async function PullCommitsPage({ params }: PageProps<"/[owner]/[repo]/pulls/[number]/commits">) { | |
| 14 | + const { owner, repo: repoParam, number } = await params; | |
| 15 | + const { repo } = await getPullForPage(owner, repoParam, number); | |
| 16 | + const commits = await getPullCommits(owner, repoParam, number); | |
| 17 | + const statuses = await commitStatuses(repo.id, commits.map((c) => c.sha)); | |
| 18 | + if (commits.length === 0) { | |
| 19 | + return ( | |
| 20 | + <Box> | |
| 21 | + <Blankslate icon={<CommitIcon />} title="No commits" /> | |
| 22 | + </Box> | |
| 23 | + ); | |
| 24 | + } | |
| 25 | + return ( | |
| 26 | + <Box className="p-1.5"> | |
| 27 | + <ul> | |
| 28 | + {commits.map((c) => { | |
| 29 | + const agent = agentKeyFromEmail(c.committerEmail) ? c.committerName : null; | |
| 30 | + const ci = statuses.get(c.sha); | |
| 31 | + return ( | |
| 32 | + <li key={c.sha} className={`${liftRowClass} flex items-center gap-3.5 px-3 py-2.5`}> | |
| 33 | + <ActorAvatar name={c.authorName} agent={agent} size={28} /> | |
| 34 | + <div className="min-w-0 flex-1"> | |
| 35 | + <Link href={`${repo.base}/commit/${c.sha}`} className="block truncate type-title text-ink decoration-ink3 underline-offset-4 hover:underline"> | |
| 36 | + {c.subject} | |
| 37 | + </Link> | |
| 38 | + <div className="mt-0.5 flex items-center gap-1 type-meta text-ink3"> | |
| 39 | + <span className="font-medium text-ink2">{agent ? `${agent} for ${c.authorName}` : c.authorName}</span> committed {timeAgo(c.date)} | |
| 40 | + {ci && ( | |
| 41 | + <Link href={`${repo.base}/actions/runs/${ci.runNumber}`} className="ml-1"> | |
| 42 | + <RunStatusIcon status={ci.status} size={13} /> | |
| 43 | + </Link> | |
| 44 | + )} | |
| 45 | + </div> | |
| 46 | + </div> | |
| 47 | + <ShaChip sha={c.sha} href={`${repo.base}/commit/${c.sha}`} /> | |
| 48 | + </li> | |
| 49 | + ); | |
| 50 | + })} | |
| 51 | + </ul> | |
| 52 | + </Box> | |
| 53 | + ); | |
| 54 | + } | |
| @@ -0,0 +1,72 @@ | ||
| 1 | + import { FileIcon } from "@/components/icons"; | |
| 2 | + import { Markdown } from "@/components/markdown"; | |
| 3 | + import { lineKey } from "@/components/pulls/line-key"; | |
| 4 | + import { PullDiff } from "@/components/pulls/pull-diff"; | |
| 5 | + import { Snippet } from "@/components/pulls/snippet"; | |
| 6 | + import { ThreadView } from "@/components/pulls/thread"; | |
| 7 | + import { Blankslate, Box } from "@/components/ui"; | |
| 8 | + import { encodePath, timeAgo } from "@/lib/format"; | |
| 9 | + import { getPullFiles, getPullForPage } from "@/lib/pull-page"; | |
| 10 | + import { listPullComments, pullRange, threadsOf, type Thread } from "@/lib/pulls"; | |
| 11 | + | |
| 12 | + export default async function PullFilesPage({ params }: PageProps<"/[owner]/[repo]/pulls/[number]/files">) { | |
| 13 | + const { owner, repo: repoParam, number } = await params; | |
| 14 | + const { repo, pr } = await getPullForPage(owner, repoParam, number); | |
| 15 | + const [files, comments, range] = await Promise.all([getPullFiles(owner, repoParam, number), listPullComments(pr.id), pullRange(repo, pr)]); | |
| 16 | + const threads = threadsOf(comments, range.head); | |
| 17 | + const viewer = repo.viewer; | |
| 18 | + const canResolve = repo.canWrite || viewer?.id === pr.authorId; | |
| 19 | + | |
| 20 | + const render = (t: Thread, withCode: boolean) => ( | |
| 21 | + <ThreadView | |
| 22 | + key={t.root.id} | |
| 23 | + owner={repo.owner} | |
| 24 | + name={repo.name} | |
| 25 | + number={pr.number} | |
| 26 | + rootId={t.root.id} | |
| 27 | + resolved={t.root.resolved} | |
| 28 | + outdated={t.outdated} | |
| 29 | + code={withCode ? <Snippet code={t.root.snippet} path={t.root.path} line={t.root.line} /> : undefined} | |
| 30 | + comments={[t.root, ...t.replies].map((c) => ({ id: c.id, author: c.author, agent: c.agent, when: timeAgo(c.createdAt), body: <Markdown>{c.body}</Markdown> }))} | |
| 31 | + canReply={!!viewer} | |
| 32 | + canResolve={canResolve} | |
| 33 | + /> | |
| 34 | + ); | |
| 35 | + // Threads on the latest commit hang under their line; the rest (outdated) are listed above the diff. | |
| 36 | + const inline: Record<string, React.ReactNode> = {}; | |
| 37 | + const outdated: Thread[] = []; | |
| 38 | + for (const t of threads) { | |
| 39 | + if (t.outdated) outdated.push(t); | |
| 40 | + else inline[lineKey(t.root.side ?? "new", t.root.path!, t.root.line!)] = render(t, false); | |
| 41 | + } | |
| 42 | + | |
| 43 | + if (files.length === 0) { | |
| 44 | + return ( | |
| 45 | + <Box> | |
| 46 | + <Blankslate icon={<FileIcon />} title="No changes"> | |
| 47 | + The branches have no differences. | |
| 48 | + </Blankslate> | |
| 49 | + </Box> | |
| 50 | + ); | |
| 51 | + } | |
| 52 | + | |
| 53 | + return ( | |
| 54 | + <div> | |
| 55 | + {outdated.length > 0 && ( | |
| 56 | + <section className="mb-5 space-y-3"> | |
| 57 | + <h2 className="px-1 type-label engraved">Comments on earlier commits</h2> | |
| 58 | + {outdated.map((t) => render(t, true))} | |
| 59 | + </section> | |
| 60 | + )} | |
| 61 | + <PullDiff | |
| 62 | + owner={repo.owner} | |
| 63 | + name={repo.name} | |
| 64 | + number={pr.number} | |
| 65 | + files={files} | |
| 66 | + threads={inline} | |
| 67 | + canComment={!!viewer && pr.state === "open"} | |
| 68 | + viewBase={pr.state === "open" ? `${pr.headRepo ? `/${pr.headRepo.fullName}` : repo.base}/blob/${encodePath(pr.head)}` : `${repo.base}/blob/${range.head}`} | |
| 69 | + /> | |
| 70 | + </div> | |
| 71 | + ); | |
| 72 | + } | |
| @@ -0,0 +1,72 @@ | ||
| 1 | + import Link from "next/link"; | |
| 2 | + import { ActorName } from "@/components/agent"; | |
| 3 | + import { PullStateLabel } from "@/components/pulls/pull-icons"; | |
| 4 | + import { PullTabs } from "@/components/pulls/pull-tabs"; | |
| 5 | + import { ForkIcon } from "@/components/icons"; | |
| 6 | + import { RefChip } from "@/components/ui"; | |
| 7 | + import { encodePath, timeAgo } from "@/lib/format"; | |
| 8 | + import { getPullCommits, getPullFiles, getPullForPage } from "@/lib/pull-page"; | |
| 9 | + import { headLabel } from "@/lib/pulls"; | |
| 10 | + | |
| 11 | + export async function generateMetadata({ params }: LayoutProps<"/[owner]/[repo]/pulls/[number]">) { | |
| 12 | + const { owner, repo, number } = await params; | |
| 13 | + const { pr } = await getPullForPage(owner, repo, number); | |
| 14 | + return { title: `${pr.title} · Pull Request #${pr.number}` }; | |
| 15 | + } | |
| 16 | + | |
| 17 | + export default async function PullLayout({ params, children }: LayoutProps<"/[owner]/[repo]/pulls/[number]">) { | |
| 18 | + const { owner, repo: repoParam, number } = await params; | |
| 19 | + const { repo, pr, href } = await getPullForPage(owner, repoParam, number); | |
| 20 | + const [commits, files] = await Promise.all([getPullCommits(owner, repoParam, number), getPullFiles(owner, repoParam, number)]); | |
| 21 | + const branch = (name: string, label = name, base = repo.base) => ( | |
| 22 | + <Link href={`${base}/tree/${encodePath(name)}`} className="rounded-[7px] transition-opacity hover:opacity-80"> | |
| 23 | + <RefChip>{label}</RefChip> | |
| 24 | + </Link> | |
| 25 | + ); | |
| 26 | + // A fork's branch, labeled with its owner like on GitHub ("bob:fix-login"). | |
| 27 | + const head = ( | |
| 28 | + <> | |
| 29 | + {branch(pr.head, headLabel(pr), pr.headRepo ? `/${pr.headRepo.fullName}` : repo.base)} | |
| 30 | + {pr.headRepo && ( | |
| 31 | + <span className="inline-flex items-center gap-1 type-meta text-ink3"> | |
| 32 | + <ForkIcon width={12} height={12} /> | |
| 33 | + {pr.headRepo.id ? ( | |
| 34 | + <Link href={`/${pr.headRepo.fullName}`} className="hover:text-ink hover:underline"> | |
| 35 | + {pr.headRepo.fullName} | |
| 36 | + </Link> | |
| 37 | + ) : ( | |
| 38 | + `${pr.headRepo.fullName} (deleted)` | |
| 39 | + )} | |
| 40 | + </span> | |
| 41 | + )} | |
| 42 | + </> | |
| 43 | + ); | |
| 44 | + | |
| 45 | + return ( | |
| 46 | + <div className="mx-auto max-w-6xl"> | |
| 47 | + <div className="mb-5"> | |
| 48 | + <h1 className="text-[22px] leading-8 font-semibold tracking-[-0.025em] text-ink"> | |
| 49 | + {pr.title} <span className="font-normal text-ink3">#{pr.number}</span> | |
| 50 | + </h1> | |
| 51 | + <div className="mt-2 flex flex-wrap items-center gap-x-1.5 gap-y-1.5 type-ui text-ink2"> | |
| 52 | + <PullStateLabel state={pr.state} draft={pr.draft} /> | |
| 53 | + {pr.state === "merged" ? ( | |
| 54 | + <> | |
| 55 | + <ActorName person={pr.mergedBy ?? pr.author} agent={pr.mergedByAgent} /> merged {commits.length} commit{commits.length === 1 ? "" : "s"} into{" "} | |
| 56 | + {branch(pr.base)} from {head} {pr.mergedAt && timeAgo(pr.mergedAt)} | |
| 57 | + </> | |
| 58 | + ) : ( | |
| 59 | + <> | |
| 60 | + <ActorName person={pr.author} agent={pr.agent?.name} /> {pr.state === "open" ? "wants to merge" : "proposed"} {commits.length} commit | |
| 61 | + {commits.length === 1 ? "" : "s"} into {branch(pr.base)} from {head} | |
| 62 | + </> | |
| 63 | + )} | |
| 64 | + </div> | |
| 65 | + </div> | |
| 66 | + <div className="mb-5"> | |
| 67 | + <PullTabs href={href} commits={commits.length} files={files.length} comments={pr.commentCount} /> | |
| 68 | + </div> | |
| 69 | + {children} | |
| 70 | + </div> | |
| 71 | + ); | |
| 72 | + } | |
| @@ -0,0 +1,328 @@ | ||
| 1 | + import Link from "next/link"; | |
| 2 | + import type { ReactNode } from "react"; | |
| 3 | + import { Surface } from "@unlocalhosted/metalui"; | |
| 4 | + import { approveWorkflowsAction, editPullAction, mergePullAction, respondPullAction, setPullStateAction } from "@/app/actions/pulls"; | |
| 5 | + import { ActionButton } from "@/components/action-button"; | |
| 6 | + import { RunStatusIcon, statusText } from "@/components/ci/run-status"; | |
| 7 | + import { ConfirmButton } from "@/components/confirm-button"; | |
| 8 | + import { AuthorBadge, CommentBox, Timeline, TimelineEvent } from "@/components/conversation"; | |
| 9 | + import { ArrowRightIcon, BranchIcon, CheckIcon, CommitIcon, IssueOpenedIcon, MergeIcon, PencilIcon, PlayIcon, PullRequestIcon, XIcon } from "@/components/icons"; | |
| 10 | + import { StatusIcon } from "@/components/issues/issue-icons"; | |
| 11 | + import { Markdown } from "@/components/markdown"; | |
| 12 | + import { EditPullForm } from "@/components/pulls/edit-pull-form"; | |
| 13 | + import { MergeForm } from "@/components/pulls/merge-form"; | |
| 14 | + import { PullStateIcon } from "@/components/pulls/pull-icons"; | |
| 15 | + import { RespondForm } from "@/components/pulls/respond-form"; | |
| 16 | + import { ThreadView } from "@/components/pulls/thread"; | |
| 17 | + import { Button, ButtonLink, Label, RefChip } from "@/components/ui"; | |
| 18 | + import { Snippet } from "@/components/pulls/snippet"; | |
| 19 | + import { person } from "@/lib/actor"; | |
| 20 | + import { shortSha, timeAgo } from "@/lib/format"; | |
| 21 | + import { getIssue } from "@/lib/issues"; | |
| 22 | + import { getPullForPage } from "@/lib/pull-page"; | |
| 23 | + import { listBranches } from "@/lib/git"; | |
| 24 | + import { | |
| 25 | + listPullComments, | |
| 26 | + listPullEvents, | |
| 27 | + listReviews, | |
| 28 | + mayDeleteHead, | |
| 29 | + pullStatus, | |
| 30 | + threadsOf, | |
| 31 | + type PullComment, | |
| 32 | + type PullEvent, | |
| 33 | + type Review, | |
| 34 | + type Thread, | |
| 35 | + } from "@/lib/pulls"; | |
| 36 | + import type { Requirement } from "@/lib/branch-rules"; | |
| 37 | + | |
| 38 | + const reviewVerb: Record<Review["state"], string> = { approved: "approved these changes", changes_requested: "requested changes", commented: "reviewed" }; | |
| 39 | + | |
| 40 | + function describeEvent(e: PullEvent): { icon: ReactNode; text: ReactNode } { | |
| 41 | + const d = e.data as Record<string, string | number | null>; | |
| 42 | + const sha = (s: unknown) => <RefChip>{shortSha(String(s ?? ""))}</RefChip>; | |
| 43 | + switch (e.kind) { | |
| 44 | + case "pushed": | |
| 45 | + return { icon: <CommitIcon width={11} height={11} />, text: <>pushed {d.commits ?? "new"} commit{d.commits === 1 ? "" : "s"} {sha(d.after)}</> }; | |
| 46 | + case "force_pushed": | |
| 47 | + return { icon: <CommitIcon width={11} height={11} />, text: <>force-pushed the branch from {sha(d.before)} to {sha(d.after)}</> }; | |
| 48 | + case "merged": | |
| 49 | + return { icon: <MergeIcon width={11} height={11} className="text-[#8250df]" />, text: <>merged commit {sha(d.sha)}{d.method === "squash" ? " (squashed)" : d.method === "push" ? " by pushing it" : ""}</> }; | |
| 50 | + case "closed": | |
| 51 | + return { icon: <XIcon width={11} height={11} className="text-red" />, text: d.reason === "head_repo_deleted" ? <>closed this: its fork was deleted</> : <>closed this</> }; | |
| 52 | + case "reopened": | |
| 53 | + return { icon: <PullRequestIcon width={11} height={11} className="text-green-deep" />, text: <>reopened this</> }; | |
| 54 | + case "title": | |
| 55 | + return { icon: <PencilIcon width={11} height={11} />, text: <>renamed this from <strong className="font-medium text-ink">“{d.from}”</strong></> }; | |
| 56 | + case "base": | |
| 57 | + return { icon: <BranchIcon width={11} height={11} />, text: <>changed the base branch from <RefChip>{d.from}</RefChip> to <RefChip>{d.to}</RefChip></> }; | |
| 58 | + case "ready": | |
| 59 | + return { icon: null, text: <>marked this ready for review</> }; | |
| 60 | + case "draft": | |
| 61 | + return { icon: null, text: <>marked this as a draft</> }; | |
| 62 | + case "head_deleted": | |
| 63 | + return { icon: <BranchIcon width={11} height={11} />, text: <>deleted the branch <RefChip>{d.branch}</RefChip></> }; | |
| 64 | + default: | |
| 65 | + return { icon: null, text: <>{e.kind}</> }; | |
| 66 | + } | |
| 67 | + } | |
| 68 | + | |
| 69 | + function RequirementRow({ r }: { r: Requirement }) { | |
| 70 | + return ( | |
| 71 | + <li className="flex items-start gap-2.5 py-1"> | |
| 72 | + {r.ok ? <CheckIcon width={15} height={15} className="mt-0.5 text-green-deep" /> : <XIcon width={15} height={15} className="mt-0.5 text-red" />} | |
| 73 | + <span className="min-w-0"> | |
| 74 | + <span className="type-ui text-ink">{r.label}</span> | |
| 75 | + {r.detail && <span className="block type-meta text-ink3">{r.detail}</span>} | |
| 76 | + </span> | |
| 77 | + </li> | |
| 78 | + ); | |
| 79 | + } | |
| 80 | + | |
| 81 | + function SideSection({ title, children }: { title: string; children: ReactNode }) { | |
| 82 | + return ( | |
| 83 | + <section className="rule-b pb-4"> | |
| 84 | + <h3 className="mb-2 type-label engraved">{title}</h3> | |
| 85 | + {children} | |
| 86 | + </section> | |
| 87 | + ); | |
| 88 | + } | |
| 89 | + | |
| 90 | + export default async function PullConversationPage({ params }: PageProps<"/[owner]/[repo]/pulls/[number]">) { | |
| 91 | + const { owner, repo: repoParam, number } = await params; | |
| 92 | + const { repo, pr, href } = await getPullForPage(owner, repoParam, number); | |
| 93 | + const viewer = repo.viewer; | |
| 94 | + const [status, reviews, comments, events] = await Promise.all([ | |
| 95 | + pullStatus(repo, pr, viewer ? person(viewer.id) : null), | |
| 96 | + listReviews(pr.id), | |
| 97 | + listPullComments(pr.id), | |
| 98 | + listPullEvents(pr.id), | |
| 99 | + ]); | |
| 100 | + const linked = (await Promise.all(pr.linkedIssues.map((n) => getIssue(repo.id, n)))).filter((i) => i !== null); | |
| 101 | + const threads = threadsOf(comments, status.headSha); | |
| 102 | + const threadsByReview = new Map<string, Thread[]>(); | |
| 103 | + for (const t of threads) if (t.root.reviewId) threadsByReview.set(t.root.reviewId, [...(threadsByReview.get(t.root.reviewId) ?? []), t]); | |
| 104 | + const canResolve = repo.canWrite || viewer?.id === pr.authorId; | |
| 105 | + // Its author may change it too, even without push access (a pull request from their fork). | |
| 106 | + const canEdit = canResolve && !!viewer && pr.state !== "merged"; | |
| 107 | + const [baseBranches, canDeleteBranch] = await Promise.all([ | |
| 108 | + canEdit && pr.state === "open" ? listBranches(repo.dir).then((all) => all.map((b) => b.name).filter((b) => pr.headRepo || b !== pr.head)) : [], | |
| 109 | + viewer && status.headExists ? mayDeleteHead(repo, pr, person(viewer.id)) : false, | |
| 110 | + ]); | |
| 111 | + | |
| 112 | + const threadView = (t: Thread) => ( | |
| 113 | + <ThreadView | |
| 114 | + key={t.root.id} | |
| 115 | + owner={repo.owner} | |
| 116 | + name={repo.name} | |
| 117 | + number={pr.number} | |
| 118 | + rootId={t.root.id} | |
| 119 | + resolved={t.root.resolved} | |
| 120 | + outdated={t.outdated} | |
| 121 | + code={<Snippet code={t.root.snippet} path={t.root.path} line={t.root.line} />} | |
| 122 | + comments={[t.root, ...t.replies].map((c: PullComment) => ({ id: c.id, author: c.author, agent: c.agent, when: timeAgo(c.createdAt), body: <Markdown>{c.body}</Markdown> }))} | |
| 123 | + canReply={!!viewer} | |
| 124 | + canResolve={canResolve} | |
| 125 | + /> | |
| 126 | + ); | |
| 127 | + | |
| 128 | + type Item = { at: number; key: string; node: ReactNode }; | |
| 129 | + const items: Item[] = [ | |
| 130 | + ...comments | |
| 131 | + .filter((c) => !c.path) | |
| 132 | + .map((c) => ({ | |
| 133 | + at: c.createdAt, | |
| 134 | + key: `c${c.id}`, | |
| 135 | + node: <CommentBox author={c.author} agent={c.agent} createdAt={c.createdAt} body={c.body} badge={c.authorId === pr.authorId && !c.agent === !pr.agent ? <AuthorBadge /> : undefined} />, | |
| 136 | + })), | |
| 137 | + ...reviews.map((r) => ({ | |
| 138 | + at: r.createdAt, | |
| 139 | + key: `r${r.id}`, | |
| 140 | + node: ( | |
| 141 | + <CommentBox | |
| 142 | + author={r.author} | |
| 143 | + agent={r.agent} | |
| 144 | + createdAt={r.createdAt} | |
| 145 | + body={r.body} | |
| 146 | + verb={reviewVerb[r.state]} | |
| 147 | + badge={r.state === "approved" ? <Label tone="success">Approved</Label> : r.state === "changes_requested" ? <Label tone="danger">Changes requested</Label> : undefined} | |
| 148 | + > | |
| 149 | + {(threadsByReview.get(r.id) ?? []).length > 0 ? (threadsByReview.get(r.id) ?? []).map(threadView) : r.body.trim() ? null : <p className="type-body text-ink3">Reviewed {shortSha(r.commitSha)}.</p>} | |
| 150 | + </CommentBox> | |
| 151 | + ), | |
| 152 | + })), | |
| 153 | + ...events.map((e) => { | |
| 154 | + const { icon, text } = describeEvent(e); | |
| 155 | + return { | |
| 156 | + at: e.createdAt, | |
| 157 | + key: `e${e.id}`, | |
| 158 | + node: ( | |
| 159 | + <TimelineEvent icon={icon} actor={e.actor} agent={e.agent} at={e.createdAt}> | |
| 160 | + {text} | |
| 161 | + </TimelineEvent> | |
| 162 | + ), | |
| 163 | + }; | |
| 164 | + }), | |
| 165 | + ].sort((a, b) => a.at - b.at); | |
| 166 | + | |
| 167 | + const ownWork = viewer?.id === pr.authorId && !pr.agent; | |
| 168 | + | |
| 169 | + return ( | |
| 170 | + <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_260px]"> | |
| 171 | + <div className="min-w-0"> | |
| 172 | + <Timeline> | |
| 173 | + <div className="relative"> | |
| 174 | + <CommentBox author={pr.author} agent={pr.agent?.name} createdAt={pr.createdAt} body={pr.body} verb="opened this" badge={ | |
| 175 | + canEdit ? ( | |
| 176 | + <EditPullForm action={editPullAction.bind(null, repo.owner, repo.name, pr.number)} title={pr.title} body={pr.body} base={pr.base} branches={baseBranches} /> | |
| 177 | + ) : undefined | |
| 178 | + } | |
| 179 | + /> | |
| 180 | + </div> | |
| 181 | + {items.map((item) => ( | |
| 182 | + <div key={item.key} className="relative"> | |
| 183 | + {item.node} | |
| 184 | + </div> | |
| 185 | + ))} | |
| 186 | + | |
| 187 | + {pr.state === "open" && ( | |
| 188 | + <div className="relative flex gap-3"> | |
| 189 | + <span className="flex size-8 shrink-0 items-center justify-center"> | |
| 190 | + <PullStateIcon state={pr.state} draft={pr.draft} size={22} /> | |
| 191 | + </span> | |
| 192 | + <Surface material="raise-lite" radius="plate" className="min-w-0 flex-1 p-5"> | |
| 193 | + <h3 className="mb-2 type-title text-ink">{status.mergeable ? "Ready to merge" : "Not ready to merge yet"}</h3> | |
| 194 | + <ul className="mb-4">{status.requirements.map((r) => <RequirementRow key={r.key} r={r} />)}</ul> | |
| 195 | + {repo.canWrite ? ( | |
| 196 | + <MergeForm | |
| 197 | + action={mergePullAction.bind(null, repo.owner, repo.name, pr.number)} | |
| 198 | + headSha={status.headSha} | |
| 199 | + mergeable={status.mergeable} | |
| 200 | + canDeleteBranch={canDeleteBranch} | |
| 201 | + /> | |
| 202 | + ) : ( | |
| 203 | + <p className="type-body text-ink3">Only collaborators can merge.</p> | |
| 204 | + )} | |
| 205 | + </Surface> | |
| 206 | + </div> | |
| 207 | + )} | |
| 208 | + | |
| 209 | + <div className="relative flex gap-3 pt-2"> | |
| 210 | + {viewer ? ( | |
| 211 | + <> | |
| 212 | + <span className="w-8 shrink-0" /> | |
| 213 | + <Surface material="raise-lite" radius="plate" className="min-w-0 flex-1 p-3"> | |
| 214 | + <RespondForm action={respondPullAction.bind(null, repo.owner, repo.name, pr.number)} canReview={repo.canWrite && !ownWork && pr.state === "open"} /> | |
| 215 | + </Surface> | |
| 216 | + </> | |
| 217 | + ) : ( | |
| 218 | + <Surface material="raise-lite" radius="plate" className="flex w-full flex-wrap items-center justify-between gap-3 p-5"> | |
| 219 | + <span className="type-body text-ink2">Sign in to join the conversation.</span> | |
| 220 | + <ButtonLink href={`/login?next=${encodeURIComponent(href)}`} variant="primary" icon={<ArrowRightIcon />}> | |
| 221 | + Sign in to comment | |
| 222 | + </ButtonLink> | |
| 223 | + </Surface> | |
| 224 | + )} | |
| 225 | + </div> | |
| 226 | + </Timeline> | |
| 227 | + </div> | |
| 228 | + | |
| 229 | + <aside className="flex flex-col gap-4 type-ui"> | |
| 230 | + <SideSection title="Reviews"> | |
| 231 | + {status.approvals.length === 0 && status.changesRequestedBy.length === 0 ? ( | |
| 232 | + <p className="type-meta text-ink3">No approvals of the latest commit yet.</p> | |
| 233 | + ) : ( | |
| 234 | + <ul className="space-y-1.5"> | |
| 235 | + {status.approvals.map((u) => ( | |
| 236 | + <li key={`a${u}`} className="flex items-center gap-2"> | |
| 237 | + <CheckIcon width={14} height={14} className="text-green-deep" /> {u} | |
| 238 | + </li> | |
| 239 | + ))} | |
| 240 | + {status.changesRequestedBy.map((u) => ( | |
| 241 | + <li key={`c${u}`} className="flex items-center gap-2"> | |
| 242 | + <XIcon width={14} height={14} className="text-red" /> {u} requested changes | |
| 243 | + </li> | |
| 244 | + ))} | |
| 245 | + </ul> | |
| 246 | + )} | |
| 247 | + </SideSection> | |
| 248 | + <SideSection title="Checks"> | |
| 249 | + {status.awaitingApproval ? ( | |
| 250 | + <div className="flex flex-col gap-2"> | |
| 251 | + <p className="type-meta text-ink3"> | |
| 252 | + Workflows of pull requests from forks only start by themselves when their author can push here, since they run on this repository's runners. | |
| 253 | + {repo.canWrite ? " Look through the changes, then start them." : " A collaborator starts them."} | |
| 254 | + </p> | |
| 255 | + {repo.canWrite && ( | |
| 256 | + <ActionButton action={approveWorkflowsAction.bind(null, repo.owner, repo.name, pr.number)} icon={<PlayIcon />}> | |
| 257 | + Run Workflows | |
| 258 | + </ActionButton> | |
| 259 | + )} | |
| 260 | + </div> | |
| 261 | + ) : status.runs.length === 0 ? ( | |
| 262 | + <p className="type-meta text-ink3">{status.checks === "pending" ? "Waiting for workflows to start." : "No workflows ran for the latest commit."}</p> | |
| 263 | + ) : ( | |
| 264 | + <ul className="space-y-1.5"> | |
| 265 | + {status.runs.map((r) => ( | |
| 266 | + <li key={r.id}> | |
| 267 | + <Link href={`${repo.base}/actions/runs/${r.number}`} className="flex items-center gap-2 hover:underline"> | |
| 268 | + <RunStatusIcon status={r.status} size={14} /> | |
| 269 | + <span className="truncate">{r.workflowName}</span> | |
| 270 | + <span className="ml-auto shrink-0 type-meta text-ink3">{r.event === "pull_request" ? "pull request" : r.event}</span> | |
| 271 | + </Link> | |
| 272 | + <span className="sr-only">{statusText(r.status)}</span> | |
| 273 | + </li> | |
| 274 | + ))} | |
| 275 | + </ul> | |
| 276 | + )} | |
| 277 | + </SideSection> | |
| 278 | + <SideSection title="Linked issues"> | |
| 279 | + {linked.length === 0 ? ( | |
| 280 | + <p className="type-meta text-ink3">“Fixes #12” in the description links an issue.</p> | |
| 281 | + ) : ( | |
| 282 | + <ul className="space-y-1.5"> | |
| 283 | + {linked.map((i) => ( | |
| 284 | + <li key={i.id}> | |
| 285 | + <Link href={`${repo.base}/issues/${i.number}`} className="flex items-center gap-2 hover:underline"> | |
| 286 | + <StatusIcon status={i.status} size={14} /> | |
| 287 | + <span className="truncate">{i.title}</span> | |
| 288 | + <span className="ml-auto type-readout text-ink3">#{i.number}</span> | |
| 289 | + </Link> | |
| 290 | + </li> | |
| 291 | + ))} | |
| 292 | + </ul> | |
| 293 | + )} | |
| 294 | + </SideSection> | |
| 295 | + {canEdit && ( | |
| 296 | + <div className="flex flex-wrap gap-2"> | |
| 297 | + {pr.state === "open" && pr.draft && ( | |
| 298 | + <form action={setPullStateAction.bind(null, repo.owner, repo.name, pr.number, { draft: false })}> | |
| 299 | + <Button icon={<IssueOpenedIcon />}>Ready for Review</Button> | |
| 300 | + </form> | |
| 301 | + )} | |
| 302 | + {pr.state === "open" && !pr.draft && ( | |
| 303 | + <form action={setPullStateAction.bind(null, repo.owner, repo.name, pr.number, { draft: true })}> | |
| 304 | + <Button variant="invisible">Convert to Draft</Button> | |
| 305 | + </form> | |
| 306 | + )} | |
| 307 | + {pr.state === "open" ? ( | |
| 308 | + <ConfirmButton | |
| 309 | + action={setPullStateAction.bind(null, repo.owner, repo.name, pr.number, { state: "closed" })} | |
| 310 | + message={`Close #${pr.number} without merging? It can be reopened later.`} | |
| 311 | + variant="default" | |
| 312 | + > | |
| 313 | + Close Pull Request | |
| 314 | + </ConfirmButton> | |
| 315 | + ) : ( | |
| 316 | + <form action={setPullStateAction.bind(null, repo.owner, repo.name, pr.number, { state: "open" })}> | |
| 317 | + <Button icon={<PullRequestIcon />}>Reopen</Button> | |
| 318 | + </form> | |
| 319 | + )} | |
| 320 | + </div> | |
| 321 | + )} | |
| 322 | + <p className="type-meta text-ink3"> | |
| 323 | + Latest commit <RefChip>{shortSha(status.headSha)}</RefChip> | |
| 324 | + </p> | |
| 325 | + </aside> | |
| 326 | + </div> | |
| 327 | + ); | |
| 328 | + } | |
| @@ -0,0 +1,134 @@ | ||
| 1 | + import Link from "next/link"; | |
| 2 | + import type { Metadata } from "next"; | |
| 3 | + import { ActorName } from "@/components/agent"; | |
| 4 | + import { RunStatusIcon } from "@/components/ci/run-status"; | |
| 5 | + import { ChevronLeftIcon, ChevronRightIcon, CommentIcon, PlusIcon, PullRequestIcon } from "@/components/icons"; | |
| 6 | + import { PullStateIcon } from "@/components/pulls/pull-icons"; | |
| 7 | + import { RecentPushes } from "@/components/pulls/recent-pushes"; | |
| 8 | + import { liftRowClass } from "@/components/repo-rows"; | |
| 9 | + import { Blankslate, Box, ButtonLink, Label, PageTitle, RefChip, Segmented, segmentClass } from "@/components/ui"; | |
| 10 | + import { encodePath, timeAgo } from "@/lib/format"; | |
| 11 | + import { listBranches } from "@/lib/git"; | |
| 12 | + import { countOpenPulls, headLabel, listPulls, type PullState } from "@/lib/pulls"; | |
| 13 | + import { recentPushes } from "@/lib/recent-pushes"; | |
| 14 | + import { getRepo } from "@/lib/repo"; | |
| 15 | + import { commitStatuses } from "@/lib/runs"; | |
| 16 | + | |
| 17 | + export const metadata: Metadata = { title: "Pull requests" }; | |
| 18 | + | |
| 19 | + const PAGE_SIZE = 25; | |
| 20 | + const STATES = ["open", "merged", "closed", "all"] as const; | |
| 21 | + | |
| 22 | + export default async function PullsPage({ params, searchParams }: PageProps<"/[owner]/[repo]/pulls">) { | |
| 23 | + const { owner, repo: repoParam } = await params; | |
| 24 | + const query = await searchParams; | |
| 25 | + const repo = await getRepo(owner, repoParam); | |
| 26 | + const state = (STATES as readonly string[]).includes(String(query.state)) ? (query.state as PullState | "all") : "open"; | |
| 27 | + const page = Math.max(1, Number(query.page) || 1); | |
| 28 | + | |
| 29 | + const [{ pulls, total }, open, branches] = await Promise.all([ | |
| 30 | + listPulls(repo.id, { state, page, perPage: PAGE_SIZE }), | |
| 31 | + countOpenPulls(repo.id), | |
| 32 | + listBranches(repo.dir), | |
| 33 | + ]); | |
| 34 | + const statuses = await commitStatuses(repo.id, pulls.map((p) => p.headSha)); | |
| 35 | + const recent = await recentPushes(repo, repo.viewer?.id ?? null, repo.canWrite); | |
| 36 | + const href = (s: string, p = 1) => `${repo.base}/pulls?state=${s}${p > 1 ? `&page=${p}` : ""}`; | |
| 37 | + | |
| 38 | + return ( | |
| 39 | + <div className="mx-auto max-w-6xl"> | |
| 40 | + <PageTitle | |
| 41 | + title="Pull requests" | |
| 42 | + description="Changes proposed from one branch to another, reviewed and merged here. Agents work this way too." | |
| 43 | + actions={ | |
| 44 | + repo.viewer && | |
| 45 | + branches.length > 0 && ( | |
| 46 | + <ButtonLink href={`${repo.base}/compare`} variant="primary" icon={<PlusIcon />}> | |
| 47 | + New Pull Request | |
| 48 | + </ButtonLink> | |
| 49 | + ) | |
| 50 | + } | |
| 51 | + /> | |
| 52 | + | |
| 53 | + <RecentPushes pushes={recent} /> | |
| 54 | + | |
| 55 | + <div className="mb-3 flex flex-wrap items-center gap-3"> | |
| 56 | + <Segmented> | |
| 57 | + {STATES.map((s) => ( | |
| 58 | + <Link key={s} href={href(s)} className={segmentClass(state === s)} aria-current={state === s ? "page" : undefined}> | |
| 59 | + {s === "open" ? `Open · ${open}` : s[0].toUpperCase() + s.slice(1)} | |
| 60 | + </Link> | |
| 61 | + ))} | |
| 62 | + </Segmented> | |
| 63 | + </div> | |
| 64 | + | |
| 65 | + <Box className="p-1.5"> | |
| 66 | + {pulls.length === 0 ? ( | |
| 67 | + <Blankslate icon={<PullRequestIcon />} title={state === "open" ? "No open pull requests" : "No pull requests here"}> | |
| 68 | + {repo.canWrite | |
| 69 | + ? "Push a branch and open a pull request to propose its changes. Agents open them from their agent/ branches." | |
| 70 | + : "Nothing has been proposed yet. Fork the repository to propose changes of your own."} | |
| 71 | + </Blankslate> | |
| 72 | + ) : ( | |
| 73 | + <ul> | |
| 74 | + {pulls.map((pr) => { | |
| 75 | + const ci = statuses.get(pr.headSha); | |
| 76 | + return ( | |
| 77 | + <li key={pr.id} className={`${liftRowClass} flex items-start gap-3 px-3 py-3`}> | |
| 78 | + <PullStateIcon state={pr.state} draft={pr.draft} className="mt-0.5" /> | |
| 79 | + <div className="min-w-0 flex-1"> | |
| 80 | + <div className="flex flex-wrap items-center gap-2"> | |
| 81 | + <Link href={`${repo.base}/pulls/${pr.number}`} className="type-title text-ink decoration-ink3 underline-offset-4 hover:underline"> | |
| 82 | + {pr.title} | |
| 83 | + </Link> | |
| 84 | + {pr.draft && pr.state === "open" && <Label>Draft</Label>} | |
| 85 | + {ci && ( | |
| 86 | + <Link href={`${repo.base}/actions/runs/${ci.runNumber}`} className="transition-opacity hover:opacity-80"> | |
| 87 | + <RunStatusIcon status={ci.status} size={13} /> | |
| 88 | + </Link> | |
| 89 | + )} | |
| 90 | + </div> | |
| 91 | + <div className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 type-meta text-ink3"> | |
| 92 | + <span className="type-readout">#{pr.number}</span> | |
| 93 | + <span> | |
| 94 | + {pr.state === "merged" ? "merged" : pr.state === "closed" ? "closed" : "opened"}{" "} | |
| 95 | + {timeAgo(pr.state === "merged" ? pr.mergedAt! : pr.state === "closed" ? (pr.closedAt ?? pr.updatedAt) : pr.createdAt)} by | |
| 96 | + </span> | |
| 97 | + <ActorName person={pr.author} agent={pr.agent?.name} className="text-ink2" /> | |
| 98 | + <span className="ml-1 inline-flex items-center gap-1"> | |
| 99 | + <Link href={`${pr.headRepo ? `/${pr.headRepo.fullName}` : repo.base}/tree/${encodePath(pr.head)}`}> | |
| 100 | + <RefChip>{headLabel(pr)}</RefChip> | |
| 101 | + </Link> | |
| 102 | + →<RefChip>{pr.base}</RefChip> | |
| 103 | + </span> | |
| 104 | + </div> | |
| 105 | + </div> | |
| 106 | + {pr.commentCount > 0 && ( | |
| 107 | + <span className="flex shrink-0 items-center gap-1 type-readout text-ink3"> | |
| 108 | + <CommentIcon width={13} height={13} /> {pr.commentCount} | |
| 109 | + </span> | |
| 110 | + )} | |
| 111 | + </li> | |
| 112 | + ); | |
| 113 | + })} | |
| 114 | + </ul> | |
| 115 | + )} | |
| 116 | + </Box> | |
| 117 | + | |
| 118 | + {(page > 1 || page * PAGE_SIZE < total) && ( | |
| 119 | + <div className="mt-6 flex justify-center gap-2"> | |
| 120 | + {page > 1 && ( | |
| 121 | + <ButtonLink href={href(state, page - 1)} icon={<ChevronLeftIcon />}> | |
| 122 | + Newer | |
| 123 | + </ButtonLink> | |
| 124 | + )} | |
| 125 | + {page * PAGE_SIZE < total && ( | |
| 126 | + <ButtonLink href={href(state, page + 1)}> | |
| 127 | + Older <ChevronRightIcon /> | |
| 128 | + </ButtonLink> | |
| 129 | + )} | |
| 130 | + </div> | |
| 131 | + )} | |
| 132 | + </div> | |
| 133 | + ); | |
| 134 | + } | |
| @@ -0,0 +1,77 @@ | ||
| 1 | + import Link from "next/link"; | |
| 2 | + import type { Metadata } from "next"; | |
| 3 | + import { SearchIcon } from "@/components/icons"; | |
| 4 | + import { Blankslate, Box, Button, inputClass, PageTitle, RefChip } from "@/components/ui"; | |
| 5 | + import { encodePath } from "@/lib/format"; | |
| 6 | + import { grep, revParse } from "@/lib/git"; | |
| 7 | + import { getRepo } from "@/lib/repo"; | |
| 8 | + | |
| 9 | + export const metadata: Metadata = { title: "Search code" }; | |
| 10 | + | |
| 11 | + const LIMIT = 300; | |
| 12 | + | |
| 13 | + export default async function SearchPage({ params, searchParams }: PageProps<"/[owner]/[repo]/search">) { | |
| 14 | + const { owner, repo: repoParam } = await params; | |
| 15 | + const query = await searchParams; | |
| 16 | + const repo = await getRepo(owner, repoParam); | |
| 17 | + const q = typeof query.q === "string" ? query.q.trim().slice(0, 500) : ""; | |
| 18 | + const ref = typeof query.ref === "string" && query.ref ? query.ref : repo.defaultBranch; | |
| 19 | + const sha = ref ? await revParse(repo.dir, ref) : null; | |
| 20 | + const result = q && sha ? await grep(repo.dir, sha, q, { limit: LIMIT }).catch(() => null) : null; | |
| 21 | + const byFile = new Map<string, { line: number; text: string }[]>(); | |
| 22 | + for (const m of result?.matches ?? []) byFile.set(m.path, [...(byFile.get(m.path) ?? []), m]); | |
| 23 | + | |
| 24 | + return ( | |
| 25 | + <div className="mx-auto max-w-6xl"> | |
| 26 | + <PageTitle title="Search code" description={ref ? <>In the files of <RefChip>{ref}</RefChip>. Agents search the same way with search_code.</> : undefined} /> | |
| 27 | + <form className="mb-6 flex gap-2" role="search"> | |
| 28 | + {ref && ref !== repo.defaultBranch && <input type="hidden" name="ref" value={ref} />} | |
| 29 | + <input name="q" defaultValue={q} autoFocus aria-label="Text to find" placeholder="Text to find" className={inputClass} /> | |
| 30 | + <Button variant="primary" icon={<SearchIcon />}> | |
| 31 | + Search | |
| 32 | + </Button> | |
| 33 | + </form> | |
| 34 | + {q && !result && ( | |
| 35 | + <Box> | |
| 36 | + <Blankslate icon={<SearchIcon />} title={sha ? "The search failed" : "Nothing to search"} /> | |
| 37 | + </Box> | |
| 38 | + )} | |
| 39 | + {result && result.matches.length === 0 && ( | |
| 40 | + <Box> | |
| 41 | + <Blankslate icon={<SearchIcon />} title="No matches"> | |
| 42 | + Nothing in {ref} contains “{q}”. | |
| 43 | + </Blankslate> | |
| 44 | + </Box> | |
| 45 | + )} | |
| 46 | + {result && result.matches.length > 0 && ( | |
| 47 | + <div className="flex flex-col gap-4"> | |
| 48 | + <p className="px-1 type-ui text-ink2"> | |
| 49 | + {result.matches.length} | |
| 50 | + {result.truncated ? "+" : ""} matching line{result.matches.length === 1 ? "" : "s"} in {byFile.size} file{byFile.size === 1 ? "" : "s"} | |
| 51 | + </p> | |
| 52 | + {[...byFile].map(([path, lines]) => ( | |
| 53 | + <Box key={path} className="overflow-hidden"> | |
| 54 | + <Link href={`${repo.base}/blob/${encodePath(ref!)}/${encodePath(path)}`} className="rule-b block px-4 py-2.5 font-mono text-[12px] font-medium text-ink hover:underline"> | |
| 55 | + {path} | |
| 56 | + </Link> | |
| 57 | + <table className="w-full font-mono text-[11.5px] leading-[22px]"> | |
| 58 | + <tbody> | |
| 59 | + {lines.map((l) => ( | |
| 60 | + <tr key={l.line}> | |
| 61 | + <td className="w-14 px-3 text-right text-ink3 select-none"> | |
| 62 | + <Link href={`${repo.base}/blob/${encodePath(ref!)}/${encodePath(path)}#L${l.line}`} className="hover:text-ink"> | |
| 63 | + {l.line} | |
| 64 | + </Link> | |
| 65 | + </td> | |
| 66 | + <td className="px-2 whitespace-pre-wrap break-all text-ink">{l.text}</td> | |
| 67 | + </tr> | |
| 68 | + ))} | |
| 69 | + </tbody> | |
| 70 | + </table> | |
| 71 | + </Box> | |
| 72 | + ))} | |
| 73 | + </div> | |
| 74 | + )} | |
| 75 | + </div> | |
| 76 | + ); | |
| 77 | + } | |
| @@ -0,0 +1,99 @@ | ||
| 1 | + "use client"; | |
| 2 | + | |
| 3 | + import { useActionState, useEffect, useRef } from "react"; | |
| 4 | + import { Checkbox } from "@unlocalhosted/metalui"; | |
| 5 | + import type { FormState } from "@/app/actions/types"; | |
| 6 | + import { ChoiceCards, TextField } from "@/components/settings-fields"; | |
| 7 | + import { PlateBody, PlateFooter } from "@/components/settings-plate"; | |
| 8 | + import { Alert, Button } from "@/components/ui"; | |
| 9 | + import type { AgentPush, BranchRule } from "@/lib/branch-rules"; | |
| 10 | + | |
| 11 | + type Action = (prev: FormState, formData: FormData) => Promise<FormState>; | |
| 12 | + | |
| 13 | + function Option({ name, title, description, defaultChecked }: { name: string; title: string; description: string; defaultChecked: boolean }) { | |
| 14 | + return ( | |
| 15 | + <label className="flex cursor-pointer items-start gap-3"> | |
| 16 | + <Checkbox name={name} defaultChecked={defaultChecked} className="mt-px" /> | |
| 17 | + <span className="flex flex-col gap-0.5"> | |
| 18 | + <span className="type-title text-ink">{title}</span> | |
| 19 | + <span className="type-body text-ink2">{description}</span> | |
| 20 | + </span> | |
| 21 | + </label> | |
| 22 | + ); | |
| 23 | + } | |
| 24 | + | |
| 25 | + /** A branch rule's settings: a new one, or an existing one to change. */ | |
| 26 | + export function RuleForm({ action, rule }: { action: Action; rule?: BranchRule }) { | |
| 27 | + const [state, formAction, pending] = useActionState(action, undefined); | |
| 28 | + const form = useRef<HTMLFormElement>(null); | |
| 29 | + useEffect(() => { | |
| 30 | + if (state?.success && !rule) form.current?.reset(); | |
| 31 | + }, [state, rule]); | |
| 32 | + return ( | |
| 33 | + <form ref={form} action={formAction}> | |
| 34 | + <PlateBody> | |
| 35 | + <TextField | |
| 36 | + label="Branch name or pattern" | |
| 37 | + name="pattern" | |
| 38 | + required | |
| 39 | + maxLength={255} | |
| 40 | + mono | |
| 41 | + defaultValue={rule?.pattern ?? ""} | |
| 42 | + placeholder="main" | |
| 43 | + description="A branch's name, or a pattern: release/* matches release/1.0, ** matches across slashes." | |
| 44 | + /> | |
| 45 | + <Option | |
| 46 | + name="requirePr" | |
| 47 | + title="Require a pull request" | |
| 48 | + description="Nobody pushes to it directly, not even the owner. Changes arrive by merging pull requests." | |
| 49 | + defaultChecked={rule?.requirePr ?? true} | |
| 50 | + /> | |
| 51 | + <div className="flex max-w-xs flex-col gap-1.5"> | |
| 52 | + <TextField | |
| 53 | + label="Required approvals" | |
| 54 | + name="requiredApprovals" | |
| 55 | + type="number" | |
| 56 | + min={0} | |
| 57 | + max={10} | |
| 58 | + defaultValue={String(rule?.requiredApprovals ?? 1)} | |
| 59 | + description="Approving reviews of the latest commit, from people with push access. Agents can't approve." | |
| 60 | + /> | |
| 61 | + </div> | |
| 62 | + <Option name="requireCi" title="Require passing checks" description="The latest commit's workflow runs must all succeed before merging." defaultChecked={rule?.requireCi ?? true} /> | |
| 63 | + <Option name="blockForcePush" title="Block force pushes" description="Nobody can rewrite its history." defaultChecked={rule?.blockForcePush ?? true} /> | |
| 64 | + <Option name="blockDeletion" title="Block deletion" description="The branch can't be deleted." defaultChecked={rule?.blockDeletion ?? true} /> | |
| 65 | + <Option | |
| 66 | + name="agentsMayMerge" | |
| 67 | + title="Agents may merge" | |
| 68 | + description="Agents can merge pull requests into it once the rules above are met. Otherwise a person merges." | |
| 69 | + defaultChecked={rule?.agentsMayMerge ?? false} | |
| 70 | + /> | |
| 71 | + {state?.error && <Alert>{state.error}</Alert>} | |
| 72 | + {state?.success && <Alert kind="success">{state.success}</Alert>} | |
| 73 | + </PlateBody> | |
| 74 | + <PlateFooter> | |
| 75 | + <Button variant="primary" disabled={pending}> | |
| 76 | + {pending ? "Saving…" : rule ? "Save Rule" : "Add Rule"} | |
| 77 | + </Button> | |
| 78 | + </PlateFooter> | |
| 79 | + </form> | |
| 80 | + ); | |
| 81 | + } | |
| 82 | + | |
| 83 | + export function AgentPushForm({ action, value, choices }: { action: Action; value: AgentPush; choices: { value: AgentPush; title: string; description: string }[] }) { | |
| 84 | + const [state, formAction, pending] = useActionState(action, undefined); | |
| 85 | + return ( | |
| 86 | + <form action={formAction}> | |
| 87 | + <PlateBody> | |
| 88 | + <ChoiceCards name="agentPush" choices={choices} defaultValue={value} columns={1} aria-label="Where agents may push" /> | |
| 89 | + {state?.error && <Alert>{state.error}</Alert>} | |
| 90 | + {state?.success && <Alert kind="success">{state.success}</Alert>} | |
| 91 | + </PlateBody> | |
| 92 | + <PlateFooter> | |
| 93 | + <Button variant="primary" disabled={pending}> | |
| 94 | + {pending ? "Saving…" : "Save"} | |
| 95 | + </Button> | |
| 96 | + </PlateFooter> | |
| 97 | + </form> | |
| 98 | + ); | |
| 99 | + } | |
| @@ -0,0 +1,49 @@ | ||
| 1 | + import type { Metadata } from "next"; | |
| 2 | + import { deleteBranchRuleAction, saveBranchRuleAction, setAgentPushAction } from "@/app/actions/protection"; | |
| 3 | + import { ConfirmButton } from "@/components/confirm-button"; | |
| 4 | + import { PlateSection } from "@/components/settings-plate"; | |
| 5 | + import { PageTitle } from "@/components/ui"; | |
| 6 | + import { AGENT_PUSH_CHOICES } from "@/lib/branch-rules"; | |
| 7 | + import { listBranchRules } from "@/lib/protection"; | |
| 8 | + import { getRepo } from "@/lib/repo"; | |
| 9 | + import { AgentPushForm, RuleForm } from "./forms"; | |
| 10 | + | |
| 11 | + export const metadata: Metadata = { title: "Branches" }; | |
| 12 | + | |
| 13 | + export default async function BranchSettingsPage({ params }: PageProps<"/[owner]/[repo]/settings/branches">) { | |
| 14 | + const { owner, repo: repoParam } = await params; | |
| 15 | + const repo = await getRepo(owner, repoParam); | |
| 16 | + const rules = await listBranchRules(repo.id); | |
| 17 | + | |
| 18 | + return ( | |
| 19 | + <div> | |
| 20 | + <PageTitle | |
| 21 | + title="Branches" | |
| 22 | + description="Who may change which branches, and what a pull request needs before it's merged. The rules hold for git pushes, the web editor and agents alike." | |
| 23 | + /> | |
| 24 | + <div className="flex flex-col gap-7"> | |
| 25 | + <PlateSection title="Where agents may push"> | |
| 26 | + <AgentPushForm action={setAgentPushAction.bind(null, repo.owner, repo.name)} value={repo.agentPush} choices={AGENT_PUSH_CHOICES} /> | |
| 27 | + </PlateSection> | |
| 28 | + {rules.map((rule) => ( | |
| 29 | + <PlateSection key={rule.id} title={`Rule · ${rule.pattern}`}> | |
| 30 | + <RuleForm action={saveBranchRuleAction.bind(null, repo.owner, repo.name, rule.id)} rule={rule} /> | |
| 31 | + {/* Its own form: forms can't nest. */} | |
| 32 | + <div className="rule-t flex px-6 py-3.5"> | |
| 33 | + <ConfirmButton | |
| 34 | + action={deleteBranchRuleAction.bind(null, repo.owner, repo.name, rule.id)} | |
| 35 | + message={`Delete the rule for ${rule.pattern}? Matching branches lose its protection right away.`} | |
| 36 | + variant="default" | |
| 37 | + > | |
| 38 | + Delete Rule | |
| 39 | + </ConfirmButton> | |
| 40 | + </div> | |
| 41 | + </PlateSection> | |
| 42 | + ))} | |
| 43 | + <PlateSection title={rules.length ? "Add a rule" : "Protect a branch"}> | |
| 44 | + <RuleForm action={saveBranchRuleAction.bind(null, repo.owner, repo.name, null)} /> | |
| 45 | + </PlateSection> | |
| 46 | + </div> | |
| 47 | + </div> | |
| 48 | + ); | |
| 49 | + } | |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { forbidden } from "next/navigation"; |
| 2 | 2 | import { SettingsNav } from "@/components/settings-nav"; |
| 3 | - import { GearIcon, PeopleIcon, ServerIcon, TagIcon } from "@/components/icons"; | |
| 3 | + import { BranchIcon, GearIcon, PeopleIcon, ServerIcon, TagIcon, WebhookIcon } from "@/components/icons"; | |
| 4 | 4 | import { getRepo } from "@/lib/repo"; |
| 5 | 5 | |
| 6 | 6 | export default async function RepoSettingsLayout({ params, children }: LayoutProps<"/[owner]/[repo]/settings">) { |
| @@ -14,8 +14,10 @@ export default async function RepoSettingsLayout({ params, children }: LayoutPro | ||
| 14 | 14 | items={[ |
| 15 | 15 | { href: `${repo.base}/settings`, label: "General", icon: <GearIcon /> }, |
| 16 | 16 | { href: `${repo.base}/settings/access`, label: "Collaborators", icon: <PeopleIcon /> }, |
| 17 | + { href: `${repo.base}/settings/branches`, label: "Branches", icon: <BranchIcon /> }, | |
| 17 | 18 | { href: `${repo.base}/settings/labels`, label: "Labels", icon: <TagIcon /> }, |
| 18 | 19 | { href: `${repo.base}/settings/runners`, label: "Runners", icon: <ServerIcon /> }, |
| 20 | + { href: `${repo.base}/settings/webhooks`, label: "Webhooks", icon: <WebhookIcon /> }, | |
| 19 | 21 | ]} |
| 20 | 22 | /> |
| 21 | 23 | <div className="min-w-0">{children}</div> |
| @@ -0,0 +1,89 @@ | ||
| 1 | + import { notFound } from "next/navigation"; | |
| 2 | + import type { Metadata } from "next"; | |
| 3 | + import { deleteWebhookAction, pingWebhookAction, updateWebhookAction } from "@/app/actions/webhooks"; | |
| 4 | + import { ConfirmButton } from "@/components/confirm-button"; | |
| 5 | + import { SendAwayIcon } from "@/components/icons"; | |
| 6 | + import { PlateSection } from "@/components/settings-plate"; | |
| 7 | + import { Blankslate, Box, Button, Label, PageTitle } from "@/components/ui"; | |
| 8 | + import { EVENT_DESCRIPTIONS, EVENT_KINDS } from "@/lib/events"; | |
| 9 | + import { formatDate, timeAgo } from "@/lib/format"; | |
| 10 | + import { getRepo } from "@/lib/repo"; | |
| 11 | + import { getWebhook, listDeliveries } from "@/lib/webhooks"; | |
| 12 | + import { WebhookForm } from "../forms"; | |
| 13 | + | |
| 14 | + export const metadata: Metadata = { title: "Webhook" }; | |
| 15 | + | |
| 16 | + function pretty(text: string) { | |
| 17 | + try { | |
| 18 | + return JSON.stringify(JSON.parse(text), null, 2); | |
| 19 | + } catch { | |
| 20 | + return text; | |
| 21 | + } | |
| 22 | + } | |
| 23 | + | |
| 24 | + export default async function WebhookPage({ params }: PageProps<"/[owner]/[repo]/settings/webhooks/[id]">) { | |
| 25 | + const { owner, repo: repoParam, id } = await params; | |
| 26 | + const repo = await getRepo(owner, repoParam); | |
| 27 | + const hook = await getWebhook(repo.id, id); | |
| 28 | + if (!hook) notFound(); | |
| 29 | + const deliveries = await listDeliveries(hook.id, 30); | |
| 30 | + const kinds = EVENT_KINDS.map((kind) => ({ kind, description: EVENT_DESCRIPTIONS[kind] })); | |
| 31 | + | |
| 32 | + return ( | |
| 33 | + <div> | |
| 34 | + <PageTitle | |
| 35 | + title="Webhook" | |
| 36 | + description={<span className="font-mono text-[12.5px]">{hook.url}</span>} | |
| 37 | + actions={ | |
| 38 | + <div className="flex gap-2"> | |
| 39 | + <form action={pingWebhookAction.bind(null, repo.owner, repo.name, hook.id)}> | |
| 40 | + <Button icon={<SendAwayIcon />}>Send a Ping</Button> | |
| 41 | + </form> | |
| 42 | + <ConfirmButton action={deleteWebhookAction.bind(null, repo.owner, repo.name, hook.id)} message="Delete this webhook? Deliveries stop right away."> | |
| 43 | + Delete | |
| 44 | + </ConfirmButton> | |
| 45 | + </div> | |
| 46 | + } | |
| 47 | + /> | |
| 48 | + <div className="flex flex-col gap-7"> | |
| 49 | + <PlateSection title="Settings"> | |
| 50 | + <WebhookForm action={updateWebhookAction.bind(null, repo.owner, repo.name, hook.id)} kinds={kinds} hook={hook} /> | |
| 51 | + </PlateSection> | |
| 52 | + <section className="flex flex-col gap-2"> | |
| 53 | + <h2 className="px-1.5 type-label engraved">Recent deliveries</h2> | |
| 54 | + {deliveries.length === 0 ? ( | |
| 55 | + <Box> | |
| 56 | + <Blankslate title="No deliveries yet">Events show up here as they are sent, with the request and the answer.</Blankslate> | |
| 57 | + </Box> | |
| 58 | + ) : ( | |
| 59 | + deliveries.map((d) => ( | |
| 60 | + <Box key={d.id}> | |
| 61 | + <details> | |
| 62 | + <summary className="flex cursor-pointer list-none flex-wrap items-center gap-3 px-5 py-3 type-ui"> | |
| 63 | + {d.status && d.status >= 200 && d.status < 300 ? <Label tone="success">{d.status}</Label> : <Label tone="danger">{d.status || "failed"}</Label>} | |
| 64 | + <code className="font-mono text-[12px] text-ink">{d.event}</code> | |
| 65 | + {d.attempt > 1 && <span className="type-meta text-ink3">retry {d.attempt - 1}</span>} | |
| 66 | + <span className="truncate type-meta text-ink3">{d.error}</span> | |
| 67 | + <span className="ml-auto type-meta text-ink3" title={formatDate(d.createdAt)}> | |
| 68 | + {d.durationMs} ms · {timeAgo(d.createdAt)} | |
| 69 | + </span> | |
| 70 | + </summary> | |
| 71 | + <div className="rule-t grid gap-4 px-5 py-4 lg:grid-cols-2"> | |
| 72 | + <div className="min-w-0"> | |
| 73 | + <h3 className="mb-1.5 type-label engraved">Request · {d.delivery}</h3> | |
| 74 | + <pre className="well max-h-96 overflow-auto rounded-plate px-3 py-2 font-mono text-[11px] leading-5 text-ink">{pretty(d.request)}</pre> | |
| 75 | + </div> | |
| 76 | + <div className="min-w-0"> | |
| 77 | + <h3 className="mb-1.5 type-label engraved">Response</h3> | |
| 78 | + <pre className="well max-h-96 overflow-auto rounded-plate px-3 py-2 font-mono text-[11px] leading-5 text-ink">{d.response || d.error || "(empty)"}</pre> | |
| 79 | + </div> | |
| 80 | + </div> | |
| 81 | + </details> | |
| 82 | + </Box> | |
| 83 | + )) | |
| 84 | + )} | |
| 85 | + </section> | |
| 86 | + </div> | |
| 87 | + </div> | |
| 88 | + ); | |
| 89 | + } | |
| @@ -0,0 +1,82 @@ | ||
| 1 | + "use client"; | |
| 2 | + | |
| 3 | + import { useActionState, useState } from "react"; | |
| 4 | + import { Checkbox } from "@unlocalhosted/metalui"; | |
| 5 | + import type { FormState } from "@/app/actions/types"; | |
| 6 | + import { ChoiceCards, TextField } from "@/components/settings-fields"; | |
| 7 | + import { PlateBody, PlateFooter } from "@/components/settings-plate"; | |
| 8 | + import { Alert, Button } from "@/components/ui"; | |
| 9 | + | |
| 10 | + type Action = (prev: FormState, formData: FormData) => Promise<FormState>; | |
| 11 | + | |
| 12 | + /** A webhook's address, secret and events: for a new hook, or to change one. */ | |
| 13 | + export function WebhookForm({ | |
| 14 | + action, | |
| 15 | + kinds, | |
| 16 | + hook, | |
| 17 | + }: { | |
| 18 | + action: Action; | |
| 19 | + kinds: { kind: string; description: string }[]; | |
| 20 | + hook?: { url: string; events: string[]; active: boolean; hasSecret: boolean }; | |
| 21 | + }) { | |
| 22 | + const [state, formAction, pending] = useActionState(action, undefined); | |
| 23 | + const [which, setWhich] = useState<"all" | "some">(!hook || hook.events.includes("*") ? "all" : "some"); | |
| 24 | + return ( | |
| 25 | + <form action={formAction}> | |
| 26 | + <PlateBody> | |
| 27 | + <TextField label="Payload URL" name="url" type="url" required defaultValue={hook?.url ?? ""} placeholder="https://example.com/hooks/vectrum" /> | |
| 28 | + <TextField | |
| 29 | + label="Secret" | |
| 30 | + name="secret" | |
| 31 | + type="password" | |
| 32 | + optional | |
| 33 | + autoComplete="off" | |
| 34 | + placeholder={hook?.hasSecret ? "•••••••• (unchanged)" : ""} | |
| 35 | + description="Signs each delivery: X-Vectrum-Signature-256 (and X-Hub-Signature-256) is sha256=HMAC of the body with this secret." | |
| 36 | + /> | |
| 37 | + {hook?.hasSecret && ( | |
| 38 | + <label className="flex cursor-pointer items-center gap-2 type-ui text-ink2"> | |
| 39 | + <Checkbox name="clearSecret" size="row" /> Remove the secret | |
| 40 | + </label> | |
| 41 | + )} | |
| 42 | + <div className="flex flex-col gap-2"> | |
| 43 | + <span className="type-ui text-ink">Events</span> | |
| 44 | + <ChoiceCards | |
| 45 | + name="which" | |
| 46 | + value={which} | |
| 47 | + onValueChange={setWhich} | |
| 48 | + choices={[ | |
| 49 | + { value: "all", title: "Everything", description: "Every event, including kinds added later." }, | |
| 50 | + { value: "some", title: "Let me choose", description: "Only the events ticked below." }, | |
| 51 | + ]} | |
| 52 | + /> | |
| 53 | + {which === "some" && ( | |
| 54 | + <div className="grid gap-2 pt-1 sm:grid-cols-2"> | |
| 55 | + {kinds.map((k) => ( | |
| 56 | + <label key={k.kind} className="flex cursor-pointer items-start gap-2.5"> | |
| 57 | + <Checkbox name="events" value={k.kind} size="row" defaultChecked={hook?.events.includes(k.kind)} className="mt-0.5" /> | |
| 58 | + <span className="flex flex-col"> | |
| 59 | + <code className="font-mono text-[12px] text-ink">{k.kind}</code> | |
| 60 | + <span className="type-meta text-ink3">{k.description}</span> | |
| 61 | + </span> | |
| 62 | + </label> | |
| 63 | + ))} | |
| 64 | + </div> | |
| 65 | + )} | |
| 66 | + </div> | |
| 67 | + {hook && ( | |
| 68 | + <label className="flex cursor-pointer items-center gap-2 type-ui text-ink"> | |
| 69 | + <Checkbox name="active" size="row" defaultChecked={hook.active} /> Active: deliver events | |
| 70 | + </label> | |
| 71 | + )} | |
| 72 | + {state?.error && <Alert>{state.error}</Alert>} | |
| 73 | + {state?.success && <Alert kind="success">{state.success}</Alert>} | |
| 74 | + </PlateBody> | |
| 75 | + <PlateFooter> | |
| 76 | + <Button variant="primary" disabled={pending}> | |
| 77 | + {pending ? "Saving…" : hook ? "Save Webhook" : "Add Webhook"} | |
| 78 | + </Button> | |
| 79 | + </PlateFooter> | |
| 80 | + </form> | |
| 81 | + ); | |
| 82 | + } | |
| @@ -0,0 +1,73 @@ | ||
| 1 | + import Link from "next/link"; | |
| 2 | + import type { Metadata } from "next"; | |
| 3 | + import { createWebhookAction } from "@/app/actions/webhooks"; | |
| 4 | + import { WebhookIcon } from "@/components/icons"; | |
| 5 | + import { SettingsSection } from "@/components/metal"; | |
| 6 | + import { PlateSection } from "@/components/settings-plate"; | |
| 7 | + import { ListRow, RowGlyph } from "@/components/settings-plate"; | |
| 8 | + import { Blankslate, ButtonLink, Label, PageTitle } from "@/components/ui"; | |
| 9 | + import { EVENT_DESCRIPTIONS, EVENT_KINDS } from "@/lib/events"; | |
| 10 | + import { timeAgo } from "@/lib/format"; | |
| 11 | + import { getRepo } from "@/lib/repo"; | |
| 12 | + import { listWebhooks } from "@/lib/webhooks"; | |
| 13 | + import { WebhookForm } from "./forms"; | |
| 14 | + | |
| 15 | + export const metadata: Metadata = { title: "Webhooks" }; | |
| 16 | + | |
| 17 | + export default async function WebhooksPage({ params }: PageProps<"/[owner]/[repo]/settings/webhooks">) { | |
| 18 | + const { owner, repo: repoParam } = await params; | |
| 19 | + const repo = await getRepo(owner, repoParam); | |
| 20 | + const hooks = await listWebhooks(repo.id); | |
| 21 | + const kinds = EVENT_KINDS.map((kind) => ({ kind, description: EVENT_DESCRIPTIONS[kind] })); | |
| 22 | + | |
| 23 | + return ( | |
| 24 | + <div> | |
| 25 | + <PageTitle | |
| 26 | + title="Webhooks" | |
| 27 | + description="Send this repository's events to other services as they happen: pushes, issues, pull requests, reviews, finished workflow runs." | |
| 28 | + /> | |
| 29 | + <div className="flex flex-col gap-7"> | |
| 30 | + {hooks.length === 0 ? ( | |
| 31 | + <PlateSection title="Webhooks"> | |
| 32 | + <Blankslate icon={<WebhookIcon />} title="No webhooks yet"> | |
| 33 | + Add one below. Each delivery is a JSON POST, signed with the hook's secret. | |
| 34 | + </Blankslate> | |
| 35 | + </PlateSection> | |
| 36 | + ) : ( | |
| 37 | + <SettingsSection title={`Webhooks · ${hooks.length}`}> | |
| 38 | + {hooks.map((h) => ( | |
| 39 | + <ListRow | |
| 40 | + key={h.id} | |
| 41 | + lead={ | |
| 42 | + <RowGlyph> | |
| 43 | + <WebhookIcon /> | |
| 44 | + </RowGlyph> | |
| 45 | + } | |
| 46 | + title={ | |
| 47 | + <> | |
| 48 | + <Link href={`${repo.base}/settings/webhooks/${h.id}`} className="truncate font-mono text-[12.5px] hover:underline"> | |
| 49 | + {h.url} | |
| 50 | + </Link> | |
| 51 | + {!h.active && <Label>Inactive</Label>} | |
| 52 | + {h.lastStatus !== null && | |
| 53 | + (h.lastStatus >= 200 && h.lastStatus < 300 ? <Label tone="success">{h.lastStatus}</Label> : <Label tone="danger">{h.lastStatus || "failed"}</Label>)} | |
| 54 | + </> | |
| 55 | + } | |
| 56 | + meta={ | |
| 57 | + <span className="type-meta text-ink3"> | |
| 58 | + {h.events.includes("*") ? "All events" : h.events.join(", ")} · {h.lastDeliveryAt ? `last delivery ${timeAgo(h.lastDeliveryAt)}` : "no deliveries yet"} | |
| 59 | + </span> | |
| 60 | + } | |
| 61 | + > | |
| 62 | + <ButtonLink href={`${repo.base}/settings/webhooks/${h.id}`}>Edit</ButtonLink> | |
| 63 | + </ListRow> | |
| 64 | + ))} | |
| 65 | + </SettingsSection> | |
| 66 | + )} | |
| 67 | + <PlateSection title="Add a webhook"> | |
| 68 | + <WebhookForm action={createWebhookAction.bind(null, repo.owner, repo.name)} kinds={kinds} /> | |
| 69 | + </PlateSection> | |
| 70 | + </div> | |
| 71 | + </div> | |
| 72 | + ); | |
| 73 | + } | |
| @@ -22,7 +22,7 @@ export default async function ProfilePage({ params, searchParams }: PageProps<"/ | ||
| 22 | 22 | if (!user) notFound(); |
| 23 | 23 | const q = String((await searchParams).q ?? "").trim().toLowerCase(); |
| 24 | 24 | const viewer = await getViewer(); |
| 25 | - const all = await withActivity(await listReposByOwner(user.id, viewer?.id ?? null)); | |
| 25 | + const all = await withActivity(await listReposByOwner(user.id, viewer?.id ?? null), viewer?.id); | |
| 26 | 26 | const repos = q ? all.filter((r) => r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)) : all; |
| 27 | 27 | const isSelf = viewer?.id === user.id; |
| 28 | 28 | const publicCount = all.filter((r) => r.visibility === "public").length; |
| @@ -0,0 +1,47 @@ | ||
| 1 | + "use server"; | |
| 2 | + | |
| 3 | + import { revalidatePath } from "next/cache"; | |
| 4 | + import { redirect } from "next/navigation"; | |
| 5 | + import { person } from "@/lib/actor"; | |
| 6 | + import { filter, findFirst } from "@/lib/db"; | |
| 7 | + import { getEvent } from "@/lib/events"; | |
| 8 | + import { isRevertable } from "@/lib/event-text"; | |
| 9 | + import { GitError } from "@/lib/git"; | |
| 10 | + import { RevertError, revertCommit, revertPush } from "@/lib/revert"; | |
| 11 | + import { findRepoById } from "@/lib/repos"; | |
| 12 | + import { ActionError, repoForAction, viewerOrThrow } from "./guard"; | |
| 13 | + | |
| 14 | + /** Undoes a push one of the viewer's agents made, from the activity page. */ | |
| 15 | + export async function revertPushAction(eventId: string) { | |
| 16 | + const viewer = await viewerOrThrow(); | |
| 17 | + // Only pushes your own agents made are yours to undo here. | |
| 18 | + const own = await findFirst("events", filter("id = {:id} && actor = {:actor}", { id: eventId, actor: viewer.id }), { fields: "id" }); | |
| 19 | + const event = own ? await getEvent(eventId) : null; | |
| 20 | + if (!event || !isRevertable(event)) throw new ActionError("That push can't be undone."); | |
| 21 | + const record = await findRepoById(event.repoId); | |
| 22 | + if (!record) throw new ActionError("The repository was deleted."); | |
| 23 | + const { repo } = await repoForAction(record.owner, record.name, "write"); | |
| 24 | + let result: Awaited<ReturnType<typeof revertPush>>; | |
| 25 | + try { | |
| 26 | + result = await revertPush(repo, person(viewer.id), event); | |
| 27 | + } catch (e) { | |
| 28 | + if (e instanceof RevertError || e instanceof GitError) throw new ActionError(e.message); | |
| 29 | + throw e; | |
| 30 | + } | |
| 31 | + revalidatePath("/settings/activity"); | |
| 32 | + redirect(result.pull ? `/${repo.owner}/${repo.name}/pulls/${result.pull}` : `/${repo.owner}/${repo.name}/commit/${result.commit}`); | |
| 33 | + } | |
| 34 | + | |
| 35 | + /** Reverts one commit, from the commit page or a commit list: a new commit on the branch that has it (or a pull request, if that branch is protected). */ | |
| 36 | + export async function revertCommitAction(owner: string, name: string, sha: string) { | |
| 37 | + const { repo, viewer } = await repoForAction(owner, name, "write"); | |
| 38 | + let result: Awaited<ReturnType<typeof revertCommit>>; | |
| 39 | + try { | |
| 40 | + result = await revertCommit(repo, person(viewer.id), sha); | |
| 41 | + } catch (e) { | |
| 42 | + if (e instanceof RevertError || e instanceof GitError) throw new ActionError(e.message); | |
| 43 | + throw e; | |
| 44 | + } | |
| 45 | + revalidatePath(`/${repo.owner}/${repo.name}`, "layout"); | |
| 46 | + redirect(result.pull ? `/${repo.owner}/${repo.name}/pulls/${result.pull}` : `/${repo.owner}/${repo.name}/commit/${result.commit}`); | |
| 47 | + } | |
| @@ -0,0 +1,40 @@ | ||
| 1 | + "use server"; | |
| 2 | + | |
| 3 | + import { revalidatePath } from "next/cache"; | |
| 4 | + import { person } from "@/lib/actor"; | |
| 5 | + import { createBranch, removeBranch } from "@/lib/branches"; | |
| 6 | + import { GitError } from "@/lib/git"; | |
| 7 | + import { ProtectionError } from "@/lib/protection"; | |
| 8 | + import { ActionError, repoForAction } from "./guard"; | |
| 9 | + import type { FormState } from "./types"; | |
| 10 | + | |
| 11 | + // Branches on their own, from the branches page. Both need push access, and the branch rules apply. | |
| 12 | + | |
| 13 | + const isUserError = (e: unknown) => e instanceof ActionError || e instanceof GitError || e instanceof ProtectionError; | |
| 14 | + | |
| 15 | + export async function createBranchAction(owner: string, name: string, _prev: FormState, formData: FormData): Promise<FormState> { | |
| 16 | + try { | |
| 17 | + const { repo, viewer } = await repoForAction(owner, name, "write"); | |
| 18 | + const branch = String(formData.get("name") ?? "").trim(); | |
| 19 | + if (!branch) throw new ActionError("Name the branch."); | |
| 20 | + await createBranch(repo, person(viewer.id), branch, String(formData.get("from") ?? "").trim() || undefined); | |
| 21 | + revalidatePath(`/${repo.owner}/${repo.name}`, "layout"); | |
| 22 | + return { success: `Created ${branch}.` }; | |
| 23 | + } catch (e) { | |
| 24 | + if (isUserError(e)) return { error: (e as Error).message }; | |
| 25 | + throw e; | |
| 26 | + } | |
| 27 | + } | |
| 28 | + | |
| 29 | + export async function deleteBranchAction(owner: string, name: string, branch: string, sha: string): Promise<FormState> { | |
| 30 | + try { | |
| 31 | + const { repo, viewer } = await repoForAction(owner, name, "write"); | |
| 32 | + const { open } = await removeBranch(repo, person(viewer.id), branch, sha); | |
| 33 | + revalidatePath(`/${repo.owner}/${repo.name}`, "layout"); | |
| 34 | + const note = open.length ? ` Pull requests from it can't be merged until it's pushed again.` : ""; | |
| 35 | + return { success: `Deleted ${branch}.${note}` }; | |
| 36 | + } catch (e) { | |
| 37 | + if (isUserError(e)) return { error: (e as Error).message }; | |
| 38 | + throw e; | |
| 39 | + } | |
| 40 | + } | |
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | import { revalidatePath } from "next/cache"; |
| 4 | 4 | import { redirect } from "next/navigation"; |
| 5 | + import { person } from "@/lib/actor"; | |
| 5 | 6 | import { CiError, dispatchWorkflow, rerunWorkflow } from "@/lib/ci"; |
| 6 | 7 | import { createRunner, deleteRunner, RunnerError, type RunnerScope } from "@/lib/runners"; |
| 7 | 8 | import { cancelRun, getRun, isFinished } from "@/lib/runs"; |
| @@ -45,7 +46,7 @@ export async function dispatchWorkflowAction(owner: string, name: string, workfl | ||
| 45 | 46 | let destination: string; |
| 46 | 47 | try { |
| 47 | 48 | const { repo, viewer } = await repoForAction(owner, name, "write"); |
| 48 | - const number = await dispatchWorkflow(repo, workflowPath, String(formData.get("branch") ?? ""), viewer.id); | |
| 49 | + const number = await dispatchWorkflow(repo, workflowPath, String(formData.get("branch") ?? ""), person(viewer.id)); | |
| 49 | 50 | destination = `/${repo.owner}/${repo.name}/actions/runs/${number}`; |
| 50 | 51 | } catch (e) { |
| 51 | 52 | if (e instanceof CiError || e instanceof ActionError) return { error: e.message }; |
| @@ -69,6 +70,6 @@ export async function cancelRunAction(owner: string, name: string, number: numbe | ||
| 69 | 70 | |
| 70 | 71 | export async function rerunAction(owner: string, name: string, number: number) { |
| 71 | 72 | const { repo, viewer, run } = await runForAction(owner, name, number); |
| 72 | - const next = await rerunWorkflow(repo, run, viewer.id); | |
| 73 | + const next = await rerunWorkflow(repo, run, person(viewer.id)); | |
| 73 | 74 | redirect(`/${repo.owner}/${repo.name}/actions/runs/${next}`); |
| 74 | 75 | } |
| @@ -1,10 +1,12 @@ | ||
| 1 | 1 | "use server"; |
| 2 | 2 | |
| 3 | 3 | import { redirect } from "next/navigation"; |
| 4 | - import { onRefsUpdated, startWorkflowsInBackground } from "@/lib/ci"; | |
| 4 | + import { person } from "@/lib/actor"; | |
| 5 | 5 | import { git, GitError, objectType, revParse } from "@/lib/git"; |
| 6 | 6 | import { cleanRepoPath, commitFiles, isValidBranchName, type FileChange } from "@/lib/git-write"; |
| 7 | 7 | import { encodePath } from "@/lib/format"; |
| 8 | + import { assertCanCommit, ProtectionError } from "@/lib/protection"; | |
| 9 | + import { afterRefsUpdatedInBackground } from "@/lib/push"; | |
| 8 | 10 | import { markPushed, type RepoRecord } from "@/lib/repos"; |
| 9 | 11 | import type { User } from "@/lib/users"; |
| 10 | 12 | import { ActionError, repoForAction } from "./guard"; |
| @@ -59,8 +61,10 @@ async function commitFromForm( | ||
| 59 | 61 | if (formData.get("target") === "branch") { |
| 60 | 62 | target = str(formData, "newBranch"); |
| 61 | 63 | if (!(await isValidBranchName(repo.dir, target))) throw new ActionError(`${target || "That"} is not a valid branch name.`); |
| 64 | + await assertCanCommit(repo, person(viewer.id), target, null); | |
| 62 | 65 | commit = await commitFiles(repo.dir, { branch: target, parent: head, expectedHead: null, changes, message, author }); |
| 63 | 66 | } else { |
| 67 | + await assertCanCommit(repo, person(viewer.id), branch, head); | |
| 64 | 68 | commit = await commitFiles(repo.dir, { branch, parent: head, changes, message, author }); |
| 65 | 69 | } |
| 66 | 70 | await markPushed(repo.id); |
| @@ -68,16 +72,25 @@ async function commitFromForm( | ||
| 68 | 72 | const ref = `refs/heads/${target}`; |
| 69 | 73 | const before = new Map<string, string>(); |
| 70 | 74 | if (head && target === branch) before.set(ref, head); |
| 71 | - startWorkflowsInBackground(onRefsUpdated(repo, before, new Map([[ref, commit]]), viewer.id)); | |
| 75 | + afterRefsUpdatedInBackground(repo, before, new Map([[ref, commit]]), person(viewer.id)); | |
| 72 | 76 | return target; |
| 73 | 77 | } |
| 74 | 78 | |
| 79 | + /** | |
| 80 | + * Where to go after committing from a form: the file (or folder) on its branch, or for a commit to a | |
| 81 | + * new branch the comparison with the branch it came from, to open a pull request ("propose changes"). | |
| 82 | + */ | |
| 83 | + function after(repo: RepoRecord, formData: FormData, branch: string, page: string) { | |
| 84 | + if (formData.get("target") !== "branch") return page; | |
| 85 | + return `/${repo.owner}/${repo.name}/compare?${new URLSearchParams({ base: str(formData, "branch"), head: branch })}`; | |
| 86 | + } | |
| 87 | + | |
| 75 | 88 | async function handle(fn: () => Promise<string>): Promise<FormState> { |
| 76 | 89 | let destination: string; |
| 77 | 90 | try { |
| 78 | 91 | destination = await fn(); |
| 79 | 92 | } catch (e) { |
| 80 | - if (e instanceof ActionError || e instanceof GitError) return { error: e.message }; | |
| 93 | + if (e instanceof ActionError || e instanceof GitError || e instanceof ProtectionError) return { error: e.message }; | |
| 81 | 94 | throw e; |
| 82 | 95 | } |
| 83 | 96 | redirect(destination); |
| @@ -102,7 +115,7 @@ export async function saveFileAction(owner: string, name: string, _prev: FormSta | ||
| 102 | 115 | const filename = path.split("/").pop()!; |
| 103 | 116 | const verb = !originalPath ? "Create" : originalPath !== path ? "Rename" : "Update"; |
| 104 | 117 | const branch = await commitFromForm(repo, viewer, formData, changes, originalPath ? [originalPath] : [], `${verb} ${filename}`); |
| 105 | - return `/${repo.owner}/${repo.name}/blob/${encodePath(branch)}/${encodePath(path)}`; | |
| 118 | + return after(repo, formData, branch, `/${repo.owner}/${repo.name}/blob/${encodePath(branch)}/${encodePath(path)}`); | |
| 106 | 119 | }); |
| 107 | 120 | } |
| 108 | 121 | |
| @@ -118,7 +131,7 @@ export async function deleteFileAction(owner: string, name: string, _prev: FormS | ||
| 118 | 131 | while (parts.length && (await objectType(repo.dir, `refs/heads/${branch}`, parts.join("/"))) !== "tree") parts.pop(); |
| 119 | 132 | const suffix = parts.length ? `/${encodePath(parts.join("/"))}` : ""; |
| 120 | 133 | const hasFiles = await revParse(repo.dir, `refs/heads/${branch}`); |
| 121 | - return hasFiles ? `/${repo.owner}/${repo.name}/tree/${encodePath(branch)}${suffix}` : `/${repo.owner}/${repo.name}`; | |
| 134 | + return after(repo, formData, branch, hasFiles ? `/${repo.owner}/${repo.name}/tree/${encodePath(branch)}${suffix}` : `/${repo.owner}/${repo.name}`); | |
| 122 | 135 | }); |
| 123 | 136 | } |
| 124 | 137 | |
| @@ -146,6 +159,6 @@ export async function uploadFilesAction(owner: string, name: string, _prev: Form | ||
| 146 | 159 | changes.map((c) => c.path), |
| 147 | 160 | files.length === 1 ? `Add ${files[0].name}` : `Add ${files.length} files`, |
| 148 | 161 | ); |
| 149 | - return `/${repo.owner}/${repo.name}/tree/${encodePath(branch)}${dir ? `/${encodePath(dir)}` : ""}`; | |
| 162 | + return after(repo, formData, branch, `/${repo.owner}/${repo.name}/tree/${encodePath(branch)}${dir ? `/${encodePath(dir)}` : ""}`); | |
| 150 | 163 | }); |
| 151 | 164 | } |
| @@ -0,0 +1,50 @@ | ||
| 1 | + "use server"; | |
| 2 | + | |
| 3 | + import { revalidatePath } from "next/cache"; | |
| 4 | + import { redirect } from "next/navigation"; | |
| 5 | + import { person } from "@/lib/actor"; | |
| 6 | + import { createFork, syncFork } from "@/lib/forks"; | |
| 7 | + import { GitError } from "@/lib/git"; | |
| 8 | + import { ProtectionError } from "@/lib/protection"; | |
| 9 | + import { RepoError } from "@/lib/repos"; | |
| 10 | + import { ActionError, repoForAction } from "./guard"; | |
| 11 | + import type { FormState } from "./types"; | |
| 12 | + | |
| 13 | + // Anyone who can read a repository may fork it; syncing a fork needs push access to the fork. | |
| 14 | + | |
| 15 | + const isUserError = (e: unknown) => e instanceof ActionError || e instanceof RepoError || e instanceof GitError || e instanceof ProtectionError; | |
| 16 | + | |
| 17 | + export async function forkRepoAction(owner: string, name: string, _prev: FormState, formData: FormData): Promise<FormState> { | |
| 18 | + let destination: string; | |
| 19 | + try { | |
| 20 | + const { repo, viewer } = await repoForAction(owner, name, "read"); | |
| 21 | + const fork = await createFork( | |
| 22 | + viewer, | |
| 23 | + repo, | |
| 24 | + { | |
| 25 | + name: String(formData.get("name") ?? ""), | |
| 26 | + description: String(formData.get("description") ?? ""), | |
| 27 | + defaultBranchOnly: formData.get("defaultBranchOnly") === "on", | |
| 28 | + }, | |
| 29 | + person(viewer.id), | |
| 30 | + ); | |
| 31 | + revalidatePath(`/${repo.owner}/${repo.name}`, "layout"); | |
| 32 | + destination = `/${fork.owner}/${fork.name}`; | |
| 33 | + } catch (e) { | |
| 34 | + if (isUserError(e)) return { error: (e as Error).message }; | |
| 35 | + throw e; | |
| 36 | + } | |
| 37 | + redirect(destination); | |
| 38 | + } | |
| 39 | + | |
| 40 | + export async function syncForkAction(owner: string, name: string): Promise<FormState> { | |
| 41 | + try { | |
| 42 | + const { repo, viewer } = await repoForAction(owner, name, "write"); | |
| 43 | + const result = await syncFork(repo, person(viewer.id)); | |
| 44 | + revalidatePath(`/${repo.owner}/${repo.name}`, "layout"); | |
| 45 | + return { success: result.updated ? "Synced with the upstream repository." : "Already up to date." }; | |
| 46 | + } catch (e) { | |
| 47 | + if (isUserError(e)) return { error: (e as Error).message }; | |
| 48 | + throw e; | |
| 49 | + } | |
| 50 | + } | |
| @@ -15,6 +15,7 @@ import { | ||
| 15 | 15 | updateIssue, |
| 16 | 16 | validateIssuePatch, |
| 17 | 17 | } from "@/lib/issues"; |
| 18 | + import { person } from "@/lib/actor"; | |
| 18 | 19 | import { canWrite, type RepoRecord } from "@/lib/repos"; |
| 19 | 20 | import { ActionError, repoForAction } from "./guard"; |
| 20 | 21 | import type { FormState } from "./types"; |
| @@ -38,7 +39,7 @@ export async function createIssueAction( | ||
| 38 | 39 | const { repo, viewer, access } = await repoForAction(owner, name, "read"); |
| 39 | 40 | const { title, body, ...properties } = await validateIssuePatch(repo, { ...input, sortOrder: undefined }); |
| 40 | 41 | if (!title) return { error: "Title can't be blank." }; |
| 41 | - const number = await createIssue(repo.id, viewer.id, { | |
| 42 | + const number = await createIssue(repo, person(viewer.id), { | |
| 42 | 43 | title, |
| 43 | 44 | body: body ?? "", |
| 44 | 45 | ...(canWrite(access) ? properties : {}), |
| @@ -58,7 +59,7 @@ export async function updateIssueAction(owner: string, name: string, number: num | ||
| 58 | 59 | if (!issue) return { error: "Issue not found." }; |
| 59 | 60 | if (!canWrite(access)) return { error: "Only collaborators can change issues. You can still comment." }; |
| 60 | 61 | const clean = await validateIssuePatch(repo, patch); |
| 61 | - await updateIssue(issue, clean, viewer.id); | |
| 62 | + await updateIssue(repo, issue, clean, person(viewer.id)); | |
| 62 | 63 | revalidateIssues(repo); |
| 63 | 64 | return {}; |
| 64 | 65 | } catch (e) { |
| @@ -72,7 +73,7 @@ export async function commentAction(owner: string, name: string, number: number, | ||
| 72 | 73 | const issue = await getIssue(repo.id, number); |
| 73 | 74 | if (!issue) throw new ActionError("Issue not found"); |
| 74 | 75 | const body = String(formData.get("body") ?? "").trim().slice(0, MAX_BODY); |
| 75 | - if (body) await addComment(issue.id, { body, authorId: viewer.id }); | |
| 76 | + if (body) await addComment(repo, issue, person(viewer.id), body); | |
| 76 | 77 | revalidateIssues(repo); |
| 77 | 78 | } |
| 78 | 79 | |
| @@ -83,7 +84,7 @@ export async function deleteIssueAction(owner: string, name: string, number: num | ||
| 83 | 84 | if (issue.authorId !== viewer.id && access !== "admin") { |
| 84 | 85 | throw new ActionError("Only the issue's creator and the repository owner can delete it."); |
| 85 | 86 | } |
| 86 | - await deleteIssue(issue.id); | |
| 87 | + await deleteIssue(repo, issue, person(viewer.id)); | |
| 87 | 88 | revalidateIssues(repo); |
| 88 | 89 | redirect(`/${repo.owner}/${repo.name}/issues`); |
| 89 | 90 | } |
| @@ -0,0 +1,55 @@ | ||
| 1 | + "use server"; | |
| 2 | + | |
| 3 | + import { revalidatePath } from "next/cache"; | |
| 4 | + import { AGENT_PUSH_CHOICES, type AgentPush } from "@/lib/branch-rules"; | |
| 5 | + import { deleteBranchRule, ProtectionError, saveBranchRule } from "@/lib/protection"; | |
| 6 | + import { setAgentPush } from "@/lib/repos"; | |
| 7 | + import { ActionError, repoForAction } from "./guard"; | |
| 8 | + import type { FormState } from "./types"; | |
| 9 | + | |
| 10 | + const settingsPath = (owner: string, name: string) => `/${owner}/${name}/settings/branches`; | |
| 11 | + | |
| 12 | + export async function saveBranchRuleAction(owner: string, name: string, ruleId: string | null, _prev: FormState, formData: FormData): Promise<FormState> { | |
| 13 | + try { | |
| 14 | + const { repo } = await repoForAction(owner, name, "admin"); | |
| 15 | + const on = (key: string) => formData.get(key) === "on"; | |
| 16 | + await saveBranchRule( | |
| 17 | + repo.id, | |
| 18 | + { | |
| 19 | + pattern: String(formData.get("pattern") ?? ""), | |
| 20 | + requirePr: on("requirePr"), | |
| 21 | + requiredApprovals: Number(formData.get("requiredApprovals") ?? 0) || 0, | |
| 22 | + requireCi: on("requireCi"), | |
| 23 | + blockForcePush: on("blockForcePush"), | |
| 24 | + blockDeletion: on("blockDeletion"), | |
| 25 | + agentsMayMerge: on("agentsMayMerge"), | |
| 26 | + }, | |
| 27 | + ruleId ?? undefined, | |
| 28 | + ); | |
| 29 | + revalidatePath(settingsPath(repo.owner, repo.name)); | |
| 30 | + return { success: ruleId ? "Rule saved." : "Rule added." }; | |
| 31 | + } catch (e) { | |
| 32 | + if (e instanceof ActionError || e instanceof ProtectionError) return { error: e.message }; | |
| 33 | + throw e; | |
| 34 | + } | |
| 35 | + } | |
| 36 | + | |
| 37 | + export async function deleteBranchRuleAction(owner: string, name: string, ruleId: string) { | |
| 38 | + const { repo } = await repoForAction(owner, name, "admin"); | |
| 39 | + await deleteBranchRule(repo.id, ruleId); | |
| 40 | + revalidatePath(settingsPath(repo.owner, repo.name)); | |
| 41 | + } | |
| 42 | + | |
| 43 | + export async function setAgentPushAction(owner: string, name: string, _prev: FormState, formData: FormData): Promise<FormState> { | |
| 44 | + try { | |
| 45 | + const { repo } = await repoForAction(owner, name, "admin"); | |
| 46 | + const value = String(formData.get("agentPush") ?? "") as AgentPush; | |
| 47 | + if (!AGENT_PUSH_CHOICES.some((c) => c.value === value)) throw new ActionError("Choose where agents may push."); | |
| 48 | + await setAgentPush(repo.id, value); | |
| 49 | + revalidatePath(`/${repo.owner}/${repo.name}`, "layout"); | |
| 50 | + return { success: "Saved." }; | |
| 51 | + } catch (e) { | |
| 52 | + if (e instanceof ActionError) return { error: e.message }; | |
| 53 | + throw e; | |
| 54 | + } | |
| 55 | + } | |
| @@ -0,0 +1,201 @@ | ||
| 1 | + "use server"; | |
| 2 | + | |
| 3 | + import { revalidatePath } from "next/cache"; | |
| 4 | + import { redirect } from "next/navigation"; | |
| 5 | + import { person } from "@/lib/actor"; | |
| 6 | + import { GitError } from "@/lib/git"; | |
| 7 | + import { ProtectionError } from "@/lib/protection"; | |
| 8 | + import { | |
| 9 | + addPullComment, | |
| 10 | + approvePullWorkflows, | |
| 11 | + createPull, | |
| 12 | + getPull, | |
| 13 | + mergePull, | |
| 14 | + PullError, | |
| 15 | + replyToComment, | |
| 16 | + submitReview, | |
| 17 | + updatePull, | |
| 18 | + type LineComment, | |
| 19 | + type MergeMethod, | |
| 20 | + type PullPatch, | |
| 21 | + type Review, | |
| 22 | + } from "@/lib/pulls"; | |
| 23 | + import { canWrite, type RepoRecord } from "@/lib/repos"; | |
| 24 | + import { ActionError, repoForAction } from "./guard"; | |
| 25 | + import type { FormState } from "./types"; | |
| 26 | + | |
| 27 | + // Permissions, as for issues: anyone who can read a repository may comment on its pull requests. | |
| 28 | + // Opening one from a branch of the repository needs push access; from a fork, read access to both. | |
| 29 | + // Its author and people with push access may change it; approving, requesting changes and starting | |
| 30 | + // a fork's workflows need push access, and merging also needs the base branch's rules to be met. | |
| 31 | + | |
| 32 | + const str = (formData: FormData, key: string) => String(formData.get(key) ?? "").trim(); | |
| 33 | + | |
| 34 | + function revalidatePulls(repo: RepoRecord) { | |
| 35 | + revalidatePath(`/${repo.owner}/${repo.name}`, "layout"); | |
| 36 | + } | |
| 37 | + | |
| 38 | + const isUserError = (e: unknown) => e instanceof ActionError || e instanceof PullError || e instanceof ProtectionError || e instanceof GitError; | |
| 39 | + | |
| 40 | + async function pullForAction(owner: string, name: string, number: number, need: "read" | "write") { | |
| 41 | + const { repo, viewer, access } = await repoForAction(owner, name, need); | |
| 42 | + const pr = await getPull(repo.id, number); | |
| 43 | + if (!pr) throw new ActionError("That pull request doesn't exist."); | |
| 44 | + return { repo, viewer, access, pr }; | |
| 45 | + } | |
| 46 | + | |
| 47 | + export async function createPullAction(owner: string, name: string, _prev: FormState, formData: FormData): Promise<FormState> { | |
| 48 | + let destination: string; | |
| 49 | + try { | |
| 50 | + const { repo, viewer, access } = await repoForAction(owner, name, "read"); | |
| 51 | + // "owner/name" of the fork the head branch is on, when it isn't this repository. | |
| 52 | + const [forkOwner, forkName] = str(formData, "head_repo").split("/"); | |
| 53 | + const headRepo = forkOwner && forkName ? (await repoForAction(forkOwner, forkName, "read")).repo : null; | |
| 54 | + const fromFork = headRepo && headRepo.id !== repo.id; | |
| 55 | + if (!fromFork && !canWrite(access)) { | |
| 56 | + throw new ActionError("You can't push to this repository. Fork it, push your changes to the fork, and open the pull request from there."); | |
| 57 | + } | |
| 58 | + const pr = await createPull(repo, person(viewer.id), { | |
| 59 | + head: str(formData, "head"), | |
| 60 | + headRepo, | |
| 61 | + base: str(formData, "base"), | |
| 62 | + title: str(formData, "title"), | |
| 63 | + body: str(formData, "body"), | |
| 64 | + draft: formData.get("draft") === "on", | |
| 65 | + }); | |
| 66 | + revalidatePulls(repo); | |
| 67 | + destination = `/${repo.owner}/${repo.name}/pulls/${pr.number}`; | |
| 68 | + } catch (e) { | |
| 69 | + if (isUserError(e)) return { error: (e as Error).message }; | |
| 70 | + throw e; | |
| 71 | + } | |
| 72 | + redirect(destination); | |
| 73 | + } | |
| 74 | + | |
| 75 | + export async function updatePullAction(owner: string, name: string, number: number, patch: PullPatch): Promise<{ error?: string }> { | |
| 76 | + try { | |
| 77 | + const { repo, viewer, access, pr } = await pullForAction(owner, name, number, "read"); | |
| 78 | + if (!canWrite(access) && pr.authorId !== viewer.id) throw new ActionError("Only collaborators and its author can change a pull request."); | |
| 79 | + await updatePull(repo, pr, person(viewer.id), patch); | |
| 80 | + revalidatePulls(repo); | |
| 81 | + return {}; | |
| 82 | + } catch (e) { | |
| 83 | + if (isUserError(e)) return { error: (e as Error).message }; | |
| 84 | + throw e; | |
| 85 | + } | |
| 86 | + } | |
| 87 | + | |
| 88 | + export async function editPullAction(owner: string, name: string, number: number, _prev: FormState, formData: FormData): Promise<FormState> { | |
| 89 | + const base = str(formData, "base"); | |
| 90 | + const result = await updatePullAction(owner, name, number, { title: str(formData, "title"), body: String(formData.get("body") ?? ""), ...(base ? { base } : {}) }); | |
| 91 | + return result.error ? result : { success: "Saved." }; | |
| 92 | + } | |
| 93 | + | |
| 94 | + /** Close, reopen, mark ready: the buttons on a pull request. */ | |
| 95 | + export async function setPullStateAction(owner: string, name: string, number: number, patch: PullPatch) { | |
| 96 | + const result = await updatePullAction(owner, name, number, patch); | |
| 97 | + if (result.error) throw new ActionError(result.error); | |
| 98 | + } | |
| 99 | + | |
| 100 | + export async function commentPullAction(owner: string, name: string, number: number, _prev: FormState, formData: FormData): Promise<FormState> { | |
| 101 | + try { | |
| 102 | + const { repo, viewer, pr } = await pullForAction(owner, name, number, "read"); | |
| 103 | + await addPullComment(repo, pr, person(viewer.id), str(formData, "body")); | |
| 104 | + revalidatePulls(repo); | |
| 105 | + return { success: "Commented." }; | |
| 106 | + } catch (e) { | |
| 107 | + if (isUserError(e)) return { error: (e as Error).message }; | |
| 108 | + throw e; | |
| 109 | + } | |
| 110 | + } | |
| 111 | + | |
| 112 | + /** The answer box on a pull request: a comment, or (from its other buttons) an approval or a request for changes. */ | |
| 113 | + export async function respondPullAction(owner: string, name: string, number: number, prev: FormState, formData: FormData): Promise<FormState> { | |
| 114 | + const intent = str(formData, "intent"); | |
| 115 | + if (intent === "approved" || intent === "changes_requested") { | |
| 116 | + formData.set("state", intent); | |
| 117 | + return reviewPullAction(owner, name, number, prev, formData); | |
| 118 | + } | |
| 119 | + return commentPullAction(owner, name, number, prev, formData); | |
| 120 | + } | |
| 121 | + | |
| 122 | + export async function reviewPullAction(owner: string, name: string, number: number, _prev: FormState, formData: FormData): Promise<FormState> { | |
| 123 | + try { | |
| 124 | + const { repo, viewer, access, pr } = await pullForAction(owner, name, number, "read"); | |
| 125 | + const state = str(formData, "state") as Review["state"]; | |
| 126 | + if (!["approved", "changes_requested", "commented"].includes(state)) throw new ActionError("Choose what the review says."); | |
| 127 | + if (state !== "commented" && !canWrite(access)) throw new ActionError("Only people with push access can approve or request changes."); | |
| 128 | + await submitReview(repo, pr, person(viewer.id), { state, body: str(formData, "body"), comments: [] }); | |
| 129 | + revalidatePulls(repo); | |
| 130 | + return { success: "Review submitted." }; | |
| 131 | + } catch (e) { | |
| 132 | + if (isUserError(e)) return { error: (e as Error).message }; | |
| 133 | + throw e; | |
| 134 | + } | |
| 135 | + } | |
| 136 | + | |
| 137 | + /** A comment on one line of the changes, from the Files tab: a review with just that comment. */ | |
| 138 | + export async function lineCommentAction(owner: string, name: string, number: number, comment: LineComment): Promise<{ error?: string }> { | |
| 139 | + try { | |
| 140 | + const { repo, viewer, pr } = await pullForAction(owner, name, number, "read"); | |
| 141 | + await submitReview(repo, pr, person(viewer.id), { state: "commented", body: "", comments: [comment] }); | |
| 142 | + revalidatePulls(repo); | |
| 143 | + return {}; | |
| 144 | + } catch (e) { | |
| 145 | + if (isUserError(e)) return { error: (e as Error).message }; | |
| 146 | + throw e; | |
| 147 | + } | |
| 148 | + } | |
| 149 | + | |
| 150 | + export async function replyThreadAction( | |
| 151 | + owner: string, | |
| 152 | + name: string, | |
| 153 | + number: number, | |
| 154 | + commentId: string, | |
| 155 | + input: { body: string; resolve?: boolean }, | |
| 156 | + ): Promise<{ error?: string }> { | |
| 157 | + try { | |
| 158 | + const { repo, viewer, access, pr } = await pullForAction(owner, name, number, "read"); | |
| 159 | + if (input.resolve !== undefined && !canWrite(access) && pr.authorId !== viewer.id) { | |
| 160 | + throw new ActionError("Only collaborators and the pull request's author can resolve threads."); | |
| 161 | + } | |
| 162 | + await replyToComment(repo, pr, person(viewer.id), commentId, input.body, input.resolve); | |
| 163 | + revalidatePulls(repo); | |
| 164 | + return {}; | |
| 165 | + } catch (e) { | |
| 166 | + if (isUserError(e)) return { error: (e as Error).message }; | |
| 167 | + throw e; | |
| 168 | + } | |
| 169 | + } | |
| 170 | + | |
| 171 | + export async function mergePullAction(owner: string, name: string, number: number, _prev: FormState, formData: FormData): Promise<FormState> { | |
| 172 | + try { | |
| 173 | + const { repo, viewer, pr } = await pullForAction(owner, name, number, "write"); | |
| 174 | + const method = (str(formData, "method") || "merge") as MergeMethod; | |
| 175 | + if (method !== "merge" && method !== "squash") throw new ActionError("Unknown merge method."); | |
| 176 | + await mergePull(repo, pr, person(viewer.id), { | |
| 177 | + method, | |
| 178 | + deleteBranch: formData.get("deleteBranch") === "on", | |
| 179 | + expectedHeadSha: str(formData, "headSha") || undefined, | |
| 180 | + }); | |
| 181 | + revalidatePulls(repo); | |
| 182 | + return { success: "Merged." }; | |
| 183 | + } catch (e) { | |
| 184 | + if (isUserError(e)) return { error: (e as Error).message }; | |
| 185 | + throw e; | |
| 186 | + } | |
| 187 | + } | |
| 188 | + | |
| 189 | + /** Starts the workflows of a pull request from a fork whose author can't push here, once someone who can has looked at its changes. */ | |
| 190 | + export async function approveWorkflowsAction(owner: string, name: string, number: number): Promise<FormState> { | |
| 191 | + try { | |
| 192 | + const { repo, viewer, pr } = await pullForAction(owner, name, number, "write"); | |
| 193 | + const started = await approvePullWorkflows(repo, pr, person(viewer.id)); | |
| 194 | + revalidatePulls(repo); | |
| 195 | + if (!started) return { error: "No workflow runs for this pull request." }; | |
| 196 | + return { success: `Started ${started} workflow${started === 1 ? "" : "s"}.` }; | |
| 197 | + } catch (e) { | |
| 198 | + if (isUserError(e)) return { error: (e as Error).message }; | |
| 199 | + throw e; | |
| 200 | + } | |
| 201 | + } | |
| @@ -2,7 +2,8 @@ | ||
| 2 | 2 | |
| 3 | 3 | import { revalidatePath } from "next/cache"; |
| 4 | 4 | import { redirect } from "next/navigation"; |
| 5 | - import { onRefsUpdated, startWorkflowsInBackground } from "@/lib/ci"; | |
| 5 | + import { person } from "@/lib/actor"; | |
| 6 | + import { afterRefUpdated } from "@/lib/push"; | |
| 6 | 7 | import { GitError } from "@/lib/git"; |
| 7 | 8 | import { |
| 8 | 9 | createRelease, |
| @@ -29,7 +30,7 @@ async function releaseOrThrow(repo: RepoRecord, id: string) { | ||
| 29 | 30 | async function afterSave(repo: RepoRecord, tag: string, createdTag: string | null, actorId: string) { |
| 30 | 31 | if (createdTag) { |
| 31 | 32 | await markPushed(repo.id); |
| 32 | - startWorkflowsInBackground(onRefsUpdated(repo, new Map(), new Map([[`refs/tags/${tag}`, createdTag]]), actorId)); | |
| 33 | + afterRefUpdated(repo, `refs/tags/${tag}`, null, createdTag, person(actorId)); | |
| 33 | 34 | } |
| 34 | 35 | revalidatePath(`/${repo.owner}/${repo.name}`, "layout"); |
| 35 | 36 | } |
| @@ -0,0 +1,65 @@ | ||
| 1 | + "use server"; | |
| 2 | + | |
| 3 | + import { revalidatePath } from "next/cache"; | |
| 4 | + import { redirect } from "next/navigation"; | |
| 5 | + import { EVENT_KINDS } from "@/lib/events"; | |
| 6 | + import { createWebhook, deleteWebhook, pingWebhook, updateWebhook, WebhookError } from "@/lib/webhooks"; | |
| 7 | + import { ActionError, repoForAction } from "./guard"; | |
| 8 | + import type { FormState } from "./types"; | |
| 9 | + | |
| 10 | + const hooksPath = (owner: string, name: string) => `/${owner}/${name}/settings/webhooks`; | |
| 11 | + | |
| 12 | + /** "*" when "all events" is chosen, else the kinds ticked. */ | |
| 13 | + const chosenEvents = (formData: FormData) => (formData.get("which") === "all" ? ["*"] : formData.getAll("events").map(String)); | |
| 14 | + | |
| 15 | + export async function createWebhookAction(owner: string, name: string, _prev: FormState, formData: FormData): Promise<FormState> { | |
| 16 | + let id: string; | |
| 17 | + try { | |
| 18 | + const { repo } = await repoForAction(owner, name, "admin"); | |
| 19 | + const hook = await createWebhook(repo.id, { url: String(formData.get("url") ?? ""), secret: String(formData.get("secret") ?? ""), events: chosenEvents(formData) }, EVENT_KINDS); | |
| 20 | + // A first delivery shows right away whether the receiver is set up. | |
| 21 | + await pingWebhook(repo, hook.id).catch(() => {}); | |
| 22 | + id = hook.id; | |
| 23 | + } catch (e) { | |
| 24 | + if (e instanceof ActionError || e instanceof WebhookError) return { error: e.message }; | |
| 25 | + throw e; | |
| 26 | + } | |
| 27 | + redirect(`${hooksPath(owner, name)}/${id}`); | |
| 28 | + } | |
| 29 | + | |
| 30 | + export async function updateWebhookAction(owner: string, name: string, hookId: string, _prev: FormState, formData: FormData): Promise<FormState> { | |
| 31 | + try { | |
| 32 | + const { repo } = await repoForAction(owner, name, "admin"); | |
| 33 | + const secret = String(formData.get("secret") ?? ""); | |
| 34 | + await updateWebhook( | |
| 35 | + repo.id, | |
| 36 | + hookId, | |
| 37 | + { | |
| 38 | + url: String(formData.get("url") ?? ""), | |
| 39 | + events: chosenEvents(formData), | |
| 40 | + active: formData.get("active") === "on", | |
| 41 | + // An empty field keeps the secret; "clear" removes it. | |
| 42 | + ...(formData.get("clearSecret") === "on" ? { secret: "" } : secret ? { secret } : {}), | |
| 43 | + }, | |
| 44 | + EVENT_KINDS, | |
| 45 | + ); | |
| 46 | + revalidatePath(hooksPath(repo.owner, repo.name), "layout"); | |
| 47 | + return { success: "Webhook saved." }; | |
| 48 | + } catch (e) { | |
| 49 | + if (e instanceof ActionError || e instanceof WebhookError) return { error: e.message }; | |
| 50 | + throw e; | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + export async function pingWebhookAction(owner: string, name: string, hookId: string) { | |
| 55 | + const { repo } = await repoForAction(owner, name, "admin"); | |
| 56 | + await pingWebhook(repo, hookId); | |
| 57 | + revalidatePath(`${hooksPath(repo.owner, repo.name)}/${hookId}`); | |
| 58 | + } | |
| 59 | + | |
| 60 | + export async function deleteWebhookAction(owner: string, name: string, hookId: string) { | |
| 61 | + const { repo } = await repoForAction(owner, name, "admin"); | |
| 62 | + await deleteWebhook(repo.id, hookId); | |
| 63 | + revalidatePath(hooksPath(repo.owner, repo.name)); | |
| 64 | + redirect(hooksPath(repo.owner, repo.name)); | |
| 65 | + } | |
| @@ -0,0 +1,16 @@ | ||
| 1 | + import { markdownViewer, renderMarkdown } from "@/lib/markdown-views"; | |
| 2 | + import { serverOrigin } from "@/lib/request-origin"; | |
| 3 | + | |
| 4 | + // Pages as Markdown: next.config.ts rewrites requests that ask for text/markdown (or ?format=md) here. | |
| 5 | + | |
| 6 | + const markdown = (body: string, status = 200) => | |
| 7 | + new Response(body, { status, headers: { "Content-Type": "text/markdown; charset=utf-8", Vary: "Accept, Authorization, Cookie", "Cache-Control": "no-store" } }); | |
| 8 | + | |
| 9 | + export async function GET(request: Request, { params }: RouteContext<"/api/markdown/[[...path]]">) { | |
| 10 | + const { path = [] } = await params; | |
| 11 | + const viewer = await markdownViewer(request); | |
| 12 | + if (viewer === "invalid") return markdown("# Unauthorized\n\nThe token is invalid or expired.\n", 401); | |
| 13 | + const body = await renderMarkdown(viewer, await serverOrigin(), path); | |
| 14 | + if (body === null) return markdown("# Not found\n\nThere's no such page, or you can't see it. Private repositories need `Authorization: Bearer <token>`.\n", 404); | |
| 15 | + return markdown(body); | |
| 16 | + } | |
| @@ -13,7 +13,7 @@ export const metadata: Metadata = { title: "Explore" }; | ||
| 13 | 13 | export default async function ExplorePage({ searchParams }: PageProps<"/explore">) { |
| 14 | 14 | const q = String((await searchParams).q ?? "").trim(); |
| 15 | 15 | const viewer = await getViewer(); |
| 16 | - const repos = await withActivity(await searchRepos(q, viewer?.id ?? null)); | |
| 16 | + const repos = await withActivity(await searchRepos(q, viewer?.id ?? null), viewer?.id); | |
| 17 | 17 | |
| 18 | 18 | return ( |
| 19 | 19 | <main className="w-full"> |
| @@ -167,6 +167,16 @@ body { | ||
| 167 | 167 | outline-offset: 2px; |
| 168 | 168 | } |
| 169 | 169 | |
| 170 | + /* Text fields show focus with their caret, not a ring: the bare control and MetalUI's well around it */ | |
| 171 | + input:not([type=checkbox], [type=radio], [type=range], [type=file], [type=color], [type=button], [type=submit], [type=reset]), | |
| 172 | + textarea, | |
| 173 | + .mu-field, | |
| 174 | + .mu-textarea, | |
| 175 | + .mu-number-field-group, | |
| 176 | + .mu-combobox { | |
| 177 | + &:focus, &:focus-visible, &:focus-within { outline: none; } | |
| 178 | + } | |
| 179 | + | |
| 170 | 180 | /* Menus and dialogs rise in on the surface spring */ |
| 171 | 181 | details[open] > .menu-panel { animation: var(--animate-pop); } |
| 172 | 182 | dialog[open] { animation: var(--animate-pop); } |
| @@ -0,0 +1,7 @@ | ||
| 1 | + import { siteOverview } from "@/lib/markdown-views"; | |
| 2 | + import { serverOrigin } from "@/lib/request-origin"; | |
| 3 | + | |
| 4 | + /** What this server is and how agents work with it (https://llmstxt.org). */ | |
| 5 | + export async function GET() { | |
| 6 | + return new Response(siteOverview(await serverOrigin()), { headers: { "Content-Type": "text/markdown; charset=utf-8" } }); | |
| 7 | + } | |
| @@ -2,6 +2,7 @@ import { corsJson, preflight, withCors } from "@/lib/cors"; | ||
| 2 | 2 | import { handleMcpPost } from "@/lib/mcp/server"; |
| 3 | 3 | import { authenticateAccessToken } from "@/lib/oauth"; |
| 4 | 4 | import { serverOrigin } from "@/lib/request-origin"; |
| 5 | + import { tokenAgentKey } from "@/lib/actor"; | |
| 5 | 6 | import { authenticateToken } from "@/lib/tokens"; |
| 6 | 7 | |
| 7 | 8 | /** |
| @@ -15,7 +16,8 @@ async function authenticate(request: Request) { | ||
| 15 | 16 | const grant = await authenticateAccessToken(secret); |
| 16 | 17 | if (grant) return grant; |
| 17 | 18 | const token = await authenticateToken(secret); |
| 18 | - return token ? { user: token.user, scope: token.token } : "invalid"; | |
| 19 | + // An API token used here is an agent too, named after the token. | |
| 20 | + return token ? { user: token.user, scope: token.token, agent: { key: tokenAgentKey(token.token.id), name: token.token.name } } : "invalid"; | |
| 19 | 21 | } |
| 20 | 22 | |
| 21 | 23 | export async function POST(request: Request) { |
| @@ -176,13 +176,13 @@ async function Dashboard({ viewer }: { viewer: User }) { | ||
| 176 | 176 | </Blankslate> |
| 177 | 177 | </Box> |
| 178 | 178 | ) : ( |
| 179 | - <Box className="p-2"> | |
| 179 | + <Box className="p-1.5"> | |
| 180 | 180 | <h2 className="flex items-center gap-2 px-3 pt-2.5 pb-2 type-label engraved"> |
| 181 | 181 | Recent activity <Counter>{activity.length}</Counter> |
| 182 | 182 | </h2> |
| 183 | 183 | <ol className="flex flex-col gap-0.5"> |
| 184 | 184 | {activity.map(({ repo, commit }) => ( |
| 185 | - <li key={`${repo.id}-${commit.sha}`} className="flex gap-3 rounded-row px-3 py-2.5 transition-row hover:recipe-row-list-hover"> | |
| 185 | + <li key={`${repo.id}-${commit.sha}`} className="flex gap-3 rounded-plate px-3 py-2.5 transition-row hover:recipe-row-list-hover"> | |
| 186 | 186 | <span className="icon-tile mt-0.5 size-8 shrink-0 rounded-full"> |
| 187 | 187 | <CommitIcon width={15} height={15} /> |
| 188 | 188 | </span> |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.