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.
| 7 | 7 | worker-configuration.d.ts | |
| 8 | 8 | .wrangler/ | |
| 9 | 9 | .dev.vars* | |
| 10 | + | ||
| 11 | + | .env* |
| 88 | 88 | lists each one it left out. Code that needs them should check that the | |
| 89 | 89 | binding is there. | |
| 90 | 90 | ||
| 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 | + | ||
| 91 | 113 | ## Previews of branches | |
| 92 | 114 | ||
| 93 | 115 | A preview is built when a pull request is opened, when it is marked ready, |
| 50 | 50 | | --- | --- | | |
| 51 | 51 | | **General** | The project's name and description, its source, and its **root directory**. | | |
| 52 | 52 | | **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). | | |
| 53 | 54 | | **Secrets and variables** | The project's rows. See [Secrets and variables](/guides/secrets-and-variables/). | | |
| 54 | 55 | | **Repository** | The repository's visibility, branch protection, required approvals, checks, the merge queue and auto-merge. | | |
| 55 | 56 | | **Webhooks** | The repository's [webhooks](/guides/webhooks/). | | |
| 83 | 84 | and agents. | |
| 84 | 85 | - **Several projects on one repository**, each from its own root | |
| 85 | 86 | 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 | 87 | ||
| 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 | + | ||
| 90 | 118 | ## From the API | |
| 91 | 119 | ||
| 92 | 120 | Projects keep their repository's routes: `/repos/{workspace}/{project}/…` |
| 1 | − | import { GitBranch, Lock, Rocket, Settings, Webhook } from "lucide-react"; | |
| 1 | + | import { GitBranch, Lock, Network, Rocket, Settings, Webhook } from "lucide-react"; | |
| 2 | 2 | ||
| 3 | 3 | import { TabLink } from "./ui"; | |
| 4 | 4 | ||
| 15 | 15 | <TabLink to={`${base}/settings/deployments`} icon={<Rocket size={15} />}> | |
| 16 | 16 | Deployments | |
| 17 | 17 | </TabLink> | |
| 18 | + | <TabLink to={`${base}/settings/dependencies`} icon={<Network size={15} />}> | |
| 19 | + | Dependencies | |
| 20 | + | </TabLink> | |
| 18 | 21 | <TabLink to={`${base}/settings/secrets`} icon={<Lock size={15} />}> | |
| 19 | 22 | Secrets and variables | |
| 20 | 23 | </TabLink> |
| 624 | 624 | actions: "Actions", | |
| 625 | 625 | deployments: "Deployments", | |
| 626 | 626 | repository: "Repository", | |
| 627 | + | dependencies: "Dependencies", | |
| 627 | 628 | code: "Code", | |
| 628 | 629 | secrets: "Secrets and variables", | |
| 629 | 630 | settings: "Settings", |
| 59 | 59 | route("settings/webhooks", "routes/repo/webhooks.tsx"), | |
| 60 | 60 | route("settings/secrets", "routes/repo/secrets.tsx"), | |
| 61 | 61 | route("settings/deployments", "routes/repo/settings-deployments.tsx"), | |
| 62 | + | route("settings/dependencies", "routes/repo/settings-dependencies.tsx"), | |
| 62 | 63 | ]), | |
| 63 | 64 | // Anything else: a 404 that still knows who is signed in. | |
| 64 | 65 | route("*", "routes/not-found.tsx"), |
| 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"; | |
| 2 | 2 | import { Form, Link, useNavigation } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/overview"; | |
| 15 | 15 | const member = roleIn(viewer, params.owner) != null; | |
| 16 | 16 | const path = { namespace: params.owner, name: params.repo }; | |
| 17 | 17 | 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([ | |
| 19 | 19 | projects.get(params.owner, params.repo, viewer), | |
| 20 | 20 | member ? deployments.settings(ref, viewer) : null, | |
| 21 | 21 | member ? deployments.list(ref, viewer) : null, | |
| 22 | 22 | work.listPulls(path, viewer, "open"), | |
| 23 | 23 | repos.log(path, viewer, null, 1), | |
| 24 | 24 | work.counts(path, viewer), | |
| 25 | + | projects.dependencies(params.owner, params.repo, viewer), | |
| 25 | 26 | ]); | |
| 26 | 27 | return { | |
| 27 | 28 | member, | |
| 32 | 33 | pulls: pulls.ok ? pulls.value.slice(0, MAX_PULLS) : [], | |
| 33 | 34 | commit: log.ok ? (log.value[0] ?? null) : null, | |
| 34 | 35 | open: counts.ok ? counts.value : { issues: 0, pulls: 0 }, | |
| 36 | + | dependencies: deps.ok ? deps.value : { dependsOn: [], usedBy: [] }, | |
| 35 | 37 | }; | |
| 36 | 38 | } | |
| 37 | 39 | ||
| 43 | 45 | } | |
| 44 | 46 | ||
| 45 | 47 | 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; | |
| 47 | 49 | const base = `/${params.owner}/${params.repo}`; | |
| 48 | 50 | const production = live.find((app) => app.kind === "production") ?? null; | |
| 49 | 51 | const previews = live.filter((app) => app.kind === "preview"); | |
| 302 | 304 | </Link> | |
| 303 | 305 | </section> | |
| 304 | 306 | ||
| 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 | + | ||
| 305 | 363 | {project && ( | |
| 306 | 364 | <section className="rounded-xl border border-line p-5 text-xs text-muted"> | |
| 307 | 365 | <p className="flex items-center gap-2 font-medium text-fg"> |
| 16 | 16 | Sparkles, | |
| 17 | 17 | MessageSquare, | |
| 18 | 18 | MessagesSquare, | |
| 19 | + | Network, | |
| 19 | 20 | StickyNote, | |
| 20 | 21 | User, | |
| 21 | 22 | Wrench, | |
| 50 | 51 | TimelineItem, | |
| 51 | 52 | verdicts, | |
| 52 | 53 | } from "../../components/work"; | |
| 53 | − | import { identity, repos, work } from "../../lib/services.server"; | |
| 54 | + | import { deployments, identity, projects, repos, work } from "../../lib/services.server"; | |
| 54 | 55 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| 55 | 56 | ||
| 56 | 57 | const REFRESH_MS = 4000; | |
| 76 | 77 | (membership) => membership.slug === params.owner, | |
| 77 | 78 | ); | |
| 78 | 79 | // 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([ | |
| 80 | 82 | work.getPull(path, number, viewer), | |
| 81 | 83 | repos.get(path, viewer), | |
| 82 | 84 | work.getSettings(path, viewer), | |
| 83 | 85 | env.RUNNER.enabled(viewer, path), | |
| 84 | 86 | // A member picks reviewers and assignees from the workspace's people. | |
| 85 | 87 | 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, | |
| 86 | 91 | ]); | |
| 87 | 92 | if (!found.ok) { | |
| 88 | 93 | // Issues and pull requests share numbers; this one may be an issue. | |
| 118 | 123 | requiredApprovals: settings.ok ? settings.value.requiredApprovals : 0, | |
| 119 | 124 | canIgnoreChecks: !settings.ok || settings.value.allowIgnoringChecks, | |
| 120 | 125 | 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, | |
| 121 | 129 | }; | |
| 122 | 130 | } | |
| 123 | 131 | ||
| 135 | 143 | ...form.getAll(field).map(String), | |
| 136 | 144 | ...String(form.get("others") ?? "").split(/[\s,]+/), | |
| 137 | 145 | ]; | |
| 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 | + | } | |
| 138 | 153 | // Asking a g1t agent for its review records the request, then starts it. | |
| 139 | 154 | if (action === "agent-review") { | |
| 140 | 155 | const asked = await work.updatePull(user, path, number, { | |
| 320 | 335 | lifecycle, | |
| 321 | 336 | messages, | |
| 322 | 337 | statuses = [], | |
| 338 | + | affects, | |
| 339 | + | preview, | |
| 323 | 340 | landing, | |
| 324 | 341 | stalled, | |
| 325 | 342 | requireUpToDate, | |
| 862 | 879 | /> | |
| 863 | 880 | {actionData?.action === "recheck" && <ErrorText>{actionData.error}</ErrorText>} | |
| 864 | 881 | <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 | + | )} | |
| 865 | 919 | ||
| 866 | 920 | <section> | |
| 867 | 921 | <h3 className="text-sm font-medium">Reviewers</h3> |
| 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 | + | } |
| 9880 | 9880 | "dev": true, | |
| 9881 | 9881 | "license": "ISC" | |
| 9882 | 9882 | }, | |
| 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 | + | }, | |
| 9883 | 9898 | "node_modules/yargs-parser": { | |
| 9884 | 9899 | "version": "22.0.0", | |
| 9885 | 9900 | "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", | |
| 9981 | 9996 | "version": "0.1.0", | |
| 9982 | 9997 | "license": "MIT", | |
| 9983 | 9998 | "dependencies": { | |
| 9984 | − | "@g1t/contracts": "*" | |
| 9999 | + | "@g1t/contracts": "*", | |
| 10000 | + | "yaml": "^2.9.1" | |
| 9985 | 10001 | } | |
| 9986 | 10002 | }, | |
| 9987 | 10003 | "services/runner": { |
| 273 | 273 | get: (project, id, viewer) => call("get", { project, id, viewer }), | |
| 274 | 274 | redeploy: (actor, project, branch) => call("redeploy", { actor, project, branch }), | |
| 275 | 275 | takeDown: (actor, project, branch) => call("take_down", { actor, project, branch }), | |
| 276 | + | stack: (actor, project, branch) => call("stack", { actor, project, branch }), | |
| 276 | 277 | overview: (workspace, viewer) => call("overview", { workspace, viewer }), | |
| 277 | 278 | usage: (workspace, viewer) => call("usage", { workspace, viewer }), | |
| 278 | 279 | }; | |
| 286 | 287 | byRepo: (repoId) => call("by_repo", { repoId }), | |
| 287 | 288 | create: (actor, workspace, input) => call("create", { actor, workspace, input }), | |
| 288 | 289 | 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 }), | |
| 289 | 294 | }; | |
| 290 | 295 | } |
| 116 | 116 | get(project: ProjectRef, id: string, viewer: Viewer): Promise<Result<Deployment & { log: string | null }>>; | |
| 117 | 117 | /** Builds production (`branch` null), or a branch's preview, again from its head. */ | |
| 118 | 118 | 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[]>>; | |
| 119 | 126 | /** Takes production (`branch` null), or a branch's preview, down now. */ | |
| 120 | 127 | takeDown(actor: User, project: ProjectRef, branch: string | null): Promise<Result<true>>; | |
| 121 | 128 | /** Every project of a workspace at a glance. Members only. */ |
| 46 | 46 | rootDir?: string; | |
| 47 | 47 | }; | |
| 48 | 48 | ||
| 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 | + | ||
| 49 | 68 | export interface ProjectsApi { | |
| 50 | 69 | /** A workspace's projects, by name. Members, or anyone for public repositories. */ | |
| 51 | 70 | list(workspace: string, viewer: Viewer): Promise<Result<Project[]>>; | |
| 61 | 80 | slug: string, | |
| 62 | 81 | changes: { name?: string; description?: string | null; rootDir?: string }, | |
| 63 | 82 | ): 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>; | |
| 64 | 94 | } |
| 11 | 11 | use serde::Deserialize; | |
| 12 | 12 | use worker::Result; | |
| 13 | 13 | ||
| 14 | − | use crate::stripe::StripeSubscription; | |
| 14 | + | use crate::stripe::{StripeSubscription, is_missing}; | |
| 15 | 15 | use crate::{Billing, Touched, members_only, optional}; | |
| 16 | 16 | ||
| 17 | 17 | #[derive(Deserialize)] | |
| 174 | 174 | let stale = row.period_end.as_deref().is_none_or(|end| end <= rfc3339(now_ms()).as_str()) | |
| 175 | 175 | && row.status != "canceled"; | |
| 176 | 176 | 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 | + | } | |
| 179 | 189 | return self.subscription_row(workspace, feature).await; | |
| 180 | 190 | } | |
| 181 | 191 | Ok(Some(row)) | |
| 227 | 237 | } | |
| 228 | 238 | let plan = self.plan(a.feature); | |
| 229 | 239 | 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 | + | }; | |
| 240 | 266 | let Some(url) = session.url else { | |
| 241 | 267 | return Err(worker::Error::RustError( | |
| 242 | 268 | "the card processor returned no payment page".into(), |
| 384 | 384 | )); | |
| 385 | 385 | } | |
| 386 | 386 | 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 | + | }; | |
| 395 | 399 | let Some(url) = session.url else { | |
| 396 | 400 | return Err(worker::Error::RustError( | |
| 397 | 401 | "the card processor returned no payment page".into(), | |
| 469 | 473 | Ok(Outcome::Ok(self.standing(&workspace).await?)) | |
| 470 | 474 | } | |
| 471 | 475 | ||
| 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 | + | ||
| 472 | 486 | /// A refusal if the workspace has no credit to start an agent with. | |
| 473 | 487 | async fn out_of_credit<T>(&self, workspace: &str) -> Result<Option<Outcome<T>>> { | |
| 474 | 488 | // While g1t is being built out, no one needs credit. |
| 244 | 244 | } | |
| 245 | 245 | } | |
| 246 | 246 | ||
| 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 | + | ||
| 247 | 254 | pub(crate) fn is_live(key: &str) -> bool { | |
| 248 | 255 | key.starts_with("sk_live_") || key.starts_with("rk_live_") | |
| 249 | 256 | } |
| 216 | 216 | project: { id: string; slug: string; repoId: string; repo: RepoPath }, | |
| 217 | 217 | environment: DeployKind, | |
| 218 | 218 | trusted: boolean, | |
| 219 | + | branch: string | null, | |
| 219 | 220 | ): 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> }> { | |
| 220 | 260 | const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", { | |
| 221 | 261 | method: "POST", | |
| 222 | 262 | headers: { "content-type": "application/json" }, | |
| 392 | 432 | return (await this.deployPreview(project, app.number, a.actor.username, true)) ?? fail("conflict", "Its pull request is not open."); | |
| 393 | 433 | } | |
| 394 | 434 | ||
| 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 | + | ||
| 395 | 491 | async takeDown(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<true>> { | |
| 396 | 492 | const project = await this.memberProject(a.project, a.actor); | |
| 397 | 493 | if (!project.ok) return project; | |
| 530 | 626 | { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path }, | |
| 531 | 627 | input.kind, | |
| 532 | 628 | input.trusted, | |
| 629 | + | input.branch, | |
| 533 | 630 | ); | |
| 534 | 631 | const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", { | |
| 535 | 632 | method: "POST", | |
| 662 | 759 | { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } }, | |
| 663 | 760 | row.kind, | |
| 664 | 761 | !!row.trusted, | |
| 762 | + | row.branch, | |
| 665 | 763 | ); | |
| 666 | 764 | await cloudflare.putScript( | |
| 667 | 765 | row.script, | |
| 972 | 1070 | } | |
| 973 | 1071 | ||
| 974 | 1072 | /** `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> { | |
| 976 | 1074 | switch (method) { | |
| 977 | 1075 | case "settings": | |
| 978 | 1076 | return service.settings(args); | |
| 986 | 1084 | return service.redeploy(args); | |
| 987 | 1085 | case "take_down": | |
| 988 | 1086 | return service.takeDown(args); | |
| 1087 | + | case "stack": | |
| 1088 | + | return service.stack(args, (work) => ctx.waitUntil(work)); | |
| 989 | 1089 | case "overview": | |
| 990 | 1090 | return service.overview(args); | |
| 991 | 1091 | case "usage": | |
| 996 | 1096 | } | |
| 997 | 1097 | ||
| 998 | 1098 | export default { | |
| 999 | − | async fetch(request: Request, env: Env): Promise<Response> { | |
| 1099 | + | async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { | |
| 1000 | 1100 | const { pathname } = new URL(request.url); | |
| 1001 | 1101 | if (request.method !== "POST") return new Response("Not found\n", { status: 404 }); | |
| 1002 | 1102 | const service = new Deployments(env); | |
| 1003 | 1103 | const body = (await request.json().catch(() => ({}))) as Record<string, unknown>; | |
| 1004 | 1104 | const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/); | |
| 1005 | 1105 | if (rpcMatch) { | |
| 1006 | − | const result = await rpc(service, rpcMatch[1], body); | |
| 1106 | + | const result = await rpc(service, rpcMatch[1], body, ctx); | |
| 1007 | 1107 | return result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result); | |
| 1008 | 1108 | } | |
| 1009 | 1109 | // A build's reports, forwarded by the API. |
| 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); |
| 10 | 10 | "deploy": "wrangler deploy" | |
| 11 | 11 | }, | |
| 12 | 12 | "dependencies": { | |
| 13 | − | "@g1t/contracts": "*" | |
| 13 | + | "@g1t/contracts": "*", | |
| 14 | + | "yaml": "^2.9.1" | |
| 14 | 15 | } | |
| 15 | 16 | } |
| 12 | 12 | * Reached through service bindings: `POST /rpc/<method>`. | |
| 13 | 13 | */ | |
| 14 | 14 | ||
| 15 | + | import { parse as parseYaml } from "yaml"; | |
| 16 | + | ||
| 15 | 17 | import { | |
| 16 | 18 | fail, | |
| 17 | 19 | identityClient, | |
| 18 | 20 | newId, | |
| 19 | 21 | ok, | |
| 20 | 22 | reposClient, | |
| 23 | + | type Dependencies, | |
| 24 | + | type DependencyLink, | |
| 21 | 25 | type G1tEvent, | |
| 22 | 26 | type NewProject, | |
| 23 | 27 | type Project, | |
| 28 | + | type ProjectGraph, | |
| 24 | 29 | type Repo, | |
| 25 | 30 | type Result, | |
| 26 | 31 | type ServiceBinding, | |
| 55 | 60 | ||
| 56 | 61 | const now = () => new Date().toISOString(); | |
| 57 | 62 | ||
| 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 | + | ||
| 58 | 71 | function toProject(row: Row): Project { | |
| 59 | 72 | return { | |
| 60 | 73 | id: row.id, | |
| 292 | 305 | return ok(toProject((await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>())!)); | |
| 293 | 306 | } | |
| 294 | 307 | ||
| 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 | + | ||
| 295 | 483 | 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 | + | } | |
| 296 | 489 | if (event.type !== "repo.created") return; | |
| 297 | 490 | const actor = await this.workspaceActor(event.data.namespace); | |
| 298 | 491 | if (!actor) return; | |
| 318 | 511 | return Response.json(await service.create(args)); | |
| 319 | 512 | case "update": | |
| 320 | 513 | 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)); | |
| 321 | 524 | default: | |
| 322 | 525 | return new Response("Unknown method\n", { status: 404 }); | |
| 323 | 526 | } |
| 43 | 43 | ACTIONS: ServiceBinding; | |
| 44 | 44 | /** Told when a deploy sandbox dies without reporting. */ | |
| 45 | 45 | DEPLOYMENTS: ServiceBinding; | |
| 46 | + | /** What a repository's projects use and what uses them, for agents. */ | |
| 47 | + | PROJECTS: ServiceBinding; | |
| 46 | 48 | /** | |
| 47 | 49 | * The model proxy, which every sandbox's model requests go through with a | |
| 48 | 50 | * token for their run, so that no sandbox holds a key. When unset, | |
| 561 | 563 | number: number, | |
| 562 | 564 | text: string, | |
| 563 | 565 | ): 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; | |
| 568 | 573 | if (number > 0) { | |
| 569 | 574 | await workClient(this.env.WORK).appendSession(actor, repo, number, [ | |
| 570 | 575 | { | |
| 573 | 578 | }, | |
| 574 | 579 | ]); | |
| 575 | 580 | } | |
| 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"); | |
| 577 | 618 | } | |
| 578 | 619 | ||
| 579 | 620 | /** The same, for a step g1t takes by itself: a refusal stops the step. */ |
| 31 | 31 | { "binding": "BILLING", "service": "g1t-billing" }, | |
| 32 | 32 | { "binding": "INTEGRATIONS", "service": "g1t-integrations" }, | |
| 33 | 33 | { "binding": "ACTIONS", "service": "g1t-actions" }, | |
| 34 | − | { "binding": "DEPLOYMENTS", "service": "g1t-deployments" } | |
| 34 | + | { "binding": "DEPLOYMENTS", "service": "g1t-deployments" }, | |
| 35 | + | { "binding": "PROJECTS", "service": "g1t-projects" } | |
| 35 | 36 | ], | |
| 36 | 37 | // A sweep for lifecycle steps whose trigger was missed or whose sandbox | |
| 37 | 38 | // died before reporting. |