Commit

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.

syntaqxcommitted Parentf9cce52Browse files
59 files+1783−6650/59 viewed
+3−3
999999 "items": { "type": "string" },
10001000 "description": "The environments it applies to, such as production and preview, or a workflow job's environment. Empty is every environment."
10011001 },
1002− "repositories": {
1002+ "projects": {
10031003 "type": "array",
10041004 "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."
10061006 },
10071007 "note": { "type": "string", "description": "Where to rotate it, or who to ask." },
10081008 })),
17361736 }
17371737 // Request bodies arrive in snake_case; the actions service
17381738 // 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")] {
17401740 if let Some(list) = strings(input, key) {
17411741 args[to] = json!(list);
17421742 }
+8−2
6969 ],
7070 },
7171 {
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+ {
7280 label: 'Agents',
7381 items: [
7482 { label: 'g1t agents', slug: 'guides/g1t-agents' },
8492 { label: 'Model providers', slug: 'guides/models' },
8593 { label: 'Webhooks', slug: 'guides/webhooks' },
8694 { label: 'GitHub Actions', slug: 'guides/actions' },
87− { label: 'Secrets and variables', slug: 'guides/secrets-and-variables' },
8895 ],
8996 },
9097 {
9198 label: 'Landing changes',
9299 items: [
93100 { label: 'The merge queue', slug: 'guides/merge-queue' },
94− { label: 'Deployments', slug: 'guides/deployments' },
95101 { label: 'Sessions and why-blame', slug: 'guides/why-blame' },
96102 { label: 'Forks and branches', slug: 'concepts/forks' },
97103 ],
+27−20
11 ---
22 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.
44 ---
55
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
89 production on every push. Reviewers, and the agents reviewing for you,
910 click through the change instead of reading a diff.
1011
1213
1314 | | Address |
1415 | --- | --- |
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.
1723
1824 An app runs only while it answers a request. One nobody visits runs
1925 nothing and costs nothing, and the next visit wakes it in milliseconds.
2026
2127 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
2329 one click turns it off again.
2430
2531 ## Turn on deployments
2834 **Settings → Billing**, `g1t.sh/<workspace>/-/billing`, and under
2935 **Plans** chooses **Turn on Deployments**, then pays on the card page.
3036 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,
3340 and chooses **Turn on deployments**.
3441
3542 Production starts building at once from the default branch. Every pull
4047
4148 ## What deploys
4249
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.
4451 You do not configure anything for the common cases.
4552
46−| The repository has | g1t |
53+| The project has | g1t |
4754 | --- | --- |
4855 | `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`. |
4956 | 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. |
5158
5259 Dependencies are installed by the lockfile that is there: `npm ci`,
5360 `pnpm install --frozen-lockfile`, `yarn install` or `bun install`, and
7279 are served under the binding name your config gives them. Cron triggers
7380 in the config are not scheduled.
7481 - `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/)
7683 available to Deployments are bound too, and replace a `var` of the same
7784 name: secrets as secret bindings.
7885 - **Not provisioned yet:** D1, KV, R2, Durable Objects, Queues, service
8188 lists each one it left out. Code that needs them should check that the
8289 binding is there.
8390
84−## Previews on pull requests
91+## Previews of branches
8592
8693 A preview is built when a pull request is opened, when it is marked ready,
8794 and on every push to it, including an agent's. Pull requests from forks,
112119 | | |
113120 | --- | --- |
114121 | 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. |
116123 | You choose **Take down** on the Deployments page | That app, at once. |
117124 | 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. |
119126 | The workspace's plan ends or its payment fails | Every app of the workspace, at the next sweep. |
120127
121128 A preview that came down comes back with the next push to its pull
123130
124131 ## Settings
125132
126−Under the repository's **Settings → Deployments**,
133+Under the project's **Settings → Deployments**,
127134 `g1t.sh/<workspace>/<repo>/settings/deployments`:
128135
129136 | Setting | Default | |
130137 | --- | --- | --- |
131138 | 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. |
133140 | Build command | The project's own | Runs instead of `npm run build`, or before bundling a Workers project. |
134141 | Output directory | Found by itself | What a static site serves. |
135142 | Idle days | 7 | 1 to 90. A preview no one visits this long comes down. |
136143
137144 ## Secrets and variables
138145
139−Builds and running apps read the repository's
146+Builds and running apps read the project's
140147 [secrets and variables](/guides/secrets-and-variables/) that are
141148 available to Deployments, and the workspace's that reach it:
142149
163170
164171 | Included | |
165172 | --- | --- |
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. |
167174 | 1 million requests | To all of the workspace's apps. |
168175 | 3 million CPU milliseconds | Time your code spends computing. Waiting on the network is not counted. |
169176
198205
199206 ## Turn it off
200207
201−- **For a repository:** **Turn off deployments** under its **Settings →
208+- **For a project:** **Turn off deployments** under its **Settings →
202209 Deployments**. Every app comes down at once. Turning it on again
203210 rebuilds production.
204211 - **For the workspace:** an owner chooses **Turn off at the end of the
+2−2
101101
102102 ### What a repository can ask for
103103
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
105105 rules its pull requests follow:
106106
107107 | Setting | Default | What it does |
133133
134134 A repository can land a g1t agent's pull request by itself once it is
135135 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
137137 `g1t`, the issue closes naming the pull request, and nothing short of
138138 ready is ever merged this way. One that is behind `main` is brought up to
139139 date as part of the merge. Pull requests from people and from other
+1−1
4747
4848 ## Protected branches
4949
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
5151 it is then refused, for members and agents alike, and git says why:
5252
5353 ```text
+1−1
1616
1717 ## Turn it on
1818
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
2020 workspace.
2121 2. Turn on **Merge through a queue**.
2222 3. Save.
+94−0
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/).
+7−6
33 description: One list of keys and values for workflows and deployments. Each row says which environments it applies to and who reads it.
44 ---
55
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.
78 Workflows and deployments both read from it; each row says whether one,
89 the other or both do, and which environments it applies to.
910
1011 | Where | Page | Who changes it |
1112 | --- | --- | --- |
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 |
1314 | A workspace | **Settings → Secrets and variables**, `g1t.sh/<workspace>/-/secrets` | Owners |
1415
1516 ## A row
2223 | **Note** | Optional: where to rotate it, or who to ask. |
2324 | **Environments** | **All environments**, or only some: **Production**, **Preview**, or any name a workflow job uses in `environment:`, such as `staging`. |
2425 | **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. |
2627
2728 ### A value per environment
2829
5859 else the row for all environments. A row only for other environments gives
5960 it nothing.
6061
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
6364 **Workspace**, until it sets the key itself.
6465
6566 A running app's rows are bound by g1t when it puts the app up; they never
121122 | `id` | The row to change or remove, from a list. |
122123 | `available_to` | `["workflows"]`, `["deployments"]` or both. `availableTo` works too. |
123124 | `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.) |
125126 | `note` | Where to rotate it, or who to ask. |
126127
127128 ```sh
+1−1
1212
1313 | | Sent the events of | Managed by | Where |
1414 | --- | --- | --- | --- |
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** |
1616 | A workspace's | Every repository in the workspace | Owners | The workspace's **Settings → Webhooks** |
1717
1818 ## Add one
+3−2
6666 | **Secrets and variables** | Members | [Rows every repository, or the ones linked, reads](/guides/secrets-and-variables/). Owners change them. |
6767 | **Webhooks** | Members | [Every repository's events](/guides/webhooks/), sent to your addresses. Owners manage them. |
6868
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**.
7172
7273 The arrow at the top of the settings goes back.
7374
+25−0
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+}
+8−2
1−import { Lock, Rocket, Settings, Webhook } from "lucide-react";
1+import { GitBranch, Lock, Rocket, Settings, Webhook } from "lucide-react";
22
33 import { TabLink } from "./ui";
44
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+ */
69 export function RepoSettingsTabs({ base }: { base: string }) {
710 return (
811 <nav className="mb-8 flex gap-x-6 border-b border-line">
1518 <TabLink to={`${base}/settings/secrets`} icon={<Lock size={15} />}>
1619 Secrets and variables
1720 </TabLink>
21+ <TabLink to={`${base}/settings/repository`} icon={<GitBranch size={15} />}>
22+ Repository
23+ </TabLink>
1824 <TabLink to={`${base}/settings/webhooks`} icon={<Webhook size={15} />}>
1925 Webhooks
2026 </TabLink>
+17−17
11 /**
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
33 * way Vercel lists environment variables: each row is a key, its type
44 * (Secret or Config), the environments it applies to and who reads it.
55 * Adding and editing happen in a side panel, opened by `?add` or
3535 }: {
3636 data: SecretsData;
3737 action: SecretsAction | undefined;
38− scope: "repository" | "workspace";
38+ scope: "project" | "workspace";
3939 manage: boolean;
4040 }) {
4141 const location = useLocation();
6868 and <code className="text-fg">vars.KEY</code>), <strong className="font-medium text-fg">deployments</strong>{" "}
6969 (the build's environment and the running app's <code className="text-fg">env.KEY</code>), or both read it.
7070 {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."}{" "}
7373 <a href="https://docs.g1t.sh/guides/secrets-and-variables/" className="text-fg hover:underline">
7474 How they are read
7575 </a>
135135 </div>
136136
137137 {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} />
139139 )}
140140 </div>
141141 );
167167 <span className="rounded-full px-2 py-px text-xs text-muted ring-1 ring-line">Workspace</span>
168168 ) : (
169169 <>
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"}
173173 </span>
174174 )}
175175 {manage && (
206206 function Drawer({
207207 row,
208208 scope,
209− repositories,
209+ projects,
210210 error,
211211 }: {
212212 row: Setting | undefined;
213− scope: "repository" | "workspace";
214− repositories: string[];
213+ scope: "project" | "workspace";
214+ projects: string[];
215215 error: string | undefined;
216216 }) {
217217 const busy = useNavigation().state === "submitting";
218218 const editing = !!row;
219219 const [type, setType] = useState<"secret" | "config">(row?.kind === "variable" ? "config" : "secret");
220220 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");
222222 const custom = row?.environments.filter((env) => !KNOWN_ENVIRONMENTS.includes(env)) ?? [];
223223 const field =
224224 "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";
381381
382382 {scope === "workspace" && (
383383 <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>
385385 <div className="space-y-2 text-sm">
386386 <label className="flex items-center gap-2">
387387 <input type="radio" name="reach" value="all" checked={reach === "all"} onChange={() => setReach("all")} className="accent-accent" />
388− Every repository
388+ Every project
389389 </label>
390390 <label className="flex items-center gap-2">
391391 <input type="radio" name="reach" value="some" checked={reach === "some"} onChange={() => setReach("some")} className="accent-accent" />
393393 </label>
394394 {reach === "some" && (
395395 <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) => (
397397 <label key={name} className="flex items-center gap-2 font-mono text-xs">
398398 <input
399399 type="checkbox"
400− name="repo"
400+ name="project"
401401 value={name}
402− defaultChecked={row?.repositories.includes(name)}
402+ defaultChecked={row?.projects.includes(name)}
403403 className="accent-accent"
404404 />
405405 {name}
+46−31
1515 History,
1616 Fingerprint,
1717 KeyRound,
18+ Box,
1819 Layers,
1920 LayoutDashboard,
21+ LayoutGrid,
2022 ListTree,
2123 Lock,
2224 LogOut,
5254 export type ShellData = {
5355 /** The workspace the sidebar is about: the one being looked at, or their first. */
5456 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. */
5860 repo: {
5961 namespace: string;
6062 name: string;
8385 icon: ReactNode;
8486 end?: boolean;
8587 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[];
8890 children: ReactNode;
8991 }) {
9092 const { pathname } = useLocation();
9496 end={end}
9597 prefetch="intent"
9698 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 + "/"));
98101 return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
99102 current
100103 ? "bg-raised font-medium text-fg"
334337 }
335338
336339 /**
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.
340343 */
341344 function RepoMenu({ repo, isPrivate, open }: { repo: MenuRepo; isPrivate: boolean; open: boolean }) {
342345 const base = `/${repo.namespace}/${repo.name}`;
354357 className="mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-raised/60"
355358 >
356359 <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} />}
358361 </span>
359362 <span className="min-w-0 truncate font-mono text-[0.8125rem]">
360363 <span className="text-faint">{repo.namespace}/</span>
361364 <span className="font-semibold text-fg">{repo.name}</span>
362365 </span>
363366 </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>
364377 <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`]}>
366379 Code
367380 </SidebarLink>
368381 <SidebarLink to={`${base}/commits`} also={`${base}/commit`} icon={<History size={15} />}>
389402 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
390403 Actions
391404 </SidebarLink>
392− {repo.member && (
393− <SidebarLink to={`${base}/deployments`} icon={<Rocket size={15} />}>
394− Deployments
395− </SidebarLink>
396− )}
397405 </SidebarGroup>
398406 {repo.member && (
399− <SidebarGroup title="Repository">
407+ <SidebarGroup title="Project">
400408 <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />}>
401409 Settings
402410 </SidebarLink>
528536 </div>
529537
530538 <SidebarGroup
531− title="Repositories"
539+ title="Projects"
532540 action={
533541 ws && (
534542 <Link
535543 to={`/new?workspace=${ws.slug}`}
536− aria-label="New repository"
544+ aria-label="New project"
537545 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
538546 >
539547 <Plus size={13} />
550558 <div key={base}>
551559 <SidebarLink
552560 to={base}
553− icon={repo.isPrivate ? <Lock size={15} /> : <BookMarked size={15} />}
561+ icon={repo.isPrivate ? <Lock size={15} /> : <Box size={15} />}
554562 >
555− <span className="font-mono text-[0.8125rem]">
563+ <span className="text-[0.8125rem]">
556564 {repo.namespace !== ws?.slug && (
557− <span className="text-faint">{repo.namespace}/</span>
565+ <span className="font-mono text-faint">{repo.namespace}/</span>
558566 )}
559− {repo.name}
567+ {"title" in repo && repo.title ? repo.title : repo.name}
560568 </span>
561569 </SidebarLink>
562570 </div>
615623 plans: "Plan",
616624 actions: "Actions",
617625 deployments: "Deployments",
626+ repository: "Repository",
627+ code: "Code",
618628 secrets: "Secrets and variables",
619629 settings: "Settings",
620630 people: "Members",
690700 const commands: Command[] = [
691701 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
692702 { 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} /> },
694704 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
695705 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
696706 ];
699709 const base = `/${repo.namespace}/${repo.name}`;
700710 const name = `${repo.namespace}/${repo.name}`;
701711 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} /> },
702714 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
703715 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
704716 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
706718 ...(repo.member
707719 ? [
708720 { 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} /> },
710725 ]
711726 : []),
712727 );
715730 commands.push(
716731 { label: membership.slug, hint: "Workspace", to: `/${membership.slug}`, icon: <Avatar name={membership.slug} size={15} square /> },
717732 { 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} /> },
719734 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
720735 { label: "Integrations", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/integrations`, icon: <Plug size={15} /> },
721736 );
722737 }
723738 for (const listed of shell.repos) {
724739 commands.push({
725− label: `${listed.namespace}/${listed.name}`,
726− hint: "Repository",
740+ label: listed.title ?? listed.name,
741+ hint: `${listed.namespace} · Project`,
727742 to: `/${listed.namespace}/${listed.name}`,
728− icon: <BookMarked size={15} />,
743+ icon: <Box size={15} />,
729744 });
730745 }
731746 return commands;
979994 )}
980995 <DropdownMenuItem asChild>
981996 <Link to="/new">
982− <BookMarked />
983− New repository
997+ <Box />
998+ New project
984999 </Link>
9851000 </DropdownMenuItem>
9861001 <DropdownMenuItem asChild>
+8−8
22
33 import type { Setting, SettingKind, SettingReader, SettingsOwner, User } from "@g1t/contracts";
44
5−import { actions, repos } from "./services.server";
5+import { actions, projects } from "./services.server";
66
77 export type SecretsData = {
88 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[];
1111 error: string | null;
1212 };
1313
1515 export async function loadSecrets(owner: SettingsOwner, actor: User): Promise<SecretsData> {
1616 const [rows, list] = await Promise.all([
1717 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),
1919 ]);
2020 return {
2121 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) : [],
2323 error: rows.ok ? null : rows.error.message,
2424 };
2525 }
6969 }
7070 const availableTo = form.getAll("availableTo").map(String) as SettingReader[];
7171 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) : [];
7474 const note = String(form.get("note") ?? "");
7575 const key = String(form.get("key") ?? "").trim();
7676 const value = String(form.get("value") ?? "");
8484 id,
8585 availableTo,
8686 environments,
87− repositories: "workspace" in owner ? repositories : undefined,
87+ projects: "workspace" in owner ? linked : undefined,
8888 note,
8989 });
9090 if (!saved.ok) return { error: pasted.length > 0 ? `${name}: ${saved.error.message}` : saved.error.message };
+2−0
44 actionsClient,
55 billingClient,
66 deploymentsClient,
7+ projectsClient,
78 eventsClient,
89 identityClient,
910 integrationsClient,
2122 export const webhooks = webhooksClient(env.WEBHOOKS);
2223 export const actions = actionsClient(env.ACTIONS);
2324 export const deployments = deploymentsClient(env.DEPLOYMENTS);
25+export const projects = projectsClient(env.PROJECTS);
+11−5
3737 DropdownMenuTrigger,
3838 } from "./components/ui/dropdown-menu";
3939 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";
4141 import { getViewer, roleIn, viewerMiddleware } from "./lib/session.server";
4242
4343 export const links: Route.LinksFunction = () => [
9090 const now = new Date();
9191 const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
9292 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),
9494 path ? work.counts(path, user) : Promise.resolve(null),
9595 workspace ? billing.account(workspace.slug, user) : Promise.resolve(null),
9696 workspace ? billing.usage(workspace.slug, user, monthStart) : Promise.resolve(null),
9797 ]);
9898 return {
9999 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+ : [],
103109 repo:
104110 path && counts?.ok
105111 ? {
+6−1
2929 ]),
3030 // Why a line is the way it is, fetched by the blame view.
3131 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.
3235 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"),
3438 route("tree/:ref/*", "routes/repo/tree.tsx"),
3539 route("blob/:ref/*", "routes/repo/blob.tsx"),
3640 route("commits", "routes/repo/commits.tsx"),
5155 route("plans", "routes/repo/plans.tsx"),
5256 route("plans/:id", "routes/repo/plan.tsx"),
5357 route("settings", "routes/repo/settings.tsx"),
58+ route("settings/repository", "routes/repo/settings-repository.tsx"),
5459 route("settings/webhooks", "routes/repo/webhooks.tsx"),
5560 route("settings/secrets", "routes/repo/secrets.tsx"),
5661 route("settings/deployments", "routes/repo/settings-deployments.tsx"),
+105−29
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";
24
35 import type { Route } from "./+types/new";
46 import { Button, ErrorText, Field, Input } from "../components/ui";
68 import { assertSameOrigin, requireUser } from "../lib/session.server";
79
810 export function meta({}: Route.MetaArgs) {
9− return [{ title: "New repository · g1t" }];
11+ return [{ title: "New project · g1t" }];
1012 }
1113
1214 export function loader({ request, context }: Route.LoaderArgs) {
1315 const user = requireUser(context, request);
1416 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.
1618 if (workspaces.length === 0) throw redirect("/workspaces/new");
1719 const asked = new URL(request.url).searchParams.get("workspace");
1820 return {
2527 assertSameOrigin(request);
2628 const user = requireUser(context, request);
2729 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.
2832 const result = await repos.create(user, {
2933 namespace: String(form.get("workspace") ?? ""),
3034 name: String(form.get("name") ?? ""),
3135 description: String(form.get("description") ?? ""),
3236 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,
3438 });
3539 if (!result.ok) return { error: result.error.message };
3640 throw redirect(`/${result.value.namespace}/${result.value.name}`);
3741 }
3842
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";
4370 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>.">
48131 <div className="flex items-center gap-2 font-mono text-sm">
49132 <select
50133 name="workspace"
59142 ))}
60143 </select>
61144 <span className="text-muted">/</span>
62− <Input name="name" required autoFocus maxLength={100} />
145+ <Input name="name" required autoFocus maxLength={100} placeholder="my-app" />
63146 </div>
64147 </Field>
65148 <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" />
77150 </Field>
78151 <fieldset className="space-y-2 text-sm">
152+ <legend className="mb-2 font-medium text-muted">Who can see it</legend>
79153 <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
82156 </label>
83157 <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
86160 </label>
87161 </fieldset>
88162 <ErrorText>{actionData?.error}</ErrorText>
89− <Button type="submit">Create repository</Button>
163+ <Button type="submit" variant="accent" disabled={busy}>
164+ Create project
165+ </Button>
90166 </Form>
91167 </main>
92168 );
+7−5
1313 export async function loader({ params, context }: Route.LoaderArgs) {
1414 const viewer = getViewer(context);
1515 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);
1717 if (!found.ok) throw data(null, { status: 404 });
1818 return { build: found.value };
1919 }
3838 <h1 className="mt-3 text-xl font-semibold tracking-tight">
3939 {build.kind === "production" ? "Production" : (
4040 <>
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+ )}
4547 </>
4648 )}
4749 <span className="ml-3 font-mono text-sm font-normal text-faint">{build.commit.slice(0, 12)}</span>
+30−41
22 import type { ReactNode } from "react";
33 import { Form, Link, data, useNavigation } from "react-router";
44
5−import type { Deployment, DeployStatus, FeatureState } from "@g1t/contracts";
5+import type { Deployment, FeatureState } from "@g1t/contracts";
66
7+import { host, StatusDot } from "../../components/deploy";
8+
79 import type { Route } from "./+types/deployments";
810 import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
911 import { billing, deployments } from "../../lib/services.server";
1820 const role = roleIn(viewer, params.owner);
1921 // Members only; to anyone else the page does not exist.
2022 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 };
2224 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),
2527 billing.features(params.owner, viewer),
2628 ]);
2729 const plan = unwrap(features).find((state) => state.plan.feature === "deployments") ?? null;
3234 assertSameOrigin(request);
3335 const user = requireUser(context, request);
3436 const form = await request.formData();
35− const path = { namespace: params.owner, name: params.repo };
37+ const ref = { workspace: params.owner, slug: params.repo };
3638 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;
3840 if (intent === "redeploy") {
39− const started = await deployments.redeploy(user, path, number);
41+ const started = await deployments.redeploy(user, ref, branch);
4042 return started.ok ? { notice: "Build started." } : { error: started.error.message };
4143 }
4244 if (intent === "take-down") {
43− const done = await deployments.takeDown(user, path, number);
45+ const done = await deployments.takeDown(user, ref, branch);
4446 return done.ok ? { notice: "Taken down." } : { error: done.error.message };
4547 }
4648 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" });
4850 return saved.ok
4951 ? { notice: intent === "enable" ? "Deployments are on. Production is building." : "Deployments are off, and every app is down." }
5052 : { error: saved.error.message };
5153 }
5254 return { error: "Unknown request." };
5355 }
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−};
6256
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−
7357 export default function RepoDeployments({ loaderData, actionData, params }: Route.ComponentProps) {
7458 const { settings, deployments: builds, live, plan } = loaderData;
7559 const busy = useNavigation().state === "submitting";
9983 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"
10084 >
10185 <Globe size={13} />
102− {settings.productionUrl.replace("https://", "")}
86+ {host(settings.productionUrl)}
10387 </a>
10488 )}
10589 </header>
11599 <section className="mt-2 rounded-xl border border-accent/30 bg-accent/5 p-6">
116100 <h2 className="font-medium">Deploy {params.repo}</h2>
117101 <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>{" "}
119103 as soon as you turn this on, and every open pull request gets its own preview. Workers projects (a{" "}
120104 <code className="font-mono text-fg">wrangler.jsonc</code>) and static sites deploy without configuration.
121105 </p>
135119 app={production}
136120 off={!settings.production}
137121 actions={
138− <AppActions number={null} up={!!production} busy={busy} />
122+ <AppActions branch={null} up={!!production} busy={busy} />
139123 }
140124 />
141125 <div className="rounded-xl border border-line bg-surface p-5">
142126 <h2 className="text-sm font-medium">Previews</h2>
143127 <p className="mt-0.5 text-xs text-faint">
144128 {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.`
146130 : "Off for this repository."}
147131 </p>
148132 {previews.length === 0 ? (
151135 <ul className="mt-3 divide-y divide-line">
152136 {previews.map((app) => (
153137 <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>
157146 <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)}
159148 </a>
160149 <span className="ml-auto shrink-0">
161− <AppActions number={app.number} up busy={busy} compact />
150+ <AppActions branch={app.branch} up busy={busy} compact />
162151 </span>
163152 </li>
164153 ))}
202191 <section className="mt-2 rounded-xl border border-accent/30 bg-accent/5 p-6">
203192 <h2 className="font-medium">Deployments are part of a paid plan</h2>
204193 <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
206195 request and production on g1t.page.
207196 {plan && ` $${(plan.plan.monthlyCents / 100).toFixed(0)} a month, including:`}
208197 </p>
246235 {app ? (
247236 <>
248237 <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)}
250239 <ExternalLink size={12} />
251240 </a>
252241 <p className="mt-1 text-xs text-faint">
261250 );
262251 }
263252
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 }) {
265254 return (
266255 <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} />}
268257 <Button variant="quiet" type="submit" name="intent" value="redeploy" disabled={busy} title="Build again from the current head">
269258 <RotateCw size={13} />
270259 {!compact && "Redeploy"}
288277 </span>
289278 <span className="min-w-0 grow">
290279 <span className="block truncate font-medium">
291− {build.kind === "production" ? "Production" : `Preview of #${build.number}`}
280+ {build.kind === "production" ? "Production" : `Preview of ${build.branch}`}
292281 <span className="ml-2 font-mono text-xs font-normal text-faint">{build.commit.slice(0, 8)}</span>
293282 </span>
294283 {build.error && <span className="mt-0.5 block truncate text-xs text-muted">{build.error}</span>}
+85−65
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";
125
136 import type { Route } from "./+types/layout";
147 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";
169 import { getViewer, roleIn, unwrap } from "../../lib/session.server";
1710
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` }];
2013 }
2114
2215 export async function loader({ params, context }: Route.LoaderArgs) {
2316 const viewer = getViewer(context);
2417 const path = { namespace: params.owner, name: params.repo };
25− const [repo, counts] = await Promise.all([
18+ const [repo, counts, found] = await Promise.all([
2619 repos.get(path, viewer),
2720 work.counts(path, viewer),
21+ projects.get(params.owner, params.repo, viewer),
2822 ]);
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+ }
2930 return {
3031 repo: unwrap(repo),
32+ project,
3133 open: counts.ok ? counts.value : { issues: 0, pulls: 0 },
3234 member: roleIn(viewer, params.owner) != null,
3335 };
3436 }
3537
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;
3876 const base = `/${repo.namespace}/${repo.name}`;
3977 const signedIn = useRouteLoaderData("root")?.user != null;
78+ const description = project?.description ?? repo.description;
4079 if (signedIn) {
4180 return (
4281 <>
4382 <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+ />
6391 </div>
6492 </div>
6593 <div className="mx-auto max-w-6xl px-4 py-6 sm:px-6">
7098 }
7199 return (
72100 <>
73− {/* The repository's own header band, under the site header. */}
101+ {/* The project's own header band, under the site header. */}
74102 <div className="border-b border-line bg-surface/60">
75103 <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} />}>
98118 Code
99119 </Tab>
100120 <Tab to={`${base}/issues`} icon={<CircleDot size={15} />} count={open.issues}>
101121 Issues
102122 </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}>
109124 Pull requests
110125 </Tab>
111126 <Tab to={`${base}/commits`} icon={<History size={15} />}>
117132 </Tab>
118133 )}
119134 {member && (
135+ <Tab to={`${base}/deployments`} icon={<Rocket size={15} />}>
136+ Deployments
137+ </Tab>
138+ )}
139+ {member && (
120140 <Tab to={`${base}/settings`} icon={<Settings size={15} />}>
121141 Settings
122142 </Tab>
+323−0
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+}
+1−1
2727 return (
2828 <div>
2929 <RepoSettingsTabs base={`/${params.owner}/${params.repo}`} />
30− <SecretsPanel data={loaderData} action={actionData} scope="repository" manage />
30+ <SecretsPanel data={loaderData} action={actionData} scope="project" manage />
3131 </div>
3232 );
3333 }
+6−6
1313 export async function loader({ params, context }: Route.LoaderArgs) {
1414 const viewer = getViewer(context);
1515 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);
1717 return { settings: unwrap(settings) };
1818 }
1919
2121 assertSameOrigin(request);
2222 const user = requireUser(context, request);
2323 const form = await request.formData();
24− const path = { namespace: params.owner, name: params.repo };
24+ const ref = { workspace: params.owner, slug: params.repo };
2525 const intent = form.get("intent");
2626 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" });
2828 return saved.ok
2929 ? { notice: intent === "enable" ? "Deployments are on. Production is building." : "Deployments are off, and every app is down." }
3030 : { error: saved.error.message };
3131 }
3232 const on = (name: string) => form.get(name) === "on";
33− const saved = await deployments.updateSettings(user, path, {
33+ const saved = await deployments.updateSettings(user, ref, {
3434 previews: on("previews"),
3535 production: on("production"),
3636 buildCommand: String(form.get("buildCommand") ?? ""),
8686 Deploy the default branch on every push.
8787 </Check>
8888 <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.
9090 </Check>
9191 </div>
9292 <div className="grid gap-4 md:grid-cols-3">
115115 <section className="mt-10 rounded-xl border border-danger/30 p-5">
116116 <h2 className="text-sm font-medium">Turn off deployments</h2>
117117 <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
119119 running or costing anything. The workspace's plan stays on; turn it off under Billing.
120120 </p>
121121 <Form method="post" className="mt-3">
+300−0
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+}
+70−267
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";
33
4−import { RepoSettingsTabs } from "../../components/repo-settings-tabs";
54 import type { Route } from "./+types/settings";
5+import { RepoSettingsTabs } from "../../components/repo-settings-tabs";
66 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";
159
1610 export function meta({ params }: Route.MetaArgs) {
1711 return [{ title: `Settings · ${params.owner}/${params.repo} · g1t` }];
2115 const viewer = getViewer(context);
2216 // Members only; to anyone else the page does not exist.
2317 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)) };
3619 }
3720
3821 export async function action({ request, params, context }: Route.ActionArgs) {
3922 assertSameOrigin(request);
4023 const user = requireUser(context, request);
4124 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") ?? ""),
4827 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") ?? ""),
6229 });
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 };
10931 }
11032
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;
14935 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;
15138 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>
18956
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."
19885 >
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>
25189
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">
28491 <Button type="submit" disabled={saving}>
285− {saving ? "Saving…" : "Save settings"}
92+ Save
28693 </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>
29698 </div>
99+ <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
297100 </Form>
298− </>
101+ </div>
299102 );
300103 }
+187−64
1−import { ArrowRight, KeyRound } from "lucide-react";
1+import { ArrowRight, ArrowUpRight, Box, CircleDot, GitBranch, GitPullRequest, KeyRound, Lock, Plus, Rocket } from "lucide-react";
22 import { Link } from "react-router";
33
4+import type { Project, ProjectDeploys } from "@g1t/contracts";
5+
46 import type { Route } from "./+types/overview";
5−import { RepoList } from "../../components/repo-list";
7+import { host, StatusDot } from "../../components/deploy";
68 import { Avatar, ButtonLink, CopyLine, Pill, TimeAgo } from "../../components/ui";
79 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";
911 import { getViewer, roleIn } from "../../lib/session.server";
1012
11−/** Repositories whose open issues and pull requests are counted. */
13+/** Projects whose open issues and pull requests are counted. */
1214 const MAX_COUNTED = 30;
13−/** Repositories whose pull requests are listed under "In progress". */
15+/** Projects whose pull requests are listed under "In progress". */
1416 const MAX_LISTED = 8;
1517 const MAX_PULLS = 8;
1618 const MAX_FACES = 8;
1921 const viewer = getViewer(context);
2022 const slug = params.owner.toLowerCase();
2123 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),
2426 role ? identity.listMembers(slug, viewer) : null,
27+ role ? deployments.overview(slug, viewer) : null,
2528 ]);
29+ const projects = listed.ok ? listed.value : [];
2630
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)));
3135 const open: Record<string, { issues: number; pulls: number }> = {};
32− counted.forEach((repo, i) => {
36+ counted.forEach((project, i) => {
3337 const result = counts[i];
34− if (result.ok) open[repo.id] = result.value;
38+ if (result.ok) open[project.id] = result.value;
3539 });
3640
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")));
4343 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] })) : []))
4745 .sort((a, b) => b.pull.updatedAt.localeCompare(a.pull.updatedAt))
4846 .slice(0, MAX_PULLS);
4947
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+ };
5160 }
5261
5362 function Stat({ value, label }: { value: number; label: string }) {
5968 );
6069 }
6170
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. */
6372 function GetStarted({ slug }: { slug: string }) {
6473 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>
6779 <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.
7082 </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>
7388 </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>
7590 <div className="mt-2 max-w-xl">
7691 <CopyLine prompt text={`git push https://g1t.sh/${slug}/my-project.git main`} />
7792 </div>
7994 );
8095 }
8196
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+
82178 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;
84180 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 }),
89182 { issues: 0, pulls: 0 },
90183 );
184+ const liveApps = Object.values(deploys).filter((entry) => entry.production).length;
91185 return (
92186 <div className="grid gap-10 lg:grid-cols-[1fr_18rem]">
93187 <div className="min-w-0 space-y-10">
94− {repos.length === 0 && role ? (
188+ {projects.length === 0 && role ? (
95189 <GetStarted slug={slug} />
96190 ) : (
97191 <>
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" />
103195 <Stat value={totals.issues} label="Open issues" />
104196 <Stat value={totals.pulls} label="Pull requests in progress" />
105197 </div>
106198
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+
107222 {pulls.length > 0 && (
108223 <section>
109224 <h2 className="text-sm font-medium text-muted">In progress</h2>
110225 <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 }) => (
112227 <li key={pull.id}>
113228 <Link
114229 prefetch="intent"
115− to={`/${slug}/${repo}/pull/${pull.number}`}
230+ to={`/${slug}/${project}/pull/${pull.number}`}
116231 className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised"
117232 >
118233 <PullIcon status={pull.status} />
119234 <span className="min-w-0 grow">
120235 <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}
125239 </span>
126240 </span>
127241 <span className="hidden shrink-0 text-xs text-muted sm:block">
136250 </ul>
137251 </section>
138252 )}
139−
140− <section>
141− <h2 className="text-sm font-medium text-muted">Repositories</h2>
142− <RepoList repos={repos} open={open} />
143− </section>
144253 </>
145254 )}
146255 </div>
149258 {members ? (
150259 <section>
151260 <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>
153262 <Link to={`/${slug}/-/people`} className="text-xs text-muted hover:text-fg">
154263 {role === "owner" ? "Manage" : "See all"}
155264 </Link>
163272 </li>
164273 ))}
165274 </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>}
171276 </section>
172277 ) : (
173278 <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.
177280 </p>
178281 )}
179282
180283 {role && (
181284 <section className="rounded-xl border border-line bg-surface p-5">
182285 <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">
183306 <KeyRound size={15} className="text-faint" />
184307 Automate without a service account
185308 </h2>
186309 <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.
189312 </p>
190313 <Link
191314 prefetch="intent"
+20−11
252252 which cannot change secrets. Guide:
253253 https://docs.g1t.sh/guides/secrets-and-variables/
254254
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+
255264 ## Deployments
256265
257266 A paid feature: an owner turns on the Deployments plan under the
258267 workspace's Billing ($5 a month: 10 apps, 1M requests, 3M CPU ms; builds
259268 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/
271280
272281 ## Facts
273282
+1−0
1414 WEBHOOKS: ServiceBinding;
1515 ACTIONS: ServiceBinding;
1616 DEPLOYMENTS: ServiceBinding;
17+ PROJECTS: ServiceBinding;
1718 BLOBS: KVNamespace;
1819 }
1920 }
+2−1
2121 { "binding": "INTEGRATIONS", "service": "g1t-integrations" },
2222 { "binding": "WEBHOOKS", "service": "g1t-webhooks" },
2323 { "binding": "ACTIONS", "service": "g1t-actions" },
24− { "binding": "DEPLOYMENTS", "service": "g1t-deployments" }
24+ { "binding": "DEPLOYMENTS", "service": "g1t-deployments" },
25+ { "binding": "PROJECTS", "service": "g1t-projects" }
2526 ],
2627 "observability": { "enabled": true },
2728 "upload_source_maps": true
+13−6
175175 pub kind: String,
176176 /// A variable's value; secrets' are never returned.
177177 pub value: Option<String>,
178− /// `repository` or `workspace`.
178+ /// `project` (a repository's, which belong to its project) or
179+ /// `workspace`.
179180 pub scope: String,
180181 pub updated_at: String,
181182 /// `workflows` and/or `deployments`.
184185 /// The environments it applies to; empty is every environment.
185186 #[serde(default)]
186187 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.
189190 #[serde(default)]
190− pub repositories: Vec<String>,
191+ pub projects: Vec<String>,
191192 #[serde(default)]
192193 pub note: Option<String>,
193194 #[serde(default)]
326327 /// unchanged.
327328 #[serde(default)]
328329 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.
330331 #[serde(default)]
331− pub repositories: Option<Vec<String>>,
332+ pub projects: Option<Vec<String>>,
332333 #[serde(default)]
333334 pub note: Option<String>,
334335 }
341342 pub struct ResolveSettingsArgs {
342343 pub repo_id: String,
343344 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>,
344351 /// `workflows` or `deployments`.
345352 pub consumer: String,
346353 /// The environment being read for, such as `production` or `preview`.
+1−0
1212 mod ids;
1313 mod names;
1414 mod outcome;
15+pub mod projects;
1516 pub mod repos;
1617 pub mod time;
1718 pub mod webhooks;
+26−0
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+}
+18−4
2222 //!
2323 //! - `G1T_API`, `DEPLOY_ID`, `DEPLOY_TOKEN`: where and how to report.
2424 //! - `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.
2628 //! - `BUILD_ENV`, `BUILD_SECRETS`: JSON objects of the repository's
2729 //! variables and secrets for deploy builds. Both are set for the build;
2830 //! secrets' values are redacted from its log.
104106 /// Runs a command in the checkout, logging it; fails if it fails.
105107 fn step(log: &mut Log, command: &str, secrets: &[String]) -> Result<()> {
106108 log.line(&format!("$ {command}"));
107− let result = run_command(command, Path::new(WORKDIR), secrets);
109+ let result = run_command(command, &project_dir(), secrets);
108110 if !result.output_text().is_empty() {
109111 log.line(result.output_text());
110112 }
433435 }
434436
435437 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();
437440 let config = read_wrangler(dir)?;
438441 let custom_build = std::env::var("BUILD_COMMAND").ok().filter(|c| !c.trim().is_empty());
439442 if let Some(install) = install_command(dir) {
519522 }
520523 }
521524
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+
522536 fn check_out(secrets: &[String]) -> Result<()> {
523537 let remote = env("GIT_REMOTE")?;
524538 let commit = env("GIT_COMMIT")?;
557571 "warnings": built.warnings,
558572 });
559573 if let Some(dir) = &built.assets_dir {
560− let skip_project = dir == Path::new(WORKDIR);
574+ let skip_project = *dir == project_dir();
561575 let mut assets = Vec::new();
562576 collect(dir, dir, skip_project, &mut assets)?;
563577 if assets.is_empty() {
+24−0
715715 6. **Groups, owners, scorecards** feeding the upkeep agents.
716716 7. **Environments** with protection rules; custom domains; logs.
717717
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+
718742 1 and 2 serve the competition directly (multi-agent coordination across
719743 projects is 25% of the score); 3 is the demo's best moment if time allows.
720744
+12−0
15961596 "resolved": "services/pages",
15971597 "link": true
15981598 },
1599+ "node_modules/@g1t/projects": {
1600+ "resolved": "services/projects",
1601+ "link": true
1602+ },
15991603 "node_modules/@g1t/runner": {
16001604 "resolved": "services/runner",
16011605 "link": true
99729976 "version": "0.1.0",
99739977 "license": "MIT"
99749978 },
9979+ "services/projects": {
9980+ "name": "@g1t/projects",
9981+ "version": "0.1.0",
9982+ "license": "MIT",
9983+ "dependencies": {
9984+ "@g1t/contracts": "*"
9985+ }
9986+ },
99759987 "services/runner": {
99769988 "name": "@g1t/runner",
99779989 "version": "0.1.0",
+6−4
116116 kind: SettingKind;
117117 /** A variable's value. */
118118 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";
120121 updatedAt: string;
121122 availableTo: SettingReader[];
122123 /** The environments it applies to; empty is every environment. */
123124 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[];
126127 /** Where to rotate it, or who to ask. */
127128 note: string | null;
128129 updatedBy: string | null;
134135 id?: string;
135136 availableTo?: SettingReader[];
136137 environments?: string[];
137− repositories?: string[];
138+ /** A workspace's row: project slugs; empty for every one. */
139+ projects?: string[];
138140 note?: string;
139141 };
140142
+19−6
11 import type { ActionsApi } from "./actions";
22 import type { BillingApi } from "./billing";
33 import type { DeploymentsApi } from "./deployments";
4+import type { ProjectsApi } from "./projects";
45 import type { EventsApi } from "./events";
56 import type { IdentityApi } from "./identity";
67 import type { IntegrationsApi } from "./integrations";
266267 export function deploymentsClient(service: ServiceBinding): DeploymentsApi {
267268 const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
268269 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 }),
275277 usage: (workspace, viewer) => call("usage", { workspace, viewer }),
276278 };
277279 }
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+}
+40−22
11 import type { User, Viewer } from "./identity";
2−import type { RepoPath } from "./repos";
32 import type { Result } from "./result";
43
54 /**
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.
1111 */
1212
1313 /** The domain apps are served on. Never g1t.sh, so they share no cookies with it. */
1414 export const DEPLOYMENTS_DOMAIN = "g1t.page";
1515
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. */
1720 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. */
1922 enabled: boolean;
20− /** A preview for every open pull request. */
23+ /** A preview for every branch with an open pull request. */
2124 previews: boolean;
2225 /** The default branch deployed to production on every push. */
2326 production: boolean;
4649 export type Deployment = {
4750 id: string;
4851 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. */
5055 number: number | null;
5156 commit: string;
5257 status: DeployStatus;
6368 finishedAt: string | null;
6469 };
6570
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. */
6772 export type LiveApp = {
6873 kind: DeployKind;
74+ branch: string | null;
6975 number: number | null;
7076 url: string;
7177 commit: string;
7379 deployedAt: string;
7480 };
7581
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+
7692 /** What a workspace's apps used this month against its plan. */
7793 export type DeployUsage = {
7894 /** `YYYY-MM`. */
91107
92108 export interface DeploymentsApi {
93109 /** 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[]>>;
105123 /** What the workspace's apps used this month. Members only. */
106124 usage(workspace: string, viewer: Viewer): Promise<Result<DeployUsage>>;
107125 }
+1−1
11 const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
22
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";
44
55 let lastMs = 0;
66 let lastCounter = 0;
+1−0
88 export * from "./integrations";
99 export * from "./names";
1010 export * from "./oauth";
11+export * from "./projects";
1112 export * from "./repos";
1213 export * from "./result";
1314 export * from "./runner";
+64−0
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+}
+1−0
1919 services/identity
2020 services/repos
2121 services/work
22+ services/projects
2223 services/billing
2324 services/integrations
2425 services/webhooks
+3−0
7373 identity: Fetcher,
7474 runner: Fetcher,
7575 events: Fetcher,
76+ /// Projects: a repository's secrets and variables belong to its project.
77+ projects: Fetcher,
7678 /// Seals secrets; absent until `ACTIONS_KEY` is set, when secrets
7779 /// cannot be saved.
7880 sealer: Option<Sealer>,
8789 identity: env.service("IDENTITY")?,
8890 runner: env.service("RUNNER")?,
8991 events: env.service("EVENTS")?,
92+ projects: env.service("PROJECTS")?,
9093 sealer: env.secret("ACTIONS_KEY").ok().and_then(|key| Sealer::new(&key.to_string())),
9194 })
9295 }
+70−29
66 //! previews can hold different values; a key's rows never overlap.
77 //!
88 //! 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
1116 //! them without regard to case. Agents never read any.
1217
1318 use g1t_contracts::actions::{
1419 CONSUMERS, DeleteSettingArgs, ResolveSettingsArgs, ResolvedSettings, SetSettingArgs, Setting, SettingsArgs,
1520 SettingsOwner,
1621 };
22+use g1t_contracts::projects::{ByRepoArgs, ProjectRef};
1723 use g1t_contracts::time::rfc3339;
1824 use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, new_id};
1925 use g1t_kit::now_ms;
109115 split(&self.environments)
110116 }
111117
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> {
113121 self.repositories.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default()
114122 }
115123
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))
119127 }
120128
121129 /// Whether it and rows for `environments` would both apply somewhere.
128136 Setting {
129137 available_to: split(&self.available_to),
130138 environments: self.environments(),
131− repositories: self.repositories(),
139+ projects: self.projects(),
132140 value: (self.kind == "variable").then_some(self.value),
133141 id: self.id,
134142 name: self.name,
141149 }
142150 }
143151
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.
146154 struct Place {
147155 scope: &'static str,
148156 owner: String,
149157 namespace: String,
158+ /// The project's slug, for a project's place.
159+ slug: String,
150160 }
151161
152162 impl Actions {
170180 let Some(repo) = self.visible_repo(path, &Some(actor.clone())).await? else {
171181 return Ok(fail(FailureCode::NotFound, "There is no such repository."));
172182 };
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 }))
174187 }
175188 (None, Some(slug)) => {
176189 let slug = slug.to_lowercase();
178191 match role {
179192 None => Ok(fail(FailureCode::Forbidden, format!("Only members of {slug} can see its secrets and variables."))),
180193 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() })),
182195 }
183196 }
184197 (None, None) => Ok(fail(FailureCode::Invalid, "Give `repo` or `workspace`.")),
185198 }
186199 }
187200
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+
188217 /// `secret`, `variable`, or `None` for both.
189218 fn kind(kind: &str) -> Outcome<Option<&'static str>> {
190219 match kind {
207236 pub async fn settings(&self, a: SettingsArgs) -> Result<Outcome<Vec<Setting>>> {
208237 let kind = check!(Self::kind(&a.kind));
209238 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());
211239 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" {
215243 let own: Vec<String> = self.rows(&place.owner).await?.into_iter().map(|row| row.name).collect();
216244 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) {
218246 out.push(row.describe());
219247 }
220248 }
255283 Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
256284 };
257285 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."));
260288 }
261289 let rows = self.rows(&place.owner).await?;
262290 // A secret and a variable may share a key, as on GitHub, where
304332 .map(|r| r.join(","))
305333 .or_else(|| existing.map(|row| row.available_to.clone()))
306334 .unwrap_or_else(|| CONSUMERS.join(","));
307− let repositories: Option<String> = match &a.repositories {
335+ let repositories: Option<String> = match &a.projects {
308336 Some(list) if list.is_empty() => None,
309337 Some(list) => Some(serde_json::to_string(list).unwrap_or_default()),
310338 None => existing.and_then(|row| row.repositories.clone()),
381409 #[allow(clippy::too_many_arguments)]
382410 async fn resolved(
383411 &self,
384− repo_id: &str,
385− repo_name: &str,
412+ project_id: &str,
413+ project_slug: &str,
386414 namespace: &str,
387415 kind: &str,
388416 consumer: &str,
394422 }
395423 let environment = environment.map(str::to_ascii_lowercase);
396424 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()] {
398426 let rows: Vec<SettingRow> = self
399427 .rows(&owner)
400428 .await?
401429 .into_iter()
402430 .filter(|row| row.kind == kind)
403431 .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))
405433 .collect();
406434 let mut names: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect();
407435 names.dedup();
434462 /// The `vars` context of a repository's runs. `environment` is the job's
435463 /// `environment:`, when it has one.
436464 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
439470 }
440471
441472 /// The `secrets` context of a repository's runs, opened.
442473 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
445479 }
446480
447481 /// `resolve_settings`, for the deployments service: what a deploy build
448482 /// and its running app get.
449483 pub async fn resolve_settings(&self, a: ResolveSettingsArgs) -> Result<ResolvedSettings> {
450484 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+ };
451492 Ok(ResolvedSettings {
452493 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)
454495 .await?,
455496 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)
457498 .await?,
458499 })
459500 }
+3−1
2424 { "binding": "WORK", "service": "g1t-work" },
2525 { "binding": "IDENTITY", "service": "g1t-identity" },
2626 { "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" }
2830 ],
2931 // Every event on the bus: what starts workflows, and pushes that change them.
3032 "queues": {
+74−0
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);
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.