Projects: what a workspace builds and runs, first on every page
A project is the thing you build and run, with one source where its code lives. Everything about running it (deployments, its address, secrets and variables) belongs to the project; branches, pull requests, review and merge rules stay with its repository. Every repository is a project of its own name, so every address keeps working. - services/projects (new): projects with their source (a hosted repository and a root directory), made for every repository when it is created (`repo.created`) and, for older ones, the first time their workspace's projects are asked for. RPC: list, get, by_repo, create, update. - Secrets and variables belong to the project: a repository's rows are its primary project's, moved over the first time they are touched; workflows read their repository's project; a workspace's rows link to projects. - Deployments are keyed by project, and previews by branch: production at <project>-<workspace>.g1t.page, a branch's preview at <project>-git-<branch>-<workspace>.g1t.page (a fork's pull request is pr-<n>), the same address for every push. Every project of a repository builds on its events, from its root directory (ROOT_DIR in the runner). Migration 0002 replaces the first test tables; the sweep now removes scripts no app holds, such as ones under old names. Workspace overview for the site. - Site, projects first: the workspace page is a grid of project cards with where each is deployed, its latest build and its open work; the sidebar lists projects; g1t.sh/<workspace>/<project> opens the project's overview (production, previews, work in progress, recent builds, its source), with code under /code; a project menu (Overview, Deployments, Code, Work, Automate, Project); Settings tabs General (name, description, source, root directory), Deployments, Secrets and variables, Repository (the old settings page: visibility, protection, merge rules) and Webhooks; New project chooses where the code comes from (start empty, import; mirroring GitHub, GitLab and Bitbucket shown as next); the command palette knows projects. - Docs: guides/projects; Deployments, Secrets and variables, Workspaces, agents, git, merge queue and webhooks guides updated; a Projects section in the docs sidebar; llms.txt. - Plan: step 1 marked shipped; profiles at /u/<username> and search with a filter sidebar added.
| 999 | 999 | "items": { "type": "string" }, | |
| 1000 | 1000 | "description": "The environments it applies to, such as production and preview, or a workflow job's environment. Empty is every environment." | |
| 1001 | 1001 | }, | |
| 1002 | − | "repositories": { | |
| 1002 | + | "projects": { | |
| 1003 | 1003 | "type": "array", | |
| 1004 | 1004 | "items": { "type": "string" }, | |
| 1005 | − | "description": "A workspace's row: the repositories it reaches, by name. Empty is every one." | |
| 1005 | + | "description": "A workspace's row: the projects it reaches, by slug. Empty is every one." | |
| 1006 | 1006 | }, | |
| 1007 | 1007 | "note": { "type": "string", "description": "Where to rotate it, or who to ask." }, | |
| 1008 | 1008 | })), | |
| 1736 | 1736 | } | |
| 1737 | 1737 | // Request bodies arrive in snake_case; the actions service | |
| 1738 | 1738 | // takes `availableTo`. | |
| 1739 | − | for (key, to) in [("available_to", "availableTo"), ("environments", "environments"), ("repositories", "repositories")] { | |
| 1739 | + | for (key, to) in [("available_to", "availableTo"), ("environments", "environments"), ("repositories", "projects"), ("projects", "projects")] { | |
| 1740 | 1740 | if let Some(list) = strings(input, key) { | |
| 1741 | 1741 | args[to] = json!(list); | |
| 1742 | 1742 | } |
| 69 | 69 | ], | |
| 70 | 70 | }, | |
| 71 | 71 | { | |
| 72 | + | label: 'Projects', | |
| 73 | + | items: [ | |
| 74 | + | { label: 'Projects', slug: 'guides/projects' }, | |
| 75 | + | { label: 'Deployments', slug: 'guides/deployments' }, | |
| 76 | + | { label: 'Secrets and variables', slug: 'guides/secrets-and-variables' }, | |
| 77 | + | ], | |
| 78 | + | }, | |
| 79 | + | { | |
| 72 | 80 | label: 'Agents', | |
| 73 | 81 | items: [ | |
| 74 | 82 | { label: 'g1t agents', slug: 'guides/g1t-agents' }, | |
| 84 | 92 | { label: 'Model providers', slug: 'guides/models' }, | |
| 85 | 93 | { label: 'Webhooks', slug: 'guides/webhooks' }, | |
| 86 | 94 | { label: 'GitHub Actions', slug: 'guides/actions' }, | |
| 87 | − | { label: 'Secrets and variables', slug: 'guides/secrets-and-variables' }, | |
| 88 | 95 | ], | |
| 89 | 96 | }, | |
| 90 | 97 | { | |
| 91 | 98 | label: 'Landing changes', | |
| 92 | 99 | items: [ | |
| 93 | 100 | { label: 'The merge queue', slug: 'guides/merge-queue' }, | |
| 94 | − | { label: 'Deployments', slug: 'guides/deployments' }, | |
| 95 | 101 | { label: 'Sessions and why-blame', slug: 'guides/why-blame' }, | |
| 96 | 102 | { label: 'Forks and branches', slug: 'concepts/forks' }, | |
| 97 | 103 | ], |
| 1 | 1 | --- | |
| 2 | 2 | title: Deployments | |
| 3 | − | description: A live preview for every pull request and production for every push, on g1t.page. Scales to zero, and billed to the workspace. | |
| 3 | + | description: A project's production on every push and a live preview of every branch with a pull request, on g1t.page. Scales to zero, and billed to the workspace. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | − | Deployments put your repository on the web. Every pull request gets its own | |
| 7 | − | live preview, linked on the pull request, and the default branch goes to | |
| 6 | + | Deployments put your [project](/guides/projects/) on the web. Every branch | |
| 7 | + | with a pull request gets its own live preview, linked on the pull request, | |
| 8 | + | and the default branch goes to | |
| 8 | 9 | production on every push. Reviewers, and the agents reviewing for you, | |
| 9 | 10 | click through the change instead of reading a diff. | |
| 10 | 11 | ||
| 12 | 13 | ||
| 13 | 14 | | | Address | | |
| 14 | 15 | | --- | --- | | |
| 15 | − | | Production | `https://<repo>--<workspace>.g1t.page` | | |
| 16 | − | | Preview of pull request 12 | `https://pr-12--<repo>--<workspace>.g1t.page` | | |
| 16 | + | | Production | `https://<project>-<workspace>.g1t.page` | | |
| 17 | + | | Preview of the branch `fix-login` | `https://<project>-git-fix-login-<workspace>.g1t.page` | | |
| 18 | + | ||
| 19 | + | A preview's address follows its branch, so it stays the same for every push | |
| 20 | + | to it. A pull request from a fork, as g1t's agents make them, is named | |
| 21 | + | `pr-<number>` in place of the branch. A name too long for an address, or | |
| 22 | + | one another app already has, is shortened or given a short suffix. | |
| 17 | 23 | ||
| 18 | 24 | An app runs only while it answers a request. One nobody visits runs | |
| 19 | 25 | nothing and costs nothing, and the next visit wakes it in milliseconds. | |
| 20 | 26 | ||
| 21 | 27 | Deployments are a paid feature, turned on per workspace with a monthly | |
| 22 | − | plan, and then per repository. Nothing deploys until you turn it on, and | |
| 28 | + | plan, and then per project. Nothing deploys until you turn it on, and | |
| 23 | 29 | one click turns it off again. | |
| 24 | 30 | ||
| 25 | 31 | ## Turn on deployments | |
| 28 | 34 | **Settings → Billing**, `g1t.sh/<workspace>/-/billing`, and under | |
| 29 | 35 | **Plans** chooses **Turn on Deployments**, then pays on the card page. | |
| 30 | 36 | Back on Billing, the plan says **On** with its renewal date. | |
| 31 | − | 2. **Turn on deployments for a repository.** Any member opens the | |
| 32 | − | repository's **Deployments** page, `g1t.sh/<workspace>/<repo>/deployments`, | |
| 37 | + | 2. **Turn on deployments for a project.** Any member opens the project's | |
| 38 | + | **Settings → Deployments**, `g1t.sh/<workspace>/<project>/settings/deployments`, | |
| 39 | + | or chooses **Deploy** on its overview, | |
| 33 | 40 | and chooses **Turn on deployments**. | |
| 34 | 41 | ||
| 35 | 42 | Production starts building at once from the default branch. Every pull | |
| 40 | 47 | ||
| 41 | 48 | ## What deploys | |
| 42 | 49 | ||
| 43 | − | g1t looks at the repository and builds it the way it is meant to be built. | |
| 50 | + | g1t looks at the project's root directory and builds it the way it is meant to be built. | |
| 44 | 51 | You do not configure anything for the common cases. | |
| 45 | 52 | ||
| 46 | − | | The repository has | g1t | | |
| 53 | + | | The project has | g1t | | |
| 47 | 54 | | --- | --- | | |
| 48 | 55 | | `wrangler.jsonc`, `wrangler.json` or `wrangler.toml` | Builds it as a **Workers project**: installs dependencies, runs `wrangler deploy --dry-run` to bundle it (which runs the config's own `build` command), and deploys the bundle with the config's static assets, `compatibility_date`, `compatibility_flags` and `vars`. | | |
| 49 | 56 | | A `build` script in `package.json` | Installs dependencies, runs `npm run build`, and serves the output as a **static site**. | | |
| 50 | − | | An `index.html` and nothing to build | Serves the repository as it is. | | |
| 57 | + | | An `index.html` and nothing to build | Serves it as it is. | | |
| 51 | 58 | ||
| 52 | 59 | Dependencies are installed by the lockfile that is there: `npm ci`, | |
| 53 | 60 | `pnpm install --frozen-lockfile`, `yarn install` or `bun install`, and | |
| 72 | 79 | are served under the binding name your config gives them. Cron triggers | |
| 73 | 80 | in the config are not scheduled. | |
| 74 | 81 | - `vars` are deployed as plain-text bindings (or JSON, for objects). Rows | |
| 75 | − | of the repository's [secrets and variables](/guides/secrets-and-variables/) | |
| 82 | + | of the project's [secrets and variables](/guides/secrets-and-variables/) | |
| 76 | 83 | available to Deployments are bound too, and replace a `var` of the same | |
| 77 | 84 | name: secrets as secret bindings. | |
| 78 | 85 | - **Not provisioned yet:** D1, KV, R2, Durable Objects, Queues, service | |
| 81 | 88 | lists each one it left out. Code that needs them should check that the | |
| 82 | 89 | binding is there. | |
| 83 | 90 | ||
| 84 | − | ## Previews on pull requests | |
| 91 | + | ## Previews of branches | |
| 85 | 92 | ||
| 86 | 93 | A preview is built when a pull request is opened, when it is marked ready, | |
| 87 | 94 | and on every push to it, including an agent's. Pull requests from forks, | |
| 112 | 119 | | | | | |
| 113 | 120 | | --- | --- | | |
| 114 | 121 | | Its pull request is merged or closed | That preview, at once. | | |
| 115 | − | | No one visits a preview for the repository's **idle days** | That preview, at the next sweep (every 10 minutes). The default is 7 days. | | |
| 122 | + | | No one visits a preview for the project's **idle days** | That preview, at the next sweep (every 10 minutes). The default is 7 days. | | |
| 116 | 123 | | You choose **Take down** on the Deployments page | That app, at once. | | |
| 117 | 124 | | You turn off previews or production | All of that kind, at once. | | |
| 118 | − | | You choose **Turn off deployments** | Every app of the repository, at once, and no more builds. | | |
| 125 | + | | You choose **Turn off deployments** | Every app of the project, at once, and no more builds. | | |
| 119 | 126 | | The workspace's plan ends or its payment fails | Every app of the workspace, at the next sweep. | | |
| 120 | 127 | ||
| 121 | 128 | A preview that came down comes back with the next push to its pull | |
| 123 | 130 | ||
| 124 | 131 | ## Settings | |
| 125 | 132 | ||
| 126 | − | Under the repository's **Settings → Deployments**, | |
| 133 | + | Under the project's **Settings → Deployments**, | |
| 127 | 134 | `g1t.sh/<workspace>/<repo>/settings/deployments`: | |
| 128 | 135 | ||
| 129 | 136 | | Setting | Default | | | |
| 130 | 137 | | --- | --- | --- | | |
| 131 | 138 | | Production | On | Deploy the default branch on every push. | | |
| 132 | − | | Previews | On | A preview for every open pull request. | | |
| 139 | + | | Previews | On | A preview for every branch with an open pull request. | | |
| 133 | 140 | | Build command | The project's own | Runs instead of `npm run build`, or before bundling a Workers project. | | |
| 134 | 141 | | Output directory | Found by itself | What a static site serves. | | |
| 135 | 142 | | Idle days | 7 | 1 to 90. A preview no one visits this long comes down. | | |
| 136 | 143 | ||
| 137 | 144 | ## Secrets and variables | |
| 138 | 145 | ||
| 139 | − | Builds and running apps read the repository's | |
| 146 | + | Builds and running apps read the project's | |
| 140 | 147 | [secrets and variables](/guides/secrets-and-variables/) that are | |
| 141 | 148 | available to Deployments, and the workspace's that reach it: | |
| 142 | 149 | ||
| 163 | 170 | ||
| 164 | 171 | | Included | | | |
| 165 | 172 | | --- | --- | | |
| 166 | − | | 10 apps | The most apps up at once: production and previews together, across the workspace's repositories. | | |
| 173 | + | | 10 apps | The most apps up at once: production and previews together, across the workspace's projects. | | |
| 167 | 174 | | 1 million requests | To all of the workspace's apps. | | |
| 168 | 175 | | 3 million CPU milliseconds | Time your code spends computing. Waiting on the network is not counted. | | |
| 169 | 176 | ||
| 198 | 205 | ||
| 199 | 206 | ## Turn it off | |
| 200 | 207 | ||
| 201 | − | - **For a repository:** **Turn off deployments** under its **Settings → | |
| 208 | + | - **For a project:** **Turn off deployments** under its **Settings → | |
| 202 | 209 | Deployments**. Every app comes down at once. Turning it on again | |
| 203 | 210 | rebuilds production. | |
| 204 | 211 | - **For the workspace:** an owner chooses **Turn off at the end of the |
| 101 | 101 | ||
| 102 | 102 | ### What a repository can ask for | |
| 103 | 103 | ||
| 104 | − | Under a repository's **Settings** tab, a member of its workspace sets the | |
| 104 | + | Under a project's **Settings → Repository**, a member of its workspace sets the | |
| 105 | 105 | rules its pull requests follow: | |
| 106 | 106 | ||
| 107 | 107 | | Setting | Default | What it does | | |
| 133 | 133 | ||
| 134 | 134 | A repository can land a g1t agent's pull request by itself once it is | |
| 135 | 135 | ready. A member of the workspace turns this on under the repository's | |
| 136 | − | **Settings** tab; it is off to begin with. The merge is recorded as made by | |
| 136 | + | **Settings → Repository**; it is off to begin with. The merge is recorded as made by | |
| 137 | 137 | `g1t`, the issue closes naming the pull request, and nothing short of | |
| 138 | 138 | ready is ever merged this way. One that is behind `main` is brought up to | |
| 139 | 139 | date as part of the merge. Pull requests from people and from other |
| 47 | 47 | ||
| 48 | 48 | ## Protected branches | |
| 49 | 49 | ||
| 50 | − | A repository can protect its default branch under **Settings**. Pushing to | |
| 50 | + | A repository can protect its default branch under **Settings → Repository**. Pushing to | |
| 51 | 51 | it is then refused, for members and agents alike, and git says why: | |
| 52 | 52 | ||
| 53 | 53 | ```text |
| 16 | 16 | ||
| 17 | 17 | ## Turn it on | |
| 18 | 18 | ||
| 19 | − | 1. Open the repository's **Settings** tab. You need to be a member of its | |
| 19 | + | 1. Open the project's **Settings → Repository**. You need to be a member of its | |
| 20 | 20 | workspace. | |
| 21 | 21 | 2. Turn on **Merge through a queue**. | |
| 22 | 22 | 3. Save. |
| 1 | + | --- | |
| 2 | + | title: Projects | |
| 3 | + | description: A project is what you build and run. Its code lives in its source; its deployments, secrets and variables belong to the project. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | A project is the thing you are building and running: a site, an API, a | |
| 7 | + | worker, a library. Every project has one **source**, where its code lives, | |
| 8 | + | and everything about running it belongs to the project: | |
| 9 | + | ||
| 10 | + | | Belongs to the project | Belongs to its repository | | |
| 11 | + | | --- | --- | | |
| 12 | + | | [Deployments](/guides/deployments/): production and previews | Branches and commits | | |
| 13 | + | | Its address on g1t.page | Issues and pull requests | | |
| 14 | + | | [Secrets and variables](/guides/secrets-and-variables/) | Review, merge rules and the [merge queue](/guides/merge-queue/) | | |
| 15 | + | | Its name, description and root directory | Webhooks | | |
| 16 | + | ||
| 17 | + | That split is what lets the code live anywhere while the project stays the | |
| 18 | + | same. | |
| 19 | + | ||
| 20 | + | ## Your projects | |
| 21 | + | ||
| 22 | + | Every repository on g1t is a project of its own name, with nothing to set | |
| 23 | + | up: `g1t.sh/acme/web` is the `web` project, built from the `acme/web` | |
| 24 | + | repository. Repositories made before projects existed became projects the | |
| 25 | + | first time their workspace was opened. | |
| 26 | + | ||
| 27 | + | A workspace's page, `g1t.sh/<workspace>`, shows its projects first, each | |
| 28 | + | with where it is deployed, its latest build, and its open issues and pull | |
| 29 | + | requests. The sidebar lists them too. | |
| 30 | + | ||
| 31 | + | ## A project's pages | |
| 32 | + | ||
| 33 | + | | Page | Address | | | |
| 34 | + | | --- | --- | --- | | |
| 35 | + | | **Overview** | `g1t.sh/<workspace>/<project>` | Production and its address, live previews, what is in progress, recent builds, and the source. | | |
| 36 | + | | **Code** | `…/code` | The repository's files, commits and branches. | | |
| 37 | + | | **Issues**, **Pull requests**, **Merge queue**, **Plan** | `…/issues` and so on | As they always were. | | |
| 38 | + | | **Actions** | `…/actions` | [GitHub Actions workflows](/guides/actions/). | | |
| 39 | + | | **Deployments** | `…/deployments` | What is up now and every build. | | |
| 40 | + | | **Settings** | `…/settings` | See below. | | |
| 41 | + | ||
| 42 | + | Every address that pointed into a repository before still works: the | |
| 43 | + | project has the repository's name. | |
| 44 | + | ||
| 45 | + | ## Settings | |
| 46 | + | ||
| 47 | + | A project's **Settings** has a tab for each part: | |
| 48 | + | ||
| 49 | + | | Tab | What it holds | | |
| 50 | + | | --- | --- | | |
| 51 | + | | **General** | The project's name and description, its source, and its **root directory**. | | |
| 52 | + | | **Deployments** | Production, previews, build command, output directory and idle days. See [Deployments](/guides/deployments/#settings). | | |
| 53 | + | | **Secrets and variables** | The project's rows. See [Secrets and variables](/guides/secrets-and-variables/). | | |
| 54 | + | | **Repository** | The repository's visibility, branch protection, required approvals, checks, the merge queue and auto-merge. | | |
| 55 | + | | **Webhooks** | The repository's [webhooks](/guides/webhooks/). | | |
| 56 | + | ||
| 57 | + | The **root directory** says where in the repository the project lives, | |
| 58 | + | such as `apps/web`. Builds run there. Leave it empty for the whole | |
| 59 | + | repository. | |
| 60 | + | ||
| 61 | + | ## Create a project | |
| 62 | + | ||
| 63 | + | 1. Choose **New project** in the sidebar, or go to `g1t.sh/new`. | |
| 64 | + | 2. Choose where its code comes from: | |
| 65 | + | - **Start empty**: a new repository on g1t. | |
| 66 | + | - **Import code**: copy a public repository from GitHub or any git host | |
| 67 | + | into a new one on g1t. | |
| 68 | + | 3. Give it a name and say who can see it, then choose **Create project**. | |
| 69 | + | ||
| 70 | + | Pushing a repository that does not exist yet makes one, and with it a | |
| 71 | + | project: | |
| 72 | + | ||
| 73 | + | ```sh | |
| 74 | + | git push https://g1t.sh/acme/my-app.git main | |
| 75 | + | ``` | |
| 76 | + | ||
| 77 | + | ## Sources | |
| 78 | + | ||
| 79 | + | Today a project's source is a repository hosted on g1t. Coming next: | |
| 80 | + | ||
| 81 | + | - **Mirrored from GitHub, GitLab or Bitbucket.** The code stays there; the | |
| 82 | + | project gets g1t's deployments, previews on its pull requests, secrets | |
| 83 | + | and agents. | |
| 84 | + | - **Several projects on one repository**, each from its own root | |
| 85 | + | directory, with a push building only the projects it touched. | |
| 86 | + | - **Dependencies between projects**: `web` uses `api`, so a preview of | |
| 87 | + | `web` can point at `api`'s preview, and an agent changing `api` knows | |
| 88 | + | what depends on it. | |
| 89 | + | ||
| 90 | + | ## From the API | |
| 91 | + | ||
| 92 | + | Projects keep their repository's routes: `/repos/{workspace}/{project}/…` | |
| 93 | + | reaches the project's repository, and its secrets and variables are the | |
| 94 | + | project's. See the [API reference](/reference/api/). |
| 3 | 3 | description: One list of keys and values for workflows and deployments. Each row says which environments it applies to and who reads it. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | − | A repository and a workspace each have one list of secrets and variables. | |
| 6 | + | A [project](/guides/projects/) and a workspace each have one list of | |
| 7 | + | secrets and variables. | |
| 7 | 8 | Workflows and deployments both read from it; each row says whether one, | |
| 8 | 9 | the other or both do, and which environments it applies to. | |
| 9 | 10 | ||
| 10 | 11 | | Where | Page | Who changes it | | |
| 11 | 12 | | --- | --- | --- | | |
| 12 | − | | A repository | **Settings → Secrets and variables**, `g1t.sh/<workspace>/<repo>/settings/secrets` | Members | | |
| 13 | + | | A project | **Settings → Secrets and variables**, `g1t.sh/<workspace>/<project>/settings/secrets` | Members | | |
| 13 | 14 | | A workspace | **Settings → Secrets and variables**, `g1t.sh/<workspace>/-/secrets` | Owners | | |
| 14 | 15 | ||
| 15 | 16 | ## A row | |
| 22 | 23 | | **Note** | Optional: where to rotate it, or who to ask. | | |
| 23 | 24 | | **Environments** | **All environments**, or only some: **Production**, **Preview**, or any name a workflow job uses in `environment:`, such as `staging`. | | |
| 24 | 25 | | **Available to** | **Workflows**, **Deployments**, or both (the default). | | |
| 25 | − | | **Repositories** | A workspace's row only: every repository, or the ones you choose. | | |
| 26 | + | | **Projects** | A workspace's row only: every project, or the ones you choose. | | |
| 26 | 27 | ||
| 27 | 28 | ### A value per environment | |
| 28 | 29 | ||
| 58 | 59 | else the row for all environments. A row only for other environments gives | |
| 59 | 60 | it nothing. | |
| 60 | 61 | ||
| 61 | − | A repository's row overrides its workspace's of the same key. The | |
| 62 | − | repository's list shows the workspace's rows that reach it, marked | |
| 62 | + | A project's row overrides its workspace's of the same key. The project's | |
| 63 | + | list shows the workspace's rows that reach it, marked | |
| 63 | 64 | **Workspace**, until it sets the key itself. | |
| 64 | 65 | ||
| 65 | 66 | A running app's rows are bound by g1t when it puts the app up; they never | |
| 121 | 122 | | `id` | The row to change or remove, from a list. | | |
| 122 | 123 | | `available_to` | `["workflows"]`, `["deployments"]` or both. `availableTo` works too. | | |
| 123 | 124 | | `environments` | `["production"]`, `["preview", "staging"]`; `[]` for all. | | |
| 124 | − | | `repositories` | A workspace's row: repository names; `[]` for every one. | | |
| 125 | + | | `projects` | A workspace's row: project names; `[]` for every one. (`repositories` is read the same way.) | | |
| 125 | 126 | | `note` | Where to rotate it, or who to ask. | | |
| 126 | 127 | ||
| 127 | 128 | ```sh |
| 12 | 12 | ||
| 13 | 13 | | | Sent the events of | Managed by | Where | | |
| 14 | 14 | | --- | --- | --- | --- | | |
| 15 | − | | A repository's | That repository | Members of its workspace | The repository's **Settings → Webhooks** | | |
| 15 | + | | A repository's | That repository | Members of its workspace | The project's **Settings → Webhooks** | | |
| 16 | 16 | | A workspace's | Every repository in the workspace | Owners | The workspace's **Settings → Webhooks** | | |
| 17 | 17 | ||
| 18 | 18 | ## Add one |
| 66 | 66 | | **Secrets and variables** | Members | [Rows every repository, or the ones linked, reads](/guides/secrets-and-variables/). Owners change them. | | |
| 67 | 67 | | **Webhooks** | Members | [Every repository's events](/guides/webhooks/), sent to your addresses. Owners manage them. | | |
| 68 | 68 | ||
| 69 | − | A repository's own settings are under **Settings** in its sidebar: | |
| 70 | − | **General**, **Deployments**, **Secrets and variables** and **Webhooks**. | |
| 69 | + | A [project's](/guides/projects/) own settings are under **Settings** in its | |
| 70 | + | sidebar: **General**, **Deployments**, **Secrets and variables**, | |
| 71 | + | **Repository** (merge rules and branch protection) and **Webhooks**. | |
| 71 | 72 | ||
| 72 | 73 | The arrow at the top of the settings goes back. | |
| 73 | 74 |
| 1 | + | /** Small pieces every page about deployments shares. */ | |
| 2 | + | import type { DeployStatus } from "@g1t/contracts"; | |
| 3 | + | ||
| 4 | + | export const DEPLOY_STATUS: Record<DeployStatus, { label: string; tone: string }> = { | |
| 5 | + | queued: { label: "Queued", tone: "text-muted" }, | |
| 6 | + | building: { label: "Building", tone: "text-warn" }, | |
| 7 | + | ready: { label: "Live", tone: "text-accent" }, | |
| 8 | + | failed: { label: "Failed", tone: "text-danger" }, | |
| 9 | + | skipped: { label: "Skipped", tone: "text-faint" }, | |
| 10 | + | }; | |
| 11 | + | ||
| 12 | + | export function StatusDot({ status, label }: { status: DeployStatus; label?: string }) { | |
| 13 | + | const { label: word, tone } = DEPLOY_STATUS[status]; | |
| 14 | + | return ( | |
| 15 | + | <span className={`inline-flex items-center gap-1.5 text-xs font-medium ${tone}`}> | |
| 16 | + | <span className={`size-1.5 rounded-full bg-current ${status === "building" || status === "queued" ? "animate-pulse" : ""}`} /> | |
| 17 | + | {label ?? word} | |
| 18 | + | </span> | |
| 19 | + | ); | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /** An address on g1t.page without its scheme. */ | |
| 23 | + | export function host(url: string): string { | |
| 24 | + | return url.replace(/^https?:\/\//, ""); | |
| 25 | + | } |
| 1 | − | import { Lock, Rocket, Settings, Webhook } from "lucide-react"; | |
| 1 | + | import { GitBranch, Lock, Rocket, Settings, Webhook } from "lucide-react"; | |
| 2 | 2 | ||
| 3 | 3 | import { TabLink } from "./ui"; | |
| 4 | 4 | ||
| 5 | − | /** The parts of a repository's settings. */ | |
| 5 | + | /** | |
| 6 | + | * A project's settings, and its repository's: what is about running it | |
| 7 | + | * first, then what is about the code. | |
| 8 | + | */ | |
| 6 | 9 | export function RepoSettingsTabs({ base }: { base: string }) { | |
| 7 | 10 | return ( | |
| 8 | 11 | <nav className="mb-8 flex gap-x-6 border-b border-line"> | |
| 15 | 18 | <TabLink to={`${base}/settings/secrets`} icon={<Lock size={15} />}> | |
| 16 | 19 | Secrets and variables | |
| 17 | 20 | </TabLink> | |
| 21 | + | <TabLink to={`${base}/settings/repository`} icon={<GitBranch size={15} />}> | |
| 22 | + | Repository | |
| 23 | + | </TabLink> | |
| 18 | 24 | <TabLink to={`${base}/settings/webhooks`} icon={<Webhook size={15} />}> | |
| 19 | 25 | Webhooks | |
| 20 | 26 | </TabLink> |
| 1 | 1 | /** | |
| 2 | − | * A repository's or a workspace's secrets and variables, as one list in the | |
| 2 | + | * A project's or a workspace's secrets and variables, as one list in the | |
| 3 | 3 | * way Vercel lists environment variables: each row is a key, its type | |
| 4 | 4 | * (Secret or Config), the environments it applies to and who reads it. | |
| 5 | 5 | * Adding and editing happen in a side panel, opened by `?add` or | |
| 35 | 35 | }: { | |
| 36 | 36 | data: SecretsData; | |
| 37 | 37 | action: SecretsAction | undefined; | |
| 38 | − | scope: "repository" | "workspace"; | |
| 38 | + | scope: "project" | "workspace"; | |
| 39 | 39 | manage: boolean; | |
| 40 | 40 | }) { | |
| 41 | 41 | const location = useLocation(); | |
| 68 | 68 | and <code className="text-fg">vars.KEY</code>), <strong className="font-medium text-fg">deployments</strong>{" "} | |
| 69 | 69 | (the build's environment and the running app's <code className="text-fg">env.KEY</code>), or both read it. | |
| 70 | 70 | {scope === "workspace" | |
| 71 | − | ? " Every repository, or the ones you link, reads the workspace's; a repository's own row of the same key wins." | |
| 72 | − | : " Rows from the workspace are shown too; adding the same key here replaces them for this repository."}{" "} | |
| 71 | + | ? " Every project, or the ones you link, reads the workspace's; a project's own row of the same key wins." | |
| 72 | + | : " Rows from the workspace are shown too; adding the same key here replaces them for this project."}{" "} | |
| 73 | 73 | <a href="https://docs.g1t.sh/guides/secrets-and-variables/" className="text-fg hover:underline"> | |
| 74 | 74 | How they are read | |
| 75 | 75 | </a> | |
| 135 | 135 | </div> | |
| 136 | 136 | ||
| 137 | 137 | {manage && (adding || row) && ( | |
| 138 | − | <Drawer row={row} scope={scope} repositories={data.repositories} error={action?.error} /> | |
| 138 | + | <Drawer row={row} scope={scope} projects={data.projects} error={action?.error} /> | |
| 139 | 139 | )} | |
| 140 | 140 | </div> | |
| 141 | 141 | ); | |
| 167 | 167 | <span className="rounded-full px-2 py-px text-xs text-muted ring-1 ring-line">Workspace</span> | |
| 168 | 168 | ) : ( | |
| 169 | 169 | <> | |
| 170 | − | {row.repositories.length > 0 && ( | |
| 171 | − | <span className="mr-1 text-xs text-faint" title={row.repositories.join(", ")}> | |
| 172 | − | {row.repositories.length} {row.repositories.length === 1 ? "repository" : "repositories"} | |
| 170 | + | {row.projects.length > 0 && ( | |
| 171 | + | <span className="mr-1 text-xs text-faint" title={row.projects.join(", ")}> | |
| 172 | + | {row.projects.length} {row.projects.length === 1 ? "project" : "projects"} | |
| 173 | 173 | </span> | |
| 174 | 174 | )} | |
| 175 | 175 | {manage && ( | |
| 206 | 206 | function Drawer({ | |
| 207 | 207 | row, | |
| 208 | 208 | scope, | |
| 209 | − | repositories, | |
| 209 | + | projects, | |
| 210 | 210 | error, | |
| 211 | 211 | }: { | |
| 212 | 212 | row: Setting | undefined; | |
| 213 | − | scope: "repository" | "workspace"; | |
| 214 | − | repositories: string[]; | |
| 213 | + | scope: "project" | "workspace"; | |
| 214 | + | projects: string[]; | |
| 215 | 215 | error: string | undefined; | |
| 216 | 216 | }) { | |
| 217 | 217 | const busy = useNavigation().state === "submitting"; | |
| 218 | 218 | const editing = !!row; | |
| 219 | 219 | const [type, setType] = useState<"secret" | "config">(row?.kind === "variable" ? "config" : "secret"); | |
| 220 | 220 | const [some, setSome] = useState(!!row && row.environments.length > 0); | |
| 221 | − | const [reach, setReach] = useState(row && row.repositories.length > 0 ? "some" : "all"); | |
| 221 | + | const [reach, setReach] = useState(row && row.projects.length > 0 ? "some" : "all"); | |
| 222 | 222 | const custom = row?.environments.filter((env) => !KNOWN_ENVIRONMENTS.includes(env)) ?? []; | |
| 223 | 223 | const field = | |
| 224 | 224 | "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"; | |
| 381 | 381 | ||
| 382 | 382 | {scope === "workspace" && ( | |
| 383 | 383 | <fieldset> | |
| 384 | − | <legend className="mb-2 text-sm font-medium text-muted">Repositories</legend> | |
| 384 | + | <legend className="mb-2 text-sm font-medium text-muted">Projects</legend> | |
| 385 | 385 | <div className="space-y-2 text-sm"> | |
| 386 | 386 | <label className="flex items-center gap-2"> | |
| 387 | 387 | <input type="radio" name="reach" value="all" checked={reach === "all"} onChange={() => setReach("all")} className="accent-accent" /> | |
| 388 | − | Every repository | |
| 388 | + | Every project | |
| 389 | 389 | </label> | |
| 390 | 390 | <label className="flex items-center gap-2"> | |
| 391 | 391 | <input type="radio" name="reach" value="some" checked={reach === "some"} onChange={() => setReach("some")} className="accent-accent" /> | |
| 393 | 393 | </label> | |
| 394 | 394 | {reach === "some" && ( | |
| 395 | 395 | <div className="ml-6 grid max-h-48 gap-1.5 overflow-y-auto sm:grid-cols-2"> | |
| 396 | − | {repositories.map((name) => ( | |
| 396 | + | {projects.map((name) => ( | |
| 397 | 397 | <label key={name} className="flex items-center gap-2 font-mono text-xs"> | |
| 398 | 398 | <input | |
| 399 | 399 | type="checkbox" | |
| 400 | − | name="repo" | |
| 400 | + | name="project" | |
| 401 | 401 | value={name} | |
| 402 | − | defaultChecked={row?.repositories.includes(name)} | |
| 402 | + | defaultChecked={row?.projects.includes(name)} | |
| 403 | 403 | className="accent-accent" | |
| 404 | 404 | /> | |
| 405 | 405 | {name} |
| 15 | 15 | History, | |
| 16 | 16 | Fingerprint, | |
| 17 | 17 | KeyRound, | |
| 18 | + | Box, | |
| 18 | 19 | Layers, | |
| 19 | 20 | LayoutDashboard, | |
| 21 | + | LayoutGrid, | |
| 20 | 22 | ListTree, | |
| 21 | 23 | Lock, | |
| 22 | 24 | LogOut, | |
| 52 | 54 | export type ShellData = { | |
| 53 | 55 | /** The workspace the sidebar is about: the one being looked at, or their first. */ | |
| 54 | 56 | workspace: { slug: string; role: "owner" | "member" } | null; | |
| 55 | − | /** Its repositories, newest first. */ | |
| 56 | − | repos: { namespace: string; name: string; isPrivate: boolean }[]; | |
| 57 | − | /** The repository being looked at, if any, whoever owns it. */ | |
| 57 | + | /** Its projects, by name: `name` is the slug in their address. */ | |
| 58 | + | repos: { namespace: string; name: string; title?: string; isPrivate: boolean }[]; | |
| 59 | + | /** The project being looked at, if any, whoever owns it. */ | |
| 58 | 60 | repo: { | |
| 59 | 61 | namespace: string; | |
| 60 | 62 | name: string; | |
| 83 | 85 | icon: ReactNode; | |
| 84 | 86 | end?: boolean; | |
| 85 | 87 | count?: number; | |
| 86 | − | /** Another path prefix under which this link is the current one. */ | |
| 87 | − | also?: string; | |
| 88 | + | /** Other path prefixes under which this link is the current one. */ | |
| 89 | + | also?: string | string[]; | |
| 88 | 90 | children: ReactNode; | |
| 89 | 91 | }) { | |
| 90 | 92 | const { pathname } = useLocation(); | |
| 94 | 96 | end={end} | |
| 95 | 97 | prefetch="intent" | |
| 96 | 98 | className={({ isActive, isPending }) => { | |
| 97 | − | const current = isActive || (also != null && pathname.startsWith(also + "/")); | |
| 99 | + | const current = | |
| 100 | + | isActive || [also ?? []].flat().some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/")); | |
| 98 | 101 | return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${ | |
| 99 | 102 | current | |
| 100 | 103 | ? "bg-raised font-medium text-fg" | |
| 334 | 337 | } | |
| 335 | 338 | ||
| 336 | 339 | /** | |
| 337 | − | * A repository's own menu, which the sidebar slides to while you are in | |
| 338 | − | * it, as it does for settings: everything about the repository and nothing | |
| 339 | − | * else, with the way back to everything. | |
| 340 | + | * A project's own menu, which the sidebar slides to while you are in it, | |
| 341 | + | * as it does for settings: everything about the project, running and its | |
| 342 | + | * code, and nothing else, with the way back to everything. | |
| 340 | 343 | */ | |
| 341 | 344 | function RepoMenu({ repo, isPrivate, open }: { repo: MenuRepo; isPrivate: boolean; open: boolean }) { | |
| 342 | 345 | const base = `/${repo.namespace}/${repo.name}`; | |
| 354 | 357 | className="mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-raised/60" | |
| 355 | 358 | > | |
| 356 | 359 | <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line"> | |
| 357 | − | {isPrivate ? <Lock size={13} /> : <BookMarked size={13} />} | |
| 360 | + | {isPrivate ? <Lock size={13} /> : <Box size={13} />} | |
| 358 | 361 | </span> | |
| 359 | 362 | <span className="min-w-0 truncate font-mono text-[0.8125rem]"> | |
| 360 | 363 | <span className="text-faint">{repo.namespace}/</span> | |
| 361 | 364 | <span className="font-semibold text-fg">{repo.name}</span> | |
| 362 | 365 | </span> | |
| 363 | 366 | </Link> | |
| 367 | + | <div className="mt-3 space-y-px"> | |
| 368 | + | <SidebarLink to={base} end icon={<LayoutGrid size={15} />}> | |
| 369 | + | Overview | |
| 370 | + | </SidebarLink> | |
| 371 | + | {repo.member && ( | |
| 372 | + | <SidebarLink to={`${base}/deployments`} icon={<Rocket size={15} />}> | |
| 373 | + | Deployments | |
| 374 | + | </SidebarLink> | |
| 375 | + | )} | |
| 376 | + | </div> | |
| 364 | 377 | <SidebarGroup title="Code"> | |
| 365 | − | <SidebarLink to={base} end icon={<Code2 size={15} />} also={`${base}/tree`}> | |
| 378 | + | <SidebarLink to={`${base}/code`} icon={<Code2 size={15} />} also={[`${base}/tree`, `${base}/blob`]}> | |
| 366 | 379 | Code | |
| 367 | 380 | </SidebarLink> | |
| 368 | 381 | <SidebarLink to={`${base}/commits`} also={`${base}/commit`} icon={<History size={15} />}> | |
| 389 | 402 | <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}> | |
| 390 | 403 | Actions | |
| 391 | 404 | </SidebarLink> | |
| 392 | − | {repo.member && ( | |
| 393 | − | <SidebarLink to={`${base}/deployments`} icon={<Rocket size={15} />}> | |
| 394 | − | Deployments | |
| 395 | − | </SidebarLink> | |
| 396 | − | )} | |
| 397 | 405 | </SidebarGroup> | |
| 398 | 406 | {repo.member && ( | |
| 399 | − | <SidebarGroup title="Repository"> | |
| 407 | + | <SidebarGroup title="Project"> | |
| 400 | 408 | <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />}> | |
| 401 | 409 | Settings | |
| 402 | 410 | </SidebarLink> | |
| 528 | 536 | </div> | |
| 529 | 537 | ||
| 530 | 538 | <SidebarGroup | |
| 531 | − | title="Repositories" | |
| 539 | + | title="Projects" | |
| 532 | 540 | action={ | |
| 533 | 541 | ws && ( | |
| 534 | 542 | <Link | |
| 535 | 543 | to={`/new?workspace=${ws.slug}`} | |
| 536 | − | aria-label="New repository" | |
| 544 | + | aria-label="New project" | |
| 537 | 545 | className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg" | |
| 538 | 546 | > | |
| 539 | 547 | <Plus size={13} /> | |
| 550 | 558 | <div key={base}> | |
| 551 | 559 | <SidebarLink | |
| 552 | 560 | to={base} | |
| 553 | − | icon={repo.isPrivate ? <Lock size={15} /> : <BookMarked size={15} />} | |
| 561 | + | icon={repo.isPrivate ? <Lock size={15} /> : <Box size={15} />} | |
| 554 | 562 | > | |
| 555 | − | <span className="font-mono text-[0.8125rem]"> | |
| 563 | + | <span className="text-[0.8125rem]"> | |
| 556 | 564 | {repo.namespace !== ws?.slug && ( | |
| 557 | − | <span className="text-faint">{repo.namespace}/</span> | |
| 565 | + | <span className="font-mono text-faint">{repo.namespace}/</span> | |
| 558 | 566 | )} | |
| 559 | − | {repo.name} | |
| 567 | + | {"title" in repo && repo.title ? repo.title : repo.name} | |
| 560 | 568 | </span> | |
| 561 | 569 | </SidebarLink> | |
| 562 | 570 | </div> | |
| 615 | 623 | plans: "Plan", | |
| 616 | 624 | actions: "Actions", | |
| 617 | 625 | deployments: "Deployments", | |
| 626 | + | repository: "Repository", | |
| 627 | + | code: "Code", | |
| 618 | 628 | secrets: "Secrets and variables", | |
| 619 | 629 | settings: "Settings", | |
| 620 | 630 | people: "Members", | |
| 690 | 700 | const commands: Command[] = [ | |
| 691 | 701 | { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> }, | |
| 692 | 702 | { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> }, | |
| 693 | − | { label: "New repository", to: "/new", icon: <Plus size={15} /> }, | |
| 703 | + | { label: "New project", to: "/new", icon: <Plus size={15} /> }, | |
| 694 | 704 | { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> }, | |
| 695 | 705 | { label: "Your settings", to: "/settings", icon: <Settings size={15} /> }, | |
| 696 | 706 | ]; | |
| 699 | 709 | const base = `/${repo.namespace}/${repo.name}`; | |
| 700 | 710 | const name = `${repo.namespace}/${repo.name}`; | |
| 701 | 711 | commands.unshift( | |
| 712 | + | { label: "Overview", hint: name, to: base, icon: <LayoutGrid size={15} /> }, | |
| 713 | + | { label: "Code", hint: name, to: `${base}/code`, icon: <Code2 size={15} /> }, | |
| 702 | 714 | { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> }, | |
| 703 | 715 | { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> }, | |
| 704 | 716 | { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> }, | |
| 706 | 718 | ...(repo.member | |
| 707 | 719 | ? [ | |
| 708 | 720 | { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> }, | |
| 709 | − | { label: "Repository settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> }, | |
| 721 | + | { label: "Deployments", hint: name, to: `${base}/deployments`, icon: <Rocket size={15} /> }, | |
| 722 | + | { label: "Secrets and variables", hint: name, to: `${base}/settings/secrets`, icon: <Lock size={15} /> }, | |
| 723 | + | { label: "Project settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> }, | |
| 724 | + | { label: "Repository settings", hint: name, to: `${base}/settings/repository`, icon: <Settings size={15} /> }, | |
| 710 | 725 | ] | |
| 711 | 726 | : []), | |
| 712 | 727 | ); | |
| 715 | 730 | commands.push( | |
| 716 | 731 | { label: membership.slug, hint: "Workspace", to: `/${membership.slug}`, icon: <Avatar name={membership.slug} size={15} square /> }, | |
| 717 | 732 | { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> }, | |
| 718 | − | { label: "Billing", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> }, | |
| 733 | + | { label: "Billing and plans", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> }, | |
| 719 | 734 | { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> }, | |
| 720 | 735 | { label: "Integrations", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/integrations`, icon: <Plug size={15} /> }, | |
| 721 | 736 | ); | |
| 722 | 737 | } | |
| 723 | 738 | for (const listed of shell.repos) { | |
| 724 | 739 | commands.push({ | |
| 725 | − | label: `${listed.namespace}/${listed.name}`, | |
| 726 | − | hint: "Repository", | |
| 740 | + | label: listed.title ?? listed.name, | |
| 741 | + | hint: `${listed.namespace} · Project`, | |
| 727 | 742 | to: `/${listed.namespace}/${listed.name}`, | |
| 728 | − | icon: <BookMarked size={15} />, | |
| 743 | + | icon: <Box size={15} />, | |
| 729 | 744 | }); | |
| 730 | 745 | } | |
| 731 | 746 | return commands; | |
| 979 | 994 | )} | |
| 980 | 995 | <DropdownMenuItem asChild> | |
| 981 | 996 | <Link to="/new"> | |
| 982 | − | <BookMarked /> | |
| 983 | − | New repository | |
| 997 | + | <Box /> | |
| 998 | + | New project | |
| 984 | 999 | </Link> | |
| 985 | 1000 | </DropdownMenuItem> | |
| 986 | 1001 | <DropdownMenuItem asChild> |
| 2 | 2 | ||
| 3 | 3 | import type { Setting, SettingKind, SettingReader, SettingsOwner, User } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | − | import { actions, repos } from "./services.server"; | |
| 5 | + | import { actions, projects } from "./services.server"; | |
| 6 | 6 | ||
| 7 | 7 | export type SecretsData = { | |
| 8 | 8 | rows: Setting[]; | |
| 9 | − | /** For a workspace: its repositories, to link rows to. */ | |
| 10 | − | repositories: string[]; | |
| 9 | + | /** For a workspace: its projects' slugs, to link rows to. */ | |
| 10 | + | projects: string[]; | |
| 11 | 11 | error: string | null; | |
| 12 | 12 | }; | |
| 13 | 13 | ||
| 15 | 15 | export async function loadSecrets(owner: SettingsOwner, actor: User): Promise<SecretsData> { | |
| 16 | 16 | const [rows, list] = await Promise.all([ | |
| 17 | 17 | actions.settings(actor, owner, "all"), | |
| 18 | − | "workspace" in owner ? repos.list(actor, { namespace: owner.workspace }) : Promise.resolve(null), | |
| 18 | + | "workspace" in owner ? projects.list(owner.workspace, actor) : Promise.resolve(null), | |
| 19 | 19 | ]); | |
| 20 | 20 | return { | |
| 21 | 21 | rows: rows.ok ? rows.value : [], | |
| 22 | − | repositories: Array.isArray(list) ? list.filter((repo) => !repo.forkOf).map((repo) => repo.name).sort() : [], | |
| 22 | + | projects: list?.ok ? list.value.map((project) => project.slug) : [], | |
| 23 | 23 | error: rows.ok ? null : rows.error.message, | |
| 24 | 24 | }; | |
| 25 | 25 | } | |
| 69 | 69 | } | |
| 70 | 70 | const availableTo = form.getAll("availableTo").map(String) as SettingReader[]; | |
| 71 | 71 | if (availableTo.length === 0) return { error: "Choose who reads it: Workflows, Deployments, or both." }; | |
| 72 | − | const repositories = | |
| 73 | − | "workspace" in owner && form.get("reach") === "some" ? form.getAll("repo").map(String) : []; | |
| 72 | + | const linked = | |
| 73 | + | "workspace" in owner && form.get("reach") === "some" ? form.getAll("project").map(String) : []; | |
| 74 | 74 | const note = String(form.get("note") ?? ""); | |
| 75 | 75 | const key = String(form.get("key") ?? "").trim(); | |
| 76 | 76 | const value = String(form.get("value") ?? ""); | |
| 84 | 84 | id, | |
| 85 | 85 | availableTo, | |
| 86 | 86 | environments, | |
| 87 | − | repositories: "workspace" in owner ? repositories : undefined, | |
| 87 | + | projects: "workspace" in owner ? linked : undefined, | |
| 88 | 88 | note, | |
| 89 | 89 | }); | |
| 90 | 90 | if (!saved.ok) return { error: pasted.length > 0 ? `${name}: ${saved.error.message}` : saved.error.message }; |
| 4 | 4 | actionsClient, | |
| 5 | 5 | billingClient, | |
| 6 | 6 | deploymentsClient, | |
| 7 | + | projectsClient, | |
| 7 | 8 | eventsClient, | |
| 8 | 9 | identityClient, | |
| 9 | 10 | integrationsClient, | |
| 21 | 22 | export const webhooks = webhooksClient(env.WEBHOOKS); | |
| 22 | 23 | export const actions = actionsClient(env.ACTIONS); | |
| 23 | 24 | export const deployments = deploymentsClient(env.DEPLOYMENTS); | |
| 25 | + | export const projects = projectsClient(env.PROJECTS); |
| 37 | 37 | DropdownMenuTrigger, | |
| 38 | 38 | } from "./components/ui/dropdown-menu"; | |
| 39 | 39 | import { AppShell, Progress, type ShellData } from "./components/shell"; | |
| 40 | − | import { billing, repos, work } from "./lib/services.server"; | |
| 40 | + | import { billing, projects, work } from "./lib/services.server"; | |
| 41 | 41 | import { getViewer, roleIn, viewerMiddleware } from "./lib/session.server"; | |
| 42 | 42 | ||
| 43 | 43 | export const links: Route.LinksFunction = () => [ | |
| 90 | 90 | const now = new Date(); | |
| 91 | 91 | const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString(); | |
| 92 | 92 | const [listed, counts, account, usage] = await Promise.all([ | |
| 93 | − | workspace ? repos.list(user, { namespace: workspace.slug }) : Promise.resolve([]), | |
| 93 | + | workspace ? projects.list(workspace.slug, user) : Promise.resolve(null), | |
| 94 | 94 | path ? work.counts(path, user) : Promise.resolve(null), | |
| 95 | 95 | workspace ? billing.account(workspace.slug, user) : Promise.resolve(null), | |
| 96 | 96 | workspace ? billing.usage(workspace.slug, user, monthStart) : Promise.resolve(null), | |
| 97 | 97 | ]); | |
| 98 | 98 | return { | |
| 99 | 99 | workspace, | |
| 100 | − | repos: listed | |
| 101 | − | .filter((repo) => !repo.forkOf) | |
| 102 | − | .map(({ namespace, name, isPrivate }) => ({ namespace, name, isPrivate })), | |
| 100 | + | // Projects are what the sidebar lists: what the workspace builds and runs. | |
| 101 | + | repos: listed?.ok | |
| 102 | + | ? listed.value.map((project) => ({ | |
| 103 | + | namespace: project.workspace, | |
| 104 | + | name: project.slug, | |
| 105 | + | title: project.name, | |
| 106 | + | isPrivate: project.private, | |
| 107 | + | })) | |
| 108 | + | : [], | |
| 103 | 109 | repo: | |
| 104 | 110 | path && counts?.ok | |
| 105 | 111 | ? { |
| 29 | 29 | ]), | |
| 30 | 30 | // Why a line is the way it is, fetched by the blame view. | |
| 31 | 31 | route(":owner/:repo/why/:hash", "routes/repo/why.ts"), | |
| 32 | + | // A project: its overview first, its repository's code under Code. The | |
| 33 | + | // 1:1 project of a repository has the repository's name, so every | |
| 34 | + | // repository address below keeps working. | |
| 32 | 35 | route(":owner/:repo", "routes/repo/layout.tsx", [ | |
| 33 | − | index("routes/repo/code.tsx"), | |
| 36 | + | index("routes/repo/overview.tsx"), | |
| 37 | + | route("code", "routes/repo/code.tsx"), | |
| 34 | 38 | route("tree/:ref/*", "routes/repo/tree.tsx"), | |
| 35 | 39 | route("blob/:ref/*", "routes/repo/blob.tsx"), | |
| 36 | 40 | route("commits", "routes/repo/commits.tsx"), | |
| 51 | 55 | route("plans", "routes/repo/plans.tsx"), | |
| 52 | 56 | route("plans/:id", "routes/repo/plan.tsx"), | |
| 53 | 57 | route("settings", "routes/repo/settings.tsx"), | |
| 58 | + | route("settings/repository", "routes/repo/settings-repository.tsx"), | |
| 54 | 59 | route("settings/webhooks", "routes/repo/webhooks.tsx"), | |
| 55 | 60 | route("settings/secrets", "routes/repo/secrets.tsx"), | |
| 56 | 61 | route("settings/deployments", "routes/repo/settings-deployments.tsx"), |
| 1 | − | import { Form, redirect } from "react-router"; | |
| 1 | + | import { Box, Download, GitBranch, Sparkles } from "lucide-react"; | |
| 2 | + | import { useState } from "react"; | |
| 3 | + | import { Form, redirect, useNavigation } from "react-router"; | |
| 2 | 4 | ||
| 3 | 5 | import type { Route } from "./+types/new"; | |
| 4 | 6 | import { Button, ErrorText, Field, Input } from "../components/ui"; | |
| 6 | 8 | import { assertSameOrigin, requireUser } from "../lib/session.server"; | |
| 7 | 9 | ||
| 8 | 10 | export function meta({}: Route.MetaArgs) { | |
| 9 | − | return [{ title: "New repository · g1t" }]; | |
| 11 | + | return [{ title: "New project · g1t" }]; | |
| 10 | 12 | } | |
| 11 | 13 | ||
| 12 | 14 | export function loader({ request, context }: Route.LoaderArgs) { | |
| 13 | 15 | const user = requireUser(context, request); | |
| 14 | 16 | const workspaces = (user.workspaces ?? []).map((membership) => membership.slug); | |
| 15 | − | // Repositories live in a workspace, so there has to be one first. | |
| 17 | + | // Projects live in a workspace, so there has to be one first. | |
| 16 | 18 | if (workspaces.length === 0) throw redirect("/workspaces/new"); | |
| 17 | 19 | const asked = new URL(request.url).searchParams.get("workspace"); | |
| 18 | 20 | return { | |
| 25 | 27 | assertSameOrigin(request); | |
| 26 | 28 | const user = requireUser(context, request); | |
| 27 | 29 | const form = await request.formData(); | |
| 30 | + | // A project hosted on g1t: its repository is made with it, and the | |
| 31 | + | // project takes the repository's name. | |
| 28 | 32 | const result = await repos.create(user, { | |
| 29 | 33 | namespace: String(form.get("workspace") ?? ""), | |
| 30 | 34 | name: String(form.get("name") ?? ""), | |
| 31 | 35 | description: String(form.get("description") ?? ""), | |
| 32 | 36 | isPrivate: form.get("visibility") === "private", | |
| 33 | − | importUrl: String(form.get("importUrl") ?? "").trim() || undefined, | |
| 37 | + | importUrl: form.get("source") === "import" ? String(form.get("importUrl") ?? "").trim() || undefined : undefined, | |
| 34 | 38 | }); | |
| 35 | 39 | if (!result.ok) return { error: result.error.message }; | |
| 36 | 40 | throw redirect(`/${result.value.namespace}/${result.value.name}`); | |
| 37 | 41 | } | |
| 38 | 42 | ||
| 39 | − | export default function NewRepo({ | |
| 40 | − | loaderData, | |
| 41 | − | actionData, | |
| 42 | − | }: Route.ComponentProps) { | |
| 43 | + | type Source = "empty" | "import"; | |
| 44 | + | ||
| 45 | + | const SOURCES: { id: Source | "mirror"; title: string; text: string; icon: React.ReactNode; soon?: boolean }[] = [ | |
| 46 | + | { | |
| 47 | + | id: "empty", | |
| 48 | + | title: "Start empty", | |
| 49 | + | text: "A new repository on g1t. Push to it, or hand an agent the first issue.", | |
| 50 | + | icon: <Sparkles size={16} />, | |
| 51 | + | }, | |
| 52 | + | { | |
| 53 | + | id: "import", | |
| 54 | + | title: "Import code", | |
| 55 | + | text: "Copy a public repository from GitHub or any git host into a new one on g1t.", | |
| 56 | + | icon: <Download size={16} />, | |
| 57 | + | }, | |
| 58 | + | { | |
| 59 | + | id: "mirror", | |
| 60 | + | title: "Mirror GitHub, GitLab or Bitbucket", | |
| 61 | + | text: "Keep the code where it is and get g1t's deployments and agents on it.", | |
| 62 | + | icon: <GitBranch size={16} />, | |
| 63 | + | soon: true, | |
| 64 | + | }, | |
| 65 | + | ]; | |
| 66 | + | ||
| 67 | + | export default function NewProject({ loaderData, actionData }: Route.ComponentProps) { | |
| 68 | + | const [source, setSource] = useState<Source>("empty"); | |
| 69 | + | const busy = useNavigation().state === "submitting"; | |
| 43 | 70 | return ( | |
| 44 | − | <main className="mx-auto max-w-lg px-4 py-12"> | |
| 45 | − | <h1 className="text-xl font-semibold">New repository</h1> | |
| 46 | − | <Form method="post" className="mt-8 space-y-4"> | |
| 47 | − | <Field label="Name"> | |
| 71 | + | <main className="mx-auto max-w-2xl px-4 py-12"> | |
| 72 | + | <span className="flex size-10 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/30"> | |
| 73 | + | <Box size={18} /> | |
| 74 | + | </span> | |
| 75 | + | <h1 className="mt-4 text-2xl font-semibold tracking-tight">New project</h1> | |
| 76 | + | <p className="mt-1.5 text-sm text-muted"> | |
| 77 | + | A project is what you build and run. Its code lives in its source; its deployments, environments, secrets and | |
| 78 | + | variables belong to the project. | |
| 79 | + | </p> | |
| 80 | + | ||
| 81 | + | <Form method="post" className="mt-8 space-y-8"> | |
| 82 | + | <fieldset> | |
| 83 | + | <legend className="mb-3 text-sm font-medium text-muted">Where its code comes from</legend> | |
| 84 | + | <div className="grid gap-3 sm:grid-cols-3"> | |
| 85 | + | {SOURCES.map((option) => { | |
| 86 | + | const chosen = source === option.id; | |
| 87 | + | return ( | |
| 88 | + | <label | |
| 89 | + | key={option.id} | |
| 90 | + | className={`relative rounded-xl border p-4 transition-colors ${ | |
| 91 | + | option.soon | |
| 92 | + | ? "cursor-not-allowed border-dashed border-line opacity-60" | |
| 93 | + | : chosen | |
| 94 | + | ? "cursor-pointer border-accent bg-accent/5" | |
| 95 | + | : "cursor-pointer border-line hover:border-line-strong" | |
| 96 | + | }`} | |
| 97 | + | > | |
| 98 | + | <input | |
| 99 | + | type="radio" | |
| 100 | + | name="source" | |
| 101 | + | value={option.id} | |
| 102 | + | checked={chosen} | |
| 103 | + | disabled={option.soon} | |
| 104 | + | onChange={() => !option.soon && setSource(option.id as Source)} | |
| 105 | + | className="sr-only" | |
| 106 | + | /> | |
| 107 | + | <span className={chosen ? "text-accent" : "text-muted"}>{option.icon}</span> | |
| 108 | + | <span className="mt-2 block text-sm font-medium">{option.title}</span> | |
| 109 | + | <span className="mt-1 block text-xs text-muted">{option.text}</span> | |
| 110 | + | {option.soon && ( | |
| 111 | + | <span className="absolute top-3 right-3 rounded-full bg-raised px-2 py-0.5 text-[0.6875rem] text-muted ring-1 ring-line"> | |
| 112 | + | Soon | |
| 113 | + | </span> | |
| 114 | + | )} | |
| 115 | + | </label> | |
| 116 | + | ); | |
| 117 | + | })} | |
| 118 | + | </div> | |
| 119 | + | </fieldset> | |
| 120 | + | ||
| 121 | + | {source === "import" && ( | |
| 122 | + | <Field | |
| 123 | + | label="Repository to import" | |
| 124 | + | hint="The address of a public repository. Its default branch is copied, up to 40 MB." | |
| 125 | + | > | |
| 126 | + | <Input name="importUrl" type="url" required placeholder="https://github.com/owner/repo" /> | |
| 127 | + | </Field> | |
| 128 | + | )} | |
| 129 | + | ||
| 130 | + | <Field label="Name" hint="The project and its repository share it: g1t.sh/<workspace>/<name>."> | |
| 48 | 131 | <div className="flex items-center gap-2 font-mono text-sm"> | |
| 49 | 132 | <select | |
| 50 | 133 | name="workspace" | |
| 59 | 142 | ))} | |
| 60 | 143 | </select> | |
| 61 | 144 | <span className="text-muted">/</span> | |
| 62 | − | <Input name="name" required autoFocus maxLength={100} /> | |
| 145 | + | <Input name="name" required autoFocus maxLength={100} placeholder="my-app" /> | |
| 63 | 146 | </div> | |
| 64 | 147 | </Field> | |
| 65 | 148 | <Field label="Description (optional)"> | |
| 66 | − | <Input name="description" maxLength={200} /> | |
| 67 | − | </Field> | |
| 68 | − | <Field | |
| 69 | − | label="Import from (optional)" | |
| 70 | − | hint="The address of a public repository on GitHub or any git host. Its default branch is copied, up to 40 MB. Leave empty to start with nothing." | |
| 71 | − | > | |
| 72 | − | <Input | |
| 73 | − | name="importUrl" | |
| 74 | − | type="url" | |
| 75 | − | placeholder="https://github.com/owner/repo" | |
| 76 | − | /> | |
| 149 | + | <Input name="description" maxLength={200} placeholder="What it is, in a line" /> | |
| 77 | 150 | </Field> | |
| 78 | 151 | <fieldset className="space-y-2 text-sm"> | |
| 152 | + | <legend className="mb-2 font-medium text-muted">Who can see it</legend> | |
| 79 | 153 | <label className="flex items-center gap-2"> | |
| 80 | − | <input type="radio" name="visibility" value="public" defaultChecked /> | |
| 81 | − | Public — anyone can see and clone it | |
| 154 | + | <input type="radio" name="visibility" value="public" defaultChecked className="accent-accent" /> | |
| 155 | + | Public: anyone can see its code and open issues | |
| 82 | 156 | </label> | |
| 83 | 157 | <label className="flex items-center gap-2"> | |
| 84 | − | <input type="radio" name="visibility" value="private" /> | |
| 85 | − | Private — only members of the workspace | |
| 158 | + | <input type="radio" name="visibility" value="private" className="accent-accent" /> | |
| 159 | + | Private: only members of the workspace | |
| 86 | 160 | </label> | |
| 87 | 161 | </fieldset> | |
| 88 | 162 | <ErrorText>{actionData?.error}</ErrorText> | |
| 89 | − | <Button type="submit">Create repository</Button> | |
| 163 | + | <Button type="submit" variant="accent" disabled={busy}> | |
| 164 | + | Create project | |
| 165 | + | </Button> | |
| 90 | 166 | </Form> | |
| 91 | 167 | </main> | |
| 92 | 168 | ); |
| 13 | 13 | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 14 | 14 | const viewer = getViewer(context); | |
| 15 | 15 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 16 | − | const found = await deployments.get({ namespace: params.owner, name: params.repo }, params.id, viewer); | |
| 16 | + | const found = await deployments.get({ workspace: params.owner, slug: params.repo }, params.id, viewer); | |
| 17 | 17 | if (!found.ok) throw data(null, { status: 404 }); | |
| 18 | 18 | return { build: found.value }; | |
| 19 | 19 | } | |
| 38 | 38 | <h1 className="mt-3 text-xl font-semibold tracking-tight"> | |
| 39 | 39 | {build.kind === "production" ? "Production" : ( | |
| 40 | 40 | <> | |
| 41 | − | Preview of{" "} | |
| 42 | − | <Link to={`${base}/pull/${build.number}`} className="hover:underline"> | |
| 43 | − | #{build.number} | |
| 44 | − | </Link> | |
| 41 | + | Preview of <span className="font-mono">{build.branch}</span> | |
| 42 | + | {build.number != null && ( | |
| 43 | + | <Link to={`${base}/pull/${build.number}`} className="ml-2 text-base font-normal text-muted hover:underline"> | |
| 44 | + | #{build.number} | |
| 45 | + | </Link> | |
| 46 | + | )} | |
| 45 | 47 | </> | |
| 46 | 48 | )} | |
| 47 | 49 | <span className="ml-3 font-mono text-sm font-normal text-faint">{build.commit.slice(0, 12)}</span> |
| 2 | 2 | import type { ReactNode } from "react"; | |
| 3 | 3 | import { Form, Link, data, useNavigation } from "react-router"; | |
| 4 | 4 | ||
| 5 | − | import type { Deployment, DeployStatus, FeatureState } from "@g1t/contracts"; | |
| 5 | + | import type { Deployment, FeatureState } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | + | import { host, StatusDot } from "../../components/deploy"; | |
| 8 | + | ||
| 7 | 9 | import type { Route } from "./+types/deployments"; | |
| 8 | 10 | import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "../../components/ui"; | |
| 9 | 11 | import { billing, deployments } from "../../lib/services.server"; | |
| 18 | 20 | const role = roleIn(viewer, params.owner); | |
| 19 | 21 | // Members only; to anyone else the page does not exist. | |
| 20 | 22 | if (!role) throw data(null, { status: 404 }); | |
| 21 | − | const path = { namespace: params.owner, name: params.repo }; | |
| 23 | + | const ref = { workspace: params.owner, slug: params.repo }; | |
| 22 | 24 | const [settings, list, features] = await Promise.all([ | |
| 23 | − | deployments.settings(path, viewer), | |
| 24 | − | deployments.list(path, viewer), | |
| 25 | + | deployments.settings(ref, viewer), | |
| 26 | + | deployments.list(ref, viewer), | |
| 25 | 27 | billing.features(params.owner, viewer), | |
| 26 | 28 | ]); | |
| 27 | 29 | const plan = unwrap(features).find((state) => state.plan.feature === "deployments") ?? null; | |
| 32 | 34 | assertSameOrigin(request); | |
| 33 | 35 | const user = requireUser(context, request); | |
| 34 | 36 | const form = await request.formData(); | |
| 35 | − | const path = { namespace: params.owner, name: params.repo }; | |
| 37 | + | const ref = { workspace: params.owner, slug: params.repo }; | |
| 36 | 38 | const intent = form.get("intent"); | |
| 37 | − | const number = form.get("number") ? Number(form.get("number")) : null; | |
| 39 | + | const branch = form.get("branch") ? String(form.get("branch")) : null; | |
| 38 | 40 | if (intent === "redeploy") { | |
| 39 | − | const started = await deployments.redeploy(user, path, number); | |
| 41 | + | const started = await deployments.redeploy(user, ref, branch); | |
| 40 | 42 | return started.ok ? { notice: "Build started." } : { error: started.error.message }; | |
| 41 | 43 | } | |
| 42 | 44 | if (intent === "take-down") { | |
| 43 | − | const done = await deployments.takeDown(user, path, number); | |
| 45 | + | const done = await deployments.takeDown(user, ref, branch); | |
| 44 | 46 | return done.ok ? { notice: "Taken down." } : { error: done.error.message }; | |
| 45 | 47 | } | |
| 46 | 48 | if (intent === "enable" || intent === "disable") { | |
| 47 | − | const saved = await deployments.updateSettings(user, path, { enabled: intent === "enable" }); | |
| 49 | + | const saved = await deployments.updateSettings(user, ref, { enabled: intent === "enable" }); | |
| 48 | 50 | return saved.ok | |
| 49 | 51 | ? { notice: intent === "enable" ? "Deployments are on. Production is building." : "Deployments are off, and every app is down." } | |
| 50 | 52 | : { error: saved.error.message }; | |
| 51 | 53 | } | |
| 52 | 54 | return { error: "Unknown request." }; | |
| 53 | 55 | } | |
| 54 | − | ||
| 55 | − | const STATUS: Record<DeployStatus, { label: string; tone: string }> = { | |
| 56 | − | queued: { label: "Queued", tone: "text-muted" }, | |
| 57 | − | building: { label: "Building", tone: "text-warn" }, | |
| 58 | − | ready: { label: "Live", tone: "text-accent" }, | |
| 59 | − | failed: { label: "Failed", tone: "text-danger" }, | |
| 60 | − | skipped: { label: "Skipped", tone: "text-faint" }, | |
| 61 | − | }; | |
| 62 | 56 | ||
| 63 | − | function StatusDot({ status }: { status: DeployStatus }) { | |
| 64 | − | const { label, tone } = STATUS[status]; | |
| 65 | − | return ( | |
| 66 | − | <span className={`inline-flex items-center gap-1.5 text-xs font-medium ${tone}`}> | |
| 67 | − | <span className={`size-1.5 rounded-full bg-current ${status === "building" ? "animate-pulse" : ""}`} /> | |
| 68 | − | {label} | |
| 69 | − | </span> | |
| 70 | − | ); | |
| 71 | − | } | |
| 72 | − | ||
| 73 | 57 | export default function RepoDeployments({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 74 | 58 | const { settings, deployments: builds, live, plan } = loaderData; | |
| 75 | 59 | const busy = useNavigation().state === "submitting"; | |
| 99 | 83 | className="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 font-mono text-xs text-muted hover:border-line-strong hover:text-fg" | |
| 100 | 84 | > | |
| 101 | 85 | <Globe size={13} /> | |
| 102 | − | {settings.productionUrl.replace("https://", "")} | |
| 86 | + | {host(settings.productionUrl)} | |
| 103 | 87 | </a> | |
| 104 | 88 | )} | |
| 105 | 89 | </header> | |
| 115 | 99 | <section className="mt-2 rounded-xl border border-accent/30 bg-accent/5 p-6"> | |
| 116 | 100 | <h2 className="font-medium">Deploy {params.repo}</h2> | |
| 117 | 101 | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| 118 | − | Production goes up from <span className="font-mono text-fg">{settings.productionUrl.replace("https://", "")}</span>{" "} | |
| 102 | + | Production goes up from <span className="font-mono text-fg">{host(settings.productionUrl)}</span>{" "} | |
| 119 | 103 | as soon as you turn this on, and every open pull request gets its own preview. Workers projects (a{" "} | |
| 120 | 104 | <code className="font-mono text-fg">wrangler.jsonc</code>) and static sites deploy without configuration. | |
| 121 | 105 | </p> | |
| 135 | 119 | app={production} | |
| 136 | 120 | off={!settings.production} | |
| 137 | 121 | actions={ | |
| 138 | − | <AppActions number={null} up={!!production} busy={busy} /> | |
| 122 | + | <AppActions branch={null} up={!!production} busy={busy} /> | |
| 139 | 123 | } | |
| 140 | 124 | /> | |
| 141 | 125 | <div className="rounded-xl border border-line bg-surface p-5"> | |
| 142 | 126 | <h2 className="text-sm font-medium">Previews</h2> | |
| 143 | 127 | <p className="mt-0.5 text-xs text-faint"> | |
| 144 | 128 | {settings.previews | |
| 145 | − | ? `One per open pull request; down when it closes or after ${settings.idleDays} days without a visit.` | |
| 129 | + | ? `One per branch with an open pull request; down when it closes or after ${settings.idleDays} days without a visit.` | |
| 146 | 130 | : "Off for this repository."} | |
| 147 | 131 | </p> | |
| 148 | 132 | {previews.length === 0 ? ( | |
| 151 | 135 | <ul className="mt-3 divide-y divide-line"> | |
| 152 | 136 | {previews.map((app) => ( | |
| 153 | 137 | <li key={app.url} className="flex items-center gap-3 py-2 text-sm"> | |
| 154 | − | <Link to={`${base}/pull/${app.number}`} className="font-medium hover:underline"> | |
| 155 | − | #{app.number} | |
| 156 | − | </Link> | |
| 138 | + | <span className="shrink-0"> | |
| 139 | + | <span className="font-medium">{app.branch}</span> | |
| 140 | + | {app.number != null && ( | |
| 141 | + | <Link to={`${base}/pull/${app.number}`} className="ml-1.5 text-xs text-muted hover:underline"> | |
| 142 | + | #{app.number} | |
| 143 | + | </Link> | |
| 144 | + | )} | |
| 145 | + | </span> | |
| 157 | 146 | <a href={app.url} className="min-w-0 truncate font-mono text-xs text-muted hover:text-fg"> | |
| 158 | − | {app.url.replace("https://", "")} | |
| 147 | + | {host(app.url)} | |
| 159 | 148 | </a> | |
| 160 | 149 | <span className="ml-auto shrink-0"> | |
| 161 | − | <AppActions number={app.number} up busy={busy} compact /> | |
| 150 | + | <AppActions branch={app.branch} up busy={busy} compact /> | |
| 162 | 151 | </span> | |
| 163 | 152 | </li> | |
| 164 | 153 | ))} | |
| 202 | 191 | <section className="mt-2 rounded-xl border border-accent/30 bg-accent/5 p-6"> | |
| 203 | 192 | <h2 className="font-medium">Deployments are part of a paid plan</h2> | |
| 204 | 193 | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| 205 | − | Turn on Deployments for the {owner} workspace and every repository in it can have previews for each pull | |
| 194 | + | Turn on Deployments for the {owner} workspace and every project in it can have a preview for each pull | |
| 206 | 195 | request and production on g1t.page. | |
| 207 | 196 | {plan && ` $${(plan.plan.monthlyCents / 100).toFixed(0)} a month, including:`} | |
| 208 | 197 | </p> | |
| 246 | 235 | {app ? ( | |
| 247 | 236 | <> | |
| 248 | 237 | <a href={app.url} className="mt-3 flex items-center gap-1.5 font-mono text-sm text-accent hover:underline"> | |
| 249 | − | {app.url.replace("https://", "")} | |
| 238 | + | {host(app.url)} | |
| 250 | 239 | <ExternalLink size={12} /> | |
| 251 | 240 | </a> | |
| 252 | 241 | <p className="mt-1 text-xs text-faint"> | |
| 261 | 250 | ); | |
| 262 | 251 | } | |
| 263 | 252 | ||
| 264 | − | function AppActions({ number, up, busy, compact }: { number: number | null; up: boolean; busy: boolean; compact?: boolean }) { | |
| 253 | + | function AppActions({ branch, up, busy, compact }: { branch: string | null; up: boolean; busy: boolean; compact?: boolean }) { | |
| 265 | 254 | return ( | |
| 266 | 255 | <Form method="post" className="flex items-center gap-2"> | |
| 267 | − | {number != null && <input type="hidden" name="number" value={number} />} | |
| 256 | + | {branch != null && <input type="hidden" name="branch" value={branch} />} | |
| 268 | 257 | <Button variant="quiet" type="submit" name="intent" value="redeploy" disabled={busy} title="Build again from the current head"> | |
| 269 | 258 | <RotateCw size={13} /> | |
| 270 | 259 | {!compact && "Redeploy"} | |
| 288 | 277 | </span> | |
| 289 | 278 | <span className="min-w-0 grow"> | |
| 290 | 279 | <span className="block truncate font-medium"> | |
| 291 | − | {build.kind === "production" ? "Production" : `Preview of #${build.number}`} | |
| 280 | + | {build.kind === "production" ? "Production" : `Preview of ${build.branch}`} | |
| 292 | 281 | <span className="ml-2 font-mono text-xs font-normal text-faint">{build.commit.slice(0, 8)}</span> | |
| 293 | 282 | </span> | |
| 294 | 283 | {build.error && <span className="mt-0.5 block truncate text-xs text-muted">{build.error}</span>} |
| 1 | − | import { | |
| 2 | − | BookMarked, | |
| 3 | − | CircleDot, | |
| 4 | − | Code2, | |
| 5 | − | GitPullRequest, | |
| 6 | − | History, | |
| 7 | − | ListTree, | |
| 8 | − | Lock, | |
| 9 | − | Settings, | |
| 10 | − | } from "lucide-react"; | |
| 11 | − | import { Link, Outlet, useRouteLoaderData } from "react-router"; | |
| 1 | + | import { Box, CircleDot, Code2, GitPullRequest, History, LayoutGrid, ListTree, Lock, Rocket, Settings } from "lucide-react"; | |
| 2 | + | import { Link, Outlet, data, useRouteLoaderData } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { Project } from "@g1t/contracts"; | |
| 12 | 5 | ||
| 13 | 6 | import type { Route } from "./+types/layout"; | |
| 14 | 7 | import { Pill, TabLink as Tab } from "../../components/ui"; | |
| 15 | − | import { repos, work } from "../../lib/services.server"; | |
| 8 | + | import { projects, repos, work } from "../../lib/services.server"; | |
| 16 | 9 | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 17 | 10 | ||
| 18 | − | export function meta({ params }: Route.MetaArgs) { | |
| 19 | − | return [{ title: `${params.owner}/${params.repo} · g1t` }]; | |
| 11 | + | export function meta({ loaderData: loaded, params }: Route.MetaArgs) { | |
| 12 | + | return [{ title: `${loaded?.project?.name ?? params.repo} · ${params.owner} · g1t` }]; | |
| 20 | 13 | } | |
| 21 | 14 | ||
| 22 | 15 | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 23 | 16 | const viewer = getViewer(context); | |
| 24 | 17 | const path = { namespace: params.owner, name: params.repo }; | |
| 25 | − | const [repo, counts] = await Promise.all([ | |
| 18 | + | const [repo, counts, found] = await Promise.all([ | |
| 26 | 19 | repos.get(path, viewer), | |
| 27 | 20 | work.counts(path, viewer), | |
| 21 | + | projects.get(params.owner, params.repo, viewer), | |
| 28 | 22 | ]); | |
| 23 | + | if (!repo.ok && !found.ok) throw data(null, { status: 404 }); | |
| 24 | + | let project: Project | null = found.ok ? found.value : null; | |
| 25 | + | // A repository made a moment ago, before its project: make it now. | |
| 26 | + | if (!project && repo.ok && !repo.value.forkOf) { | |
| 27 | + | const own = await projects.byRepo(repo.value.id); | |
| 28 | + | project = own.find((p) => p.slug === params.repo.toLowerCase()) ?? own[0] ?? null; | |
| 29 | + | } | |
| 29 | 30 | return { | |
| 30 | 31 | repo: unwrap(repo), | |
| 32 | + | project, | |
| 31 | 33 | open: counts.ok ? counts.value : { issues: 0, pulls: 0 }, | |
| 32 | 34 | member: roleIn(viewer, params.owner) != null, | |
| 33 | 35 | }; | |
| 34 | 36 | } | |
| 35 | 37 | ||
| 36 | − | export default function RepoLayout({ loaderData }: Route.ComponentProps) { | |
| 37 | − | const { repo, open, member } = loaderData; | |
| 38 | + | /** The project the page is in, for the pages under it. */ | |
| 39 | + | export function useProject() { | |
| 40 | + | return useRouteLoaderData<typeof loader>("routes/repo/layout"); | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | function Header({ project, isPrivate, namespace, name, description, large }: { | |
| 44 | + | project: Project | null; | |
| 45 | + | isPrivate: boolean; | |
| 46 | + | namespace: string; | |
| 47 | + | name: string; | |
| 48 | + | description: string | null; | |
| 49 | + | large?: boolean; | |
| 50 | + | }) { | |
| 51 | + | const base = `/${namespace}/${name}`; | |
| 52 | + | return ( | |
| 53 | + | <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> | |
| 54 | + | <span | |
| 55 | + | className={`flex shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line ${large ? "size-8" : "size-6"}`} | |
| 56 | + | > | |
| 57 | + | {isPrivate ? <Lock size={large ? 15 : 13} /> : <Box size={large ? 15 : 13} />} | |
| 58 | + | </span> | |
| 59 | + | <h1 className={large ? "text-lg" : "text-[0.9375rem]"}> | |
| 60 | + | <Link to={`/${namespace}`} className="font-mono text-muted hover:text-fg"> | |
| 61 | + | {namespace} | |
| 62 | + | </Link> | |
| 63 | + | <span className="mx-1.5 text-faint">/</span> | |
| 64 | + | <Link to={base} className="font-semibold hover:underline"> | |
| 65 | + | {project?.name ?? name} | |
| 66 | + | </Link> | |
| 67 | + | </h1> | |
| 68 | + | <Pill>{isPrivate ? "private" : "public"}</Pill> | |
| 69 | + | {description && <p className="min-w-0 truncate text-sm text-muted">{description}</p>} | |
| 70 | + | </div> | |
| 71 | + | ); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | export default function ProjectLayout({ loaderData }: Route.ComponentProps) { | |
| 75 | + | const { repo, project, open, member } = loaderData; | |
| 38 | 76 | const base = `/${repo.namespace}/${repo.name}`; | |
| 39 | 77 | const signedIn = useRouteLoaderData("root")?.user != null; | |
| 78 | + | const description = project?.description ?? repo.description; | |
| 40 | 79 | if (signedIn) { | |
| 41 | 80 | return ( | |
| 42 | 81 | <> | |
| 43 | 82 | <div className="border-b border-line"> | |
| 44 | − | <div className="mx-auto flex max-w-6xl flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 sm:px-6"> | |
| 45 | − | {repo.isPrivate ? ( | |
| 46 | − | <Lock size={15} className="text-faint" /> | |
| 47 | − | ) : ( | |
| 48 | − | <BookMarked size={15} className="text-faint" /> | |
| 49 | − | )} | |
| 50 | − | <h1 className="font-mono text-[0.9375rem]"> | |
| 51 | − | <Link to={`/${repo.namespace}`} className="text-muted hover:text-fg"> | |
| 52 | − | {repo.namespace} | |
| 53 | − | </Link> | |
| 54 | − | <span className="mx-1 text-faint">/</span> | |
| 55 | − | <Link to={base} className="font-semibold hover:underline"> | |
| 56 | − | {repo.name} | |
| 57 | − | </Link> | |
| 58 | − | </h1> | |
| 59 | − | <Pill>{repo.isPrivate ? "private" : "public"}</Pill> | |
| 60 | − | {repo.description && ( | |
| 61 | − | <p className="min-w-0 truncate text-sm text-muted">{repo.description}</p> | |
| 62 | − | )} | |
| 83 | + | <div className="mx-auto max-w-6xl px-4 py-3 sm:px-6"> | |
| 84 | + | <Header | |
| 85 | + | project={project} | |
| 86 | + | isPrivate={repo.isPrivate} | |
| 87 | + | namespace={repo.namespace} | |
| 88 | + | name={repo.name} | |
| 89 | + | description={description} | |
| 90 | + | /> | |
| 63 | 91 | </div> | |
| 64 | 92 | </div> | |
| 65 | 93 | <div className="mx-auto max-w-6xl px-4 py-6 sm:px-6"> | |
| 70 | 98 | } | |
| 71 | 99 | return ( | |
| 72 | 100 | <> | |
| 73 | − | {/* The repository's own header band, under the site header. */} | |
| 101 | + | {/* The project's own header band, under the site header. */} | |
| 74 | 102 | <div className="border-b border-line bg-surface/60"> | |
| 75 | 103 | <div className="mx-auto max-w-6xl px-4 pt-6"> | |
| 76 | − | <div className="flex flex-wrap items-center gap-2.5"> | |
| 77 | − | {repo.isPrivate ? ( | |
| 78 | − | <Lock size={17} className="text-faint" /> | |
| 79 | − | ) : ( | |
| 80 | − | <BookMarked size={17} className="text-faint" /> | |
| 81 | − | )} | |
| 82 | − | <h1 className="font-mono text-lg"> | |
| 83 | − | <Link to={`/${repo.namespace}`} className="text-muted hover:text-fg"> | |
| 84 | − | {repo.namespace} | |
| 85 | − | </Link> | |
| 86 | − | <span className="mx-1 text-faint">/</span> | |
| 87 | − | <Link to={base} className="font-semibold hover:underline"> | |
| 88 | − | {repo.name} | |
| 89 | − | </Link> | |
| 90 | − | </h1> | |
| 91 | − | <Pill>{repo.isPrivate ? "private" : "public"}</Pill> | |
| 92 | − | </div> | |
| 93 | − | {repo.description && ( | |
| 94 | − | <p className="mt-2 max-w-2xl text-sm text-muted">{repo.description}</p> | |
| 95 | − | )} | |
| 96 | − | <nav className="mt-5 flex gap-6"> | |
| 97 | − | <Tab to={base} end icon={<Code2 size={15} />}> | |
| 104 | + | <Header | |
| 105 | + | project={project} | |
| 106 | + | isPrivate={repo.isPrivate} | |
| 107 | + | namespace={repo.namespace} | |
| 108 | + | name={repo.name} | |
| 109 | + | description={null} | |
| 110 | + | large | |
| 111 | + | /> | |
| 112 | + | {description && <p className="mt-2 max-w-2xl text-sm text-muted">{description}</p>} | |
| 113 | + | <nav className="mt-5 flex gap-6 overflow-x-auto"> | |
| 114 | + | <Tab to={base} end icon={<LayoutGrid size={15} />}> | |
| 115 | + | Overview | |
| 116 | + | </Tab> | |
| 117 | + | <Tab to={`${base}/code`} also={`${base}/tree`} icon={<Code2 size={15} />}> | |
| 98 | 118 | Code | |
| 99 | 119 | </Tab> | |
| 100 | 120 | <Tab to={`${base}/issues`} icon={<CircleDot size={15} />} count={open.issues}> | |
| 101 | 121 | Issues | |
| 102 | 122 | </Tab> | |
| 103 | − | <Tab | |
| 104 | − | to={`${base}/pulls`} | |
| 105 | − | also={`${base}/pull`} | |
| 106 | − | icon={<GitPullRequest size={15} />} | |
| 107 | − | count={open.pulls} | |
| 108 | − | > | |
| 123 | + | <Tab to={`${base}/pulls`} also={`${base}/pull`} icon={<GitPullRequest size={15} />} count={open.pulls}> | |
| 109 | 124 | Pull requests | |
| 110 | 125 | </Tab> | |
| 111 | 126 | <Tab to={`${base}/commits`} icon={<History size={15} />}> | |
| 117 | 132 | </Tab> | |
| 118 | 133 | )} | |
| 119 | 134 | {member && ( | |
| 135 | + | <Tab to={`${base}/deployments`} icon={<Rocket size={15} />}> | |
| 136 | + | Deployments | |
| 137 | + | </Tab> | |
| 138 | + | )} | |
| 139 | + | {member && ( | |
| 120 | 140 | <Tab to={`${base}/settings`} icon={<Settings size={15} />}> | |
| 121 | 141 | Settings | |
| 122 | 142 | </Tab> |
| 1 | + | import { ArrowUpRight, Box, CircleDot, Code2, GitBranch, GitCommitHorizontal, GitPullRequest, Lock, Rocket, RotateCw } from "lucide-react"; | |
| 2 | + | import { Form, Link, useNavigation } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { Route } from "./+types/overview"; | |
| 5 | + | import { host, StatusDot } from "../../components/deploy"; | |
| 6 | + | import { Button, ButtonLink, CopyLine, EmptyState, TimeAgo } from "../../components/ui"; | |
| 7 | + | import { PullIcon } from "../../components/work"; | |
| 8 | + | import { deployments, projects, repos, work } from "../../lib/services.server"; | |
| 9 | + | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 10 | + | ||
| 11 | + | const MAX_PULLS = 5; | |
| 12 | + | ||
| 13 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 14 | + | const viewer = getViewer(context); | |
| 15 | + | const member = roleIn(viewer, params.owner) != null; | |
| 16 | + | const path = { namespace: params.owner, name: params.repo }; | |
| 17 | + | const ref = { workspace: params.owner, slug: params.repo }; | |
| 18 | + | const [project, settings, list, pulls, log, counts] = await Promise.all([ | |
| 19 | + | projects.get(params.owner, params.repo, viewer), | |
| 20 | + | member ? deployments.settings(ref, viewer) : null, | |
| 21 | + | member ? deployments.list(ref, viewer) : null, | |
| 22 | + | work.listPulls(path, viewer, "open"), | |
| 23 | + | repos.log(path, viewer, null, 1), | |
| 24 | + | work.counts(path, viewer), | |
| 25 | + | ]); | |
| 26 | + | return { | |
| 27 | + | member, | |
| 28 | + | project: project.ok ? project.value : null, | |
| 29 | + | settings: settings?.ok ? settings.value : null, | |
| 30 | + | builds: list?.ok ? list.value.deployments : [], | |
| 31 | + | live: list?.ok ? list.value.live : [], | |
| 32 | + | pulls: pulls.ok ? pulls.value.slice(0, MAX_PULLS) : [], | |
| 33 | + | commit: log.ok ? (log.value[0] ?? null) : null, | |
| 34 | + | open: counts.ok ? counts.value : { issues: 0, pulls: 0 }, | |
| 35 | + | }; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 39 | + | assertSameOrigin(request); | |
| 40 | + | const user = requireUser(context, request); | |
| 41 | + | const started = await deployments.redeploy(user, { workspace: params.owner, slug: params.repo }, null); | |
| 42 | + | return started.ok ? { notice: "Production is building." } : { error: started.error.message }; | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | export default function ProjectOverview({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 46 | + | const { member, project, settings, builds, live, pulls, commit, open } = loaderData; | |
| 47 | + | const base = `/${params.owner}/${params.repo}`; | |
| 48 | + | const production = live.find((app) => app.kind === "production") ?? null; | |
| 49 | + | const previews = live.filter((app) => app.kind === "preview"); | |
| 50 | + | const latestProduction = builds.find((build) => build.kind === "production") ?? null; | |
| 51 | + | const busy = useNavigation().state === "submitting"; | |
| 52 | + | const source = project?.source.kind === "hosted" ? project.source : null; | |
| 53 | + | ||
| 54 | + | return ( | |
| 55 | + | <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]"> | |
| 56 | + | <div className="min-w-0 space-y-6"> | |
| 57 | + | {/* Production: what the project is, running. */} | |
| 58 | + | <section className="overflow-hidden rounded-2xl border border-line bg-surface"> | |
| 59 | + | <div className="flex flex-wrap items-start justify-between gap-4 p-6"> | |
| 60 | + | <div className="min-w-0"> | |
| 61 | + | <p className="flex items-center gap-2 text-xs font-medium tracking-wide text-muted uppercase"> | |
| 62 | + | <Rocket size={13} className="text-accent" /> | |
| 63 | + | Production | |
| 64 | + | </p> | |
| 65 | + | {production ? ( | |
| 66 | + | <> | |
| 67 | + | <a | |
| 68 | + | href={production.url} | |
| 69 | + | className="mt-2 flex items-center gap-1.5 truncate font-mono text-lg font-medium hover:text-accent" | |
| 70 | + | > | |
| 71 | + | {host(production.url)} | |
| 72 | + | <ArrowUpRight size={16} className="shrink-0 text-faint" /> | |
| 73 | + | </a> | |
| 74 | + | <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted"> | |
| 75 | + | {latestProduction && <StatusDot status={latestProduction.status} />} | |
| 76 | + | <span className="inline-flex items-center gap-1 font-mono"> | |
| 77 | + | <GitCommitHorizontal size={13} className="text-faint" /> | |
| 78 | + | {production.commit.slice(0, 7)} | |
| 79 | + | </span> | |
| 80 | + | <span> | |
| 81 | + | deployed <TimeAgo at={production.deployedAt} /> | |
| 82 | + | </span> | |
| 83 | + | </p> | |
| 84 | + | </> | |
| 85 | + | ) : settings?.enabled ? ( | |
| 86 | + | <> | |
| 87 | + | <p className="mt-2 font-mono text-lg text-muted">{host(settings.productionUrl)}</p> | |
| 88 | + | <p className="mt-1.5 text-xs text-muted"> | |
| 89 | + | {latestProduction ? ( | |
| 90 | + | <StatusDot status={latestProduction.status} label={latestProduction.status === "failed" ? "The last build failed" : undefined} /> | |
| 91 | + | ) : ( | |
| 92 | + | "Not deployed yet. Push to the default branch, or deploy it now." | |
| 93 | + | )} | |
| 94 | + | </p> | |
| 95 | + | </> | |
| 96 | + | ) : ( | |
| 97 | + | <> | |
| 98 | + | <p className="mt-2 text-lg font-medium">Not deployed</p> | |
| 99 | + | <p className="mt-1 max-w-lg text-sm text-muted"> | |
| 100 | + | Put {project?.name ?? params.repo} on g1t.page: production from the default branch, and a live | |
| 101 | + | preview for every pull request. It runs only while someone visits. | |
| 102 | + | </p> | |
| 103 | + | </> | |
| 104 | + | )} | |
| 105 | + | </div> | |
| 106 | + | {member && ( | |
| 107 | + | <div className="flex shrink-0 items-center gap-2"> | |
| 108 | + | {production && ( | |
| 109 | + | <ButtonLink to={production.url} variant="accent" reloadDocument> | |
| 110 | + | Visit | |
| 111 | + | <ArrowUpRight size={14} /> | |
| 112 | + | </ButtonLink> | |
| 113 | + | )} | |
| 114 | + | {settings?.enabled ? ( | |
| 115 | + | <Form method="post"> | |
| 116 | + | <Button type="submit" variant="quiet" disabled={busy} title="Build production again from the default branch"> | |
| 117 | + | <RotateCw size={14} /> | |
| 118 | + | Redeploy | |
| 119 | + | </Button> | |
| 120 | + | </Form> | |
| 121 | + | ) : ( | |
| 122 | + | <ButtonLink to={`${base}/settings/deployments`} variant="accent"> | |
| 123 | + | <Rocket size={14} /> | |
| 124 | + | Deploy | |
| 125 | + | </ButtonLink> | |
| 126 | + | )} | |
| 127 | + | </div> | |
| 128 | + | )} | |
| 129 | + | </div> | |
| 130 | + | {(actionData && "notice" in actionData) || (actionData && "error" in actionData) ? ( | |
| 131 | + | <p className={`border-t border-line px-6 py-2.5 text-sm ${"error" in actionData ? "text-danger" : "text-accent"}`}> | |
| 132 | + | {"error" in actionData ? actionData.error : actionData.notice} | |
| 133 | + | </p> | |
| 134 | + | ) : null} | |
| 135 | + | {member && settings?.enabled && ( | |
| 136 | + | <div className="grid border-t border-line text-sm sm:grid-cols-3 sm:divide-x sm:divide-line"> | |
| 137 | + | <Link to={`${base}/deployments`} className="px-6 py-3 hover:bg-raised/50"> | |
| 138 | + | <span className="block text-xs text-muted">Previews up</span> | |
| 139 | + | <span className="font-medium tabular-nums">{previews.length}</span> | |
| 140 | + | </Link> | |
| 141 | + | <Link to={`${base}/deployments`} className="px-6 py-3 hover:bg-raised/50"> | |
| 142 | + | <span className="block text-xs text-muted">Builds</span> | |
| 143 | + | <span className="font-medium tabular-nums">{builds.length}</span> | |
| 144 | + | </Link> | |
| 145 | + | <Link to={`${base}/settings/secrets`} className="px-6 py-3 hover:bg-raised/50"> | |
| 146 | + | <span className="block text-xs text-muted">Secrets and variables</span> | |
| 147 | + | <span className="font-medium">Manage</span> | |
| 148 | + | </Link> | |
| 149 | + | </div> | |
| 150 | + | )} | |
| 151 | + | </section> | |
| 152 | + | ||
| 153 | + | {previews.length > 0 && ( | |
| 154 | + | <section> | |
| 155 | + | <h2 className="text-sm font-medium text-muted">Previews</h2> | |
| 156 | + | <ul className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 157 | + | {previews.map((app) => ( | |
| 158 | + | <li key={app.url} className="flex items-center gap-3 px-4 py-3 text-sm"> | |
| 159 | + | <GitBranch size={14} className="shrink-0 text-faint" /> | |
| 160 | + | <span className="min-w-0 grow"> | |
| 161 | + | <a href={app.url} className="block truncate font-mono text-[0.8125rem] hover:text-accent"> | |
| 162 | + | {host(app.url)} | |
| 163 | + | </a> | |
| 164 | + | <span className="text-xs text-muted"> | |
| 165 | + | {app.branch} | |
| 166 | + | {app.number != null && ( | |
| 167 | + | <> | |
| 168 | + | {" · "} | |
| 169 | + | <Link to={`${base}/pull/${app.number}`} className="hover:underline"> | |
| 170 | + | #{app.number} | |
| 171 | + | </Link> | |
| 172 | + | </> | |
| 173 | + | )} | |
| 174 | + | </span> | |
| 175 | + | </span> | |
| 176 | + | <span className="shrink-0 text-xs text-faint"> | |
| 177 | + | <TimeAgo at={app.deployedAt} /> | |
| 178 | + | </span> | |
| 179 | + | </li> | |
| 180 | + | ))} | |
| 181 | + | </ul> | |
| 182 | + | </section> | |
| 183 | + | )} | |
| 184 | + | ||
| 185 | + | <section> | |
| 186 | + | <div className="flex items-center justify-between"> | |
| 187 | + | <h2 className="text-sm font-medium text-muted">In progress</h2> | |
| 188 | + | <Link to={`${base}/pulls`} className="text-xs text-muted hover:text-fg"> | |
| 189 | + | All pull requests | |
| 190 | + | </Link> | |
| 191 | + | </div> | |
| 192 | + | <div className="mt-3"> | |
| 193 | + | {pulls.length === 0 ? ( | |
| 194 | + | <EmptyState title="Nothing in progress"> | |
| 195 | + | Open an issue and assign it to g1t-agent, or push a branch and open a pull request. | |
| 196 | + | </EmptyState> | |
| 197 | + | ) : ( | |
| 198 | + | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 199 | + | {pulls.map((pull) => { | |
| 200 | + | const preview = previews.find((app) => app.number === pull.number); | |
| 201 | + | return ( | |
| 202 | + | <li key={pull.id}> | |
| 203 | + | <Link | |
| 204 | + | prefetch="intent" | |
| 205 | + | to={`${base}/pull/${pull.number}`} | |
| 206 | + | className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised" | |
| 207 | + | > | |
| 208 | + | <PullIcon status={pull.status} /> | |
| 209 | + | <span className="min-w-0 grow"> | |
| 210 | + | <span className="block truncate font-medium">{pull.title}</span> | |
| 211 | + | <span className="font-mono text-xs text-muted"> | |
| 212 | + | #{pull.number} · {pull.author.username} | |
| 213 | + | </span> | |
| 214 | + | </span> | |
| 215 | + | {preview && <span className="hidden text-xs text-accent sm:block">Preview live</span>} | |
| 216 | + | <span className="w-14 shrink-0 text-right text-xs text-faint"> | |
| 217 | + | <TimeAgo at={pull.updatedAt} /> | |
| 218 | + | </span> | |
| 219 | + | </Link> | |
| 220 | + | </li> | |
| 221 | + | ); | |
| 222 | + | })} | |
| 223 | + | </ul> | |
| 224 | + | )} | |
| 225 | + | </div> | |
| 226 | + | </section> | |
| 227 | + | ||
| 228 | + | {member && builds.length > 0 && ( | |
| 229 | + | <section> | |
| 230 | + | <div className="flex items-center justify-between"> | |
| 231 | + | <h2 className="text-sm font-medium text-muted">Recent builds</h2> | |
| 232 | + | <Link to={`${base}/deployments`} className="text-xs text-muted hover:text-fg"> | |
| 233 | + | All deployments | |
| 234 | + | </Link> | |
| 235 | + | </div> | |
| 236 | + | <ul className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 237 | + | {builds.slice(0, 4).map((build) => ( | |
| 238 | + | <li key={build.id}> | |
| 239 | + | <Link to={`${base}/deployments/${build.id}`} className="flex items-center gap-4 px-4 py-3 text-sm hover:bg-raised"> | |
| 240 | + | <span className="w-20 shrink-0"> | |
| 241 | + | <StatusDot status={build.status} /> | |
| 242 | + | </span> | |
| 243 | + | <span className="min-w-0 grow truncate"> | |
| 244 | + | {build.kind === "production" ? "Production" : `Preview of ${build.branch}`} | |
| 245 | + | <span className="ml-2 font-mono text-xs text-faint">{build.commit.slice(0, 7)}</span> | |
| 246 | + | </span> | |
| 247 | + | <span className="shrink-0 text-xs text-faint"> | |
| 248 | + | <TimeAgo at={build.createdAt} /> | |
| 249 | + | </span> | |
| 250 | + | </Link> | |
| 251 | + | </li> | |
| 252 | + | ))} | |
| 253 | + | </ul> | |
| 254 | + | </section> | |
| 255 | + | )} | |
| 256 | + | </div> | |
| 257 | + | ||
| 258 | + | <aside className="space-y-4 text-sm"> | |
| 259 | + | <section className="rounded-xl border border-line bg-surface p-5"> | |
| 260 | + | <h2 className="text-xs font-medium tracking-wide text-muted uppercase">Source</h2> | |
| 261 | + | {source && ( | |
| 262 | + | <> | |
| 263 | + | <Link to={`${base}/code`} className="mt-3 flex items-center gap-2 font-mono text-[0.8125rem] hover:text-accent"> | |
| 264 | + | {project?.private ? <Lock size={14} className="text-faint" /> : <Code2 size={14} className="text-faint" />} | |
| 265 | + | {source.repo.namespace}/{source.repo.name} | |
| 266 | + | </Link> | |
| 267 | + | <p className="mt-1 text-xs text-muted">Hosted on g1t{source.rootDir ? ` · in ${source.rootDir}/` : ""}</p> | |
| 268 | + | <dl className="mt-4 space-y-2 text-xs"> | |
| 269 | + | <div className="flex justify-between gap-3"> | |
| 270 | + | <dt className="text-muted">Default branch</dt> | |
| 271 | + | <dd className="font-mono">{source.defaultBranch}</dd> | |
| 272 | + | </div> | |
| 273 | + | {commit && ( | |
| 274 | + | <div className="flex justify-between gap-3"> | |
| 275 | + | <dt className="text-muted">Latest commit</dt> | |
| 276 | + | <dd className="min-w-0 truncate text-right"> | |
| 277 | + | <Link to={`${base}/commit/${commit.hash}`} className="font-mono hover:underline"> | |
| 278 | + | {commit.hash.slice(0, 7)} | |
| 279 | + | </Link> | |
| 280 | + | </dd> | |
| 281 | + | </div> | |
| 282 | + | )} | |
| 283 | + | </dl> | |
| 284 | + | {commit && <p className="mt-2 line-clamp-2 text-xs text-muted">{commit.message.split("\n")[0]}</p>} | |
| 285 | + | <div className="mt-4"> | |
| 286 | + | <CopyLine text={`git clone https://g1t.sh/${source.repo.namespace}/${source.repo.name}.git`} /> | |
| 287 | + | </div> | |
| 288 | + | </> | |
| 289 | + | )} | |
| 290 | + | </section> | |
| 291 | + | ||
| 292 | + | <section className="grid grid-cols-2 gap-3"> | |
| 293 | + | <Link to={`${base}/issues`} className="rounded-xl border border-line bg-surface p-4 hover:border-line-strong"> | |
| 294 | + | <CircleDot size={14} className="text-faint" /> | |
| 295 | + | <p className="mt-2 text-xl font-semibold tabular-nums">{open.issues}</p> | |
| 296 | + | <p className="text-xs text-muted">Open issues</p> | |
| 297 | + | </Link> | |
| 298 | + | <Link to={`${base}/pulls`} className="rounded-xl border border-line bg-surface p-4 hover:border-line-strong"> | |
| 299 | + | <GitPullRequest size={14} className="text-faint" /> | |
| 300 | + | <p className="mt-2 text-xl font-semibold tabular-nums">{open.pulls}</p> | |
| 301 | + | <p className="text-xs text-muted">Pull requests</p> | |
| 302 | + | </Link> | |
| 303 | + | </section> | |
| 304 | + | ||
| 305 | + | {project && ( | |
| 306 | + | <section className="rounded-xl border border-line p-5 text-xs text-muted"> | |
| 307 | + | <p className="flex items-center gap-2 font-medium text-fg"> | |
| 308 | + | <Box size={14} className="text-faint" /> | |
| 309 | + | About projects | |
| 310 | + | </p> | |
| 311 | + | <p className="mt-1.5"> | |
| 312 | + | A project is what you build and run. Its deployments, environments, secrets and variables belong to it; | |
| 313 | + | its code lives in its source.{" "} | |
| 314 | + | <a href="https://docs.g1t.sh/guides/projects/" className="text-fg hover:underline"> | |
| 315 | + | Learn more | |
| 316 | + | </a> | |
| 317 | + | </p> | |
| 318 | + | </section> | |
| 319 | + | )} | |
| 320 | + | </aside> | |
| 321 | + | </div> | |
| 322 | + | ); | |
| 323 | + | } |
| 27 | 27 | return ( | |
| 28 | 28 | <div> | |
| 29 | 29 | <RepoSettingsTabs base={`/${params.owner}/${params.repo}`} /> | |
| 30 | − | <SecretsPanel data={loaderData} action={actionData} scope="repository" manage /> | |
| 30 | + | <SecretsPanel data={loaderData} action={actionData} scope="project" manage /> | |
| 31 | 31 | </div> | |
| 32 | 32 | ); | |
| 33 | 33 | } |
| 13 | 13 | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 14 | 14 | const viewer = getViewer(context); | |
| 15 | 15 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 16 | − | const settings = await deployments.settings({ namespace: params.owner, name: params.repo }, viewer); | |
| 16 | + | const settings = await deployments.settings({ workspace: params.owner, slug: params.repo }, viewer); | |
| 17 | 17 | return { settings: unwrap(settings) }; | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 21 | 21 | assertSameOrigin(request); | |
| 22 | 22 | const user = requireUser(context, request); | |
| 23 | 23 | const form = await request.formData(); | |
| 24 | − | const path = { namespace: params.owner, name: params.repo }; | |
| 24 | + | const ref = { workspace: params.owner, slug: params.repo }; | |
| 25 | 25 | const intent = form.get("intent"); | |
| 26 | 26 | if (intent === "enable" || intent === "disable") { | |
| 27 | − | const saved = await deployments.updateSettings(user, path, { enabled: intent === "enable" }); | |
| 27 | + | const saved = await deployments.updateSettings(user, ref, { enabled: intent === "enable" }); | |
| 28 | 28 | return saved.ok | |
| 29 | 29 | ? { notice: intent === "enable" ? "Deployments are on. Production is building." : "Deployments are off, and every app is down." } | |
| 30 | 30 | : { error: saved.error.message }; | |
| 31 | 31 | } | |
| 32 | 32 | const on = (name: string) => form.get(name) === "on"; | |
| 33 | − | const saved = await deployments.updateSettings(user, path, { | |
| 33 | + | const saved = await deployments.updateSettings(user, ref, { | |
| 34 | 34 | previews: on("previews"), | |
| 35 | 35 | production: on("production"), | |
| 36 | 36 | buildCommand: String(form.get("buildCommand") ?? ""), | |
| 86 | 86 | Deploy the default branch on every push. | |
| 87 | 87 | </Check> | |
| 88 | 88 | <Check name="previews" on={settings.previews} title="Previews"> | |
| 89 | − | A preview for every open pull request, linked on it. | |
| 89 | + | A preview for every branch with an open pull request, linked on it. | |
| 90 | 90 | </Check> | |
| 91 | 91 | </div> | |
| 92 | 92 | <div className="grid gap-4 md:grid-cols-3"> | |
| 115 | 115 | <section className="mt-10 rounded-xl border border-danger/30 p-5"> | |
| 116 | 116 | <h2 className="text-sm font-medium">Turn off deployments</h2> | |
| 117 | 117 | <p className="mt-1 text-sm text-muted"> | |
| 118 | − | Takes production and every preview down now, and stops building. Nothing of this repository's keeps | |
| 118 | + | Takes production and every preview down now, and stops building. Nothing of this project's keeps | |
| 119 | 119 | running or costing anything. The workspace's plan stays on; turn it off under Billing. | |
| 120 | 120 | </p> | |
| 121 | 121 | <Form method="post" className="mt-3"> |
| 1 | + | import type { ReactNode } from "react"; | |
| 2 | + | import { Form, data, useNavigation } from "react-router"; | |
| 3 | + | ||
| 4 | + | import { RepoSettingsTabs } from "../../components/repo-settings-tabs"; | |
| 5 | + | import type { Route } from "./+types/settings-repository"; | |
| 6 | + | import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui"; | |
| 7 | + | import { repos, work } from "../../lib/services.server"; | |
| 8 | + | import { | |
| 9 | + | assertSameOrigin, | |
| 10 | + | getViewer, | |
| 11 | + | requireUser, | |
| 12 | + | roleIn, | |
| 13 | + | unwrap, | |
| 14 | + | } from "../../lib/session.server"; | |
| 15 | + | ||
| 16 | + | export function meta({ params }: Route.MetaArgs) { | |
| 17 | + | return [{ title: `Repository settings · ${params.owner}/${params.repo} · g1t` }]; | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 21 | + | const viewer = getViewer(context); | |
| 22 | + | // Members only; to anyone else the page does not exist. | |
| 23 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 24 | + | const path = { namespace: params.owner, name: params.repo }; | |
| 25 | + | const [repo, settings] = await Promise.all([ | |
| 26 | + | repos.get(path, viewer), | |
| 27 | + | work.getSettings(path, viewer), | |
| 28 | + | ]); | |
| 29 | + | return { repo: unwrap(repo), settings: unwrap(settings) }; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /** A whole number from a form field, kept within `min` and `max`. */ | |
| 33 | + | function count(value: FormDataEntryValue | null, min: number, max: number): number { | |
| 34 | + | const number = Math.trunc(Number(value)); | |
| 35 | + | return Number.isFinite(number) ? Math.min(Math.max(number, min), max) : min; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 39 | + | assertSameOrigin(request); | |
| 40 | + | const user = requireUser(context, request); | |
| 41 | + | const form = await request.formData(); | |
| 42 | + | const path = { namespace: params.owner, name: params.repo }; | |
| 43 | + | const on = (name: string) => form.get(name) === "on"; | |
| 44 | + | ||
| 45 | + | // The repository's own details belong to one service and how its pull | |
| 46 | + | // requests are handled to another; the page is one form over both. | |
| 47 | + | const repo = await repos.update(user, path, { | |
| 48 | + | description: String(form.get("description") ?? ""), | |
| 49 | + | isPrivate: form.get("visibility") === "private", | |
| 50 | + | protected: on("protected"), | |
| 51 | + | }); | |
| 52 | + | if (!repo.ok) return { saved: false, error: repo.error.message }; | |
| 53 | + | const settings = await work.updateSettings(user, path, { | |
| 54 | + | autoMerge: on("autoMerge"), | |
| 55 | + | requireUpToDate: on("requireUpToDate"), | |
| 56 | + | requiredApprovals: count(form.get("requiredApprovals"), 0, 6), | |
| 57 | + | countAgentApprovals: on("countAgentApprovals"), | |
| 58 | + | allowIgnoringChecks: !on("requireChecks"), | |
| 59 | + | agentReview: on("agentReview"), | |
| 60 | + | maxRevisions: count(form.get("maxRevisions"), 0, 5), | |
| 61 | + | mergeQueue: on("mergeQueue"), | |
| 62 | + | }); | |
| 63 | + | return settings.ok | |
| 64 | + | ? { saved: true, error: null } | |
| 65 | + | : { saved: false, error: settings.error.message }; | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | function Section({ | |
| 69 | + | title, | |
| 70 | + | about, | |
| 71 | + | children, | |
| 72 | + | }: { | |
| 73 | + | title: string; | |
| 74 | + | about: string; | |
| 75 | + | children: ReactNode; | |
| 76 | + | }) { | |
| 77 | + | return ( | |
| 78 | + | <section className="grid gap-x-10 gap-y-4 border-t border-line pt-8 first:border-t-0 first:pt-0 lg:grid-cols-[16rem_1fr]"> | |
| 79 | + | <div> | |
| 80 | + | <h2 className="font-medium">{title}</h2> | |
| 81 | + | <p className="mt-1 text-sm text-muted">{about}</p> | |
| 82 | + | </div> | |
| 83 | + | <div className="space-y-3">{children}</div> | |
| 84 | + | </section> | |
| 85 | + | ); | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /** A setting that is on or off, with what it means. */ | |
| 89 | + | function Toggle({ | |
| 90 | + | name, | |
| 91 | + | on, | |
| 92 | + | title, | |
| 93 | + | children, | |
| 94 | + | }: { | |
| 95 | + | name: string; | |
| 96 | + | on: boolean; | |
| 97 | + | title: string; | |
| 98 | + | children: ReactNode; | |
| 99 | + | }) { | |
| 100 | + | return ( | |
| 101 | + | <label className="flex cursor-pointer items-start gap-3 rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"> | |
| 102 | + | <input type="checkbox" name={name} defaultChecked={on} className="mt-1 accent-accent" /> | |
| 103 | + | <span> | |
| 104 | + | <span className="block text-sm font-medium">{title}</span> | |
| 105 | + | <span className="mt-1 block text-sm text-muted">{children}</span> | |
| 106 | + | </span> | |
| 107 | + | </label> | |
| 108 | + | ); | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /** A setting chosen from a few numbers. */ | |
| 112 | + | function Choice({ | |
| 113 | + | name, | |
| 114 | + | value, | |
| 115 | + | options, | |
| 116 | + | title, | |
| 117 | + | children, | |
| 118 | + | }: { | |
| 119 | + | name: string; | |
| 120 | + | value: number; | |
| 121 | + | options: [number, string][]; | |
| 122 | + | title: string; | |
| 123 | + | children: ReactNode; | |
| 124 | + | }) { | |
| 125 | + | return ( | |
| 126 | + | <div className="flex items-start gap-4 rounded-xl border border-line bg-surface p-4"> | |
| 127 | + | <div className="grow"> | |
| 128 | + | <p className="text-sm font-medium">{title}</p> | |
| 129 | + | <p className="mt-1 text-sm text-muted">{children}</p> | |
| 130 | + | </div> | |
| 131 | + | <select | |
| 132 | + | name={name} | |
| 133 | + | defaultValue={value} | |
| 134 | + | aria-label={title} | |
| 135 | + | className="shrink-0 rounded-md border border-line bg-bg px-2 py-1.5 text-sm" | |
| 136 | + | > | |
| 137 | + | {options.map(([option, label]) => ( | |
| 138 | + | <option key={option} value={option}> | |
| 139 | + | {label} | |
| 140 | + | </option> | |
| 141 | + | ))} | |
| 142 | + | </select> | |
| 143 | + | </div> | |
| 144 | + | ); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | export default function RepoSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 148 | + | const { repo, settings } = loaderData; | |
| 149 | + | const saving = useNavigation().state === "submitting"; | |
| 150 | + | const branch = repo.defaultBranch; | |
| 151 | + | return ( | |
| 152 | + | <> | |
| 153 | + | <RepoSettingsTabs base={`/${repo.namespace}/${repo.name}`} /> | |
| 154 | + | <Form method="post" className="max-w-4xl space-y-8"> | |
| 155 | + | <Section title="General" about="What the repository is and who can see it."> | |
| 156 | + | <Field label="Description"> | |
| 157 | + | <Input name="description" maxLength={200} defaultValue={repo.description ?? ""} /> | |
| 158 | + | </Field> | |
| 159 | + | <fieldset className="space-y-2 rounded-xl border border-line bg-surface p-4 text-sm"> | |
| 160 | + | <legend className="sr-only">Visibility</legend> | |
| 161 | + | <label className="flex items-start gap-3"> | |
| 162 | + | <input | |
| 163 | + | type="radio" | |
| 164 | + | name="visibility" | |
| 165 | + | value="public" | |
| 166 | + | defaultChecked={!repo.isPrivate} | |
| 167 | + | className="mt-1 accent-accent" | |
| 168 | + | /> | |
| 169 | + | <span> | |
| 170 | + | <span className="block font-medium">Public</span> | |
| 171 | + | <span className="text-muted">Anyone can see and clone it.</span> | |
| 172 | + | </span> | |
| 173 | + | </label> | |
| 174 | + | <label className="flex items-start gap-3"> | |
| 175 | + | <input | |
| 176 | + | type="radio" | |
| 177 | + | name="visibility" | |
| 178 | + | value="private" | |
| 179 | + | defaultChecked={repo.isPrivate} | |
| 180 | + | className="mt-1 accent-accent" | |
| 181 | + | /> | |
| 182 | + | <span> | |
| 183 | + | <span className="block font-medium">Private</span> | |
| 184 | + | <span className="text-muted">Only members of the workspace can see it.</span> | |
| 185 | + | </span> | |
| 186 | + | </label> | |
| 187 | + | </fieldset> | |
| 188 | + | </Section> | |
| 189 | + | ||
| 190 | + | <Section | |
| 191 | + | title="Branch protection" | |
| 192 | + | about={`Rules for ${branch}, the branch everything lands on.`} | |
| 193 | + | > | |
| 194 | + | <Toggle | |
| 195 | + | name="protected" | |
| 196 | + | on={repo.protected} | |
| 197 | + | title={`Require a pull request to change ${branch}`} | |
| 198 | + | > | |
| 199 | + | Pushing to {branch} is refused, for members and agents alike, and git says why. | |
| 200 | + | Changes reach it only by merging a pull request. The first push to an empty | |
| 201 | + | repository is still allowed. | |
| 202 | + | </Toggle> | |
| 203 | + | <Choice | |
| 204 | + | name="requiredApprovals" | |
| 205 | + | value={settings.requiredApprovals} | |
| 206 | + | title="Required approvals" | |
| 207 | + | options={[ | |
| 208 | + | [0, "None"], | |
| 209 | + | [1, "1"], | |
| 210 | + | [2, "2"], | |
| 211 | + | [3, "3"], | |
| 212 | + | ]} | |
| 213 | + | > | |
| 214 | + | How many reviewers must approve before a pull request can merge. A reviewer | |
| 215 | + | who has since asked for changes blocks it, and nobody approves their own. | |
| 216 | + | </Choice> | |
| 217 | + | <Toggle | |
| 218 | + | name="countAgentApprovals" | |
| 219 | + | on={settings.countAgentApprovals} | |
| 220 | + | title="A g1t agent's approval counts" | |
| 221 | + | > | |
| 222 | + | With this off, required approvals have to come from people, and an agent's | |
| 223 | + | review is advice. | |
| 224 | + | </Toggle> | |
| 225 | + | <Toggle | |
| 226 | + | name="requireChecks" | |
| 227 | + | on={!settings.allowIgnoringChecks} | |
| 228 | + | title="Require acceptance checks to pass" | |
| 229 | + | > | |
| 230 | + | With this off, a member can choose to merge although the issue's checks | |
| 231 | + | failed or have not finished. With it on, nobody can. | |
| 232 | + | </Toggle> | |
| 233 | + | <Toggle | |
| 234 | + | name="requireUpToDate" | |
| 235 | + | on={settings.requireUpToDate} | |
| 236 | + | title="Require pull requests to be up to date before merging" | |
| 237 | + | > | |
| 238 | + | With this off, a pull request can be merged after {branch} has moved: g1t | |
| 239 | + | brings it up to date as part of merging, and asks you only if there is a | |
| 240 | + | conflict it cannot resolve. With it on, it has to catch up first and its | |
| 241 | + | checks run again on the result, so what lands is exactly what was checked. | |
| 242 | + | </Toggle> | |
| 243 | + | <Toggle name="mergeQueue" on={settings.mergeQueue} title="Merge through a queue"> | |
| 244 | + | Merging adds a pull request to the queue instead of changing {branch} at once. | |
| 245 | + | g1t tests it together with every pull request ahead of it, several | |
| 246 | + | combinations at a time, and {branch} only ever moves to a combination whose | |
| 247 | + | checks passed. One that fails leaves the queue and goes back to its author, | |
| 248 | + | and the ones behind it are tested again without it. | |
| 249 | + | </Toggle> | |
| 250 | + | </Section> | |
| 251 | + | ||
| 252 | + | <Section | |
| 253 | + | title="g1t agents" | |
| 254 | + | about="What happens to a pull request a g1t agent makes, from the moment it is ready." | |
| 255 | + | > | |
| 256 | + | <Toggle name="agentReview" on={settings.agentReview} title="Review by a second agent"> | |
| 257 | + | A different agent reads each change and posts comments on lines, a summary | |
| 258 | + | and a verdict. If it asks for changes, the author is sent back to make them. | |
| 259 | + | With this off, review is left to people. | |
| 260 | + | </Toggle> | |
| 261 | + | <Choice | |
| 262 | + | name="maxRevisions" | |
| 263 | + | value={settings.maxRevisions} | |
| 264 | + | title="Revisions before asking you" | |
| 265 | + | options={[ | |
| 266 | + | [0, "None"], | |
| 267 | + | [1, "1"], | |
| 268 | + | [2, "2"], | |
| 269 | + | [3, "3"], | |
| 270 | + | [5, "5"], | |
| 271 | + | ]} | |
| 272 | + | > | |
| 273 | + | How many times an agent is sent back to fix failed checks or address a | |
| 274 | + | review before g1t stops and the pull request says it needs you. | |
| 275 | + | </Choice> | |
| 276 | + | <Toggle name="autoMerge" on={settings.autoMerge} title="Merge automatically when ready"> | |
| 277 | + | A g1t agent's pull request lands without anyone pressing merge once every | |
| 278 | + | rule above is met. With this off, it waits for a member. Pull requests from | |
| 279 | + | people and from other agents always wait. | |
| 280 | + | </Toggle> | |
| 281 | + | </Section> | |
| 282 | + | ||
| 283 | + | <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur"> | |
| 284 | + | <Button type="submit" disabled={saving}> | |
| 285 | + | {saving ? "Saving…" : "Save settings"} | |
| 286 | + | </Button> | |
| 287 | + | {actionData?.saved && <span className="text-sm text-muted">Saved.</span>} | |
| 288 | + | <ErrorText>{actionData?.error}</ErrorText> | |
| 289 | + | {settings.updatedBy && settings.updatedAt && !actionData && ( | |
| 290 | + | <span className="text-xs text-faint"> | |
| 291 | + | Merge rules last changed by{" "} | |
| 292 | + | <span className="font-mono">{settings.updatedBy}</span>{" "} | |
| 293 | + | <TimeAgo at={settings.updatedAt} /> | |
| 294 | + | </span> | |
| 295 | + | )} | |
| 296 | + | </div> | |
| 297 | + | </Form> | |
| 298 | + | </> | |
| 299 | + | ); | |
| 300 | + | } |
| 1 | − | import type { ReactNode } from "react"; | |
| 2 | − | import { Form, data, useNavigation } from "react-router"; | |
| 1 | + | import { Box, Code2, GitBranch } from "lucide-react"; | |
| 2 | + | import { Form, Link, data, useNavigation } from "react-router"; | |
| 3 | 3 | ||
| 4 | − | import { RepoSettingsTabs } from "../../components/repo-settings-tabs"; | |
| 5 | 4 | import type { Route } from "./+types/settings"; | |
| 5 | + | import { RepoSettingsTabs } from "../../components/repo-settings-tabs"; | |
| 6 | 6 | import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui"; | |
| 7 | − | import { repos, work } from "../../lib/services.server"; | |
| 8 | − | import { | |
| 9 | − | assertSameOrigin, | |
| 10 | − | getViewer, | |
| 11 | − | requireUser, | |
| 12 | − | roleIn, | |
| 13 | − | unwrap, | |
| 14 | − | } from "../../lib/session.server"; | |
| 7 | + | import { projects } from "../../lib/services.server"; | |
| 8 | + | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 15 | 9 | ||
| 16 | 10 | export function meta({ params }: Route.MetaArgs) { | |
| 17 | 11 | return [{ title: `Settings · ${params.owner}/${params.repo} · g1t` }]; | |
| 21 | 15 | const viewer = getViewer(context); | |
| 22 | 16 | // Members only; to anyone else the page does not exist. | |
| 23 | 17 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 24 | − | const path = { namespace: params.owner, name: params.repo }; | |
| 25 | − | const [repo, settings] = await Promise.all([ | |
| 26 | − | repos.get(path, viewer), | |
| 27 | − | work.getSettings(path, viewer), | |
| 28 | − | ]); | |
| 29 | − | return { repo: unwrap(repo), settings: unwrap(settings) }; | |
| 30 | − | } | |
| 31 | − | ||
| 32 | − | /** A whole number from a form field, kept within `min` and `max`. */ | |
| 33 | − | function count(value: FormDataEntryValue | null, min: number, max: number): number { | |
| 34 | − | const number = Math.trunc(Number(value)); | |
| 35 | − | return Number.isFinite(number) ? Math.min(Math.max(number, min), max) : min; | |
| 18 | + | return { project: unwrap(await projects.get(params.owner, params.repo, viewer)) }; | |
| 36 | 19 | } | |
| 37 | 20 | ||
| 38 | 21 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 39 | 22 | assertSameOrigin(request); | |
| 40 | 23 | const user = requireUser(context, request); | |
| 41 | 24 | const form = await request.formData(); | |
| 42 | − | const path = { namespace: params.owner, name: params.repo }; | |
| 43 | − | const on = (name: string) => form.get(name) === "on"; | |
| 44 | − | ||
| 45 | − | // The repository's own details belong to one service and how its pull | |
| 46 | − | // requests are handled to another; the page is one form over both. | |
| 47 | − | const repo = await repos.update(user, path, { | |
| 25 | + | const saved = await projects.update(user, params.owner, params.repo, { | |
| 26 | + | name: String(form.get("name") ?? ""), | |
| 48 | 27 | description: String(form.get("description") ?? ""), | |
| 49 | − | isPrivate: form.get("visibility") === "private", | |
| 50 | − | protected: on("protected"), | |
| 51 | − | }); | |
| 52 | − | if (!repo.ok) return { saved: false, error: repo.error.message }; | |
| 53 | − | const settings = await work.updateSettings(user, path, { | |
| 54 | − | autoMerge: on("autoMerge"), | |
| 55 | − | requireUpToDate: on("requireUpToDate"), | |
| 56 | − | requiredApprovals: count(form.get("requiredApprovals"), 0, 6), | |
| 57 | − | countAgentApprovals: on("countAgentApprovals"), | |
| 58 | − | allowIgnoringChecks: !on("requireChecks"), | |
| 59 | − | agentReview: on("agentReview"), | |
| 60 | − | maxRevisions: count(form.get("maxRevisions"), 0, 5), | |
| 61 | − | mergeQueue: on("mergeQueue"), | |
| 28 | + | rootDir: String(form.get("rootDir") ?? ""), | |
| 62 | 29 | }); | |
| 63 | − | return settings.ok | |
| 64 | − | ? { saved: true, error: null } | |
| 65 | − | : { saved: false, error: settings.error.message }; | |
| 66 | − | } | |
| 67 | − | ||
| 68 | − | function Section({ | |
| 69 | − | title, | |
| 70 | − | about, | |
| 71 | − | children, | |
| 72 | − | }: { | |
| 73 | − | title: string; | |
| 74 | − | about: string; | |
| 75 | − | children: ReactNode; | |
| 76 | − | }) { | |
| 77 | − | return ( | |
| 78 | − | <section className="grid gap-x-10 gap-y-4 border-t border-line pt-8 first:border-t-0 first:pt-0 lg:grid-cols-[16rem_1fr]"> | |
| 79 | − | <div> | |
| 80 | − | <h2 className="font-medium">{title}</h2> | |
| 81 | − | <p className="mt-1 text-sm text-muted">{about}</p> | |
| 82 | − | </div> | |
| 83 | − | <div className="space-y-3">{children}</div> | |
| 84 | − | </section> | |
| 85 | − | ); | |
| 86 | − | } | |
| 87 | − | ||
| 88 | − | /** A setting that is on or off, with what it means. */ | |
| 89 | − | function Toggle({ | |
| 90 | − | name, | |
| 91 | − | on, | |
| 92 | − | title, | |
| 93 | − | children, | |
| 94 | − | }: { | |
| 95 | − | name: string; | |
| 96 | − | on: boolean; | |
| 97 | − | title: string; | |
| 98 | − | children: ReactNode; | |
| 99 | − | }) { | |
| 100 | − | return ( | |
| 101 | − | <label className="flex cursor-pointer items-start gap-3 rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"> | |
| 102 | − | <input type="checkbox" name={name} defaultChecked={on} className="mt-1 accent-accent" /> | |
| 103 | − | <span> | |
| 104 | − | <span className="block text-sm font-medium">{title}</span> | |
| 105 | − | <span className="mt-1 block text-sm text-muted">{children}</span> | |
| 106 | − | </span> | |
| 107 | − | </label> | |
| 108 | − | ); | |
| 30 | + | return saved.ok ? { saved: true as const } : { error: saved.error.message }; | |
| 109 | 31 | } | |
| 110 | 32 | ||
| 111 | − | /** A setting chosen from a few numbers. */ | |
| 112 | − | function Choice({ | |
| 113 | − | name, | |
| 114 | − | value, | |
| 115 | − | options, | |
| 116 | − | title, | |
| 117 | − | children, | |
| 118 | − | }: { | |
| 119 | − | name: string; | |
| 120 | − | value: number; | |
| 121 | − | options: [number, string][]; | |
| 122 | − | title: string; | |
| 123 | − | children: ReactNode; | |
| 124 | − | }) { | |
| 125 | − | return ( | |
| 126 | − | <div className="flex items-start gap-4 rounded-xl border border-line bg-surface p-4"> | |
| 127 | − | <div className="grow"> | |
| 128 | − | <p className="text-sm font-medium">{title}</p> | |
| 129 | − | <p className="mt-1 text-sm text-muted">{children}</p> | |
| 130 | − | </div> | |
| 131 | − | <select | |
| 132 | − | name={name} | |
| 133 | − | defaultValue={value} | |
| 134 | − | aria-label={title} | |
| 135 | − | className="shrink-0 rounded-md border border-line bg-bg px-2 py-1.5 text-sm" | |
| 136 | − | > | |
| 137 | − | {options.map(([option, label]) => ( | |
| 138 | − | <option key={option} value={option}> | |
| 139 | − | {label} | |
| 140 | − | </option> | |
| 141 | − | ))} | |
| 142 | − | </select> | |
| 143 | − | </div> | |
| 144 | − | ); | |
| 145 | − | } | |
| 146 | − | ||
| 147 | − | export default function RepoSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 148 | − | const { repo, settings } = loaderData; | |
| 33 | + | export default function ProjectSettings({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 34 | + | const { project } = loaderData; | |
| 149 | 35 | const saving = useNavigation().state === "submitting"; | |
| 150 | − | const branch = repo.defaultBranch; | |
| 36 | + | const base = `/${params.owner}/${params.repo}`; | |
| 37 | + | const source = project.source.kind === "hosted" ? project.source : null; | |
| 151 | 38 | return ( | |
| 152 | − | <> | |
| 153 | − | <RepoSettingsTabs base={`/${repo.namespace}/${repo.name}`} /> | |
| 154 | − | <Form method="post" className="max-w-4xl space-y-8"> | |
| 155 | − | <Section title="General" about="What the repository is and who can see it."> | |
| 156 | − | <Field label="Description"> | |
| 157 | − | <Input name="description" maxLength={200} defaultValue={repo.description ?? ""} /> | |
| 158 | − | </Field> | |
| 159 | − | <fieldset className="space-y-2 rounded-xl border border-line bg-surface p-4 text-sm"> | |
| 160 | − | <legend className="sr-only">Visibility</legend> | |
| 161 | − | <label className="flex items-start gap-3"> | |
| 162 | − | <input | |
| 163 | − | type="radio" | |
| 164 | − | name="visibility" | |
| 165 | − | value="public" | |
| 166 | − | defaultChecked={!repo.isPrivate} | |
| 167 | − | className="mt-1 accent-accent" | |
| 168 | − | /> | |
| 169 | − | <span> | |
| 170 | − | <span className="block font-medium">Public</span> | |
| 171 | − | <span className="text-muted">Anyone can see and clone it.</span> | |
| 172 | − | </span> | |
| 173 | − | </label> | |
| 174 | − | <label className="flex items-start gap-3"> | |
| 175 | − | <input | |
| 176 | − | type="radio" | |
| 177 | − | name="visibility" | |
| 178 | − | value="private" | |
| 179 | − | defaultChecked={repo.isPrivate} | |
| 180 | − | className="mt-1 accent-accent" | |
| 181 | − | /> | |
| 182 | − | <span> | |
| 183 | − | <span className="block font-medium">Private</span> | |
| 184 | − | <span className="text-muted">Only members of the workspace can see it.</span> | |
| 185 | − | </span> | |
| 186 | − | </label> | |
| 187 | − | </fieldset> | |
| 188 | − | </Section> | |
| 39 | + | <div className="max-w-4xl"> | |
| 40 | + | <RepoSettingsTabs base={base} /> | |
| 41 | + | <Form method="post" className="space-y-8"> | |
| 42 | + | <section className="space-y-4"> | |
| 43 | + | <h2 className="flex items-center gap-2 text-sm font-medium"> | |
| 44 | + | <Box size={15} className="text-accent" /> | |
| 45 | + | Project | |
| 46 | + | </h2> | |
| 47 | + | <div className="grid gap-4 md:grid-cols-2"> | |
| 48 | + | <Field label="Name" hint={`Its address stays g1t.sh/${project.workspace}/${project.slug}.`}> | |
| 49 | + | <Input name="name" defaultValue={project.name} required maxLength={100} /> | |
| 50 | + | </Field> | |
| 51 | + | <Field label="Description"> | |
| 52 | + | <Input name="description" defaultValue={project.description ?? ""} maxLength={200} placeholder="What it is, in a line" /> | |
| 53 | + | </Field> | |
| 54 | + | </div> | |
| 55 | + | </section> | |
| 189 | 56 | ||
| 190 | − | <Section | |
| 191 | − | title="Branch protection" | |
| 192 | − | about={`Rules for ${branch}, the branch everything lands on.`} | |
| 193 | − | > | |
| 194 | − | <Toggle | |
| 195 | − | name="protected" | |
| 196 | − | on={repo.protected} | |
| 197 | − | title={`Require a pull request to change ${branch}`} | |
| 57 | + | <section className="space-y-4"> | |
| 58 | + | <h2 className="flex items-center gap-2 text-sm font-medium"> | |
| 59 | + | <Code2 size={15} className="text-accent" /> | |
| 60 | + | Source | |
| 61 | + | </h2> | |
| 62 | + | {source && ( | |
| 63 | + | <div className="rounded-xl border border-line bg-surface p-4 text-sm"> | |
| 64 | + | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 65 | + | <div> | |
| 66 | + | <Link to={`${base}/code`} className="font-mono hover:text-accent"> | |
| 67 | + | {source.repo.namespace}/{source.repo.name} | |
| 68 | + | </Link> | |
| 69 | + | <p className="mt-0.5 text-xs text-muted"> | |
| 70 | + | Hosted on g1t · <GitBranch size={11} className="inline" /> {source.defaultBranch} | |
| 71 | + | </p> | |
| 72 | + | </div> | |
| 73 | + | <Link to={`${base}/settings/repository`} className="text-xs text-muted hover:text-fg"> | |
| 74 | + | Repository settings | |
| 75 | + | </Link> | |
| 76 | + | </div> | |
| 77 | + | <p className="mt-3 text-xs text-faint"> | |
| 78 | + | Mirroring a repository from GitHub, GitLab or Bitbucket as a project's source is coming next. | |
| 79 | + | </p> | |
| 80 | + | </div> | |
| 81 | + | )} | |
| 82 | + | <Field | |
| 83 | + | label="Root directory" | |
| 84 | + | hint="Where in the repository this project lives, such as apps/web. Builds run there. Empty for the whole repository." | |
| 198 | 85 | > | |
| 199 | − | Pushing to {branch} is refused, for members and agents alike, and git says why. | |
| 200 | − | Changes reach it only by merging a pull request. The first push to an empty | |
| 201 | − | repository is still allowed. | |
| 202 | − | </Toggle> | |
| 203 | − | <Choice | |
| 204 | − | name="requiredApprovals" | |
| 205 | − | value={settings.requiredApprovals} | |
| 206 | − | title="Required approvals" | |
| 207 | − | options={[ | |
| 208 | − | [0, "None"], | |
| 209 | − | [1, "1"], | |
| 210 | − | [2, "2"], | |
| 211 | − | [3, "3"], | |
| 212 | − | ]} | |
| 213 | − | > | |
| 214 | − | How many reviewers must approve before a pull request can merge. A reviewer | |
| 215 | − | who has since asked for changes blocks it, and nobody approves their own. | |
| 216 | − | </Choice> | |
| 217 | − | <Toggle | |
| 218 | − | name="countAgentApprovals" | |
| 219 | − | on={settings.countAgentApprovals} | |
| 220 | − | title="A g1t agent's approval counts" | |
| 221 | − | > | |
| 222 | − | With this off, required approvals have to come from people, and an agent's | |
| 223 | − | review is advice. | |
| 224 | − | </Toggle> | |
| 225 | − | <Toggle | |
| 226 | − | name="requireChecks" | |
| 227 | − | on={!settings.allowIgnoringChecks} | |
| 228 | − | title="Require acceptance checks to pass" | |
| 229 | − | > | |
| 230 | − | With this off, a member can choose to merge although the issue's checks | |
| 231 | − | failed or have not finished. With it on, nobody can. | |
| 232 | − | </Toggle> | |
| 233 | − | <Toggle | |
| 234 | − | name="requireUpToDate" | |
| 235 | − | on={settings.requireUpToDate} | |
| 236 | − | title="Require pull requests to be up to date before merging" | |
| 237 | − | > | |
| 238 | − | With this off, a pull request can be merged after {branch} has moved: g1t | |
| 239 | − | brings it up to date as part of merging, and asks you only if there is a | |
| 240 | − | conflict it cannot resolve. With it on, it has to catch up first and its | |
| 241 | − | checks run again on the result, so what lands is exactly what was checked. | |
| 242 | − | </Toggle> | |
| 243 | − | <Toggle name="mergeQueue" on={settings.mergeQueue} title="Merge through a queue"> | |
| 244 | − | Merging adds a pull request to the queue instead of changing {branch} at once. | |
| 245 | − | g1t tests it together with every pull request ahead of it, several | |
| 246 | − | combinations at a time, and {branch} only ever moves to a combination whose | |
| 247 | − | checks passed. One that fails leaves the queue and goes back to its author, | |
| 248 | − | and the ones behind it are tested again without it. | |
| 249 | − | </Toggle> | |
| 250 | − | </Section> | |
| 86 | + | <Input name="rootDir" defaultValue={source?.rootDir ?? ""} placeholder="apps/web" className="font-mono" /> | |
| 87 | + | </Field> | |
| 88 | + | </section> | |
| 251 | 89 | ||
| 252 | − | <Section | |
| 253 | − | title="g1t agents" | |
| 254 | − | about="What happens to a pull request a g1t agent makes, from the moment it is ready." | |
| 255 | − | > | |
| 256 | − | <Toggle name="agentReview" on={settings.agentReview} title="Review by a second agent"> | |
| 257 | − | A different agent reads each change and posts comments on lines, a summary | |
| 258 | − | and a verdict. If it asks for changes, the author is sent back to make them. | |
| 259 | − | With this off, review is left to people. | |
| 260 | − | </Toggle> | |
| 261 | − | <Choice | |
| 262 | − | name="maxRevisions" | |
| 263 | − | value={settings.maxRevisions} | |
| 264 | − | title="Revisions before asking you" | |
| 265 | − | options={[ | |
| 266 | − | [0, "None"], | |
| 267 | − | [1, "1"], | |
| 268 | − | [2, "2"], | |
| 269 | − | [3, "3"], | |
| 270 | − | [5, "5"], | |
| 271 | − | ]} | |
| 272 | − | > | |
| 273 | − | How many times an agent is sent back to fix failed checks or address a | |
| 274 | − | review before g1t stops and the pull request says it needs you. | |
| 275 | − | </Choice> | |
| 276 | − | <Toggle name="autoMerge" on={settings.autoMerge} title="Merge automatically when ready"> | |
| 277 | − | A g1t agent's pull request lands without anyone pressing merge once every | |
| 278 | − | rule above is met. With this off, it waits for a member. Pull requests from | |
| 279 | − | people and from other agents always wait. | |
| 280 | − | </Toggle> | |
| 281 | − | </Section> | |
| 282 | − | ||
| 283 | − | <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur"> | |
| 90 | + | <div className="flex items-center gap-3"> | |
| 284 | 91 | <Button type="submit" disabled={saving}> | |
| 285 | − | {saving ? "Saving…" : "Save settings"} | |
| 92 | + | Save | |
| 286 | 93 | </Button> | |
| 287 | − | {actionData?.saved && <span className="text-sm text-muted">Saved.</span>} | |
| 288 | − | <ErrorText>{actionData?.error}</ErrorText> | |
| 289 | − | {settings.updatedBy && settings.updatedAt && !actionData && ( | |
| 290 | − | <span className="text-xs text-faint"> | |
| 291 | − | Merge rules last changed by{" "} | |
| 292 | − | <span className="font-mono">{settings.updatedBy}</span>{" "} | |
| 293 | − | <TimeAgo at={settings.updatedAt} /> | |
| 294 | − | </span> | |
| 295 | − | )} | |
| 94 | + | {actionData && "saved" in actionData && <span className="text-sm text-accent">Saved.</span>} | |
| 95 | + | <span className="ml-auto text-xs text-faint"> | |
| 96 | + | Created by {project.createdBy} <TimeAgo at={project.createdAt} /> | |
| 97 | + | </span> | |
| 296 | 98 | </div> | |
| 99 | + | <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText> | |
| 297 | 100 | </Form> | |
| 298 | − | </> | |
| 101 | + | </div> | |
| 299 | 102 | ); | |
| 300 | 103 | } |
| 1 | − | import { ArrowRight, KeyRound } from "lucide-react"; | |
| 1 | + | import { ArrowRight, ArrowUpRight, Box, CircleDot, GitBranch, GitPullRequest, KeyRound, Lock, Plus, Rocket } from "lucide-react"; | |
| 2 | 2 | import { Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | + | import type { Project, ProjectDeploys } from "@g1t/contracts"; | |
| 5 | + | ||
| 4 | 6 | import type { Route } from "./+types/overview"; | |
| 5 | − | import { RepoList } from "../../components/repo-list"; | |
| 7 | + | import { host, StatusDot } from "../../components/deploy"; | |
| 6 | 8 | import { Avatar, ButtonLink, CopyLine, Pill, TimeAgo } from "../../components/ui"; | |
| 7 | 9 | import { PullIcon } from "../../components/work"; | |
| 8 | − | import { identity, repos as reposApi, work } from "../../lib/services.server"; | |
| 10 | + | import { deployments, identity, projects as projectsApi, work } from "../../lib/services.server"; | |
| 9 | 11 | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 10 | 12 | ||
| 11 | − | /** Repositories whose open issues and pull requests are counted. */ | |
| 13 | + | /** Projects whose open issues and pull requests are counted. */ | |
| 12 | 14 | const MAX_COUNTED = 30; | |
| 13 | − | /** Repositories whose pull requests are listed under "In progress". */ | |
| 15 | + | /** Projects whose pull requests are listed under "In progress". */ | |
| 14 | 16 | const MAX_LISTED = 8; | |
| 15 | 17 | const MAX_PULLS = 8; | |
| 16 | 18 | const MAX_FACES = 8; | |
| 19 | 21 | const viewer = getViewer(context); | |
| 20 | 22 | const slug = params.owner.toLowerCase(); | |
| 21 | 23 | const role = roleIn(viewer, slug); | |
| 22 | − | const [repos, members] = await Promise.all([ | |
| 23 | − | reposApi.list(viewer, { namespace: slug }), | |
| 24 | + | const [listed, members, deploys] = await Promise.all([ | |
| 25 | + | projectsApi.list(slug, viewer), | |
| 24 | 26 | role ? identity.listMembers(slug, viewer) : null, | |
| 27 | + | role ? deployments.overview(slug, viewer) : null, | |
| 25 | 28 | ]); | |
| 29 | + | const projects = listed.ok ? listed.value : []; | |
| 26 | 30 | ||
| 27 | − | const counted = repos.slice(0, MAX_COUNTED); | |
| 28 | − | const counts = await Promise.all( | |
| 29 | − | counted.map((repo) => work.counts({ namespace: repo.namespace, name: repo.name }, viewer)), | |
| 30 | − | ); | |
| 31 | + | const pathOf = (project: Project) => | |
| 32 | + | project.source.kind === "hosted" ? project.source.repo : { namespace: project.workspace, name: project.slug }; | |
| 33 | + | const counted = projects.slice(0, MAX_COUNTED); | |
| 34 | + | const counts = await Promise.all(counted.map((project) => work.counts(pathOf(project), viewer))); | |
| 31 | 35 | const open: Record<string, { issues: number; pulls: number }> = {}; | |
| 32 | − | counted.forEach((repo, i) => { | |
| 36 | + | counted.forEach((project, i) => { | |
| 33 | 37 | const result = counts[i]; | |
| 34 | − | if (result.ok) open[repo.id] = result.value; | |
| 38 | + | if (result.ok) open[project.id] = result.value; | |
| 35 | 39 | }); | |
| 36 | 40 | ||
| 37 | − | const busy = counted.filter((repo) => (open[repo.id]?.pulls ?? 0) > 0).slice(0, MAX_LISTED); | |
| 38 | − | const lists = await Promise.all( | |
| 39 | − | busy.map((repo) => | |
| 40 | − | work.listPulls({ namespace: repo.namespace, name: repo.name }, viewer, "open"), | |
| 41 | − | ), | |
| 42 | − | ); | |
| 41 | + | const busy = counted.filter((project) => (open[project.id]?.pulls ?? 0) > 0).slice(0, MAX_LISTED); | |
| 42 | + | const lists = await Promise.all(busy.map((project) => work.listPulls(pathOf(project), viewer, "open"))); | |
| 43 | 43 | const pulls = lists | |
| 44 | − | .flatMap((result, i) => | |
| 45 | − | result.ok ? result.value.map((pull) => ({ pull, repo: busy[i].name })) : [], | |
| 46 | − | ) | |
| 44 | + | .flatMap((result, i) => (result.ok ? result.value.map((pull) => ({ pull, project: busy[i] })) : [])) | |
| 47 | 45 | .sort((a, b) => b.pull.updatedAt.localeCompare(a.pull.updatedAt)) | |
| 48 | 46 | .slice(0, MAX_PULLS); | |
| 49 | 47 | ||
| 50 | − | return { slug, role, repos, open, pulls, members: members?.ok ? members.value : null }; | |
| 48 | + | const bySlug: Record<string, ProjectDeploys> = {}; | |
| 49 | + | for (const entry of deploys?.ok ? deploys.value : []) bySlug[entry.slug] = entry; | |
| 50 | + | ||
| 51 | + | return { | |
| 52 | + | slug, | |
| 53 | + | role, | |
| 54 | + | projects, | |
| 55 | + | open, | |
| 56 | + | deploys: bySlug, | |
| 57 | + | pulls: pulls.map(({ pull, project }) => ({ pull, slug: project.slug, name: project.name })), | |
| 58 | + | members: members?.ok ? members.value : null, | |
| 59 | + | }; | |
| 51 | 60 | } | |
| 52 | 61 | ||
| 53 | 62 | function Stat({ value, label }: { value: number; label: string }) { | |
| 59 | 68 | ); | |
| 60 | 69 | } | |
| 61 | 70 | ||
| 62 | − | /** What a member sees in a workspace that has no repositories yet. */ | |
| 71 | + | /** What a member sees in a workspace with no projects yet. */ | |
| 63 | 72 | function GetStarted({ slug }: { slug: string }) { | |
| 64 | 73 | return ( | |
| 65 | − | <div className="rounded-xl border border-line bg-surface p-6"> | |
| 66 | − | <h2 className="font-medium">Add the first repository</h2> | |
| 74 | + | <div className="rounded-2xl border border-line bg-surface p-8"> | |
| 75 | + | <span className="flex size-10 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/30"> | |
| 76 | + | <Box size={18} /> | |
| 77 | + | </span> | |
| 78 | + | <h2 className="mt-4 text-lg font-semibold tracking-tight">Create your first project</h2> | |
| 67 | 79 | <p className="mt-1.5 max-w-xl text-sm text-muted"> | |
| 68 | − | A repository holds code, the issues that say what should change, and | |
| 69 | − | the pull requests agents and people make for them. | |
| 80 | + | A project is what you build and run: its code, the issues that say what should change, the agents and | |
| 81 | + | people who change it, and where it is deployed. | |
| 70 | 82 | </p> | |
| 71 | − | <div className="mt-5 flex flex-wrap gap-3"> | |
| 72 | − | <ButtonLink to={`/new?workspace=${slug}`}>Create or import a repository</ButtonLink> | |
| 83 | + | <div className="mt-6 flex flex-wrap gap-3"> | |
| 84 | + | <ButtonLink to={`/new?workspace=${slug}`} variant="accent"> | |
| 85 | + | <Plus size={14} /> | |
| 86 | + | New project | |
| 87 | + | </ButtonLink> | |
| 73 | 88 | </div> | |
| 74 | − | <p className="mt-6 text-sm text-muted">Or push one you already have:</p> | |
| 89 | + | <p className="mt-6 text-sm text-muted">Or push code you already have, and it becomes a project:</p> | |
| 75 | 90 | <div className="mt-2 max-w-xl"> | |
| 76 | 91 | <CopyLine prompt text={`git push https://g1t.sh/${slug}/my-project.git main`} /> | |
| 77 | 92 | </div> | |
| 79 | 94 | ); | |
| 80 | 95 | } | |
| 81 | 96 | ||
| 97 | + | function ProjectCard({ | |
| 98 | + | project, | |
| 99 | + | open, | |
| 100 | + | deploys, | |
| 101 | + | member, | |
| 102 | + | }: { | |
| 103 | + | project: Project; | |
| 104 | + | open: { issues: number; pulls: number } | undefined; | |
| 105 | + | deploys: ProjectDeploys | undefined; | |
| 106 | + | member: boolean; | |
| 107 | + | }) { | |
| 108 | + | const base = `/${project.workspace}/${project.slug}`; | |
| 109 | + | const production = deploys?.production ?? null; | |
| 110 | + | const latest = deploys?.latest ?? null; | |
| 111 | + | const source = project.source.kind === "hosted" ? project.source : null; | |
| 112 | + | return ( | |
| 113 | + | <li className="group relative flex flex-col rounded-2xl border border-line bg-surface p-5 transition-colors hover:border-line-strong"> | |
| 114 | + | <div className="flex items-start gap-3"> | |
| 115 | + | <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-raised text-muted ring-1 ring-line"> | |
| 116 | + | {project.private ? <Lock size={15} /> : <Box size={15} />} | |
| 117 | + | </span> | |
| 118 | + | <div className="min-w-0 grow"> | |
| 119 | + | <Link to={base} prefetch="intent" className="font-medium after:absolute after:inset-0 hover:underline"> | |
| 120 | + | {project.name} | |
| 121 | + | </Link> | |
| 122 | + | {production ? ( | |
| 123 | + | <a | |
| 124 | + | href={production.url} | |
| 125 | + | className="relative z-10 mt-0.5 flex items-center gap-1 truncate font-mono text-xs text-muted hover:text-accent" | |
| 126 | + | > | |
| 127 | + | {host(production.url)} | |
| 128 | + | <ArrowUpRight size={11} className="shrink-0" /> | |
| 129 | + | </a> | |
| 130 | + | ) : ( | |
| 131 | + | <p className="mt-0.5 truncate text-xs text-faint">{project.description ?? "Not deployed"}</p> | |
| 132 | + | )} | |
| 133 | + | </div> | |
| 134 | + | {project.private && <Pill>private</Pill>} | |
| 135 | + | </div> | |
| 136 | + | ||
| 137 | + | {project.description && production && <p className="mt-3 line-clamp-2 text-sm text-muted">{project.description}</p>} | |
| 138 | + | ||
| 139 | + | <div className="mt-auto pt-5"> | |
| 140 | + | {member && latest && ( | |
| 141 | + | <p className="mb-3 flex items-center gap-2 text-xs text-muted"> | |
| 142 | + | <StatusDot status={latest.status} /> | |
| 143 | + | <span className="truncate"> | |
| 144 | + | {latest.kind === "production" ? "Production" : `Preview of ${latest.branch}`} | |
| 145 | + | </span> | |
| 146 | + | <span className="ml-auto shrink-0 text-faint"> | |
| 147 | + | <TimeAgo at={latest.createdAt} /> | |
| 148 | + | </span> | |
| 149 | + | </p> | |
| 150 | + | )} | |
| 151 | + | <div className="flex items-center gap-4 border-t border-line pt-3 text-xs text-muted"> | |
| 152 | + | {source && ( | |
| 153 | + | <span className="flex min-w-0 items-center gap-1.5 font-mono"> | |
| 154 | + | <GitBranch size={12} className="shrink-0 text-faint" /> | |
| 155 | + | <span className="truncate">{source.defaultBranch}</span> | |
| 156 | + | </span> | |
| 157 | + | )} | |
| 158 | + | <span className="flex items-center gap-1"> | |
| 159 | + | <CircleDot size={12} className="text-faint" /> | |
| 160 | + | {open?.issues ?? 0} | |
| 161 | + | </span> | |
| 162 | + | <span className="flex items-center gap-1"> | |
| 163 | + | <GitPullRequest size={12} className="text-faint" /> | |
| 164 | + | {open?.pulls ?? 0} | |
| 165 | + | </span> | |
| 166 | + | {member && deploys?.enabled && (deploys.previews ?? 0) > 0 && ( | |
| 167 | + | <span className="ml-auto flex items-center gap-1 text-accent"> | |
| 168 | + | <Rocket size={12} /> | |
| 169 | + | {deploys.previews} {deploys.previews === 1 ? "preview" : "previews"} | |
| 170 | + | </span> | |
| 171 | + | )} | |
| 172 | + | </div> | |
| 173 | + | </div> | |
| 174 | + | </li> | |
| 175 | + | ); | |
| 176 | + | } | |
| 177 | + | ||
| 82 | 178 | export default function WorkspaceOverview({ loaderData }: Route.ComponentProps) { | |
| 83 | − | const { slug, role, repos, open, pulls, members } = loaderData; | |
| 179 | + | const { slug, role, projects, open, deploys, pulls, members } = loaderData; | |
| 84 | 180 | const totals = Object.values(open).reduce( | |
| 85 | − | (sum, counts) => ({ | |
| 86 | − | issues: sum.issues + counts.issues, | |
| 87 | − | pulls: sum.pulls + counts.pulls, | |
| 88 | − | }), | |
| 181 | + | (sum, counts) => ({ issues: sum.issues + counts.issues, pulls: sum.pulls + counts.pulls }), | |
| 89 | 182 | { issues: 0, pulls: 0 }, | |
| 90 | 183 | ); | |
| 184 | + | const liveApps = Object.values(deploys).filter((entry) => entry.production).length; | |
| 91 | 185 | return ( | |
| 92 | 186 | <div className="grid gap-10 lg:grid-cols-[1fr_18rem]"> | |
| 93 | 187 | <div className="min-w-0 space-y-10"> | |
| 94 | − | {repos.length === 0 && role ? ( | |
| 188 | + | {projects.length === 0 && role ? ( | |
| 95 | 189 | <GetStarted slug={slug} /> | |
| 96 | 190 | ) : ( | |
| 97 | 191 | <> | |
| 98 | − | <div className="grid grid-cols-3 gap-3"> | |
| 99 | − | <Stat | |
| 100 | − | value={repos.length} | |
| 101 | − | label={repos.length === 1 ? "Repository" : "Repositories"} | |
| 102 | − | /> | |
| 192 | + | <div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> | |
| 193 | + | <Stat value={projects.length} label={projects.length === 1 ? "Project" : "Projects"} /> | |
| 194 | + | <Stat value={liveApps} label="In production" /> | |
| 103 | 195 | <Stat value={totals.issues} label="Open issues" /> | |
| 104 | 196 | <Stat value={totals.pulls} label="Pull requests in progress" /> | |
| 105 | 197 | </div> | |
| 106 | 198 | ||
| 199 | + | <section> | |
| 200 | + | <div className="flex items-center justify-between"> | |
| 201 | + | <h2 className="text-sm font-medium text-muted">Projects</h2> | |
| 202 | + | {role && ( | |
| 203 | + | <Link to={`/new?workspace=${slug}`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 204 | + | <Plus size={13} /> | |
| 205 | + | New project | |
| 206 | + | </Link> | |
| 207 | + | )} | |
| 208 | + | </div> | |
| 209 | + | <ul className="mt-3 grid gap-4 sm:grid-cols-2"> | |
| 210 | + | {projects.map((project) => ( | |
| 211 | + | <ProjectCard | |
| 212 | + | key={project.id} | |
| 213 | + | project={project} | |
| 214 | + | open={open[project.id]} | |
| 215 | + | deploys={deploys[project.slug]} | |
| 216 | + | member={role != null} | |
| 217 | + | /> | |
| 218 | + | ))} | |
| 219 | + | </ul> | |
| 220 | + | </section> | |
| 221 | + | ||
| 107 | 222 | {pulls.length > 0 && ( | |
| 108 | 223 | <section> | |
| 109 | 224 | <h2 className="text-sm font-medium text-muted">In progress</h2> | |
| 110 | 225 | <ul className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 111 | − | {pulls.map(({ pull, repo }) => ( | |
| 226 | + | {pulls.map(({ pull, slug: project, name }) => ( | |
| 112 | 227 | <li key={pull.id}> | |
| 113 | 228 | <Link | |
| 114 | 229 | prefetch="intent" | |
| 115 | − | to={`/${slug}/${repo}/pull/${pull.number}`} | |
| 230 | + | to={`/${slug}/${project}/pull/${pull.number}`} | |
| 116 | 231 | className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised" | |
| 117 | 232 | > | |
| 118 | 233 | <PullIcon status={pull.status} /> | |
| 119 | 234 | <span className="min-w-0 grow"> | |
| 120 | 235 | <span className="block truncate font-medium">{pull.title}</span> | |
| 121 | − | <span className="font-mono text-xs text-muted"> | |
| 122 | − | {repo}#{pull.number} | |
| 123 | − | {pull.issue != null && ` · for #${pull.issue}`} ·{" "} | |
| 124 | − | {pull.author.username} | |
| 236 | + | <span className="text-xs text-muted"> | |
| 237 | + | {name} <span className="font-mono">#{pull.number}</span> | |
| 238 | + | {pull.issue != null && ` · for #${pull.issue}`} · {pull.author.username} | |
| 125 | 239 | </span> | |
| 126 | 240 | </span> | |
| 127 | 241 | <span className="hidden shrink-0 text-xs text-muted sm:block"> | |
| 136 | 250 | </ul> | |
| 137 | 251 | </section> | |
| 138 | 252 | )} | |
| 139 | − | ||
| 140 | − | <section> | |
| 141 | − | <h2 className="text-sm font-medium text-muted">Repositories</h2> | |
| 142 | − | <RepoList repos={repos} open={open} /> | |
| 143 | − | </section> | |
| 144 | 253 | </> | |
| 145 | 254 | )} | |
| 146 | 255 | </div> | |
| 149 | 258 | {members ? ( | |
| 150 | 259 | <section> | |
| 151 | 260 | <div className="flex items-center justify-between"> | |
| 152 | − | <h2 className="text-sm font-medium">People</h2> | |
| 261 | + | <h2 className="text-sm font-medium">Members</h2> | |
| 153 | 262 | <Link to={`/${slug}/-/people`} className="text-xs text-muted hover:text-fg"> | |
| 154 | 263 | {role === "owner" ? "Manage" : "See all"} | |
| 155 | 264 | </Link> | |
| 163 | 272 | </li> | |
| 164 | 273 | ))} | |
| 165 | 274 | </ul> | |
| 166 | − | {members.length > MAX_FACES && ( | |
| 167 | − | <p className="mt-2 text-xs text-faint"> | |
| 168 | − | and {members.length - MAX_FACES} more | |
| 169 | − | </p> | |
| 170 | − | )} | |
| 275 | + | {members.length > MAX_FACES && <p className="mt-2 text-xs text-faint">and {members.length - MAX_FACES} more</p>} | |
| 171 | 276 | </section> | |
| 172 | 277 | ) : ( | |
| 173 | 278 | <p className="text-sm text-muted"> | |
| 174 | − | Members of this workspace create repositories here and merge pull | |
| 175 | − | requests into them. Anyone can open an issue or a pull request on | |
| 176 | − | a public repository. | |
| 279 | + | Members of this workspace build its projects. Anyone can open an issue or a pull request on a public one. | |
| 177 | 280 | </p> | |
| 178 | 281 | )} | |
| 179 | 282 | ||
| 180 | 283 | {role && ( | |
| 181 | 284 | <section className="rounded-xl border border-line bg-surface p-5"> | |
| 182 | 285 | <h2 className="flex items-center gap-2 font-medium"> | |
| 286 | + | <Rocket size={15} className="text-faint" /> | |
| 287 | + | Deploy on g1t.page | |
| 288 | + | </h2> | |
| 289 | + | <p className="mt-1.5 text-sm text-muted"> | |
| 290 | + | Production from each project's default branch, and a live preview for every pull request. Apps cost | |
| 291 | + | nothing while no one visits. | |
| 292 | + | </p> | |
| 293 | + | <Link | |
| 294 | + | prefetch="intent" | |
| 295 | + | to={`/${slug}/-/billing`} | |
| 296 | + | className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline" | |
| 297 | + | > | |
| 298 | + | Billing and plans <ArrowRight size={13} /> | |
| 299 | + | </Link> | |
| 300 | + | </section> | |
| 301 | + | )} | |
| 302 | + | ||
| 303 | + | {role && ( | |
| 304 | + | <section className="rounded-xl border border-line bg-surface p-5"> | |
| 305 | + | <h2 className="flex items-center gap-2 font-medium"> | |
| 183 | 306 | <KeyRound size={15} className="text-faint" /> | |
| 184 | 307 | Automate without a service account | |
| 185 | 308 | </h2> | |
| 186 | 309 | <p className="mt-1.5 text-sm text-muted"> | |
| 187 | − | A workspace has access tokens of its own for CI, integrations | |
| 188 | − | and agents. They act as the workspace, not as a person. | |
| 310 | + | A workspace has access tokens of its own for CI, integrations and agents. They act as the workspace, | |
| 311 | + | not as a person. | |
| 189 | 312 | </p> | |
| 190 | 313 | <Link | |
| 191 | 314 | prefetch="intent" |
| 252 | 252 | which cannot change secrets. Guide: | |
| 253 | 253 | https://docs.g1t.sh/guides/secrets-and-variables/ | |
| 254 | 254 | ||
| 255 | + | ## Projects | |
| 256 | + | ||
| 257 | + | A project is what a workspace builds and runs; every repository is a | |
| 258 | + | project of its own name (`g1t.sh/<owner>/<project>` opens its overview; its | |
| 259 | + | code is under `/code`; every repository address still works). Deployments, | |
| 260 | + | secrets and variables belong to the project; branches, pull requests, | |
| 261 | + | review and merge rules to its repository (Settings → Repository). Guide: | |
| 262 | + | https://docs.g1t.sh/guides/projects/ | |
| 263 | + | ||
| 255 | 264 | ## Deployments | |
| 256 | 265 | ||
| 257 | 266 | A paid feature: an owner turns on the Deployments plan under the | |
| 258 | 267 | workspace's Billing ($5 a month: 10 apps, 1M requests, 3M CPU ms; builds | |
| 259 | 268 | and usage past that from credit at cost + 20%; never free). Then a member | |
| 260 | − | turns deployments on from the repository's Deployments page | |
| 261 | − | (`g1t.sh/<owner>/<repo>/deployments`; settings under | |
| 262 | − | `…/settings/deployments`). Builds and running apps read the secrets and | |
| 263 | − | variables available to Deployments, each key's Production or Preview row. | |
| 264 | − | Every pull request gets a preview at | |
| 265 | − | `https://pr-<n>--<repo>--<owner>.g1t.page`, shown on it as the check | |
| 266 | − | `g1t / deploy`; the default branch deploys to | |
| 267 | − | `https://<repo>--<owner>.g1t.page` on each push. Workers projects | |
| 268 | − | (`wrangler.jsonc`) and static sites build without configuration. Previews | |
| 269 | − | come down when the pull request closes and after idle days. There is no API | |
| 270 | − | for deployments yet. Guide: https://docs.g1t.sh/guides/deployments/ | |
| 269 | + | turns deployments on for a project (Settings → Deployments, or Deploy on | |
| 270 | + | its overview). Production deploys from the default branch to | |
| 271 | + | `https://<project>-<owner>.g1t.page` on each push; every branch with an | |
| 272 | + | open pull request gets a preview at | |
| 273 | + | `https://<project>-git-<branch>-<owner>.g1t.page` (a fork's pull request is | |
| 274 | + | `pr-<n>`), shown on it as the check `g1t / deploy`. Builds and running apps | |
| 275 | + | read the project's secrets and variables available to Deployments, each | |
| 276 | + | key's Production or Preview row. Workers projects (`wrangler.jsonc`) and | |
| 277 | + | static sites build without configuration. Previews come down when the pull | |
| 278 | + | request closes and after idle days. There is no API for deployments yet. | |
| 279 | + | Guide: https://docs.g1t.sh/guides/deployments/ | |
| 271 | 280 | ||
| 272 | 281 | ## Facts | |
| 273 | 282 |
| 14 | 14 | WEBHOOKS: ServiceBinding; | |
| 15 | 15 | ACTIONS: ServiceBinding; | |
| 16 | 16 | DEPLOYMENTS: ServiceBinding; | |
| 17 | + | PROJECTS: ServiceBinding; | |
| 17 | 18 | BLOBS: KVNamespace; | |
| 18 | 19 | } | |
| 19 | 20 | } |
| 21 | 21 | { "binding": "INTEGRATIONS", "service": "g1t-integrations" }, | |
| 22 | 22 | { "binding": "WEBHOOKS", "service": "g1t-webhooks" }, | |
| 23 | 23 | { "binding": "ACTIONS", "service": "g1t-actions" }, | |
| 24 | − | { "binding": "DEPLOYMENTS", "service": "g1t-deployments" } | |
| 24 | + | { "binding": "DEPLOYMENTS", "service": "g1t-deployments" }, | |
| 25 | + | { "binding": "PROJECTS", "service": "g1t-projects" } | |
| 25 | 26 | ], | |
| 26 | 27 | "observability": { "enabled": true }, | |
| 27 | 28 | "upload_source_maps": true |
| 175 | 175 | pub kind: String, | |
| 176 | 176 | /// A variable's value; secrets' are never returned. | |
| 177 | 177 | pub value: Option<String>, | |
| 178 | − | /// `repository` or `workspace`. | |
| 178 | + | /// `project` (a repository's, which belong to its project) or | |
| 179 | + | /// `workspace`. | |
| 179 | 180 | pub scope: String, | |
| 180 | 181 | pub updated_at: String, | |
| 181 | 182 | /// `workflows` and/or `deployments`. | |
| 184 | 185 | /// The environments it applies to; empty is every environment. | |
| 185 | 186 | #[serde(default)] | |
| 186 | 187 | pub environments: Vec<String>, | |
| 187 | − | /// A workspace's row: the repositories it reaches, by name; empty is | |
| 188 | − | /// every repository. | |
| 188 | + | /// A workspace's row: the projects it reaches, by slug; empty is every | |
| 189 | + | /// project. | |
| 189 | 190 | #[serde(default)] | |
| 190 | − | pub repositories: Vec<String>, | |
| 191 | + | pub projects: Vec<String>, | |
| 191 | 192 | #[serde(default)] | |
| 192 | 193 | pub note: Option<String>, | |
| 193 | 194 | #[serde(default)] | |
| 326 | 327 | /// unchanged. | |
| 327 | 328 | #[serde(default)] | |
| 328 | 329 | pub environments: Option<Vec<String>>, | |
| 329 | − | /// A workspace's row: repository names; empty for every one. | |
| 330 | + | /// A workspace's row: project slugs; empty for every one. | |
| 330 | 331 | #[serde(default)] | |
| 331 | − | pub repositories: Option<Vec<String>>, | |
| 332 | + | pub projects: Option<Vec<String>>, | |
| 332 | 333 | #[serde(default)] | |
| 333 | 334 | pub note: Option<String>, | |
| 334 | 335 | } | |
| 341 | 342 | pub struct ResolveSettingsArgs { | |
| 342 | 343 | pub repo_id: String, | |
| 343 | 344 | pub repo: RepoPath, | |
| 345 | + | /// The project being read for; its repository's primary project if left | |
| 346 | + | /// out. | |
| 347 | + | #[serde(default)] | |
| 348 | + | pub project_id: Option<String>, | |
| 349 | + | #[serde(default)] | |
| 350 | + | pub project_slug: Option<String>, | |
| 344 | 351 | /// `workflows` or `deployments`. | |
| 345 | 352 | pub consumer: String, | |
| 346 | 353 | /// The environment being read for, such as `production` or `preview`. |
| 12 | 12 | mod ids; | |
| 13 | 13 | mod names; | |
| 14 | 14 | mod outcome; | |
| 15 | + | pub mod projects; | |
| 15 | 16 | pub mod repos; | |
| 16 | 17 | pub mod time; | |
| 17 | 18 | pub mod webhooks; |
| 1 | + | //! The projects service, as the Rust services call it: a project is what a | |
| 2 | + | //! workspace builds and runs, with one source. Mirrors | |
| 3 | + | //! `packages/contracts/src/projects.ts`, as far as Rust callers need it. | |
| 4 | + | ||
| 5 | + | use serde::{Deserialize, Serialize}; | |
| 6 | + | ||
| 7 | + | /// `by_repo`: the projects built from a repository, its primary one first; | |
| 8 | + | /// a repository from before projects is given its own. Returns | |
| 9 | + | /// `Vec<ProjectRef>`. | |
| 10 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 11 | + | #[serde(rename_all = "camelCase")] | |
| 12 | + | pub struct ByRepoArgs { | |
| 13 | + | pub repo_id: String, | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /// A project, as much of it as other services need. | |
| 17 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 18 | + | #[serde(rename_all = "camelCase")] | |
| 19 | + | pub struct ProjectRef { | |
| 20 | + | pub id: String, | |
| 21 | + | pub workspace: String, | |
| 22 | + | pub slug: String, | |
| 23 | + | /// The project its repository's workflows read secrets from. | |
| 24 | + | #[serde(default)] | |
| 25 | + | pub primary: bool, | |
| 26 | + | } |
| 22 | 22 | //! | |
| 23 | 23 | //! - `G1T_API`, `DEPLOY_ID`, `DEPLOY_TOKEN`: where and how to report. | |
| 24 | 24 | //! - `GIT_REMOTE`, `GIT_COMMIT`, `G1T_USER`, `G1T_TOKEN`: what to check out. | |
| 25 | − | //! - `BUILD_COMMAND`, `OUTPUT_DIR`: the repository's own choices, if any. | |
| 25 | + | //! - `ROOT_DIR`: where in the repository the project lives; empty for all | |
| 26 | + | //! of it. Everything below is relative to it. | |
| 27 | + | //! - `BUILD_COMMAND`, `OUTPUT_DIR`: the project's own choices, if any. | |
| 26 | 28 | //! - `BUILD_ENV`, `BUILD_SECRETS`: JSON objects of the repository's | |
| 27 | 29 | //! variables and secrets for deploy builds. Both are set for the build; | |
| 28 | 30 | //! secrets' values are redacted from its log. | |
| 104 | 106 | /// Runs a command in the checkout, logging it; fails if it fails. | |
| 105 | 107 | fn step(log: &mut Log, command: &str, secrets: &[String]) -> Result<()> { | |
| 106 | 108 | log.line(&format!("$ {command}")); | |
| 107 | − | let result = run_command(command, Path::new(WORKDIR), secrets); | |
| 109 | + | let result = run_command(command, &project_dir(), secrets); | |
| 108 | 110 | if !result.output_text().is_empty() { | |
| 109 | 111 | log.line(result.output_text()); | |
| 110 | 112 | } | |
| 433 | 435 | } | |
| 434 | 436 | ||
| 435 | 437 | fn build(log: &mut Log, secrets: &[String]) -> Result<Built> { | |
| 436 | − | let dir = Path::new(WORKDIR); | |
| 438 | + | let project = project_dir(); | |
| 439 | + | let dir = project.as_path(); | |
| 437 | 440 | let config = read_wrangler(dir)?; | |
| 438 | 441 | let custom_build = std::env::var("BUILD_COMMAND").ok().filter(|c| !c.trim().is_empty()); | |
| 439 | 442 | if let Some(install) = install_command(dir) { | |
| 519 | 522 | } | |
| 520 | 523 | } | |
| 521 | 524 | ||
| 525 | + | /// Where the project lives in the checkout: its root directory. | |
| 526 | + | fn project_dir() -> PathBuf { | |
| 527 | + | let root = std::env::var("ROOT_DIR").unwrap_or_default(); | |
| 528 | + | let root = root.trim_matches('/'); | |
| 529 | + | if root.is_empty() || root.split('/').any(|part| part == "..") { | |
| 530 | + | PathBuf::from(WORKDIR) | |
| 531 | + | } else { | |
| 532 | + | Path::new(WORKDIR).join(root) | |
| 533 | + | } | |
| 534 | + | } | |
| 535 | + | ||
| 522 | 536 | fn check_out(secrets: &[String]) -> Result<()> { | |
| 523 | 537 | let remote = env("GIT_REMOTE")?; | |
| 524 | 538 | let commit = env("GIT_COMMIT")?; | |
| 557 | 571 | "warnings": built.warnings, | |
| 558 | 572 | }); | |
| 559 | 573 | if let Some(dir) = &built.assets_dir { | |
| 560 | − | let skip_project = dir == Path::new(WORKDIR); | |
| 574 | + | let skip_project = *dir == project_dir(); | |
| 561 | 575 | let mut assets = Vec::new(); | |
| 562 | 576 | collect(dir, dir, skip_project, &mut assets)?; | |
| 563 | 577 | if assets.is_empty() { |
| 715 | 715 | 6. **Groups, owners, scorecards** feeding the upkeep agents. | |
| 716 | 716 | 7. **Environments** with protection rules; custom domains; logs. | |
| 717 | 717 | ||
| 718 | + | **Step 1 shipped 2026-10-04:** `services/projects`; every repository a | |
| 719 | + | project of its own name; the site projects-first (workspace page of | |
| 720 | + | project cards, the project overview at `g1t.sh/<workspace>/<project>`, code | |
| 721 | + | under `/code`, the sidebar and the New project flow); deployments keyed by | |
| 722 | + | project and branch at `<project>-<workspace>.g1t.page` and | |
| 723 | + | `<project>-git-<branch>-<workspace>.g1t.page`; secrets and variables owned | |
| 724 | + | by the project. | |
| 725 | + | ||
| 726 | + | ### People and search | |
| 727 | + | ||
| 728 | + | > **2026-10-04:** "pull up user profiles, using a /u/username prefix kinda | |
| 729 | + | > like DockerHub … search for users if you search that explicitly, how | |
| 730 | + | > GitHub has the aside for searching against filters … a significantly | |
| 731 | + | > stronger implementation." | |
| 732 | + | ||
| 733 | + | - **Profiles at `g1t.sh/u/<username>`**, apart from workspaces at | |
| 734 | + | `g1t.sh/<workspace>`: who they are, their workspaces, recent work, | |
| 735 | + | agents' sessions they steered. | |
| 736 | + | - **Search with a filter sidebar:** Projects, Code, Issues, Pull requests, | |
| 737 | + | Users, Workspaces, each with its count; filters by workspace, language, | |
| 738 | + | state, author, agent, label, date; qualifiers in the query | |
| 739 | + | (`user:`, `is:open`, `agent:g1t-agent`) as on GitHub. Typing `u/name` | |
| 740 | + | searches people directly, as Docker Hub does. | |
| 741 | + | ||
| 718 | 742 | 1 and 2 serve the competition directly (multi-agent coordination across | |
| 719 | 743 | projects is 25% of the score); 3 is the demo's best moment if time allows. | |
| 720 | 744 |
| 1596 | 1596 | "resolved": "services/pages", | |
| 1597 | 1597 | "link": true | |
| 1598 | 1598 | }, | |
| 1599 | + | "node_modules/@g1t/projects": { | |
| 1600 | + | "resolved": "services/projects", | |
| 1601 | + | "link": true | |
| 1602 | + | }, | |
| 1599 | 1603 | "node_modules/@g1t/runner": { | |
| 1600 | 1604 | "resolved": "services/runner", | |
| 1601 | 1605 | "link": true | |
| 9972 | 9976 | "version": "0.1.0", | |
| 9973 | 9977 | "license": "MIT" | |
| 9974 | 9978 | }, | |
| 9979 | + | "services/projects": { | |
| 9980 | + | "name": "@g1t/projects", | |
| 9981 | + | "version": "0.1.0", | |
| 9982 | + | "license": "MIT", | |
| 9983 | + | "dependencies": { | |
| 9984 | + | "@g1t/contracts": "*" | |
| 9985 | + | } | |
| 9986 | + | }, | |
| 9975 | 9987 | "services/runner": { | |
| 9976 | 9988 | "name": "@g1t/runner", | |
| 9977 | 9989 | "version": "0.1.0", |
| 116 | 116 | kind: SettingKind; | |
| 117 | 117 | /** A variable's value. */ | |
| 118 | 118 | value: string | null; | |
| 119 | − | scope: "repository" | "workspace"; | |
| 119 | + | /** A project's (a repository's belong to its project) or the workspace's. */ | |
| 120 | + | scope: "project" | "workspace"; | |
| 120 | 121 | updatedAt: string; | |
| 121 | 122 | availableTo: SettingReader[]; | |
| 122 | 123 | /** The environments it applies to; empty is every environment. */ | |
| 123 | 124 | environments: string[]; | |
| 124 | − | /** A workspace's row: the repositories it reaches; empty is every one. */ | |
| 125 | − | repositories: string[]; | |
| 125 | + | /** A workspace's row: the projects it reaches, by slug; empty is every one. */ | |
| 126 | + | projects: string[]; | |
| 126 | 127 | /** Where to rotate it, or who to ask. */ | |
| 127 | 128 | note: string | null; | |
| 128 | 129 | updatedBy: string | null; | |
| 134 | 135 | id?: string; | |
| 135 | 136 | availableTo?: SettingReader[]; | |
| 136 | 137 | environments?: string[]; | |
| 137 | − | repositories?: string[]; | |
| 138 | + | /** A workspace's row: project slugs; empty for every one. */ | |
| 139 | + | projects?: string[]; | |
| 138 | 140 | note?: string; | |
| 139 | 141 | }; | |
| 140 | 142 |
| 1 | 1 | import type { ActionsApi } from "./actions"; | |
| 2 | 2 | import type { BillingApi } from "./billing"; | |
| 3 | 3 | import type { DeploymentsApi } from "./deployments"; | |
| 4 | + | import type { ProjectsApi } from "./projects"; | |
| 4 | 5 | import type { EventsApi } from "./events"; | |
| 5 | 6 | import type { IdentityApi } from "./identity"; | |
| 6 | 7 | import type { IntegrationsApi } from "./integrations"; | |
| 266 | 267 | export function deploymentsClient(service: ServiceBinding): DeploymentsApi { | |
| 267 | 268 | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); | |
| 268 | 269 | return { | |
| 269 | − | settings: (repo, viewer) => call("settings", { repo, viewer }), | |
| 270 | − | updateSettings: (actor, repo, changes) => call("update_settings", { actor, repo, changes }), | |
| 271 | − | list: (repo, viewer) => call("list", { repo, viewer }), | |
| 272 | − | get: (repo, id, viewer) => call("get", { repo, id, viewer }), | |
| 273 | − | redeploy: (actor, repo, number) => call("redeploy", { actor, repo, number }), | |
| 274 | − | takeDown: (actor, repo, number) => call("take_down", { actor, repo, number }), | |
| 270 | + | settings: (project, viewer) => call("settings", { project, viewer }), | |
| 271 | + | updateSettings: (actor, project, changes) => call("update_settings", { actor, project, changes }), | |
| 272 | + | list: (project, viewer) => call("list", { project, viewer }), | |
| 273 | + | get: (project, id, viewer) => call("get", { project, id, viewer }), | |
| 274 | + | redeploy: (actor, project, branch) => call("redeploy", { actor, project, branch }), | |
| 275 | + | takeDown: (actor, project, branch) => call("take_down", { actor, project, branch }), | |
| 276 | + | overview: (workspace, viewer) => call("overview", { workspace, viewer }), | |
| 275 | 277 | usage: (workspace, viewer) => call("usage", { workspace, viewer }), | |
| 276 | 278 | }; | |
| 277 | 279 | } | |
| 280 | + | ||
| 281 | + | export function projectsClient(service: ServiceBinding): ProjectsApi { | |
| 282 | + | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); | |
| 283 | + | return { | |
| 284 | + | list: (workspace, viewer) => call("list", { workspace, viewer }), | |
| 285 | + | get: (workspace, slug, viewer) => call("get", { workspace, slug, viewer }), | |
| 286 | + | byRepo: (repoId) => call("by_repo", { repoId }), | |
| 287 | + | create: (actor, workspace, input) => call("create", { actor, workspace, input }), | |
| 288 | + | update: (actor, workspace, slug, changes) => call("update", { actor, workspace, slug, changes }), | |
| 289 | + | }; | |
| 290 | + | } |
| 1 | 1 | import type { User, Viewer } from "./identity"; | |
| 2 | − | import type { RepoPath } from "./repos"; | |
| 3 | 2 | import type { Result } from "./result"; | |
| 4 | 3 | ||
| 5 | 4 | /** | |
| 6 | − | * Deployments: every pull request gets a live preview on g1t.page, and the | |
| 7 | − | * default branch goes to production on each push. Apps run as Workers in a | |
| 8 | − | * Workers for Platforms namespace, so an app no one visits costs nothing. | |
| 9 | − | * A paid feature: the workspace turns it on with a monthly plan (see | |
| 10 | − | * `Feature` in `./billing`), and each repository then chooses for itself. | |
| 5 | + | * Deployments: a project's production, deployed from its default branch on | |
| 6 | + | * every push, and a live preview of every branch with an open pull request. | |
| 7 | + | * Apps run as Workers in a Workers for Platforms namespace, so an app no | |
| 8 | + | * one visits costs nothing. A paid feature: the workspace turns it on with | |
| 9 | + | * a monthly plan (see `Feature` in `./billing`), and each project then | |
| 10 | + | * chooses for itself. | |
| 11 | 11 | */ | |
| 12 | 12 | ||
| 13 | 13 | /** The domain apps are served on. Never g1t.sh, so they share no cookies with it. */ | |
| 14 | 14 | export const DEPLOYMENTS_DOMAIN = "g1t.page"; | |
| 15 | 15 | ||
| 16 | − | /** A repository's deployment settings. */ | |
| 16 | + | /** A project, as deployments name it. */ | |
| 17 | + | export type ProjectRef = { workspace: string; slug: string }; | |
| 18 | + | ||
| 19 | + | /** A project's deployment settings. */ | |
| 17 | 20 | export type DeploySettings = { | |
| 18 | − | /** Whether this repository deploys at all. Off until someone turns it on. */ | |
| 21 | + | /** Whether this project deploys at all. Off until someone turns it on. */ | |
| 19 | 22 | enabled: boolean; | |
| 20 | − | /** A preview for every open pull request. */ | |
| 23 | + | /** A preview for every branch with an open pull request. */ | |
| 21 | 24 | previews: boolean; | |
| 22 | 25 | /** The default branch deployed to production on every push. */ | |
| 23 | 26 | production: boolean; | |
| 46 | 49 | export type Deployment = { | |
| 47 | 50 | id: string; | |
| 48 | 51 | kind: DeployKind; | |
| 49 | − | /** For a preview: the pull request. */ | |
| 52 | + | /** For a preview: the branch, or `pr-<n>` for a pull request from a fork. */ | |
| 53 | + | branch: string | null; | |
| 54 | + | /** For a preview: its pull request. */ | |
| 50 | 55 | number: number | null; | |
| 51 | 56 | commit: string; | |
| 52 | 57 | status: DeployStatus; | |
| 63 | 68 | finishedAt: string | null; | |
| 64 | 69 | }; | |
| 65 | 70 | ||
| 66 | − | /** An app that is up: production, or one pull request's preview. */ | |
| 71 | + | /** An app that is up: production, or one branch's preview. */ | |
| 67 | 72 | export type LiveApp = { | |
| 68 | 73 | kind: DeployKind; | |
| 74 | + | branch: string | null; | |
| 69 | 75 | number: number | null; | |
| 70 | 76 | url: string; | |
| 71 | 77 | commit: string; | |
| 73 | 79 | deployedAt: string; | |
| 74 | 80 | }; | |
| 75 | 81 | ||
| 82 | + | /** One project at a glance, for the workspace's page. */ | |
| 83 | + | export type ProjectDeploys = { | |
| 84 | + | slug: string; | |
| 85 | + | enabled: boolean; | |
| 86 | + | production: LiveApp | null; | |
| 87 | + | previews: number; | |
| 88 | + | /** The newest build, whatever its status. */ | |
| 89 | + | latest: Deployment | null; | |
| 90 | + | }; | |
| 91 | + | ||
| 76 | 92 | /** What a workspace's apps used this month against its plan. */ | |
| 77 | 93 | export type DeployUsage = { | |
| 78 | 94 | /** `YYYY-MM`. */ | |
| 91 | 107 | ||
| 92 | 108 | export interface DeploymentsApi { | |
| 93 | 109 | /** Members of the workspace only. */ | |
| 94 | − | settings(repo: RepoPath, viewer: Viewer): Promise<Result<DeploySettings>>; | |
| 95 | − | /** Members of the workspace only. Turning deployments on needs the plan. */ | |
| 96 | − | updateSettings(actor: User, repo: RepoPath, changes: Partial<DeploySettings>): Promise<Result<DeploySettings>>; | |
| 97 | − | /** The newest deployments first, and what is up now. */ | |
| 98 | − | list(repo: RepoPath, viewer: Viewer): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>>; | |
| 99 | − | /** One deployment, with its build log. */ | |
| 100 | − | get(repo: RepoPath, id: string, viewer: Viewer): Promise<Result<Deployment & { log: string | null }>>; | |
| 101 | − | /** Builds production, or a pull request's preview, again from its current head. */ | |
| 102 | − | redeploy(actor: User, repo: RepoPath, number: number | null): Promise<Result<Deployment>>; | |
| 103 | − | /** Takes production, or a pull request's preview, down now. */ | |
| 104 | − | takeDown(actor: User, repo: RepoPath, number: number | null): Promise<Result<true>>; | |
| 110 | + | settings(project: ProjectRef, viewer: Viewer): Promise<Result<DeploySettings>>; | |
| 111 | + | /** Members only. Turning deployments on needs the workspace's plan. */ | |
| 112 | + | updateSettings(actor: User, project: ProjectRef, changes: Partial<DeploySettings>): Promise<Result<DeploySettings>>; | |
| 113 | + | /** The newest builds first, and what is up now. Members only. */ | |
| 114 | + | list(project: ProjectRef, viewer: Viewer): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>>; | |
| 115 | + | /** One build, with its log. Members only. */ | |
| 116 | + | get(project: ProjectRef, id: string, viewer: Viewer): Promise<Result<Deployment & { log: string | null }>>; | |
| 117 | + | /** Builds production (`branch` null), or a branch's preview, again from its head. */ | |
| 118 | + | redeploy(actor: User, project: ProjectRef, branch: string | null): Promise<Result<Deployment>>; | |
| 119 | + | /** Takes production (`branch` null), or a branch's preview, down now. */ | |
| 120 | + | takeDown(actor: User, project: ProjectRef, branch: string | null): Promise<Result<true>>; | |
| 121 | + | /** Every project of a workspace at a glance. Members only. */ | |
| 122 | + | overview(workspace: string, viewer: Viewer): Promise<Result<ProjectDeploys[]>>; | |
| 105 | 123 | /** What the workspace's apps used this month. Members only. */ | |
| 106 | 124 | usage(workspace: string, viewer: Viewer): Promise<Result<DeployUsage>>; | |
| 107 | 125 | } |
| 1 | 1 | const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"; | |
| 2 | 2 | ||
| 3 | − | export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt" | "dpl"; | |
| 3 | + | export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt" | "dpl" | "prj"; | |
| 4 | 4 | ||
| 5 | 5 | let lastMs = 0; | |
| 6 | 6 | let lastCounter = 0; |
| 8 | 8 | export * from "./integrations"; | |
| 9 | 9 | export * from "./names"; | |
| 10 | 10 | export * from "./oauth"; | |
| 11 | + | export * from "./projects"; | |
| 11 | 12 | export * from "./repos"; | |
| 12 | 13 | export * from "./result"; | |
| 13 | 14 | export * from "./runner"; |
| 1 | + | import type { User, Viewer } from "./identity"; | |
| 2 | + | import type { RepoPath } from "./repos"; | |
| 3 | + | import type { Result } from "./result"; | |
| 4 | + | ||
| 5 | + | /** | |
| 6 | + | * Projects: the thing a workspace builds and runs. A project has exactly | |
| 7 | + | * one source, where its code lives; everything about running it | |
| 8 | + | * (deployments, environments, domains, secrets and variables) belongs to | |
| 9 | + | * the project, while branches, pull requests and review stay with the | |
| 10 | + | * repository. One repository may carry several projects, each from its own | |
| 11 | + | * root directory. Every repository on g1t gets a project of its own name. | |
| 12 | + | */ | |
| 13 | + | ||
| 14 | + | /** Where a project's code lives. */ | |
| 15 | + | export type ProjectSource = | |
| 16 | + | /** A repository hosted on g1t. */ | |
| 17 | + | | { kind: "hosted"; repoId: string; repo: RepoPath; rootDir: string; defaultBranch: string } | |
| 18 | + | /** Mirrored from another host; coming next. */ | |
| 19 | + | | { kind: "mirror"; provider: "github" | "gitlab" | "bitbucket"; url: string; rootDir: string }; | |
| 20 | + | ||
| 21 | + | export type Project = { | |
| 22 | + | id: string; | |
| 23 | + | /** The workspace's slug. */ | |
| 24 | + | workspace: string; | |
| 25 | + | /** Unique in its workspace; the project's address is `g1t.sh/<workspace>/<slug>`. */ | |
| 26 | + | slug: string; | |
| 27 | + | name: string; | |
| 28 | + | description: string | null; | |
| 29 | + | source: ProjectSource; | |
| 30 | + | /** Whether only the workspace's members can see it: its repository is private. */ | |
| 31 | + | private: boolean; | |
| 32 | + | /** Whether it is the project its repository's workflows read secrets from. */ | |
| 33 | + | primary: boolean; | |
| 34 | + | createdBy: string; | |
| 35 | + | /** RFC 3339. */ | |
| 36 | + | createdAt: string; | |
| 37 | + | updatedAt: string; | |
| 38 | + | }; | |
| 39 | + | ||
| 40 | + | export type NewProject = { | |
| 41 | + | name: string; | |
| 42 | + | description?: string | null; | |
| 43 | + | /** The hosted repository it builds from. */ | |
| 44 | + | repo: RepoPath; | |
| 45 | + | /** Where in the repository it lives; empty for the whole repository. */ | |
| 46 | + | rootDir?: string; | |
| 47 | + | }; | |
| 48 | + | ||
| 49 | + | export interface ProjectsApi { | |
| 50 | + | /** A workspace's projects, by name. Members, or anyone for public repositories. */ | |
| 51 | + | list(workspace: string, viewer: Viewer): Promise<Result<Project[]>>; | |
| 52 | + | get(workspace: string, slug: string, viewer: Viewer): Promise<Result<Project>>; | |
| 53 | + | /** The projects built from a repository, its primary one first. For services. */ | |
| 54 | + | byRepo(repoId: string): Promise<Project[]>; | |
| 55 | + | /** Members only. */ | |
| 56 | + | create(actor: User, workspace: string, input: NewProject): Promise<Result<Project>>; | |
| 57 | + | /** Members only. */ | |
| 58 | + | update( | |
| 59 | + | actor: User, | |
| 60 | + | workspace: string, | |
| 61 | + | slug: string, | |
| 62 | + | changes: { name?: string; description?: string | null; rootDir?: string }, | |
| 63 | + | ): Promise<Result<Project>>; | |
| 64 | + | } |
| 19 | 19 | services/identity | |
| 20 | 20 | services/repos | |
| 21 | 21 | services/work | |
| 22 | + | services/projects | |
| 22 | 23 | services/billing | |
| 23 | 24 | services/integrations | |
| 24 | 25 | services/webhooks |
| 73 | 73 | identity: Fetcher, | |
| 74 | 74 | runner: Fetcher, | |
| 75 | 75 | events: Fetcher, | |
| 76 | + | /// Projects: a repository's secrets and variables belong to its project. | |
| 77 | + | projects: Fetcher, | |
| 76 | 78 | /// Seals secrets; absent until `ACTIONS_KEY` is set, when secrets | |
| 77 | 79 | /// cannot be saved. | |
| 78 | 80 | sealer: Option<Sealer>, | |
| 87 | 89 | identity: env.service("IDENTITY")?, | |
| 88 | 90 | runner: env.service("RUNNER")?, | |
| 89 | 91 | events: env.service("EVENTS")?, | |
| 92 | + | projects: env.service("PROJECTS")?, | |
| 90 | 93 | sealer: env.secret("ACTIONS_KEY").ok().and_then(|key| Sealer::new(&key.to_string())), | |
| 91 | 94 | }) | |
| 92 | 95 | } |
| 6 | 6 | //! previews can hold different values; a key's rows never overlap. | |
| 7 | 7 | //! | |
| 8 | 8 | //! A reader asking for an environment gets the row naming it, else the | |
| 9 | − | //! key's row for every environment. A repository's row overrides its | |
| 10 | − | //! workspace's of the same key. Names are upper-cased, as GitHub treats | |
| 9 | + | //! key's row for every environment. A project's row overrides its | |
| 10 | + | //! workspace's of the same key. | |
| 11 | + | //! | |
| 12 | + | //! A repository's rows belong to its project (its primary one, when it | |
| 13 | + | //! carries several): asked for by repository, as GitHub's API does, they | |
| 14 | + | //! are the project's. Rows from before projects move over the first time | |
| 15 | + | //! they are touched. Names are upper-cased, as GitHub treats | |
| 11 | 16 | //! them without regard to case. Agents never read any. | |
| 12 | 17 | ||
| 13 | 18 | use g1t_contracts::actions::{ | |
| 14 | 19 | CONSUMERS, DeleteSettingArgs, ResolveSettingsArgs, ResolvedSettings, SetSettingArgs, Setting, SettingsArgs, | |
| 15 | 20 | SettingsOwner, | |
| 16 | 21 | }; | |
| 22 | + | use g1t_contracts::projects::{ByRepoArgs, ProjectRef}; | |
| 17 | 23 | use g1t_contracts::time::rfc3339; | |
| 18 | 24 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, new_id}; | |
| 19 | 25 | use g1t_kit::now_ms; | |
| 109 | 115 | split(&self.environments) | |
| 110 | 116 | } | |
| 111 | 117 | ||
| 112 | − | fn repositories(&self) -> Vec<String> { | |
| 118 | + | /// A workspace's row: the projects it reaches. The column predates | |
| 119 | + | /// projects; it holds their slugs. | |
| 120 | + | fn projects(&self) -> Vec<String> { | |
| 113 | 121 | self.repositories.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default() | |
| 114 | 122 | } | |
| 115 | 123 | ||
| 116 | − | fn reaches(&self, repo: &str) -> bool { | |
| 117 | − | let list = self.repositories(); | |
| 118 | − | list.is_empty() || list.iter().any(|r| r.eq_ignore_ascii_case(repo)) | |
| 124 | + | fn reaches(&self, project: &str) -> bool { | |
| 125 | + | let list = self.projects(); | |
| 126 | + | list.is_empty() || list.iter().any(|p| p.eq_ignore_ascii_case(project)) | |
| 119 | 127 | } | |
| 120 | 128 | ||
| 121 | 129 | /// Whether it and rows for `environments` would both apply somewhere. | |
| 128 | 136 | Setting { | |
| 129 | 137 | available_to: split(&self.available_to), | |
| 130 | 138 | environments: self.environments(), | |
| 131 | − | repositories: self.repositories(), | |
| 139 | + | projects: self.projects(), | |
| 132 | 140 | value: (self.kind == "variable").then_some(self.value), | |
| 133 | 141 | id: self.id, | |
| 134 | 142 | name: self.name, | |
| 141 | 149 | } | |
| 142 | 150 | } | |
| 143 | 151 | ||
| 144 | − | /// Where settings live: `(scope, owner)` with the owner a repository id or | |
| 145 | − | /// a workspace slug. | |
| 152 | + | /// Where settings live: `(scope, owner)` with the owner a project id or a | |
| 153 | + | /// workspace slug. | |
| 146 | 154 | struct Place { | |
| 147 | 155 | scope: &'static str, | |
| 148 | 156 | owner: String, | |
| 149 | 157 | namespace: String, | |
| 158 | + | /// The project's slug, for a project's place. | |
| 159 | + | slug: String, | |
| 150 | 160 | } | |
| 151 | 161 | ||
| 152 | 162 | impl Actions { | |
| 170 | 180 | let Some(repo) = self.visible_repo(path, &Some(actor.clone())).await? else { | |
| 171 | 181 | return Ok(fail(FailureCode::NotFound, "There is no such repository.")); | |
| 172 | 182 | }; | |
| 173 | − | Ok(Outcome::Ok(Place { scope: "repository", owner: repo.id, namespace: repo.namespace })) | |
| 183 | + | let Some(project) = self.project_of(&repo.id).await? else { | |
| 184 | + | return Ok(fail(FailureCode::NotFound, "The repository has no project.")); | |
| 185 | + | }; | |
| 186 | + | Ok(Outcome::Ok(Place { scope: "project", owner: project.id, namespace: repo.namespace, slug: project.slug })) | |
| 174 | 187 | } | |
| 175 | 188 | (None, Some(slug)) => { | |
| 176 | 189 | let slug = slug.to_lowercase(); | |
| 178 | 191 | match role { | |
| 179 | 192 | None => Ok(fail(FailureCode::Forbidden, format!("Only members of {slug} can see its secrets and variables."))), | |
| 180 | 193 | Some(Role::Member) if changing => Ok(fail(FailureCode::Forbidden, format!("Only owners of {slug} can change its secrets and variables."))), | |
| 181 | − | Some(_) => Ok(Outcome::Ok(Place { scope: "workspace", owner: slug.clone(), namespace: slug })), | |
| 194 | + | Some(_) => Ok(Outcome::Ok(Place { scope: "workspace", owner: slug.clone(), namespace: slug, slug: String::new() })), | |
| 182 | 195 | } | |
| 183 | 196 | } | |
| 184 | 197 | (None, None) => Ok(fail(FailureCode::Invalid, "Give `repo` or `workspace`.")), | |
| 185 | 198 | } | |
| 186 | 199 | } | |
| 187 | 200 | ||
| 201 | + | /// A repository's primary project, with the rows kept under the | |
| 202 | + | /// repository before projects moved to it. | |
| 203 | + | pub(crate) async fn project_of(&self, repo_id: &str) -> Result<Option<ProjectRef>> { | |
| 204 | + | let projects: Vec<ProjectRef> = | |
| 205 | + | g1t_kit::call(&self.projects, "by_repo", &ByRepoArgs { repo_id: repo_id.to_owned() }).await?; | |
| 206 | + | let Some(project) = projects.into_iter().find(|p| p.primary) else { | |
| 207 | + | return Ok(None); | |
| 208 | + | }; | |
| 209 | + | self.db | |
| 210 | + | .prepare("UPDATE settings SET owner = ?, scope = 'project' WHERE owner = ?") | |
| 211 | + | .bind(&[project.id.as_str().into(), repo_id.into()])? | |
| 212 | + | .run() | |
| 213 | + | .await?; | |
| 214 | + | Ok(Some(project)) | |
| 215 | + | } | |
| 216 | + | ||
| 188 | 217 | /// `secret`, `variable`, or `None` for both. | |
| 189 | 218 | fn kind(kind: &str) -> Outcome<Option<&'static str>> { | |
| 190 | 219 | match kind { | |
| 207 | 236 | pub async fn settings(&self, a: SettingsArgs) -> Result<Outcome<Vec<Setting>>> { | |
| 208 | 237 | let kind = check!(Self::kind(&a.kind)); | |
| 209 | 238 | let place = check!(self.place(&a.actor, &a.owner, false).await?); | |
| 210 | − | let repo_name = a.owner.repo.as_ref().map(|r| r.name.clone()); | |
| 211 | 239 | let mut out: Vec<Setting> = Vec::new(); | |
| 212 | − | // A repository's list shows the workspace's rows that reach it, but | |
| 213 | − | // for keys it sets itself. | |
| 214 | − | if place.scope == "repository" { | |
| 240 | + | // A project's list shows the workspace's rows that reach it, but for | |
| 241 | + | // keys it sets itself. | |
| 242 | + | if place.scope == "project" { | |
| 215 | 243 | let own: Vec<String> = self.rows(&place.owner).await?.into_iter().map(|row| row.name).collect(); | |
| 216 | 244 | for row in self.rows(&place.namespace.to_lowercase()).await? { | |
| 217 | − | if repo_name.as_deref().is_some_and(|name| row.reaches(name)) && !own.contains(&row.name) { | |
| 245 | + | if row.reaches(&place.slug) && !own.contains(&row.name) { | |
| 218 | 246 | out.push(row.describe()); | |
| 219 | 247 | } | |
| 220 | 248 | } | |
| 255 | 283 | Err(problem) => return Ok(fail(FailureCode::Invalid, problem)), | |
| 256 | 284 | }; | |
| 257 | 285 | let place = check!(self.place(&a.actor, &a.owner, true).await?); | |
| 258 | − | if a.repositories.as_ref().is_some_and(|r| !r.is_empty()) && place.scope != "workspace" { | |
| 259 | − | return Ok(fail(FailureCode::Invalid, "Only a workspace's rows choose repositories.")); | |
| 286 | + | if a.projects.as_ref().is_some_and(|r| !r.is_empty()) && place.scope != "workspace" { | |
| 287 | + | return Ok(fail(FailureCode::Invalid, "Only a workspace's rows choose projects.")); | |
| 260 | 288 | } | |
| 261 | 289 | let rows = self.rows(&place.owner).await?; | |
| 262 | 290 | // A secret and a variable may share a key, as on GitHub, where | |
| 304 | 332 | .map(|r| r.join(",")) | |
| 305 | 333 | .or_else(|| existing.map(|row| row.available_to.clone())) | |
| 306 | 334 | .unwrap_or_else(|| CONSUMERS.join(",")); | |
| 307 | − | let repositories: Option<String> = match &a.repositories { | |
| 335 | + | let repositories: Option<String> = match &a.projects { | |
| 308 | 336 | Some(list) if list.is_empty() => None, | |
| 309 | 337 | Some(list) => Some(serde_json::to_string(list).unwrap_or_default()), | |
| 310 | 338 | None => existing.and_then(|row| row.repositories.clone()), | |
| 381 | 409 | #[allow(clippy::too_many_arguments)] | |
| 382 | 410 | async fn resolved( | |
| 383 | 411 | &self, | |
| 384 | − | repo_id: &str, | |
| 385 | − | repo_name: &str, | |
| 412 | + | project_id: &str, | |
| 413 | + | project_slug: &str, | |
| 386 | 414 | namespace: &str, | |
| 387 | 415 | kind: &str, | |
| 388 | 416 | consumer: &str, | |
| 394 | 422 | } | |
| 395 | 423 | let environment = environment.map(str::to_ascii_lowercase); | |
| 396 | 424 | let mut out = Map::new(); | |
| 397 | − | for owner in [namespace.to_lowercase(), repo_id.to_owned()] { | |
| 425 | + | for owner in [namespace.to_lowercase(), project_id.to_owned()] { | |
| 398 | 426 | let rows: Vec<SettingRow> = self | |
| 399 | 427 | .rows(&owner) | |
| 400 | 428 | .await? | |
| 401 | 429 | .into_iter() | |
| 402 | 430 | .filter(|row| row.kind == kind) | |
| 403 | 431 | .filter(|row| split(&row.available_to).iter().any(|r| r == consumer)) | |
| 404 | − | .filter(|row| row.scope != "workspace" || row.reaches(repo_name)) | |
| 432 | + | .filter(|row| row.scope != "workspace" || row.reaches(project_slug)) | |
| 405 | 433 | .collect(); | |
| 406 | 434 | let mut names: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect(); | |
| 407 | 435 | names.dedup(); | |
| 434 | 462 | /// The `vars` context of a repository's runs. `environment` is the job's | |
| 435 | 463 | /// `environment:`, when it has one. | |
| 436 | 464 | pub async fn variables_for(&self, repo_id: &str, repo: &str, environment: Option<&str>, trusted: bool) -> Result<Map<String, Value>> { | |
| 437 | − | let (namespace, name) = repo.split_once('/').unwrap_or((repo, "")); | |
| 438 | − | self.resolved(repo_id, name, namespace, "variable", "workflows", environment, trusted).await | |
| 465 | + | let (namespace, _) = repo.split_once('/').unwrap_or((repo, "")); | |
| 466 | + | let Some(project) = self.project_of(repo_id).await? else { | |
| 467 | + | return Ok(Map::new()); | |
| 468 | + | }; | |
| 469 | + | self.resolved(&project.id, &project.slug, namespace, "variable", "workflows", environment, trusted).await | |
| 439 | 470 | } | |
| 440 | 471 | ||
| 441 | 472 | /// The `secrets` context of a repository's runs, opened. | |
| 442 | 473 | pub async fn secrets_for(&self, repo_id: &str, repo: &str, environment: Option<&str>, trusted: bool) -> Result<Map<String, Value>> { | |
| 443 | − | let (namespace, name) = repo.split_once('/').unwrap_or((repo, "")); | |
| 444 | − | self.resolved(repo_id, name, namespace, "secret", "workflows", environment, trusted).await | |
| 474 | + | let (namespace, _) = repo.split_once('/').unwrap_or((repo, "")); | |
| 475 | + | let Some(project) = self.project_of(repo_id).await? else { | |
| 476 | + | return Ok(Map::new()); | |
| 477 | + | }; | |
| 478 | + | self.resolved(&project.id, &project.slug, namespace, "secret", "workflows", environment, trusted).await | |
| 445 | 479 | } | |
| 446 | 480 | ||
| 447 | 481 | /// `resolve_settings`, for the deployments service: what a deploy build | |
| 448 | 482 | /// and its running app get. | |
| 449 | 483 | pub async fn resolve_settings(&self, a: ResolveSettingsArgs) -> Result<ResolvedSettings> { | |
| 450 | 484 | let environment = a.environment.as_deref(); | |
| 485 | + | let (project_id, slug) = match (a.project_id, a.project_slug) { | |
| 486 | + | (Some(id), Some(slug)) => (id, slug), | |
| 487 | + | _ => match self.project_of(&a.repo_id).await? { | |
| 488 | + | Some(project) => (project.id, project.slug), | |
| 489 | + | None => return Ok(ResolvedSettings::default()), | |
| 490 | + | }, | |
| 491 | + | }; | |
| 451 | 492 | Ok(ResolvedSettings { | |
| 452 | 493 | secrets: self | |
| 453 | − | .resolved(&a.repo_id, &a.repo.name, &a.repo.namespace, "secret", &a.consumer, environment, a.trusted) | |
| 494 | + | .resolved(&project_id, &slug, &a.repo.namespace, "secret", &a.consumer, environment, a.trusted) | |
| 454 | 495 | .await?, | |
| 455 | 496 | variables: self | |
| 456 | − | .resolved(&a.repo_id, &a.repo.name, &a.repo.namespace, "variable", &a.consumer, environment, a.trusted) | |
| 497 | + | .resolved(&project_id, &slug, &a.repo.namespace, "variable", &a.consumer, environment, a.trusted) | |
| 457 | 498 | .await?, | |
| 458 | 499 | }) | |
| 459 | 500 | } |
| 24 | 24 | { "binding": "WORK", "service": "g1t-work" }, | |
| 25 | 25 | { "binding": "IDENTITY", "service": "g1t-identity" }, | |
| 26 | 26 | { "binding": "RUNNER", "service": "g1t-runner" }, | |
| 27 | − | { "binding": "EVENTS", "service": "g1t-events" } | |
| 27 | + | { "binding": "EVENTS", "service": "g1t-events" }, | |
| 28 | + | // A repository's secrets and variables belong to its project. | |
| 29 | + | { "binding": "PROJECTS", "service": "g1t-projects" } | |
| 28 | 30 | ], | |
| 29 | 31 | // Every event on the bus: what starts workflows, and pushes that change them. | |
| 30 | 32 | "queues": { |
| 1 | + | -- Deployments belong to projects, and previews to branches. The tables | |
| 2 | + | -- from before held only the first test deployments; they are replaced, and | |
| 3 | + | -- the sweep takes their apps out of the namespace. | |
| 4 | + | ||
| 5 | + | DROP TABLE settings; | |
| 6 | + | DROP TABLE apps; | |
| 7 | + | DROP TABLE deployments; | |
| 8 | + | ||
| 9 | + | -- Each project's choices. A project not listed does not deploy. | |
| 10 | + | CREATE TABLE settings ( | |
| 11 | + | project_id TEXT PRIMARY KEY, | |
| 12 | + | workspace TEXT NOT NULL, | |
| 13 | + | slug TEXT NOT NULL, | |
| 14 | + | repo_id TEXT NOT NULL, | |
| 15 | + | enabled INTEGER NOT NULL DEFAULT 0, | |
| 16 | + | previews INTEGER NOT NULL DEFAULT 1, | |
| 17 | + | production INTEGER NOT NULL DEFAULT 1, | |
| 18 | + | build_command TEXT, | |
| 19 | + | output_dir TEXT, | |
| 20 | + | idle_days INTEGER NOT NULL DEFAULT 7, | |
| 21 | + | updated_by TEXT, | |
| 22 | + | updated_at TEXT NOT NULL | |
| 23 | + | ); | |
| 24 | + | CREATE INDEX settings_by_repo ON settings (repo_id); | |
| 25 | + | ||
| 26 | + | -- Apps that are up, one per script in the dispatch namespace. The script's | |
| 27 | + | -- name is the hostname's first label on g1t.page. | |
| 28 | + | CREATE TABLE apps ( | |
| 29 | + | script TEXT PRIMARY KEY, | |
| 30 | + | project_id TEXT NOT NULL, | |
| 31 | + | workspace TEXT NOT NULL, | |
| 32 | + | slug TEXT NOT NULL, | |
| 33 | + | -- preview or production. | |
| 34 | + | kind TEXT NOT NULL, | |
| 35 | + | -- For a preview: the branch (or pr-<n> for a fork) and its pull request. | |
| 36 | + | branch TEXT, | |
| 37 | + | number INTEGER, | |
| 38 | + | commit_sha TEXT NOT NULL, | |
| 39 | + | deployed_at TEXT NOT NULL, | |
| 40 | + | created_at TEXT NOT NULL, | |
| 41 | + | last_request_at TEXT | |
| 42 | + | ); | |
| 43 | + | CREATE INDEX apps_by_project ON apps (project_id, kind, branch); | |
| 44 | + | CREATE INDEX apps_by_workspace ON apps (workspace); | |
| 45 | + | ||
| 46 | + | -- Every build, and where it went. | |
| 47 | + | CREATE TABLE deployments ( | |
| 48 | + | id TEXT PRIMARY KEY, | |
| 49 | + | project_id TEXT NOT NULL, | |
| 50 | + | workspace TEXT NOT NULL, | |
| 51 | + | slug TEXT NOT NULL, | |
| 52 | + | repo_id TEXT NOT NULL, | |
| 53 | + | -- The repository, as namespace/name, for links and charges. | |
| 54 | + | repo TEXT NOT NULL, | |
| 55 | + | kind TEXT NOT NULL, | |
| 56 | + | branch TEXT, | |
| 57 | + | number INTEGER, | |
| 58 | + | commit_sha TEXT NOT NULL, | |
| 59 | + | script TEXT NOT NULL, | |
| 60 | + | -- queued, building, ready, failed or skipped. | |
| 61 | + | status TEXT NOT NULL, | |
| 62 | + | error TEXT, | |
| 63 | + | warnings TEXT NOT NULL DEFAULT '[]', | |
| 64 | + | log TEXT, | |
| 65 | + | token_hash TEXT, | |
| 66 | + | trusted INTEGER NOT NULL DEFAULT 0, | |
| 67 | + | build_seconds INTEGER, | |
| 68 | + | created_by TEXT NOT NULL, | |
| 69 | + | created_at TEXT NOT NULL, | |
| 70 | + | started_at TEXT, | |
| 71 | + | finished_at TEXT | |
| 72 | + | ); | |
| 73 | + | CREATE INDEX deployments_by_project ON deployments (project_id, id); | |
| 74 | + | CREATE INDEX deployments_by_status ON deployments (status, created_at); |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.