flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Project dependencies: addresses, preview stacks, Affects, and agents who know

A project can depend on others in its workspace, declared on the site (Settings → Dependencies) or in its .g1t/project.yml, which is read on every push to the default branch: dependsOn: - project: api as: API_URL Verified live with syntaqx/lab-api and automation-lab: production of automation-lab got API_URL=https://lab-api-syntaqx.g1t.page; lab-api#1's pull request listed automation-lab under Affects, and "Preview them against this change" built automation-lab-git-v2-syntaqx.g1t.page with API_URL pointing at lab-api's v2 preview. - services/projects: a dependencies table (migration 0002), cycles and unknown projects refused; dependencies, add_dependency, remove_dependency, graph and context_for_repo; .g1t/project.yml synced on pushes to the default branch, replacing what the file declared. - Deployments: builds and running apps get each dependency's address for the same environment under its variable (the same branch's preview when one is up); a project's own rows win. `stack` builds the dependents' previews against a branch's preview, in the background, so leaving the page does not cancel them. - Agents: the runner tells every agent what its repository's projects use and what uses them, and to keep dependents working or open an issue on each with create_issue. - Site: Settings → Dependencies; Dependencies on the overview; Affects and "Preview them against this change" on pull requests. - Billing copes with moving to another Stripe account: a saved customer the new account does not know is forgotten and the payment starts afresh; a plan whose subscription it does not know ends at renewal. The billing secret now holds the new Flagon, Inc. sandbox key. - Docs: Dependencies in the Projects guide; addresses of other projects and preview stacks in the Deployments guide.

syntaqxcommitted Parent26e42c9Browse files
22 files+853−410/22 viewed
+2−0
77 worker-configuration.d.ts
88 .wrangler/
99 .dev.vars*
10+
11+.env*
+22−0
8888 lists each one it left out. Code that needs them should check that the
8989 binding is there.
9090
91+## Addresses of other projects
92+
93+A project that [depends on another](/guides/projects/#dependencies) with
94+`as: API_URL` gets that project's address as `API_URL`, in its build and in
95+its running app:
96+
97+| Building | `API_URL` is |
98+| --- | --- |
99+| Production | The other project's production. |
100+| A preview of branch `x` | The other project's preview of `x` if it is up, else its production. |
101+
102+A secret or variable of the same name wins over it.
103+
104+### Preview stacks
105+
106+A change to an API is best seen in the apps that call it. On a pull
107+request whose preview is up, **Preview them against this change** (under
108+**Affects**) builds a preview of every project that uses this one, from its
109+own default branch, under the same branch name. Each gets this preview's
110+address through its variable. They come down with their idle days, like
111+any preview.
112+
91113 ## Previews of branches
92114
93115 A preview is built when a pull request is opened, when it is marked ready,
+31−3
5050 | --- | --- |
5151 | **General** | The project's name and description, its source, and its **root directory**. |
5252 | **Deployments** | Production, previews, build command, output directory and idle days. See [Deployments](/guides/deployments/#settings). |
53+| **Dependencies** | The projects this one uses, and the ones that use it. See [Dependencies](#dependencies). |
5354 | **Secrets and variables** | The project's rows. See [Secrets and variables](/guides/secrets-and-variables/). |
5455 | **Repository** | The repository's visibility, branch protection, required approvals, checks, the merge queue and auto-merge. |
5556 | **Webhooks** | The repository's [webhooks](/guides/webhooks/). |
8384 and agents.
8485 - **Several projects on one repository**, each from its own root
8586 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.
8987
88+
89+## Dependencies
90+
91+A project can depend on others in its workspace: `web` calls `api`'s HTTP
92+API, or consumes `ui-kit`'s package. Declare it under **Settings →
93+Dependencies**, or in a `.g1t/project.yml` in the project's root
94+directory:
95+
96+```yaml
97+dependsOn:
98+ - project: api
99+ as: API_URL
100+ - project: ui-kit
101+```
102+
103+The file is read on every push to the default branch, and its dependencies
104+replace the ones it declared before; those are marked **project.yml** and
105+changed only in the file. A dependency that would make a cycle, or names a
106+project that does not exist, is left out.
107+
108+What g1t does with them:
109+
110+| | |
111+| --- | --- |
112+| **Addresses in builds and apps** | With `as: API_URL`, `web`'s builds and its running app get `API_URL` set to `api`'s address for the same environment: production gets `api`'s production; a preview gets the preview of `api` on the same branch if one is up, else `api`'s production. A secret or variable of the same name on `web` wins. |
113+| **Preview stacks** | On a pull request of `api` whose preview is up, **Preview them against this change** builds a preview of every project that uses `api`, from its default branch, under the same branch name, so each reaches the change through its variable. A reviewer clicks through the whole change. |
114+| **Affects** | A pull request lists the projects that use its project, so reviewers see what else a change can break. |
115+| **Agents** | An agent working on a project is told what it uses and what uses it. If its change alters what those rely on, it keeps it working for them or opens an issue on each saying what to change, and says so in its summary. |
116+| **The overview** | Each project's overview shows what it depends on and what uses it. |
117+
90118 ## From the API
91119
92120 Projects keep their repository's routes: `/repos/{workspace}/{project}/…`
+4−1
1−import { GitBranch, Lock, Rocket, Settings, Webhook } from "lucide-react";
1+import { GitBranch, Lock, Network, Rocket, Settings, Webhook } from "lucide-react";
22
33 import { TabLink } from "./ui";
44
1515 <TabLink to={`${base}/settings/deployments`} icon={<Rocket size={15} />}>
1616 Deployments
1717 </TabLink>
18+ <TabLink to={`${base}/settings/dependencies`} icon={<Network size={15} />}>
19+ Dependencies
20+ </TabLink>
1821 <TabLink to={`${base}/settings/secrets`} icon={<Lock size={15} />}>
1922 Secrets and variables
2023 </TabLink>
+1−0
624624 actions: "Actions",
625625 deployments: "Deployments",
626626 repository: "Repository",
627+ dependencies: "Dependencies",
627628 code: "Code",
628629 secrets: "Secrets and variables",
629630 settings: "Settings",
+1−0
5959 route("settings/webhooks", "routes/repo/webhooks.tsx"),
6060 route("settings/secrets", "routes/repo/secrets.tsx"),
6161 route("settings/deployments", "routes/repo/settings-deployments.tsx"),
62+ route("settings/dependencies", "routes/repo/settings-dependencies.tsx"),
6263 ]),
6364 // Anything else: a 404 that still knows who is signed in.
6465 route("*", "routes/not-found.tsx"),
+61−3
1−import { ArrowUpRight, Box, CircleDot, Code2, GitBranch, GitCommitHorizontal, GitPullRequest, Lock, Rocket, RotateCw } from "lucide-react";
1+import { ArrowDownLeft, ArrowUpRight, Box, CircleDot, Code2, GitBranch, GitCommitHorizontal, GitPullRequest, Lock, Network, Rocket, RotateCw } from "lucide-react";
22 import { Form, Link, useNavigation } from "react-router";
33
44 import type { Route } from "./+types/overview";
1515 const member = roleIn(viewer, params.owner) != null;
1616 const path = { namespace: params.owner, name: params.repo };
1717 const ref = { workspace: params.owner, slug: params.repo };
18− const [project, settings, list, pulls, log, counts] = await Promise.all([
18+ const [project, settings, list, pulls, log, counts, deps] = await Promise.all([
1919 projects.get(params.owner, params.repo, viewer),
2020 member ? deployments.settings(ref, viewer) : null,
2121 member ? deployments.list(ref, viewer) : null,
2222 work.listPulls(path, viewer, "open"),
2323 repos.log(path, viewer, null, 1),
2424 work.counts(path, viewer),
25+ projects.dependencies(params.owner, params.repo, viewer),
2526 ]);
2627 return {
2728 member,
3233 pulls: pulls.ok ? pulls.value.slice(0, MAX_PULLS) : [],
3334 commit: log.ok ? (log.value[0] ?? null) : null,
3435 open: counts.ok ? counts.value : { issues: 0, pulls: 0 },
36+ dependencies: deps.ok ? deps.value : { dependsOn: [], usedBy: [] },
3537 };
3638 }
3739
4345 }
4446
4547 export default function ProjectOverview({ loaderData, actionData, params }: Route.ComponentProps) {
46− const { member, project, settings, builds, live, pulls, commit, open } = loaderData;
48+ const { member, project, settings, builds, live, pulls, commit, open, dependencies } = loaderData;
4749 const base = `/${params.owner}/${params.repo}`;
4850 const production = live.find((app) => app.kind === "production") ?? null;
4951 const previews = live.filter((app) => app.kind === "preview");
302304 </Link>
303305 </section>
304306
307+ <section className="rounded-xl border border-line bg-surface p-5">
308+ <div className="flex items-center justify-between">
309+ <h2 className="flex items-center gap-1.5 text-xs font-medium tracking-wide text-muted uppercase">
310+ <Network size={13} />
311+ Dependencies
312+ </h2>
313+ {member && (
314+ <Link to={`${base}/settings/dependencies`} className="text-xs text-muted hover:text-fg">
315+ Manage
316+ </Link>
317+ )}
318+ </div>
319+ {dependencies.dependsOn.length === 0 && dependencies.usedBy.length === 0 ? (
320+ <p className="mt-3 text-xs text-muted">
321+ Uses no other project, and none uses it. Declare one, and builds get its address and agents know what
322+ depends on what.
323+ </p>
324+ ) : (
325+ <div className="mt-3 space-y-3 text-xs">
326+ {dependencies.dependsOn.length > 0 && (
327+ <div>
328+ <p className="flex items-center gap-1 text-muted">
329+ <ArrowUpRight size={12} /> Depends on
330+ </p>
331+ <ul className="mt-1.5 space-y-1">
332+ {dependencies.dependsOn.map((d) => (
333+ <li key={d.slug} className="flex items-center justify-between gap-2">
334+ <Link to={`/${params.owner}/${d.slug}`} className="font-medium hover:underline">
335+ {d.name}
336+ </Link>
337+ {d.as && <code className="font-mono text-faint">{d.as}</code>}
338+ </li>
339+ ))}
340+ </ul>
341+ </div>
342+ )}
343+ {dependencies.usedBy.length > 0 && (
344+ <div>
345+ <p className="flex items-center gap-1 text-muted">
346+ <ArrowDownLeft size={12} /> Used by
347+ </p>
348+ <ul className="mt-1.5 space-y-1">
349+ {dependencies.usedBy.map((d) => (
350+ <li key={d.slug}>
351+ <Link to={`/${params.owner}/${d.slug}`} className="font-medium hover:underline">
352+ {d.name}
353+ </Link>
354+ </li>
355+ ))}
356+ </ul>
357+ </div>
358+ )}
359+ </div>
360+ )}
361+ </section>
362+
305363 {project && (
306364 <section className="rounded-xl border border-line p-5 text-xs text-muted">
307365 <p className="flex items-center gap-2 font-medium text-fg">
+56−2
1616 Sparkles,
1717 MessageSquare,
1818 MessagesSquare,
19+ Network,
1920 StickyNote,
2021 User,
2122 Wrench,
5051 TimelineItem,
5152 verdicts,
5253 } from "../../components/work";
53−import { identity, repos, work } from "../../lib/services.server";
54+import { deployments, identity, projects, repos, work } from "../../lib/services.server";
5455 import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
5556
5657 const REFRESH_MS = 4000;
7677 (membership) => membership.slug === params.owner,
7778 );
7879 // At once: none of these depends on another.
79− const [found, repo, settings, agentsEnabled, members] = await Promise.all([
80+ const ref = { workspace: params.owner, slug: params.repo };
81+ const [found, repo, settings, agentsEnabled, members, deps, deployed] = await Promise.all([
8082 work.getPull(path, number, viewer),
8183 repos.get(path, viewer),
8284 work.getSettings(path, viewer),
8385 env.RUNNER.enabled(viewer, path),
8486 // A member picks reviewers and assignees from the workspace's people.
8587 member ? identity.listMembers(params.owner, viewer) : null,
88+ // The projects that use this one: what a change here can affect.
89+ projects.dependencies(params.owner, params.repo, viewer),
90+ member ? deployments.list(ref, viewer) : null,
8691 ]);
8792 if (!found.ok) {
8893 // Issues and pull requests share numbers; this one may be an issue.
118123 requiredApprovals: settings.ok ? settings.value.requiredApprovals : 0,
119124 canIgnoreChecks: !settings.ok || settings.value.allowIgnoringChecks,
120125 defaultBranch: repo.ok ? repo.value.defaultBranch : "main",
126+ affects: deps.ok ? deps.value.usedBy : [],
127+ // This pull request's preview, if it is up: a stack builds on it.
128+ preview: deployed?.ok ? (deployed.value.live.find((app) => app.kind === "preview" && app.number === number) ?? null) : null,
121129 };
122130 }
123131
135143 ...form.getAll(field).map(String),
136144 ...String(form.get("others") ?? "").split(/[\s,]+/),
137145 ];
146+ // The projects that use this one, built against this pull request's preview.
147+ if (action === "stack") {
148+ const built = await deployments.stack(user, { workspace: params.owner, slug: params.repo }, String(form.get("branch") ?? ""));
149+ return built.ok
150+ ? { action, notice: `Building ${built.value.join(", ")} against this preview. They appear on their Deployments pages.` }
151+ : { action, error: built.error.message };
152+ }
138153 // Asking a g1t agent for its review records the request, then starts it.
139154 if (action === "agent-review") {
140155 const asked = await work.updatePull(user, path, number, {
320335 lifecycle,
321336 messages,
322337 statuses = [],
338+ affects,
339+ preview,
323340 landing,
324341 stalled,
325342 requireUpToDate,
862879 />
863880 {actionData?.action === "recheck" && <ErrorText>{actionData.error}</ErrorText>}
864881 <WorkflowStatuses statuses={statuses} />
882+ {affects.length > 0 && (
883+ <section>
884+ <h3 className="flex items-center gap-1.5 text-sm font-medium">
885+ <Network size={14} className="text-faint" />
886+ Affects
887+ </h3>
888+ <p className="mt-1 text-xs text-muted">Projects that use this one, and so may feel this change:</p>
889+ <ul className="mt-2 space-y-1 text-sm">
890+ {affects.map((project) => (
891+ <li key={project.slug} className="flex items-center justify-between gap-2">
892+ <Link to={`/${params.owner}/${project.slug}`} className="hover:underline">
893+ {project.name}
894+ </Link>
895+ {project.as && <code className="font-mono text-xs text-faint">{project.as}</code>}
896+ </li>
897+ ))}
898+ </ul>
899+ {preview?.branch && canMerge && (
900+ <Form method="post" className="mt-3">
901+ <input type="hidden" name="action" value="stack" />
902+ <input type="hidden" name="branch" value={preview.branch} />
903+ <button
904+ type="submit"
905+ className="w-full rounded-md border border-line px-3 py-1.5 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg"
906+ >
907+ Preview them against this change
908+ </button>
909+ </Form>
910+ )}
911+ {actionData?.action === "stack" &&
912+ ("notice" in actionData ? (
913+ <p className="mt-2 text-xs text-accent">{String(actionData.notice)}</p>
914+ ) : (
915+ <ErrorText>{actionData.error}</ErrorText>
916+ ))}
917+ </section>
918+ )}
865919
866920 <section>
867921 <h3 className="text-sm font-medium">Reviewers</h3>
+175−0
1+import { ArrowDownLeft, ArrowUpRight, FileCode2, Network, Trash2 } from "lucide-react";
2+import { Form, Link, data, useNavigation } from "react-router";
3+
4+import type { DependencyLink } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/settings-dependencies";
7+import { RepoSettingsTabs } from "../../components/repo-settings-tabs";
8+import { Button, EmptyState, ErrorText, Field, Input } from "../../components/ui";
9+import { projects } from "../../lib/services.server";
10+import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
11+
12+export function meta({ params }: Route.MetaArgs) {
13+ return [{ title: `Dependencies · ${params.owner}/${params.repo} · g1t` }];
14+}
15+
16+export async function loader({ params, context }: Route.LoaderArgs) {
17+ const viewer = getViewer(context);
18+ if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
19+ const [deps, all] = await Promise.all([
20+ projects.dependencies(params.owner, params.repo, viewer),
21+ projects.list(params.owner, viewer),
22+ ]);
23+ return { dependencies: unwrap(deps), projects: all.ok ? all.value : [] };
24+}
25+
26+export async function action({ request, params, context }: Route.ActionArgs) {
27+ assertSameOrigin(request);
28+ const user = requireUser(context, request);
29+ const form = await request.formData();
30+ const on = String(form.get("on") ?? "");
31+ if (form.get("intent") === "remove") {
32+ const done = await projects.removeDependency(user, params.owner, params.repo, on);
33+ return done.ok ? { notice: `No longer depends on ${on}.` } : { error: done.error.message };
34+ }
35+ const added = await projects.addDependency(user, params.owner, params.repo, on, String(form.get("as") ?? "") || null);
36+ return added.ok ? { notice: `Depends on ${on}.` } : { error: added.error.message };
37+}
38+
39+function Row({ link, base, removable, busy }: { link: DependencyLink; base: string; removable: boolean; busy: boolean }) {
40+ return (
41+ <li className="flex items-center gap-3 px-4 py-3 text-sm">
42+ <Link to={`/${base.split("/")[1]}/${link.slug}`} className="font-medium hover:underline">
43+ {link.name}
44+ </Link>
45+ {link.as ? (
46+ <code className="rounded bg-raised px-1.5 py-0.5 font-mono text-xs text-muted">{link.as}</code>
47+ ) : (
48+ <span className="text-xs text-faint">no variable</span>
49+ )}
50+ {link.source === "file" && (
51+ <span className="inline-flex items-center gap-1 text-xs text-faint" title="Declared in .g1t/project.yml">
52+ <FileCode2 size={12} />
53+ project.yml
54+ </span>
55+ )}
56+ {removable && link.source === "ui" && (
57+ <Form method="post" className="ml-auto">
58+ <input type="hidden" name="intent" value="remove" />
59+ <input type="hidden" name="on" value={link.slug} />
60+ <button
61+ type="submit"
62+ disabled={busy}
63+ aria-label={`Stop depending on ${link.name}`}
64+ className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger"
65+ >
66+ <Trash2 size={14} />
67+ </button>
68+ </Form>
69+ )}
70+ </li>
71+ );
72+}
73+
74+export default function DependencySettings({ loaderData, actionData, params }: Route.ComponentProps) {
75+ const { dependencies, projects: all } = loaderData;
76+ const busy = useNavigation().state === "submitting";
77+ const base = `/${params.owner}/${params.repo}`;
78+ const taken = new Set([params.repo.toLowerCase(), ...dependencies.dependsOn.map((d) => d.slug)]);
79+ const choices = all.filter((project) => !taken.has(project.slug));
80+ return (
81+ <div className="max-w-4xl">
82+ <RepoSettingsTabs base={base} />
83+ <header>
84+ <h2 className="flex items-center gap-2 text-sm font-medium">
85+ <Network size={15} className="text-accent" />
86+ Dependencies
87+ </h2>
88+ <p className="mt-1 max-w-2xl text-sm text-muted">
89+ The projects this one uses: calls their API, consumes their package. Its builds and its running app get each
90+ one's address for the same environment under the variable you name, a preview pointing at the same branch's
91+ preview when there is one. Agents working here are told what uses this project.{" "}
92+ <a href="https://docs.g1t.sh/guides/projects/#dependencies" className="text-fg hover:underline">
93+ How dependencies work
94+ </a>
95+ </p>
96+ </header>
97+
98+ <div className="mt-4 min-h-6">
99+ {actionData && "notice" in actionData && <p className="text-sm text-accent">{actionData.notice}</p>}
100+ <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
101+ </div>
102+
103+ <section>
104+ <h3 className="flex items-center gap-1.5 text-xs font-medium tracking-wide text-muted uppercase">
105+ <ArrowUpRight size={13} />
106+ Depends on
107+ </h3>
108+ <div className="mt-2">
109+ {dependencies.dependsOn.length === 0 ? (
110+ <EmptyState title="Uses no other project" />
111+ ) : (
112+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
113+ {dependencies.dependsOn.map((link) => (
114+ <Row key={link.slug} link={link} base={base} removable busy={busy} />
115+ ))}
116+ </ul>
117+ )}
118+ </div>
119+ {choices.length > 0 && (
120+ <Form method="post" className="mt-4 grid items-end gap-3 rounded-xl border border-line bg-surface p-4 sm:grid-cols-[1fr_1fr_auto]">
121+ <input type="hidden" name="intent" value="add" />
122+ <Field label="Project">
123+ <select name="on" required className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm">
124+ {choices.map((project) => (
125+ <option key={project.slug} value={project.slug}>
126+ {project.name}
127+ </option>
128+ ))}
129+ </select>
130+ </Field>
131+ <Field label="Its address as">
132+ <Input name="as" placeholder="API_URL" className="font-mono" />
133+ </Field>
134+ <Button type="submit" disabled={busy}>
135+ Add dependency
136+ </Button>
137+ </Form>
138+ )}
139+ </section>
140+
141+ <section className="mt-10">
142+ <h3 className="flex items-center gap-1.5 text-xs font-medium tracking-wide text-muted uppercase">
143+ <ArrowDownLeft size={13} />
144+ Used by
145+ </h3>
146+ <div className="mt-2">
147+ {dependencies.usedBy.length === 0 ? (
148+ <EmptyState title="No project uses this one yet" />
149+ ) : (
150+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
151+ {dependencies.usedBy.map((link) => (
152+ <Row key={link.slug} link={link} base={base} removable={false} busy={busy} />
153+ ))}
154+ </ul>
155+ )}
156+ </div>
157+ </section>
158+
159+ <section className="mt-10 rounded-xl border border-line p-5 text-sm">
160+ <h3 className="flex items-center gap-2 font-medium">
161+ <FileCode2 size={15} className="text-faint" />
162+ Or declare them in the code
163+ </h3>
164+ <p className="mt-1 text-muted">
165+ A <code className="text-fg">.g1t/project.yml</code> in the project's root directory is read on every push to
166+ the default branch, and its dependencies replace the ones it declared before:
167+ </p>
168+ <pre className="mt-3 overflow-x-auto rounded-lg border border-line bg-bg p-3 font-mono text-xs text-muted">{`dependsOn:
169+ - project: api
170+ as: API_URL
171+ - project: ui-kit`}</pre>
172+ </section>
173+ </div>
174+ );
175+}
+17−1
98809880 "dev": true,
98819881 "license": "ISC"
98829882 },
9883+ "node_modules/yaml": {
9884+ "version": "2.9.1",
9885+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
9886+ "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
9887+ "license": "ISC",
9888+ "bin": {
9889+ "yaml": "bin.mjs"
9890+ },
9891+ "engines": {
9892+ "node": ">= 14.6"
9893+ },
9894+ "funding": {
9895+ "url": "https://github.com/sponsors/eemeli"
9896+ }
9897+ },
98839898 "node_modules/yargs-parser": {
98849899 "version": "22.0.0",
98859900 "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
99819996 "version": "0.1.0",
99829997 "license": "MIT",
99839998 "dependencies": {
9984− "@g1t/contracts": "*"
9999+ "@g1t/contracts": "*",
10000+ "yaml": "^2.9.1"
998510001 }
998610002 },
998710003 "services/runner": {
+5−0
273273 get: (project, id, viewer) => call("get", { project, id, viewer }),
274274 redeploy: (actor, project, branch) => call("redeploy", { actor, project, branch }),
275275 takeDown: (actor, project, branch) => call("take_down", { actor, project, branch }),
276+ stack: (actor, project, branch) => call("stack", { actor, project, branch }),
276277 overview: (workspace, viewer) => call("overview", { workspace, viewer }),
277278 usage: (workspace, viewer) => call("usage", { workspace, viewer }),
278279 };
286287 byRepo: (repoId) => call("by_repo", { repoId }),
287288 create: (actor, workspace, input) => call("create", { actor, workspace, input }),
288289 update: (actor, workspace, slug, changes) => call("update", { actor, workspace, slug, changes }),
290+ dependencies: (workspace, slug, viewer) => call("dependencies", { workspace, slug, viewer }),
291+ addDependency: (actor, workspace, slug, on, as) => call("add_dependency", { actor, workspace, slug, on, as }),
292+ removeDependency: (actor, workspace, slug, on) => call("remove_dependency", { actor, workspace, slug, on }),
293+ graph: (projectId) => call("graph", { projectId }),
289294 };
290295 }
+7−0
116116 get(project: ProjectRef, id: string, viewer: Viewer): Promise<Result<Deployment & { log: string | null }>>;
117117 /** Builds production (`branch` null), or a branch's preview, again from its head. */
118118 redeploy(actor: User, project: ProjectRef, branch: string | null): Promise<Result<Deployment>>;
119+ /**
120+ * Builds previews of the projects that use this one, under the same
121+ * branch, each pointed at this branch's preview. Answers at once with the
122+ * names of the projects being built; the builds go on in the
123+ * background. Members only.
124+ */
125+ stack(actor: User, project: ProjectRef, branch: string): Promise<Result<string[]>>;
119126 /** Takes production (`branch` null), or a branch's preview, down now. */
120127 takeDown(actor: User, project: ProjectRef, branch: string | null): Promise<Result<true>>;
121128 /** Every project of a workspace at a glance. Members only. */
+30−0
4646 rootDir?: string;
4747 };
4848
49+/** One end of a dependency, as a page shows it. */
50+export type DependencyLink = {
51+ slug: string;
52+ name: string;
53+ /** The variable carrying the other project's address, such as `API_URL`. */
54+ as: string | null;
55+ /** Declared on the site, or in the project's `.g1t/project.yml`. */
56+ source: "ui" | "file";
57+};
58+
59+/** What a project uses, and what uses it. */
60+export type Dependencies = { dependsOn: DependencyLink[]; usedBy: DependencyLink[] };
61+
62+/** A project's dependencies by id, for services. */
63+export type ProjectGraph = {
64+ dependsOn: { id: string; slug: string; workspace: string; as: string | null }[];
65+ usedBy: { id: string; slug: string; workspace: string; as: string | null }[];
66+};
67+
4968 export interface ProjectsApi {
5069 /** A workspace's projects, by name. Members, or anyone for public repositories. */
5170 list(workspace: string, viewer: Viewer): Promise<Result<Project[]>>;
6180 slug: string,
6281 changes: { name?: string; description?: string | null; rootDir?: string },
6382 ): Promise<Result<Project>>;
83+ /** What a project uses and what uses it. Whoever may see the project. */
84+ dependencies(workspace: string, slug: string, viewer: Viewer): Promise<Result<Dependencies>>;
85+ /**
86+ * `slug` uses `on`, with `as` the variable that carries `on`'s address.
87+ * Members only; refused if it would make a cycle.
88+ */
89+ addDependency(actor: User, workspace: string, slug: string, on: string, as: string | null): Promise<Result<Dependencies>>;
90+ /** Members only. A dependency from `.g1t/project.yml` is changed there. */
91+ removeDependency(actor: User, workspace: string, slug: string, on: string): Promise<Result<Dependencies>>;
92+ /** For services: a project's dependencies by id. */
93+ graph(projectId: string): Promise<ProjectGraph>;
6494 }
+39−13
1111 use serde::Deserialize;
1212 use worker::Result;
1313
14−use crate::stripe::StripeSubscription;
14+use crate::stripe::{StripeSubscription, is_missing};
1515 use crate::{Billing, Touched, members_only, optional};
1616
1717 #[derive(Deserialize)]
174174 let stale = row.period_end.as_deref().is_none_or(|end| end <= rfc3339(now_ms()).as_str())
175175 && row.status != "canceled";
176176 if let (true, Some(stripe)) = (stale, &self.stripe) {
177− let subscription = stripe.subscription(&row.subscription_id).await?;
178− self.record(workspace, feature, &subscription, &row.started_by).await?;
177+ match stripe.subscription(&row.subscription_id).await {
178+ Ok(subscription) => self.record(workspace, feature, &subscription, &row.started_by).await?,
179+ // A plan from another Stripe account: it has ended here.
180+ Err(error) if is_missing(&error) => {
181+ self.db
182+ .prepare("UPDATE subscriptions SET status = 'canceled', updated_at = ? WHERE workspace = ? AND feature = ?")
183+ .bind(&[rfc3339(now_ms()).into(), workspace.into(), feature.as_str().into()])?
184+ .run()
185+ .await?;
186+ }
187+ Err(error) => return Err(error),
188+ }
179189 return self.subscription_row(workspace, feature).await;
180190 }
181191 Ok(Some(row))
227237 }
228238 let plan = self.plan(a.feature);
229239 let customer = self.row(&workspace).await?.and_then(|row| row.customer_id);
230− let session = stripe
231− .start_subscription(
232− &workspace,
233− a.feature.as_str(),
234− &plan.title,
235− plan.monthly_cents,
236− customer.as_deref(),
237− &a.return_url,
238− )
239− .await?;
240+ let start = |customer: Option<String>| {
241+ let plan = &plan;
242+ let workspace = &workspace;
243+ let return_url = &a.return_url;
244+ async move {
245+ stripe
246+ .start_subscription(
247+ workspace,
248+ a.feature.as_str(),
249+ &plan.title,
250+ plan.monthly_cents,
251+ customer.as_deref(),
252+ return_url,
253+ )
254+ .await
255+ }
256+ };
257+ let session = match start(customer.clone()).await {
258+ Ok(session) => session,
259+ // A customer saved under another Stripe account: start afresh.
260+ Err(error) if customer.is_some() && is_missing(&error) => {
261+ self.forget_customer(&workspace).await?;
262+ start(None).await?
263+ }
264+ Err(error) => return Err(error),
265+ };
240266 let Some(url) = session.url else {
241267 return Err(worker::Error::RustError(
242268 "the card processor returned no payment page".into(),
+22−8
384384 ));
385385 }
386386 let customer = self.row(&workspace).await?.and_then(|row| row.customer_id);
387− let session = stripe
388− .start_checkout(
389− &workspace,
390− a.amount_cents,
391− customer.as_deref(),
392− &a.return_url,
393− )
394− .await?;
387+ let session = match stripe
388+ .start_checkout(&workspace, a.amount_cents, customer.as_deref(), &a.return_url)
389+ .await
390+ {
391+ Ok(session) => session,
392+ // A customer saved under another Stripe account: start afresh.
393+ Err(error) if customer.is_some() && stripe::is_missing(&error) => {
394+ self.forget_customer(&workspace).await?;
395+ stripe.start_checkout(&workspace, a.amount_cents, None, &a.return_url).await?
396+ }
397+ Err(error) => return Err(error),
398+ };
395399 let Some(url) = session.url else {
396400 return Err(worker::Error::RustError(
397401 "the card processor returned no payment page".into(),
469473 Ok(Outcome::Ok(self.standing(&workspace).await?))
470474 }
471475
476+ /// Drops a saved customer the card processor no longer knows.
477+ pub(crate) async fn forget_customer(&self, workspace: &str) -> Result<()> {
478+ self.db
479+ .prepare("UPDATE accounts SET customer_id = NULL WHERE workspace = ?")
480+ .bind(&[workspace.into()])?
481+ .run()
482+ .await?;
483+ Ok(())
484+ }
485+
472486 /// A refusal if the workspace has no credit to start an agent with.
473487 async fn out_of_credit<T>(&self, workspace: &str) -> Result<Option<Outcome<T>>> {
474488 // While g1t is being built out, no one needs credit.
+7−0
244244 }
245245 }
246246
247+/// Whether the processor said an id it was given does not exist, as when
248+/// g1t moves to another Stripe account and ids saved from the old one stay
249+/// behind.
250+pub(crate) fn is_missing(error: &Error) -> bool {
251+ error.to_string().contains("resource_missing")
252+}
253+
247254 pub(crate) fn is_live(key: &str) -> bool {
248255 key.starts_with("sk_live_") || key.starts_with("rk_live_")
249256 }
+103−3
216216 project: { id: string; slug: string; repoId: string; repo: RepoPath },
217217 environment: DeployKind,
218218 trusted: boolean,
219+ branch: string | null,
219220 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
221+ const [rows, references] = await Promise.all([
222+ this.rows(project, environment, trusted),
223+ this.references(project.id, environment === "preview" ? branch : null),
224+ ]);
225+ // The project's own rows win over a dependency's address of the same name.
226+ return { secrets: rows.secrets, variables: { ...references, ...rows.variables } };
227+ }
228+
229+ /**
230+ * Each dependency's address, under the name the dependency gives it:
231+ * for a preview, the same branch's preview of it if one is up, else its
232+ * production; for production, its production.
233+ */
234+ private async references(projectId: string, branch: string | null): Promise<Record<string, string>> {
235+ const graph = await this.projects.graph(projectId).catch(() => null);
236+ const out: Record<string, string> = {};
237+ for (const dependency of graph?.dependsOn ?? []) {
238+ if (!dependency.as) continue;
239+ const app =
240+ (branch
241+ ? await this.db
242+ .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
243+ .bind(dependency.id, branch)
244+ .first<{ script: string }>()
245+ : null) ??
246+ (await this.db
247+ .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
248+ .bind(dependency.id)
249+ .first<{ script: string }>());
250+ out[dependency.as] = appUrl(app?.script ?? (await label(dependency.workspace, dependency.slug, null)));
251+ }
252+ return out;
253+ }
254+
255+ private async rows(
256+ project: { id: string; slug: string; repoId: string; repo: RepoPath },
257+ environment: DeployKind,
258+ trusted: boolean,
259+ ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
220260 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
221261 method: "POST",
222262 headers: { "content-type": "application/json" },
392432 return (await this.deployPreview(project, app.number, a.actor.username, true)) ?? fail("conflict", "Its pull request is not open.");
393433 }
394434
435+ /**
436+ * A preview stack: the projects that use this one get previews of their
437+ * own default branch, under the same branch name, so each reaches this
438+ * branch's preview through its dependency's variable. A change to an API
439+ * can then be clicked through in the apps that call it.
440+ */
441+ async stack(
442+ a: { actor: User; project: ProjectRef; branch: string },
443+ background: (work: Promise<unknown>) => void,
444+ ): Promise<Result<string[]>> {
445+ const found = await this.memberProject(a.project, a.actor);
446+ if (!found.ok) return found;
447+ const upstream = await this.db
448+ .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND branch = ?")
449+ .bind(found.value.id, a.branch)
450+ .first();
451+ if (!upstream) return fail("conflict", `${a.branch} has no preview up to build against.`);
452+ const graph = await this.projects.graph(found.value.id);
453+ const ready: { project: Project; settings: SettingsRow }[] = [];
454+ for (const dependent of graph.usedBy) {
455+ const project = await this.projects.get(dependent.workspace, dependent.slug, a.actor);
456+ if (!project.ok) continue;
457+ const settings = await this.settingsRow(project.value.id);
458+ if (settings?.enabled && settings.previews) ready.push({ project: project.value, settings });
459+ }
460+ if (ready.length === 0) return fail("conflict", "No project that uses this one has previews turned on.");
461+ // The builds start after the answer: a person moving on from the page
462+ // does not stop them.
463+ background(
464+ (async () => {
465+ for (const { project, settings } of ready) {
466+ const actor = await this.workspaceActor(project.workspace);
467+ if (!actor) continue;
468+ const repo = repoOf(project);
469+ const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
470+ const head = branches.ok ? branches.value.find((b) => b.name === repo.defaultBranch)?.hash : undefined;
471+ if (!head) continue;
472+ await this.start({
473+ project,
474+ kind: "preview",
475+ branch: a.branch,
476+ number: null,
477+ commit: head,
478+ source: repo.path,
479+ reader: actor,
480+ createdBy: a.actor.username,
481+ settings,
482+ // Its own default branch, asked for by a member.
483+ trusted: true,
484+ });
485+ }
486+ })().catch((error) => console.error("stack failed", a.project.slug, a.branch, error)),
487+ );
488+ return ok(ready.map(({ project }) => project.name));
489+ }
490+
395491 async takeDown(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<true>> {
396492 const project = await this.memberProject(a.project, a.actor);
397493 if (!project.ok) return project;
530626 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
531627 input.kind,
532628 input.trusted,
629+ input.branch,
533630 );
534631 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
535632 method: "POST",
662759 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
663760 row.kind,
664761 !!row.trusted,
762+ row.branch,
665763 );
666764 await cloudflare.putScript(
667765 row.script,
9721070 }
9731071
9741072 /** `POST /rpc/<method>`: the arguments are the body. */
975−async function rpc(service: Deployments, method: string, args: any): Promise<unknown> {
1073+async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
9761074 switch (method) {
9771075 case "settings":
9781076 return service.settings(args);
9861084 return service.redeploy(args);
9871085 case "take_down":
9881086 return service.takeDown(args);
1087+ case "stack":
1088+ return service.stack(args, (work) => ctx.waitUntil(work));
9891089 case "overview":
9901090 return service.overview(args);
9911091 case "usage":
9961096 }
9971097
9981098 export default {
999− async fetch(request: Request, env: Env): Promise<Response> {
1099+ async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
10001100 const { pathname } = new URL(request.url);
10011101 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
10021102 const service = new Deployments(env);
10031103 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
10041104 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
10051105 if (rpcMatch) {
1006− const result = await rpc(service, rpcMatch[1], body);
1106+ const result = await rpc(service, rpcMatch[1], body, ctx);
10071107 return result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result);
10081108 }
10091109 // A build's reports, forwarded by the API.
+17−0
1+-- One project using another: calling its API, consuming its package. A
2+-- dependency is declared in the UI or in the project's .g1t/project.yml,
3+-- which replaces the file's rows on every push to the default branch.
4+
5+CREATE TABLE dependencies (
6+ project_id TEXT NOT NULL,
7+ depends_on_id TEXT NOT NULL,
8+ -- The variable its builds and apps get with the other's address for the
9+ -- same environment, such as API_URL; null for none.
10+ alias TEXT,
11+ -- ui or file.
12+ source TEXT NOT NULL DEFAULT 'ui',
13+ created_by TEXT NOT NULL,
14+ created_at TEXT NOT NULL,
15+ PRIMARY KEY (project_id, depends_on_id)
16+);
17+CREATE INDEX dependencies_by_target ON dependencies (depends_on_id);
+2−1
1010 "deploy": "wrangler deploy"
1111 },
1212 "dependencies": {
13− "@g1t/contracts": "*"
13+ "@g1t/contracts": "*",
14+ "yaml": "^2.9.1"
1415 }
1516 }
+203−0
1212 * Reached through service bindings: `POST /rpc/<method>`.
1313 */
1414
15+import { parse as parseYaml } from "yaml";
16+
1517 import {
1618 fail,
1719 identityClient,
1820 newId,
1921 ok,
2022 reposClient,
23+ type Dependencies,
24+ type DependencyLink,
2125 type G1tEvent,
2226 type NewProject,
2327 type Project,
28+ type ProjectGraph,
2429 type Repo,
2530 type Result,
2631 type ServiceBinding,
5560
5661 const now = () => new Date().toISOString();
5762
63+/** A variable's name for a dependency's address, spelled as secrets' names are. */
64+const ALIAS = /^[A-Z_][A-Z0-9_]{0,99}$/;
65+/** How many dependencies a project may declare. */
66+const MAX_DEPENDENCIES = 50;
67+
68+type LinkRow = { slug: string; name: string; alias: string | null; source: "ui" | "file" };
69+type NodeRow = { id: string; slug: string; workspace: string; alias: string | null };
70+
5871 function toProject(row: Row): Project {
5972 return {
6073 id: row.id,
292305 return ok(toProject((await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>())!));
293306 }
294307
308+ // ---- Dependencies ------------------------------------------------------
309+
310+ private async row(workspace: string, slug: string): Promise<Row | null> {
311+ return this.db
312+ .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ?")
313+ .bind(workspace.toLowerCase(), slug.toLowerCase())
314+ .first<Row>();
315+ }
316+
317+ private async links(projectId: string): Promise<Dependencies> {
318+ const [out, into] = await Promise.all([
319+ this.db
320+ .prepare(
321+ `SELECT p.slug, p.name, d.alias, d.source FROM dependencies d JOIN projects p ON p.id = d.depends_on_id
322+ WHERE d.project_id = ? ORDER BY p.name COLLATE NOCASE`,
323+ )
324+ .bind(projectId)
325+ .all<LinkRow>(),
326+ this.db
327+ .prepare(
328+ `SELECT p.slug, p.name, d.alias, d.source FROM dependencies d JOIN projects p ON p.id = d.project_id
329+ WHERE d.depends_on_id = ? ORDER BY p.name COLLATE NOCASE`,
330+ )
331+ .bind(projectId)
332+ .all<LinkRow>(),
333+ ]);
334+ const link = (r: LinkRow): DependencyLink => ({ slug: r.slug, name: r.name, as: r.alias, source: r.source });
335+ return { dependsOn: out.results.map(link), usedBy: into.results.map(link) };
336+ }
337+
338+ /** Whether `from` already reaches `to` through dependencies. */
339+ private async reaches(from: string, to: string): Promise<boolean> {
340+ const seen = new Set<string>([from]);
341+ let frontier = [from];
342+ while (frontier.length > 0) {
343+ const marks = frontier.map(() => "?").join(", ");
344+ const next = await this.db
345+ .prepare(`SELECT depends_on_id AS id FROM dependencies WHERE project_id IN (${marks})`)
346+ .bind(...frontier)
347+ .all<{ id: string }>();
348+ frontier = [];
349+ for (const { id } of next.results) {
350+ if (id === to) return true;
351+ if (!seen.has(id)) {
352+ seen.add(id);
353+ frontier.push(id);
354+ }
355+ }
356+ }
357+ return false;
358+ }
359+
360+ async dependencies(a: { workspace: string; slug: string; viewer: Viewer }): Promise<Result<Dependencies>> {
361+ const row = await this.row(a.workspace, a.slug);
362+ if (!row || !this.visible(row, a.viewer)) return fail("not_found", "There is no such project.");
363+ return ok(await this.links(row.id));
364+ }
365+
366+ /** Records `row` using `target`, after the checks every way of declaring one shares. */
367+ private async declare(row: Row, target: Row, alias: string | null, source: "ui" | "file", by: string): Promise<Result<true>> {
368+ if (target.id === row.id) return fail("invalid", "A project cannot depend on itself.");
369+ if (alias != null && !ALIAS.test(alias)) {
370+ return fail("invalid", "The variable's name is capital letters, digits and underscores, such as API_URL.");
371+ }
372+ if (await this.reaches(target.id, row.id)) {
373+ return fail("conflict", `${target.slug} already depends on ${row.slug}, directly or through others; that would be a cycle.`);
374+ }
375+ const count = await this.db
376+ .prepare("SELECT COUNT(*) AS n FROM dependencies WHERE project_id = ?")
377+ .bind(row.id)
378+ .first<{ n: number }>();
379+ if ((count?.n ?? 0) >= MAX_DEPENDENCIES) return fail("invalid", `A project can depend on at most ${MAX_DEPENDENCIES} others.`);
380+ await this.db
381+ .prepare(
382+ `INSERT INTO dependencies (project_id, depends_on_id, alias, source, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?)
383+ ON CONFLICT (project_id, depends_on_id) DO UPDATE SET alias = excluded.alias, source = excluded.source`,
384+ )
385+ .bind(row.id, target.id, alias, source, by, now())
386+ .run();
387+ return ok(true);
388+ }
389+
390+ async addDependency(a: { actor: User; workspace: string; slug: string; on: string; as: string | null }): Promise<Result<Dependencies>> {
391+ if (!isMember(a.actor, a.workspace)) return fail("forbidden", "Only members can change a workspace's projects.");
392+ const [row, target] = await Promise.all([this.row(a.workspace, a.slug), this.row(a.workspace, a.on)]);
393+ if (!row) return fail("not_found", "There is no such project.");
394+ if (!target) return fail("not_found", `${a.workspace} has no project called ${a.on}.`);
395+ const existing = await this.db
396+ .prepare("SELECT source FROM dependencies WHERE project_id = ? AND depends_on_id = ?")
397+ .bind(row.id, target.id)
398+ .first<{ source: string }>();
399+ if (existing?.source === "file") return fail("conflict", "This dependency is declared in .g1t/project.yml; change it there.");
400+ const alias = a.as?.trim() ? a.as.trim().toUpperCase() : null;
401+ const done = await this.declare(row, target, alias, "ui", a.actor.username);
402+ if (!done.ok) return done;
403+ return ok(await this.links(row.id));
404+ }
405+
406+ async removeDependency(a: { actor: User; workspace: string; slug: string; on: string }): Promise<Result<Dependencies>> {
407+ if (!isMember(a.actor, a.workspace)) return fail("forbidden", "Only members can change a workspace's projects.");
408+ const [row, target] = await Promise.all([this.row(a.workspace, a.slug), this.row(a.workspace, a.on)]);
409+ if (!row || !target) return fail("not_found", "There is no such dependency.");
410+ const removed = await this.db
411+ .prepare("DELETE FROM dependencies WHERE project_id = ? AND depends_on_id = ? AND source = 'ui' RETURNING project_id")
412+ .bind(row.id, target.id)
413+ .first();
414+ if (!removed) return fail("conflict", "This dependency is declared in .g1t/project.yml, or does not exist; change the file.");
415+ return ok(await this.links(row.id));
416+ }
417+
418+ async graph(a: { projectId: string }): Promise<ProjectGraph> {
419+ const [out, into] = await Promise.all([
420+ this.db
421+ .prepare(
422+ `SELECT p.id, p.slug, p.workspace, d.alias FROM dependencies d JOIN projects p ON p.id = d.depends_on_id WHERE d.project_id = ?`,
423+ )
424+ .bind(a.projectId)
425+ .all<NodeRow>(),
426+ this.db
427+ .prepare(
428+ `SELECT p.id, p.slug, p.workspace, d.alias FROM dependencies d JOIN projects p ON p.id = d.project_id WHERE d.depends_on_id = ?`,
429+ )
430+ .bind(a.projectId)
431+ .all<NodeRow>(),
432+ ]);
433+ const node = (r: NodeRow) => ({ id: r.id, slug: r.slug, workspace: r.workspace, as: r.alias });
434+ return { dependsOn: out.results.map(node), usedBy: into.results.map(node) };
435+ }
436+
437+ /** For the runner: each project on a repository, with what it uses and what uses it. */
438+ async contextForRepo(a: { repoId: string }): Promise<{ slug: string; name: string; dependencies: Dependencies }[]> {
439+ const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(a.repoId).all<Row>();
440+ return Promise.all(rows.results.map(async (row) => ({ slug: row.slug, name: row.name, dependencies: await this.links(row.id) })));
441+ }
442+
443+ /**
444+ * A project's `.g1t/project.yml` at a commit of its default branch:
445+ *
446+ * dependsOn:
447+ * - project: api
448+ * as: API_URL
449+ *
450+ * Its dependencies replace the ones the file declared before. Ones that
451+ * cannot be kept (an unknown project, a cycle) are left out.
452+ */
453+ private async syncFile(row: Row, commit: string): Promise<void> {
454+ const actor = await this.workspaceActor(row.workspace);
455+ if (!actor) return;
456+ const path = row.root_dir ? `${row.root_dir}/.g1t/project.yml` : ".g1t/project.yml";
457+ const blob = await reposClient(this.env.REPOS).blob({ namespace: row.repo_namespace, name: row.repo_name }, actor, commit, path);
458+ if (!blob.ok || blob.value.text == null) {
459+ // No file (any more): what it declared goes with it.
460+ await this.db.prepare("DELETE FROM dependencies WHERE project_id = ? AND source = 'file'").bind(row.id).run();
461+ return;
462+ }
463+ let declared: unknown[] = [];
464+ try {
465+ const parsed = parseYaml(blob.value.text) as { dependsOn?: unknown } | null;
466+ if (Array.isArray(parsed?.dependsOn)) declared = parsed.dependsOn;
467+ } catch (error) {
468+ console.error("could not read", path, "of", row.slug, error);
469+ return;
470+ }
471+ await this.db.prepare("DELETE FROM dependencies WHERE project_id = ? AND source = 'file'").bind(row.id).run();
472+ for (const entry of declared.slice(0, MAX_DEPENDENCIES)) {
473+ const item = entry as { project?: unknown; as?: unknown } | string;
474+ const on = typeof item === "string" ? item : typeof item?.project === "string" ? item.project : null;
475+ if (!on) continue;
476+ const target = await this.row(row.workspace, on);
477+ if (!target) continue;
478+ const alias = typeof item === "object" && typeof item.as === "string" ? item.as.trim().toUpperCase() : null;
479+ await this.declare(row, target, alias, "file", "g1t");
480+ }
481+ }
482+
295483 async onEvent(event: G1tEvent): Promise<void> {
484+ if (event.type === "git.push" && event.data.defaultBranch) {
485+ const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
486+ for (const row of rows.results) await this.syncFile(row, event.data.after);
487+ return;
488+ }
296489 if (event.type !== "repo.created") return;
297490 const actor = await this.workspaceActor(event.data.namespace);
298491 if (!actor) return;
318511 return Response.json(await service.create(args));
319512 case "update":
320513 return Response.json(await service.update(args));
514+ case "dependencies":
515+ return Response.json(await service.dependencies(args));
516+ case "add_dependency":
517+ return Response.json(await service.addDependency(args));
518+ case "remove_dependency":
519+ return Response.json(await service.removeDependency(args));
520+ case "graph":
521+ return Response.json(await service.graph(args));
522+ case "context_for_repo":
523+ return Response.json(await service.contextForRepo(args));
321524 default:
322525 return new Response("Unknown method\n", { status: 404 });
323526 }
+46−5
4343 ACTIONS: ServiceBinding;
4444 /** Told when a deploy sandbox dies without reporting. */
4545 DEPLOYMENTS: ServiceBinding;
46+ /** What a repository's projects use and what uses them, for agents. */
47+ PROJECTS: ServiceBinding;
4648 /**
4749 * The model proxy, which every sandbox's model requests go through with a
4850 * token for their run, so that no sandbox holds a key. When unset,
561563 number: number,
562564 text: string,
563565 ): Promise<string | null> {
564− const items: ContextItem[] = await integrationsClient(this.env.INTEGRATIONS)
565− .references(repo.namespace, text)
566− .catch(() => []);
567− if (items.length === 0) return null;
566+ const [items, projects] = await Promise.all([
567+ integrationsClient(this.env.INTEGRATIONS)
568+ .references(repo.namespace, text)
569+ .catch((): ContextItem[] => []),
570+ this.projectContext(repo).catch(() => null),
571+ ]);
572+ if (items.length === 0) return projects;
568573 if (number > 0) {
569574 await workClient(this.env.WORK).appendSession(actor, repo, number, [
570575 {
573578 },
574579 ]);
575580 }
576− return describeOutside(items);
581+ return [describeOutside(items), projects].filter(Boolean).join("\n\n");
582+ }
583+
584+ /**
585+ * The projects this repository is the source of, what they use and what
586+ * uses them: so an agent changing an interface knows who calls it, and
587+ * opens issues there rather than widening its change.
588+ */
589+ private async projectContext(repo: RepoPath): Promise<string | null> {
590+ const found = await reposClient(this.env.REPOS).get(repo, null);
591+ if (!found.ok) return null;
592+ const response = await this.env.PROJECTS.fetch("https://projects/rpc/context_for_repo", {
593+ method: "POST",
594+ headers: { "content-type": "application/json" },
595+ body: JSON.stringify({ repoId: found.value.id }),
596+ });
597+ if (!response.ok) return null;
598+ const projects = (await response.json()) as {
599+ slug: string;
600+ name: string;
601+ dependencies: { dependsOn: { slug: string; as: string | null }[]; usedBy: { slug: string; as: string | null }[] };
602+ }[];
603+ const lines: string[] = [];
604+ for (const project of projects) {
605+ const { dependsOn, usedBy } = project.dependencies;
606+ if (dependsOn.length === 0 && usedBy.length === 0) continue;
607+ const named = (list: { slug: string; as: string | null }[]) =>
608+ list.map((d) => (d.as ? `${d.slug} (its address is in ${d.as})` : d.slug)).join(", ");
609+ if (dependsOn.length > 0) lines.push(`- The ${project.name} project uses: ${named(dependsOn)}.`);
610+ if (usedBy.length > 0) lines.push(`- Projects that use ${project.name}: ${named(usedBy)}.`);
611+ }
612+ if (lines.length === 0) return null;
613+ return [
614+ "This repository's projects and the projects around them in the workspace:",
615+ ...lines,
616+ "If your change alters what the projects that use this one rely on (an API, a package's exports, a message's shape), keep it working for them, or open an issue on each with create_issue saying what they need to change, and mention it in your summary. Do not change their code from here.",
617+ ].join("\n");
577618 }
578619
579620 /** The same, for a step g1t takes by itself: a refusal stops the step. */
+2−1
3131 { "binding": "BILLING", "service": "g1t-billing" },
3232 { "binding": "INTEGRATIONS", "service": "g1t-integrations" },
3333 { "binding": "ACTIONS", "service": "g1t-actions" },
34− { "binding": "DEPLOYMENTS", "service": "g1t-deployments" }
34+ { "binding": "DEPLOYMENTS", "service": "g1t-deployments" },
35+ { "binding": "PROJECTS", "service": "g1t-projects" }
3536 ],
3637 // A sweep for lifecycle steps whose trigger was missed or whose sandbox
3738 // died before reporting.