Skip to content

Commit

A desktop release goes out with what can be built. Linux is built on g1t's machines in every release; Windows and macOS each wait on a repository variable: a machine registered for them, or for Windows a cross build on Linux once aka.ms and download.visualstudio.microsoft.com are workflow-only domains, which is where the first release stopped. A platform with no machine is skipped and the download page says it is coming, with a word for the visitor whose platform this release lacks. The guide and CONTRIBUTING say so.

syntaqxcommitted Parent84c72b3Browse files
44 files+1120−2160/44 viewed
+3−1
77 for every project until you turn them on. Once they are on, every branch
88 with a pull request gets its own live preview, linked on the pull request,
99 and the default branch goes to production on every push. Reviewers, and the agents reviewing for you,
10−click through the change instead of reading a diff.
10+click through the change instead of reading a diff. A [mirror](/guides/mirroring/)
11+deploys nothing while the remote leads it: pushes copied in from the remote
12+don't build, and its **Deployments** page comes with a takeover.
1113
1214 Apps run on Cloudflare Workers, on `g1t.page`:
1315
+48−17
1313
1414 | The repository on g1t is | Who leads | What works on g1t |
1515 | --- | --- | --- |
16−| A **mirror**, standing by | The remote | Reading, cloning, search. Nothing else: it's an exact, read-only copy that runs nothing. |
17−| A mirror in **CI failover** | The remote, for code | The remote's workflows run on g1t. Code, issues and pull requests stay on the remote. |
16+| A **mirror**, standing by | The remote | Reading, cloning, search, what security scanning found, insights and settings. Nothing else: it's an exact, read-only copy that runs nothing and opens nothing. |
17+| A mirror in **CI failover** | The remote, for code | The same, and the remote's workflows run on g1t. Code, issues and pull requests stay on the remote. |
1818 | A mirror **taken over** | g1t, for now | Everything: pushes, pull requests, issues, agents, workflows. |
1919 | **Mirrored to** other hosts | g1t | Everything. Each push is sent to the followers. |
2020
3939 `refs/g1t/replaced/<branch>/<time>` for at least 30 days. Nothing a mirror
4040 held is lost silently.
4141
42−## What a mirror standing by refuses
42+## What a standby mirror offers
4343
44−A standby mirror is a backup you can switch to. It doesn't take work of its
45−own:
44+A standby mirror is a backup you can switch to: an exact copy of the
45+remote, kept up to date, that you can read and clone. It doesn't take work
46+of its own, and nothing on g1t opens anything on it by itself. One rule
47+decides, by state:
4648
47−| | Standing by | CI failover | Taken over |
48−| --- | --- | --- | --- |
49−| Clone, fetch, browse, search | ✓ | ✓ | ✓ |
50−| Push, merge, edit on the web | – | – | ✓ |
51−| Issues, pull requests, agents | – | – | ✓ |
52−| `.g1t/workflows` | Only with **Keep CI warm** | ✓ | ✓ |
53−| `.github/workflows` | – | ✓ | ✓ unless turned off |
54−| Deployments on g1t.page | – | – | ✓ |
55−| Settings, rules, webhooks | ✓ | ✓ | ✓ |
49+| | Standing by | CI failover | Taken over | Handing back |
50+| --- | --- | --- | --- | --- |
51+| **Code**: files, commits, branches, tags, compare, releases; clone, fetch, search | ✓ | ✓ | ✓ | ✓ |
52+| **Security**: what scanning found, read-only | ✓ | ✓ | ✓ | ✓ |
53+| **Insights**, **Settings** (mirroring settings included), rules, webhooks | ✓ | ✓ | ✓ | ✓ |
54+| **Workflows**: `.g1t/workflows` | Only with **Keep CI warm** | ✓ | ✓ | – |
55+| **Workflows**: `.github/workflows` | – | ✓ | ✓ unless turned off | – |
56+| Push, merge, edit on the web, make a release | – | – | ✓ | – |
57+| **Issues**, **Pull requests**, **Agents** | – | – | ✓ | – |
58+| **Deployments** on g1t.page | – | – | ✓ | – |
59+| Security updates, dependency updates, upkeep: anything that opens a branch or a pull request on its own | – | – | ✓ | – |
5660
57−What's refused says why. A `git push` to a standby mirror answers:
61+What it looks like:
62+
63+- The repository shows **Mirror of github.com/acme/web** beside its name, and
64+ so does its row on the workspace's **Projects** page, in the list and the
65+ grid, where the **Kind** filter can show **Mirrors** or **On g1t** alone.
66+ A mirror in CI failover, taken over or handing back says that instead.
67+- Its sidebar lists only what it offers: **Code**, **Security**, **Insights**
68+ and **Settings**, plus **Workflows** when they run here. Opening an address
69+ it doesn't offer, such as `/acme/web/issues` or `/acme/web/pulls/new`, shows
70+ a note that says it's a mirror, links the remote, and leads to **Settings →
71+ Mirroring**, instead of the list or the form.
72+- Its overview shows the copy: the remote, the default branch, the latest
73+ commit, when it last synced, the latest commits and the About card. There
74+ is no **Where does it run**, no **Get started** checklist and no
75+ **Right now**: nothing runs here.
76+- **Security → Vulnerabilities** still lists what the daily scan found, but
77+ no security update opens: each alert says the update waits until the
78+ repository is taken over or moved to g1t. The same goes for
79+ [dependency updates](/guides/dependency-updates/) and anything else g1t
80+ does on its own.
81+- Every button that would write is greyed out, with the reason on hover, and
82+ the services refuse the write as well: a form posted to a mirror answers
83+ `409` with the reason, and the API answers `403`.
84+
85+A `git push` to a standby mirror answers:
5886
5987 ```text
6088 remote: acme/web is a mirror of github.com/acme/web, so it is read-only here.
6290 ! [remote rejected] main -> main
6391 ```
6492
65−The repository shows **Mirror of github.com/acme/web** beside its name,
66−and every button that would write is greyed out, with the reason on hover.
93+**Taking over** changes all of it at once: the sidebar fills in, the
94+overview shows where the project runs and what is moving, security and
95+dependency updates open on the next scan, and pushes, pull requests, issues,
96+agents, workflows and deployments work until you hand back. **Moving to g1t**
97+does the same for good.
6798
6899 ## When the remote stops answering
69100
+6−0
291291 and the [merge queue](/guides/merge-queue/), like any other. When it
292292 merges, the next scan finds the vulnerability fixed.
293293
294+On a [mirror](/guides/mirroring/) that stands by, is in CI failover or is
295+handing back, nothing opens: the scan still runs and the alerts still show,
296+but each one says its security update waits until the repository is taken
297+over or moved to g1t. The first scan after that opens them.
298+
294299 Each alert shows its update's status and links the pull request:
295300
296301 | Status | Meaning |
297302 | --- | --- |
303+| Waiting | The repository is a mirror that g1t does not lead. The update opens once it is taken over or moved to g1t. |
298304 | Making the change | The sandbox is raising the version. |
299305 | Open | The pull request is open. |
300306 | Merged | The pull request merged. |
+5−0
628628 are for members of the workspace. A run for an outside collaborator is
629629 told the project's memory, never the workspace's.
630630
631+Nobody can run an agent on a [mirror](/guides/mirroring/) that g1t does not
632+lead: a standby mirror is a read-only copy, its **Issues**, **Pull requests**
633+and **Agents** pages stand aside, and assigning, mentioning and planning are
634+refused until someone takes over or moves it to g1t.
635+
631636 Agents cost g1t real money, so they run for paid workspaces. A free
632637 workspace has the whole forge, and two ways to try agents:
633638
+50−1
55 import type { RepoMirror } from "@g1t/contracts";
66
77 import { ConfirmDialog } from "./repo-lifecycle";
8−import { ButtonLink, SubmitButton, TimeAgo } from "./ui";
8+import { ButtonLink, EmptyState, SubmitButton, TimeAgo } from "./ui";
99 import { Badge } from "./ui/badge";
1010 import { Button } from "./ui/button";
1111 import { Hint } from "./ui/hint";
1212 import {
13+ MIRRORING_DOCS,
1314 type MirrorBrief,
1415 type MirroredRepo,
1516 followersOf,
304305 }
305306
306307 /**
308+ * In place of a page a mirror does not offer now (lib/chrome.ts
309+ * `hiddenOnMirror`: issues, pull requests, agents, workflows, deployments,
310+ * a new release): what the repository is, where the work happens, and the
311+ * way to take over.
312+ */
313+export function MirrorReadOnlyPage({ base, repo, mirror, admin, what }: {
314+ base: string;
315+ repo: { namespace: string; name: string };
316+ mirror: RepoMirror;
317+ /** Whether the viewer manages its mirroring: they get the way to its settings. */
318+ admin: boolean;
319+ /** What the page would list, lowercase: "issues", "pull requests". */
320+ what: string;
321+}) {
322+ const full = `${repo.namespace}/${repo.name}`;
323+ const remote = (
324+ <a href={mirror.url} target="_blank" rel="noreferrer" className="font-mono text-fg underline underline-offset-4 hover:text-accent">
325+ {mirror.remote}
326+ </a>
327+ );
328+ return (
329+ <EmptyState title={`This is a mirror of ${mirror.remote}`}>
330+ <p className="mx-auto max-w-xl">
331+ {mirror.state === "handing_back" ? (
332+ <>
333+ {full} is handing back to {remote}, so there are no {what} here until that is done.
334+ </>
335+ ) : (
336+ <>
337+ {full} is a read-only copy until someone takes over, so there are no {what} here. Its {what} are on {remote}.
338+ </>
339+ )}
340+ </p>
341+ <div className="mt-4 flex flex-wrap items-center justify-center gap-2">
342+ {admin && (
343+ <ButtonLink to={mirroringSettings(base)} variant="outline" size="sm">
344+ Mirroring settings
345+ </ButtonLink>
346+ )}
347+ <ButtonLink to={MIRRORING_DOCS} variant="ghost" size="sm" target="_blank" rel="noreferrer">
348+ How mirroring works
349+ </ButtonLink>
350+ </div>
351+ </EmptyState>
352+ );
353+}
354+
355+/**
307356 * On the repository's overview, one quiet line when no banner says it: a
308357 * mirror standing by, or the remotes that follow it, and when it last synced.
309358 */
+5−2
1919 import { type ReactNode, useEffect, useRef, useState } from "react";
2020 import { Link, useLocation, useNavigate, useSubmit } from "react-router";
2121
22−import { type Abilities, type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts";
22+import { type Abilities, type Membership, type RepoMirror, type User, hasCodeAccess, shownUsername } from "@g1t/contracts";
2323
2424 import { appIcon, pinnedApp } from "./apps";
2525 import { CountBadge, ShortcutsDialog } from "./rail";
533533 can,
534534 settings,
535535 counts,
536+ mirror,
536537 always = false,
537538 }: {
538539 /** `/<namespace>/<name>`. */
542543 /** Whether they see its settings (lib/access.ts `seesSettings`), as the sidebar decides. */
543544 settings?: boolean;
544545 counts?: { issues: number; pulls: number };
546+ /** The remote the repository mirrors: a mirror lists only what it offers (lib/chrome.ts). */
547+ mirror?: RepoMirror | null;
545548 /** On a computer too: for a visitor, who has no sidebar to list them. */
546549 always?: boolean;
547550 }) {
548551 const { pathname } = useLocation();
549552 const rest = pathname.toLowerCase().startsWith(base.toLowerCase()) ? pathname.slice(base.length) : "";
550553 const at = projectPageAt(rest, SOON_PAGES);
551− const pages: (ProjectPage | "overview")[] = ["overview", ...projectPages(member, can).filter((page) => page !== "observability" && page !== "settings"),
554+ const pages: (ProjectPage | "overview")[] = ["overview", ...projectPages(member, can, mirror).filter((page) => page !== "observability" && page !== "settings"),
552555 ...(settings ?? member ? (["settings"] as const) : []),
553556 ];
554557 const pill = (current: boolean) =>
+44−6
1919 KeyRound,
2020 Loader,
2121 Package,
22+ PauseCircle,
2223 ShieldAlert,
2324 ShieldCheck,
2425 } from "lucide-react";
540541 closed: <GitPullRequestClosed size={13} className="text-danger" />,
541542 };
542543
543−/** Where g1t's security update for a package stands. */
544−function UpdateStatus({ update, name, pulls, base }: { update: SecurityUpdate; name: string; pulls: Record<number, PullInfo>; base: string }) {
544+/**
545+ * Where g1t's security update for a package stands. On a mirror g1t does
546+ * not lead (`updatesWait`, the reason), an update that has not opened a
547+ * pull request waits: nothing is made or pushed there until a takeover.
548+ */
549+function UpdateStatus({ update, name, pulls, base, updatesWait }: {
550+ update: SecurityUpdate;
551+ name: string;
552+ pulls: Record<number, PullInfo>;
553+ base: string;
554+ updatesWait?: string | null;
555+}) {
545556 const meta = UPDATE_STATES[update.state];
546557 const pull = update.pull != null ? pulls[update.pull] : undefined;
558+ if (updatesWait && (update.state === "requested" || update.state === "failed" || update.state === "needs_code")) {
559+ return (
560+ <Card tone="plain" radius="lg" className="mt-2.5 bg-bg/40 px-3 py-2 text-xs sm:ml-7">
561+ <div className="flex flex-wrap items-center gap-x-2.5 gap-y-1.5">
562+ <PauseCircle size={13} className="text-muted" />
563+ <Badge>Security update waiting</Badge>
564+ <span className="text-faint">
565+ <TimeAgo at={update.updatedAt} />
566+ </span>
567+ </div>
568+ <p className="mt-1 text-muted wrap-anywhere">
569+ Raising {name} to {update.target} is on hold until {base.replace(/^\//, "")} is taken over or moved to g1t.
570+ </p>
571+ </Card>
572+ );
573+ }
547574 const about: Record<SecurityUpdate["state"], ReactNode> = {
548575 requested: <>A sandbox is raising {name} to {update.target}.</>,
549576 open: <>It raises {name} to {update.target} and lands through your branch's required checks.</>,
605632 fixes,
606633 pulls,
607634 upkeep,
635+ updatesWait,
608636 base,
609637 action,
610638 focus,
618646 pulls: Record<number, PullInfo>;
619647 /** Whether security updates are on. */
620648 upkeep: boolean;
649+ /** Why no update opens now: the repository is a mirror g1t does not lead (lib/mirror.ts). */
650+ updatesWait?: string | null;
621651 base: string;
622652 action: string;
623653 focus: string | null;
648678 fixes={fixes}
649679 pulls={pulls}
650680 upkeep={upkeep}
681+ updatesWait={updatesWait}
651682 base={base}
652683 action={action}
653684 focus={focus}
664695 fixes,
665696 pulls,
666697 upkeep,
698+ updatesWait,
667699 base,
668700 action,
669701 focus,
674706 fixes: Record<number, UpgradeFix>;
675707 pulls: Record<number, PullInfo>;
676708 upkeep: boolean;
709+ updatesWait?: string | null;
677710 base: string;
678711 action: string;
679712 focus: string | null;
701734 </p>
702735 </div>
703736 </div>
704− {update && <UpdateStatus update={update} name={group.name} pulls={pulls} base={base} />}
737+ {update && <UpdateStatus update={update} name={group.name} pulls={pulls} base={base} updatesWait={updatesWait} />}
705738 {issue != null && (
706739 <div className="mt-2 sm:ml-7">
707740 <FixLink issue={issue} fix={fixes[issue]} base={base} />
716749 showManifest={manifests.length > 1}
717750 activity={activity}
718751 upkeep={upkeep}
752+ updatesWait={updatesWait}
719753 base={base}
720754 action={action}
721755 focused={vuln.id === focus}
733767 showManifest,
734768 activity,
735769 upkeep,
770+ updatesWait,
736771 base,
737772 action,
738773 focused,
742777 showManifest: boolean;
743778 activity: AlertActivity[];
744779 upkeep: boolean;
780+ updatesWait?: string | null;
745781 base: string;
746782 action: string;
747783 focused: boolean;
790826 </p>
791827 <p className="mt-1 text-muted">
792828 No release fixes this yet. Dependencies are checked again daily
793− {upkeep
794− ? ", and g1t opens a pull request when a fix is published."
795− : "; turn on security updates and g1t opens a pull request when a fix is published."}
829+ {updatesWait
830+ ? ". A fix is applied once the repository is taken over or moved to g1t."
831+ : upkeep
832+ ? ", and g1t opens a pull request when a fix is published."
833+ : "; turn on security updates and g1t opens a pull request when a fix is published."}
796834 </p>
797835 <div className="mt-2 flex flex-wrap items-center gap-2">
798836 <a href={osv} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-fg-soft hover:text-fg hover:underline">
+34−20
22 import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
33 import { Link, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
5−import { type Abilities, type ChatSidebarEntry, type InboxCounts, type WorkspaceAgent, type Membership, type Spike, type User, hasCodeAccess, mayCreateTeams, shownUsername } from "@g1t/contracts";
5+import { type Abilities, type ChatSidebarEntry, type InboxCounts, type WorkspaceAgent, type Membership, type RepoMirror, type Spike, type User, hasCodeAccess, mayCreateTeams, shownUsername } from "@g1t/contracts";
66
77 import { InMain } from "./landmark";
88 import { CommandPalette, type PaletteCommand, PaletteKey, usePaletteShortcut } from "./command-palette";
782782 settings?: boolean;
783783 /** What they may do there, once the repository's page loads. */
784784 can?: Abilities;
785+ /** The remote it mirrors, once the repository's page loads: a mirror lists only what it offers. */
786+ mirror?: RepoMirror | null;
785787 issues?: number;
786788 pulls?: number;
787789 };
811813 back: { to: string; label: string };
812814 }) {
813815 const base = `/${repo.namespace}/${repo.name}`;
814− // What a member sees, and what everyone who can see the project does.
815− const shows = new Set(projectPages(repo.member, repo.can));
816+ // What a member sees, what everyone who can see the project does, and
817+ // what a mirror offers now (lib/chrome.ts).
818+ const shows = new Set(projectPages(repo.member, repo.can, repo.mirror));
816819 const { current: atOverview, pending: toOverview } = useRowState(base, { end: true });
817820 return (
818821 <nav aria-label={`${repo.namespace}/${repo.name}`} className={PANEL}>
841844 <SidebarLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`, `${base}/branches`, `${base}/tags`, `${base}/compare`, ...soonPaths(base, "Code")]} icon={<Code2 size={15} />}>
842845 Code
843846 </SidebarLink>
844− <SidebarLink to={`${base}/issues`} also={[`${base}/plans`, `${base}/milestones`, `${base}/labels`, ...soonPaths(base, "Issues")]} icon={<CircleDot size={15} />} count={repo.issues}>
845− Issues
846− </SidebarLink>
847− <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/queue`]} icon={<GitPullRequest size={15} />} count={repo.pulls}>
848− Pull requests
849− </SidebarLink>
850− <SidebarLink to={`${base}/agents`} also={[`${base}/sessions`, `${base}/memory`, ...soonPaths(base, "Agents")]} icon={<Bot size={15} />}>
851− Agents
852− </SidebarLink>
853− <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
854− Workflows
855− </SidebarLink>
847+ {/* A mirror that is not taken over lists none of these: its work happens on the remote. */}
848+ {shows.has("issues") && (
849+ <SidebarLink to={`${base}/issues`} also={[`${base}/plans`, `${base}/milestones`, `${base}/labels`, ...soonPaths(base, "Issues")]} icon={<CircleDot size={15} />} count={repo.issues}>
850+ Issues
851+ </SidebarLink>
852+ )}
853+ {shows.has("pulls") && (
854+ <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/queue`]} icon={<GitPullRequest size={15} />} count={repo.pulls}>
855+ Pull requests
856+ </SidebarLink>
857+ )}
858+ {shows.has("agents") && (
859+ <SidebarLink to={`${base}/agents`} also={[`${base}/sessions`, `${base}/memory`, ...soonPaths(base, "Agents")]} icon={<Bot size={15} />}>
860+ Agents
861+ </SidebarLink>
862+ )}
863+ {shows.has("actions") && (
864+ <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
865+ Workflows
866+ </SidebarLink>
867+ )}
856868 </NavRows>
857869 <Rule />
858870 <NavRows>
861873 Deployments
862874 </SidebarLink>
863875 ) : null}
864− <SidebarSoonLink to={`${base}/soon/logs`} also={soonPaths(base, "Observability")} icon={<Activity size={15} />} about="Logs, errors, uptime and analytics of the project's deployed apps.">
865− Observability
866− </SidebarSoonLink>
876+ {shows.has("observability") && (
877+ <SidebarSoonLink to={`${base}/soon/logs`} also={soonPaths(base, "Observability")} icon={<Activity size={15} />} about="Logs, errors, uptime and analytics of the project's deployed apps.">
878+ Observability
879+ </SidebarSoonLink>
880+ )}
867881 {shows.has("security") ? (
868882 <SidebarLink to={`${base}/security`} also={soonPaths(base, "Security")} icon={<ShieldCheck size={15} />}>
869883 Security
11071121 : null;
11081122 // The viewer's role there, from the repository's page once it has loaded.
11091123 const page = useRouteLoaderData("routes/repo/layout") as
1110− | { repo?: { namespace: string; name: string }; access?: ViewerAccess }
1124+ | { repo?: { namespace: string; name: string; mirror?: RepoMirror | null }; access?: ViewerAccess }
11111125 | undefined;
11121126 const menuRepo: MenuRepo | null =
11131127 guessed && page?.access && page.repo && sameRepo(page.repo, guessed)
1114− ? { ...guessed, member: page.access.insider, settings: seesSettings(page.access), can: page.access.can }
1128+ ? { ...guessed, member: page.access.insider, settings: seesSettings(page.access), can: page.access.can, mirror: page.repo.mirror }
11151129 : guessed;
11161130
11171131 // The way from Code's list to the one shown.
+16−0
99 can,
1010 granted,
1111 httpStatus,
12+ mirrorWritable,
1213 needs,
1314 permission,
1415 } from "@g1t/contracts";
1516
1617 import type { ViewerAccess } from "./access";
18+import { mirrorReason } from "./mirror";
1719 import { projects, repos, work } from "./services.server";
1820 import { getViewer } from "./session.server";
1921
147149 const refused = await refusal(context, params, capability);
148150 if (refused) throw data(refused, { status: 403 });
149151 }
152+
153+/**
154+ * For an action that would open, change or run something in the
155+ * repository (an issue, a pull request, a label, a release, an agent, a
156+ * deployment): a 409 that says why when it is a mirror that is not taken
157+ * over (`mirrorWritable` in the contracts), otherwise nothing. The
158+ * services refuse too; this answers before anything is tried. Settings,
159+ * scans and anything that only reads stay open on a mirror.
160+ */
161+export async function requireWritableRepo(context: Context, params: RepoParams): Promise<void> {
162+ const found = await repoFor(context, params);
163+ if (!found.ok) return;
164+ if (!mirrorWritable(found.value.mirror)) throw data(mirrorReason(found.value), { status: 409 });
165+}
+52−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { frameOf, projectPageAt, projectPages } from "./chrome.ts";
4+import type { RepoMirror } from "@g1t/contracts";
5+
6+import { frameOf, hiddenOnMirror, projectPageAt, projectPages } from "./chrome.ts";
57
68 const visitor = { signedIn: false, workspace: false };
9+const mirror = (state: RepoMirror["state"], warm = false): RepoMirror => ({
10+ state,
11+ remote: "github.com/acme/web",
12+ url: "https://github.com/acme/web",
13+ since: "2026-10-08T10:00:00Z",
14+ warm,
15+});
716 const member = { signedIn: true, workspace: true };
817 const newcomer = { signedIn: true, workspace: false };
918
6372 assert.ok(visitor.includes("deployments") && projectPages(true).includes("deployments"));
6473 });
6574
75+test("a mirror's menu lists what it offers: a read-only copy until it is taken over", () => {
76+ const can = { push: true };
77+ const all = projectPages(true, can);
78+ assert.deepEqual(projectPages(true, can, null), all);
79+ assert.deepEqual(projectPages(true, can, mirror("takeover")), all);
80+ // Standing by: code, security, insights and settings. Nothing that works or runs.
81+ assert.deepEqual(projectPages(true, can, mirror("standby")), ["code", "security", "insights", "settings"]);
82+ assert.deepEqual(projectPages(false, undefined, mirror("standby")), ["code", "insights"]);
83+ // Workflows show when they run here: CI failover, or a standby kept warm.
84+ assert.deepEqual(projectPages(true, can, mirror("ci")), ["code", "actions", "security", "insights", "settings"]);
85+ assert.ok(projectPages(true, can, mirror("standby", true)).includes("actions"));
86+ assert.ok(!projectPages(true, can, mirror("handing_back", true)).includes("actions"));
87+ assert.ok(!projectPages(true, can, mirror("handing_back")).includes("issues"));
88+});
89+
90+test("a page a mirror does not offer stands behind the read-only note, by its name", () => {
91+ const standby = mirror("standby");
92+ assert.deepEqual(hiddenOnMirror("issues", standby), { page: "issues", what: "issues" });
93+ assert.deepEqual(hiddenOnMirror("issues/new", standby), { page: "issues", what: "issues" });
94+ assert.deepEqual(hiddenOnMirror("pulls", standby), { page: "pulls", what: "pull requests" });
95+ assert.deepEqual(hiddenOnMirror("queue", standby), { page: "pulls", what: "pull requests" });
96+ assert.deepEqual(hiddenOnMirror("agents/runs/9", standby), { page: "agents", what: "agents" });
97+ assert.deepEqual(hiddenOnMirror("sessions", standby), { page: "agents", what: "agents" });
98+ assert.deepEqual(hiddenOnMirror("memory", standby), { page: "agents", what: "agents" });
99+ assert.deepEqual(hiddenOnMirror("deployments/d1", standby), { page: "deployments", what: "deployments" });
100+ assert.deepEqual(hiddenOnMirror("labels", standby), { page: "issues", what: "issues" });
101+ assert.deepEqual(hiddenOnMirror("milestones/2", standby), { page: "issues", what: "issues" });
102+ assert.deepEqual(hiddenOnMirror("plans", standby), { page: "issues", what: "issues" });
103+ assert.deepEqual(hiddenOnMirror("actions", standby), { page: "actions", what: "workflows" });
104+ assert.deepEqual(hiddenOnMirror("releases/new", standby), { page: "code", what: "releases" });
105+ // What a read-only copy still shows.
106+ for (const path of ["", "code", "tree/main/src", "commits", "branches", "releases", "compare", "security/vulnerabilities", "contributors", "settings/mirroring", "issues/12", "pull/3/files"]) {
107+ assert.equal(hiddenOnMirror(path, standby), null, path);
108+ }
109+ // Workflows stay when they run here; everything stays once g1t leads.
110+ assert.equal(hiddenOnMirror("actions", mirror("ci")), null);
111+ assert.equal(hiddenOnMirror("actions", mirror("standby", true)), null);
112+ assert.equal(hiddenOnMirror("issues", mirror("takeover")), null);
113+ assert.equal(hiddenOnMirror("issues", null), null);
114+ assert.ok(hiddenOnMirror("pulls", mirror("handing_back")));
115+});
116+
66117 test("the phone's project strip lights the page a path is under", () => {
67118 assert.equal(projectPageAt(""), "overview");
68119 assert.equal(projectPageAt("blob/main/README.md"), "code");
+45−4
1313 * Explore, Docs and signing in, the page at full width, and the footer.
1414 */
1515
16−import type { Abilities, Capability } from "@g1t/contracts";
16+import type { Abilities, Capability, RepoMirror } from "@g1t/contracts";
17+
18+import { type RepoArea, offers, writable } from "./mirror.ts";
1719
1820 export type Frame = "standalone" | "public" | "app";
1921
7880 export const PAGE_NEEDS: Partial<Record<ProjectPage, Capability>> = { security: "push" };
7981
8082 /**
83+ * The part of the repository each page is about (`RepoArea` in the
84+ * contracts): what a mirror offers decides which pages it lists.
85+ */
86+export const PAGE_AREA: Record<ProjectPage, RepoArea> = {
87+ code: "code",
88+ issues: "issues",
89+ pulls: "pulls",
90+ agents: "agents",
91+ actions: "workflows",
92+ deployments: "deployments",
93+ // Logs and traces are of something running here.
94+ observability: "deployments",
95+ security: "security",
96+ insights: "insights",
97+ settings: "settings",
98+};
99+
100+/**
81101 * A project's menu, in order. Security and Settings are for people with a
82102 * role on its repository, and a page in `PAGE_NEEDS` only for those whose
83103 * role has its capability (`can`, once the repository's page has loaded);
84− * everyone who can see the project sees the rest.
104+ * everyone who can see the project sees the rest. A mirror lists only what
105+ * it offers now (`mirror`, the repository's, once its page has loaded): a
106+ * standby mirror is a read-only copy, so Code, Security, Insights and
107+ * Settings, no Issues, Pull requests, Agents or Deployments, and Workflows
108+ * only when they run here.
85109 */
86−export function projectPages(member: boolean, can?: Partial<Abilities>): ProjectPage[] {
110+export function projectPages(member: boolean, can?: Partial<Abilities>, mirror?: RepoMirror | null): ProjectPage[] {
87111 const pages: (ProjectPage | false)[] = [
88112 "code",
89113 "issues",
98122 member && "settings",
99123 ];
100124 return pages.filter(
101− (page): page is ProjectPage => page !== false && (!can || !PAGE_NEEDS[page] || Boolean(can[PAGE_NEEDS[page]!])),
125+ (page): page is ProjectPage =>
126+ page !== false && (!can || !PAGE_NEEDS[page] || Boolean(can[PAGE_NEEDS[page]!])) && offers(mirror, PAGE_AREA[page]),
102127 );
103128 }
104129
105130 /**
131+ * The page under a project that a mirror does not offer now and `rest`
132+ * (the path under the project) is on, with what to call it, or null when
133+ * the page is there. An issue or a pull request by number stays readable,
134+ * as does a release; making a release is a write.
135+ */
136+export function hiddenOnMirror(rest: string, mirror: RepoMirror | null | undefined): { page: ProjectPage; what: string } | null {
137+ if (!mirror || writable(mirror)) return null;
138+ const path = rest.replace(/^\/+|\/+$/g, "");
139+ if (path === "releases/new") return { page: "code", what: "releases" };
140+ if (/^(issues|pull)\/\d+(\/|$)/.test(path)) return null;
141+ const page = projectPageAt(path);
142+ if (!page || page === "overview" || offers(mirror, PAGE_AREA[page])) return null;
143+ return { page, what: PROJECT_PAGE_LINKS[page].label.toLowerCase() };
144+}
145+
146+/**
106147 * A project's pages as the phone's strip of tabs under its name names them
107148 * (routes/repo/layout.tsx): the sidebar's words, and the paths under the
108149 * project at which each is the current one.
+2−1
22 // Each needs the Triage role, which work checks again.
33
44 import type { MilestoneResult } from "../components/milestones";
5−import { refusal } from "./access.server";
5+import { refusal, requireWritableRepo } from "./access.server";
66 import { work } from "./services.server";
77 import { assertSameOrigin, requireUser } from "./session.server";
88
1212 export async function milestoneAction(request: Request, params: RepoParams, context: Context): Promise<MilestoneResult> {
1313 assertSameOrigin(request);
1414 const user = requireUser(context, request);
15+ await requireWritableRepo(context, params);
1516 const form = await request.formData();
1617 const intent = String(form.get("intent") ?? "");
1718 const number = Number(form.get("number")) || undefined;
+37−2
33
44 import type { HandbackPlan, RepoMirror } from "@g1t/contracts";
55
6−import { DEFAULT_MIRROR_SETTINGS, TAKE_OVER_AFTER_MINUTES, mirrorWritable } from "../../../../packages/contracts/src/mirrors.ts";
6+import {
7+ DEFAULT_MIRROR_SETTINGS,
8+ type RepoArea,
9+ TAKE_OVER_AFTER_MINUTES,
10+ mirrorOffers,
11+ mirrorUpdatesWait,
12+ mirrorWritable,
13+} from "../../../../packages/contracts/src/mirrors.ts";
714
815 import {
916 clampMinutes,
1320 mirrorBanner,
1421 mirrorReason,
1522 mirrorSettingsFrom,
23+ offers,
1624 shortSha,
1725 TAKE_OVER_MINUTES,
26+ updatesWaitReason,
1827 workflowReason,
1928 writable,
2029 } from "./mirror.ts";
2938 const leader = (reachable: boolean) => ({ repoId: "r", role: "leader" as const, name: "github.com/acme/web", state: "standby" as const, reachable });
3039 const follower = (name: string) => ({ repoId: "r", role: "follower" as const, name, state: "following" as const, reachable: true });
3140
41+const AREAS: RepoArea[] = ["code", "issues", "pulls", "agents", "workflows", "deployments", "security", "insights", "settings", "updates"];
42+
3243 test("the copies agree with the contracts", () => {
3344 for (const state of [null, "standby", "ci", "takeover", "handing_back"] as const) {
34− assert.equal(writable(state ? mirror(state) : null), mirrorWritable(state ? mirror(state) : null));
45+ const of = state ? mirror(state) : null;
46+ assert.equal(writable(of), mirrorWritable(of));
47+ for (const area of AREAS) {
48+ assert.equal(offers(of, area), mirrorOffers(of, area), `${state} ${area}`);
49+ if (of) assert.equal(offers({ ...of, warm: true }, area), mirrorOffers({ ...of, warm: true }, area), `${state} warm ${area}`);
50+ }
51+ if (state) assert.equal(updatesWaitReason(repo(state)), mirrorOffers(mirror(state), "updates") ? null : mirrorUpdatesWait("acme", "web", mirror(state)));
3552 }
3653 assert.deepEqual([...TAKE_OVER_MINUTES], [...TAKE_OVER_AFTER_MINUTES]);
3754 });
3855
56+test("a mirror offers a read-only copy until it is taken over", () => {
57+ for (const state of ["standby", "ci", "handing_back"] as const) {
58+ for (const area of ["code", "security", "insights", "settings"] as const) assert.ok(offers(mirror(state), area), `${state} ${area}`);
59+ for (const area of ["issues", "pulls", "agents", "deployments", "updates"] as const) assert.ok(!offers(mirror(state), area), `${state} ${area}`);
60+ }
61+ for (const area of AREAS) {
62+ assert.ok(offers(mirror("takeover"), area), area);
63+ assert.ok(offers(null, area), area);
64+ }
65+ assert.ok(!offers(mirror("standby"), "workflows"));
66+ assert.ok(offers({ ...mirror("standby"), warm: true }, "workflows"), "a standby kept warm runs .g1t/workflows");
67+ assert.ok(offers(mirror("ci"), "workflows"));
68+ assert.ok(!offers({ ...mirror("handing_back"), warm: true }, "workflows"));
69+ assert.match(updatesWaitReason(repo("standby"))!, /^Security updates wait until acme\/web is taken over or moved to g1t\./);
70+ assert.equal(updatesWaitReason(repo("takeover")), null);
71+ assert.equal(updatesWaitReason(repo(null)), null);
72+});
73+
3974 test("a read-only mirror says why its buttons are off, and where to take over", () => {
4075 assert.equal(
4176 mirrorReason(repo("standby")),
+27−0
1515 return !mirror || mirror.state === "takeover";
1616 }
1717
18+/** As `RepoArea` in the contracts: the parts of a repository a mirror may or may not offer. */
19+export type RepoArea = "code" | "issues" | "pulls" | "agents" | "workflows" | "deployments" | "security" | "insights" | "settings" | "updates";
20+
21+/**
22+ * As `mirrorOffers` in the contracts. A repository that leads, or a
23+ * mirror g1t has taken over, offers everything; one standing by or handing
24+ * back is a read-only copy: code, what security scanning found, insights
25+ * and settings. Workflows run in CI failover, and while standing by only
26+ * when the link keeps CI warm.
27+ */
28+export function offers(mirror: RepoMirror | null | undefined, area: RepoArea): boolean {
29+ if (writable(mirror)) return true;
30+ if (area === "code" || area === "security" || area === "insights" || area === "settings") return true;
31+ if (area === "workflows") return mirror!.state === "ci" || (mirror!.state === "standby" && mirror!.warm === true);
32+ return false;
33+}
34+
35+/**
36+ * As `mirrorUpdatesWait` in the contracts: why g1t opens no security or
37+ * dependency updates on the repository now. Null when it does.
38+ */
39+export function updatesWaitReason(repo: MirroredRepo | null | undefined): string | null {
40+ const mirror = repo?.mirror;
41+ if (!repo || !mirror || offers(mirror, "updates")) return null;
42+ return `Security updates wait until ${repo.namespace}/${repo.name} is taken over or moved to g1t. It is a mirror of ${mirror.remote}, so nothing opens a branch or a pull request here on its own.`;
43+}
44+
1845 /** As `TAKE_OVER_AFTER_MINUTES` in the contracts: 5 minutes to a day. */
1946 export const TAKE_OVER_MINUTES = [5, 24 * 60] as const;
2047
+17−0
2828 pushedAt: null,
2929 activity: 0,
3030 deploying: false,
31+ mirror: null,
3132 ...more,
3233 };
3334 }
8586 assert.deepEqual(slugs({ deployments: true }), ["site"]);
8687 });
8788
89+test("the kind filter also tells mirrors from projects managed on g1t", () => {
90+ const list = [
91+ project("own"),
92+ project("copy", { mirror: { state: "standby", remote: "github.com/acme/copy" } }),
93+ project("taken", { kind: "library", mirror: { state: "takeover", remote: "github.com/acme/taken" } }),
94+ ];
95+ const slugs = (change: object) => filterProjects(list, { ...DEFAULT_QUERY, ...change }).map((p) => p.slug).sort();
96+ assert.deepEqual(slugs({ kind: "mirror" }), ["copy", "taken"], "a taken-over mirror is still a mirror");
97+ assert.deepEqual(slugs({ kind: "g1t" }), ["own"]);
98+ assert.deepEqual(slugs({ kind: "library" }), ["taken"]);
99+ assert.equal(q("?kind=mirror").kind, "mirror");
100+ assert.equal(q("?kind=g1t").kind, "g1t");
101+ assert.equal(projectQueryString({ ...DEFAULT_QUERY, kind: "mirror" }), "?kind=mirror");
102+ assert.equal(facetsOf(list).mirrors, 2);
103+});
104+
88105 test("search matches every word, in the name, slug or description, names that start with it first", () => {
89106 const list = [
90107 project("docs-site", { name: "Docs site", description: "The API reference" }),
+31−7
55 * filtered list can be shared, bookmarked and gone back to.
66 */
77
8−import type { Project, ProjectEcosystem, ProjectKind } from "@g1t/contracts";
8+import type { MirrorState, Project, ProjectEcosystem, ProjectKind } from "@g1t/contracts";
99
1010 export type ProjectSort = "updated" | "name" | "active" | "pushed";
1111 export type ProjectView = "list" | "grid";
1212
13+/**
14+ * The Kind filter's choices: a kind of project, or where it is managed.
15+ * `mirror` is a repository that mirrors a remote (whatever state the mirror
16+ * is in), `g1t` one that is g1t's own.
17+ */
18+export type ProjectKindFilter = "all" | ProjectKind | "mirror" | "g1t";
19+
1320 export type ProjectQuery = {
1421 /** Words in the name, slug or description. */
1522 q: string;
1623 visibility: "all" | "public" | "private";
17− kind: "all" | ProjectKind;
24+ kind: ProjectKindFilter;
1825 /** A language, from the manifests at the project's root. */
1926 language: string | null;
2027 /** Only projects with Deployments on. */
5865 python: "Python",
5966 };
6067
61−/** What the list needs of a project, with whether its Deployments are on. */
68+/** The remote a project's repository mirrors, and where the mirror stands, for its row. */
69+export type ListedMirror = { state: MirrorState; remote: string };
70+
71+/**
72+ * What the list needs of a project, with whether its Deployments are on and
73+ * the remote its repository mirrors (null for one managed on g1t).
74+ */
6275 export type Listed = Pick<
6376 Project,
6477 "id" | "slug" | "name" | "description" | "private" | "archived" | "kind" | "ecosystem" | "updatedAt" | "pushedAt" | "activity"
65−> & { deploying: boolean };
78+> & { deploying: boolean; mirror: ListedMirror | null };
6679
6780 const one = <T extends string>(value: string | null, allowed: readonly T[], fallback: T): T =>
6881 (allowed as readonly string[]).includes(value ?? "") ? (value as T) : fallback;
7386 return {
7487 q: (params.get("q") ?? "").trim().slice(0, 100),
7588 visibility: one(params.get("visibility"), ["all", "public", "private"] as const, "all"),
76− kind: one(params.get("kind"), ["all", "app", "library", "tool", "docs", "other"] as const, "all"),
89+ kind: one(params.get("kind"), ["all", "app", "library", "tool", "docs", "other", "mirror", "g1t"] as const, "all"),
7790 language: params.get("language")?.trim() || null,
7891 deployments: params.get("deployments") === "on",
7992 archived: one(params.get("archived"), ["hide", "include", "only"] as const, "hide"),
128141 (project) =>
129142 (query.archived === "include" || (query.archived === "only") === project.archived) &&
130143 (query.visibility === "all" || (query.visibility === "private") === project.private) &&
131− (query.kind === "all" || project.kind === query.kind) &&
144+ (query.kind === "all" ||
145+ (query.kind === "mirror" ? project.mirror != null : query.kind === "g1t" ? project.mirror == null : project.kind === query.kind)) &&
132146 (!query.language || (project.ecosystem != null && LANGUAGE_OF[project.ecosystem] === query.language)) &&
133147 (!query.deployments || project.deploying) &&
134148 (words.length === 0 || matches(project, words)),
170184 languages: { name: string; count: number }[];
171185 deploying: number;
172186 archived: number;
187+ /** Projects whose repository mirrors a remote; the rest are managed on g1t. */
188+ mirrors: number;
173189 };
174190
175191 /** How many of the workspace's projects each filter would show, before any is chosen. */
176192 export function facetsOf(projects: Listed[]): Facets {
177193 const languages = new Map<string, number>();
178− const facets: Facets = { visibility: { public: 0, private: 0 }, kind: { app: 0, library: 0, tool: 0, docs: 0, other: 0 }, languages: [], deploying: 0, archived: 0 };
194+ const facets: Facets = {
195+ visibility: { public: 0, private: 0 },
196+ kind: { app: 0, library: 0, tool: 0, docs: 0, other: 0 },
197+ languages: [],
198+ deploying: 0,
199+ archived: 0,
200+ mirrors: 0,
201+ };
179202 for (const project of projects) {
180203 facets.visibility[project.private ? "private" : "public"]++;
181204 facets.kind[project.kind]++;
182205 if (project.deploying) facets.deploying++;
206+ if (project.mirror) facets.mirrors++;
183207 if (project.archived) facets.archived++;
184208 if (project.ecosystem) {
185209 const language = LANGUAGE_OF[project.ecosystem];
+19−0
22 import { readFileSync } from "node:fs";
33 import { test } from "node:test";
44
5+import type { RepoMirror } from "@g1t/contracts";
6+
7+import { tabsFor } from "./project-nav.ts";
58 import { ROADMAP } from "./roadmap.ts";
69
10+const mirror = (state: RepoMirror["state"]): RepoMirror => ({ state, remote: "github.com/acme/web", url: "https://github.com/acme/web", since: "2026-10-08T10:00:00Z" });
11+const can = { security_alerts: true };
12+
13+test("a mirror has tabs only for the pages it offers", () => {
14+ const labels = (tabs: ReturnType<typeof tabsFor>) => tabs?.map((tab) => tab.label) ?? null;
15+ // Code and Security read the same whoever leads.
16+ assert.deepEqual(labels(tabsFor("commits", true, can, mirror("standby"))), labels(tabsFor("commits", true, can, null)));
17+ assert.deepEqual(labels(tabsFor("security/vulnerabilities", true, can, mirror("standby"))), labels(tabsFor("security/vulnerabilities", true, can, null)));
18+ // Issues, Agents and the pull requests' tabs come with a takeover.
19+ assert.equal(tabsFor("issues", true, can, mirror("standby")), null);
20+ assert.equal(tabsFor("sessions", true, can, mirror("ci")), null);
21+ assert.equal(tabsFor("queue", true, can, mirror("handing_back")), null);
22+ assert.ok(tabsFor("issues", true, can, mirror("takeover")));
23+ assert.ok(tabsFor("queue", true, can, mirror("takeover")));
24+});
25+
726 test("code is not docs: a project never gets a Docs tab", () => {
827 // Docs is its own workspace mode, filtered by project there.
928 assert.equal(ROADMAP.some((item) => item.section !== "Workspace" && /docs/i.test(item.key)), false);
+17−5
44 * across its top, the way GitHub and Vercel do. Soon tabs come from the
55 * roadmap and open its page for them, under the same tabs.
66 */
7−import type { Abilities, Capability } from "@g1t/contracts";
7+import type { Abilities, Capability, RepoMirror } from "@g1t/contracts";
88
9−import { type RoadmapItem, roadmapIn } from "./roadmap";
9+import { type RepoArea, offers } from "./mirror.ts";
10+import { type RoadmapItem, roadmapIn } from "./roadmap.ts";
1011
1112 export type Tab = {
1213 label: string;
2627
2728 export type Section = {
2829 key: RoadmapItem["section"] | "Workflows" | "Settings";
30+ /** The part of the repository it is about: a mirror shows the tabs of what it offers (lib/mirror.ts `offers`). */
31+ area: RepoArea;
2932 tabs: Tab[];
3033 };
3134
3639 export const SECTIONS: Section[] = [
3740 {
3841 key: "Code",
42+ area: "code",
3943 tabs: [
4044 { label: "Files", path: "code", also: ["tree", "blob"] },
4145 { label: "Commits", path: "commits", also: ["commit"] },
4852 },
4953 {
5054 key: "Issues",
55+ area: "issues",
5156 tabs: [
5257 { label: "Issues", path: "issues" },
5358 { label: "Outcomes", path: "plans" },
5762 },
5863 {
5964 key: "Agents",
65+ area: "agents",
6066 tabs: [
6167 { label: "At work", path: "agents" },
6268 { label: "Sessions", path: "sessions" },
6672 },
6773 {
6874 key: "Deployments",
75+ area: "deployments",
6976 tabs: [{ label: "Deployments", path: "deployments" }, ...soon("Deployments")],
7077 },
71− { key: "Observability", tabs: soon("Observability") },
78+ { key: "Observability", area: "deployments", tabs: soon("Observability") },
7279 {
7380 key: "Security",
81+ area: "security",
7482 tabs: [
7583 { label: "Overview", path: "security", members: true, needs: "security_alerts", exact: true },
7684 { label: "Secret scanning", path: "security/secret-scanning", members: true, needs: "security_alerts" },
8492 },
8593 {
8694 key: "Insights",
95+ area: "insights",
8796 tabs: [
8897 { label: "Contributors", path: "contributors" },
8998 { label: "Activity", path: "activity" },
96105 /** Pull requests and the merge queue share a page's tabs too. */
97106 export const PULLS: Section = {
98107 key: "Code",
108+ area: "pulls",
99109 tabs: [
100110 { label: "Pull requests", path: "pulls", also: ["pull"] },
101111 { label: "Merge queue", path: "queue" },
108118
109119 /**
110120 * The tabs for the page at `rest` (the path under the project), if it
111− * belongs to a page with more than one view.
121+ * belongs to a page with more than one view. On a mirror, only the pages
122+ * it offers now have tabs (`mirror`, the repository's).
112123 */
113−export function tabsFor(rest: string, member: boolean, can?: Partial<Abilities>): Tab[] | null {
124+export function tabsFor(rest: string, member: boolean, can?: Partial<Abilities>, mirror?: RepoMirror | null): Tab[] | null {
114125 for (const section of [...SECTIONS, PULLS]) {
126+ if (!offers(mirror, section.area)) continue;
115127 const visible = section.tabs.filter(
116128 (tab) => (member || !tab.members) && (!tab.needs || !can || Boolean(can[tab.needs])),
117129 );
+2−1
77
88 import type { Route } from "./+types/add-ci";
99 import { addCi } from "../../lib/add-ci.server";
10−import { refusal } from "../../lib/access.server";
10+import { refusal, requireWritableRepo } from "../../lib/access.server";
1111 import { assertSameOrigin, requireUser } from "../../lib/session.server";
1212
1313 export async function action({ request, params, context }: Route.ActionArgs) {
1414 assertSameOrigin(request);
1515 const user = requireUser(context, request);
16+ await requireWritableRepo(context, params);
1617 const refused = await refusal(context, params, "push");
1718 if (refused) return { error: refused };
1819 const added = await addCi(user, { namespace: params.owner, name: params.repo });
+2−1
3030 import { neverDeploys } from "../../lib/project-kind";
3131 import { billing, deployments, projects } from "../../lib/services.server";
3232 import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
33−import { refusal, requireRepo } from "../../lib/access.server";
33+import { refusal, requireRepo, requireWritableRepo } from "../../lib/access.server";
3434 import { whyNot } from "../../lib/access";
3535 import {
3636 type ListFilter,
101101 export async function action({ request, params, context }: Route.ActionArgs) {
102102 assertSameOrigin(request);
103103 const user = requireUser(context, request);
104+ await requireWritableRepo(context, params);
104105 const form = await request.formData();
105106 const ref = { workspace: params.owner, slug: params.repo };
106107 const intent = form.get("intent");
+2−1
1515 import { LabelChip } from "../../components/labels";
1616 import { integrations, work } from "../../lib/services.server";
1717 import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
18−import { accessTo } from "../../lib/access.server";
18+import { accessTo, requireWritableRepo } from "../../lib/access.server";
1919 import { computeNoteFor } from "../../lib/compute.server";
2020 import { delegateForm, issuePath, notStarted } from "../../lib/delegate";
2121
5656 export async function action({ request, params, context }: Route.ActionArgs) {
5757 assertSameOrigin(request);
5858 const user = requireUser(context, request);
59+ await requireWritableRepo(context, params);
5960 const form = await request.formData();
6061 if (form.get("intent") === "import") {
6162 const imported = await integrations.import(
+2−1
2424 import { isWaitingMessage } from "../../lib/compute";
2525 import { work } from "../../lib/services.server";
2626 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
27−import { accessTo, refusal } from "../../lib/access.server";
27+import { accessTo, refusal, requireWritableRepo } from "../../lib/access.server";
2828
2929 /** As many sandboxes as one request may start. */
3030 const MAX_ASSIGNED_AT_ONCE = 10;
6666 export async function action({ request, params, context }: Route.ActionArgs) {
6767 assertSameOrigin(request);
6868 const user = requireUser(context, request);
69+ await requireWritableRepo(context, params);
6970 const path = { namespace: params.owner, name: params.repo };
7071 const numbers = (await request.formData())
7172 .getAll("issue")
+2−1
2020 } from "../../components/ui/alert-dialog";
2121 import { Button } from "../../components/ui/button";
2222 import { Card } from "../../components/ui/card";
23−import { refusal, requireRepo } from "../../lib/access.server";
23+import { refusal, requireRepo, requireWritableRepo } from "../../lib/access.server";
2424 import { matchLabels, tidyColor } from "../../lib/labels";
2525 import { page } from "../../lib/meta";
2626 import { work } from "../../lib/services.server";
4141 export async function action({ request, params, context }: Route.ActionArgs): Promise<ActionData> {
4242 assertSameOrigin(request);
4343 const user = requireUser(context, request);
44+ await requireWritableRepo(context, params);
4445 const form = await request.formData();
4546 const intent = String(form.get("intent") ?? "");
4647 const name = String(form.get("name") ?? "");
+15−5
1919 import { PinButton } from "../../components/pin-button";
2020 import { StarButton } from "../../components/star-button";
2121 import { ArchivedBanner } from "../../components/repo-lifecycle";
22−import { MirrorBadge, MirrorBanner } from "../../components/mirror";
22+import { MirrorBadge, MirrorBanner, MirrorReadOnlyPage } from "../../components/mirror";
23+import { hiddenOnMirror } from "../../lib/chrome";
2324 import type { MirrorBrief } from "../../lib/mirror";
2425 import { WelcomeBanner } from "../../components/welcome";
2526 import { clearWelcome, welcomes } from "../../lib/invites";
196197 // The project's own description, else the repository's as it is now.
197198 const description = (project && !project.descriptionInherited ? project.description : null) ?? repo.description;
198199 const { pathname } = useLocation();
199− const tabs = tabsFor(pathname.slice(base.length + 1), member, access.can);
200+ const rest = pathname.slice(base.length + 1);
201+ const tabs = tabsFor(rest, member, access.can, repo.mirror);
202+ // A page a mirror does not offer now (lib/chrome.ts): the note that says
203+ // where the work happens stands in for its list or form, whatever the
204+ // address, and its tabs stay away with it.
205+ const hidden = hiddenOnMirror(rest, repo.mirror);
200206 // The files' own About says what it is and its topics, as the one place.
201− const filesPage = /^(code|tree|blob|commits?|branches|tags|releases|compare)(\/|$)/.test(pathname.slice(base.length + 1));
207+ const filesPage = /^(code|tree|blob|commits?|branches|tags|releases|compare)(\/|$)/.test(rest);
202208 // Everyone, signed in or not, finds the project's pages in the sidebar;
203209 // the page shows its name, and the views of the page it is on as tabs.
204210 return (
230236 {!filesPage && <Topics topics={repo.topics} />}
231237 {/* A phone, or a visitor: the project's pages, which the sidebar lists for someone signed in. */}
232238 <div className={signedIn ? "mt-3 md:hidden" : "mt-3"}>
233− <ProjectStrip base={base} member={member} can={access.can} settings={seesSettings(access)} counts={loaderData.open} always={!signedIn} />
239+ <ProjectStrip base={base} member={member} can={access.can} settings={seesSettings(access)} counts={loaderData.open} mirror={repo.mirror} always={!signedIn} />
234240 </div>
235241 {tabs && (
236242 <div className="mt-3">
263269 className="mb-6"
264270 />
265271 )}
266− <Outlet />
272+ {hidden && repo.mirror ? (
273+ <MirrorReadOnlyPage base={base} repo={repo} mirror={repo.mirror} admin={access.can.manage_integrations} what={hidden.what} />
274+ ) : (
275+ <Outlet />
276+ )}
267277 </div>
268278 </>
269279 );
+2−1
77 import { ReviewQueue, reviewAction } from "../../components/context";
88 import { agents, memoryReview } from "../../lib/services.server";
99 import { assertSameOrigin, requireUser, roleIn, unwrap } from "../../lib/session.server";
10−import { requireRepo } from "../../lib/access.server";
10+import { requireRepo, requireWritableRepo } from "../../lib/access.server";
1111
1212 export function meta({ params, ...args }: Route.MetaArgs) {
1313 return page(args, { title: `Memory · ${params.owner}/${params.repo} · g1t` });
3636 export async function action({ params, context, request }: Route.ActionArgs) {
3737 assertSameOrigin(request);
3838 const user = requireUser(context, request);
39+ await requireWritableRepo(context, params);
3940 const form = await request.formData();
4041 return (
4142 (await reviewAction(memoryReview, user, params.owner, form)) ??
+298−105
3232 type PackageSummary,
3333 type Project,
3434 type Pull,
35+ type RepoMirror,
3536 RUN_KIND_LABEL,
3637 isActiveRun,
3738 workOwner,
5152 import { ProductionShot } from "../../components/production-shot";
5253 import { GithubLinkStrip } from "../../components/github";
5354 import { MirrorOverviewNote, useRepoMirror } from "../../components/mirror";
55+import { type MirrorBrief, leaderOf, mirroringSettings, writable } from "../../lib/mirror";
5456 import { distinctFacts } from "../../lib/memory-facts";
5557 import { githubApp } from "../../lib/github.server";
5658 import { ButtonLink, CopyLine, SubmitButton, TimeAgo } from "../../components/ui";
9799 import { actions, agents, deployments, events as eventLog, identity, packages, projects, repos, work } from "../../lib/services.server";
98100 import { madeByG1t } from "../../lib/opened-by";
99101 import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
100−import { accessTo, countsFor, projectFor, refusal, repoFor } from "../../lib/access.server";
102+import { accessTo, countsFor, projectFor, refusal, repoFor, requireWritableRepo } from "../../lib/access.server";
101103 import { shotVersion } from "./production-screenshot";
102104 import { DeploymentsPanel } from "../../components/deployments-panel";
103105 import { environmentUrl, productionEnvironment } from "../../lib/deployments";
497499 return saved.ok ? { notice: "Saved." } : { error: saved.error.message };
498500 }
499501 // Deploying is compute. Syncing with GitHub is under Settings → Mirroring.
502+ await requireWritableRepo(context, params);
500503 const refused = await refusal(context, params, "run");
501504 if (refused) return { error: refused };
502505 const started = await deployments.redeploy(user, { workspace: params.owner, slug: params.repo }, null);
817820 const ages = health.ages;
818821 const oldest = ages ? Math.max(1, ...ages.map((a) => a.count)) : 1;
819822
823+ // A mirror that is not taken over is a read-only copy (lib/mirror.ts
824+ // `offers`): what it is, where it comes from and what it holds, and
825+ // nothing to start. Where it runs, the checklist, Right now and the
826+ // agents' panels come back with a takeover.
827+ const readOnlyMirror = mirrorRepo?.mirror && !writable(mirrorRepo.mirror) ? mirrorRepo.mirror : null;
828+ if (readOnlyMirror) {
829+ return (
830+ <MirrorOverview
831+ loaderData={loaderData}
832+ base={base}
833+ mirror={readOnlyMirror}
834+ briefs={mirrorBriefs}
835+ admin={mirrorAdmin}
836+ source={source}
837+ canChange={canChange}
838+ addresses={addresses}
839+ />
840+ );
841+ }
842+
820843 return (
821844 <div className="space-y-8">
822845 {loaderData.github?.mode === "import" ? (
12371260 )}
12381261 </Panel>
12391262
1240− {loaderData.commits.length > 0 && (
1241− <Panel
1242− title={`Latest on ${source?.defaultBranch ?? "main"}`}
1243− icon={<GitCommitHorizontal size={14} />}
1244− all={{ to: `${base}/commits`, label: "All commits" }}
1245− >
1246− <Card asChild divided className="overflow-hidden">
1247− <ul>
1248− {loaderData.commits.map((one) => (
1249− <li key={one.hash} className="flex items-center gap-3 px-4 py-2.5 text-sm">
1250− <CommitAvatars commit={one} size={18} max={2} />
1251− <span className="min-w-0 grow">
1252− <Link to={`${base}/commit/${one.hash}`} className="block truncate hover:text-accent">
1253− {one.message}
1254− </Link>
1255− <span className="text-xs text-muted">
1256− <CommitNames commit={one} className="hover:text-fg" />
1257− </span>
1258− </span>
1259− <Link to={`${base}/commit/${one.hash}`} className="hidden shrink-0 font-mono text-xs text-faint hover:text-fg sm:block">
1260− {one.hash.slice(0, 7)}
1261− </Link>
1262− <span className="w-16 shrink-0 text-right text-xs text-faint">
1263− <TimeAgo at={one.at} />
1264− </span>
1265− </li>
1266− ))}
1267− </ul>
1268− </Card>
1269− </Panel>
1270− )}
1263+ <LatestCommits commits={loaderData.commits} base={base} branch={source?.defaultBranch ?? "main"} />
12711264
12721265 <Panel title="Activity" icon={<ActivityIcon size={14} />}>
12731266 {loaderData.eventsLoaded ? (
13131306 <aside className="space-y-4 text-sm">
13141307 {/* Deployments panel (deployments-panel.tsx): each environment's latest deployment. */}
13151308 <DeploymentsPanel base={base} summary={loaderData.environments} className="rounded-xl border border-line bg-surface p-5" />
1316− {project && (
1317− <Card asChild className="p-5">
1318− <section>
1319− <div className="flex items-center justify-between">
1320− <h2 className="text-sm font-semibold">About</h2>
1321− {canChange && <AboutEditor project={project} />}
1322− </div>
1323− <p className={`mt-2.5 text-sm ${project.description ? "text-fg-soft" : "text-faint"}`}>
1324− {project.description ?? "No description."}
1325− </p>
1326− <LinkList links={project.links} className="mt-3" />
1327− <ul className="mt-4 space-y-2 text-xs text-muted">
1328− <li className="flex items-center gap-2">
1329− {project.kind === "library" || project.kind === "tool" ? (
1330− <PackageGlyph size={13} className="shrink-0 text-faint" />
1331− ) : project.kind === "docs" ? (
1332− <BookOpen size={13} className="shrink-0 text-faint" />
1333− ) : (
1334− <Rocket size={13} className="shrink-0 text-faint" />
1335− )}
1336− <Hint label={project.kindReason.detail}>
1337− <span>{kindLabel(project)}</span>
1338− </Hint>
1339− </li>
1340− {loaderData.release && (
1341− <li className="flex items-center gap-2">
1342− <Tag size={13} className="shrink-0 text-faint" />
1343− <Link to={`${base}/tags`} className="hover:text-fg">
1344− <span className="font-mono text-fg-soft">{loaderData.release.name}</span>
1345− {loaderData.release.at && (
1346− <span className="text-faint">
1347− {" · "}
1348− <TimeAgo at={loaderData.release.at} />
1349− </span>
1350− )}
1351− </Link>
1352− </li>
1353− )}
1354− {loaderData.languages && loaderData.languages.length > 0 && (
1355− <li className="flex items-center gap-2">
1356− <Code2 size={13} className="shrink-0 text-faint" />
1357− <span>
1358− {loaderData.languages
1359− .slice(0, 3)
1360− .map((language) => `${language.name} ${Math.round(language.share * 100)}%`)
1361− .join(" · ")}
1362− </span>
1363− </li>
1364− )}
1365− </ul>
1366− {loaderData.contributors && loaderData.contributors.length > 0 && (
1367− <div className="mt-4 flex flex-wrap gap-1">
1368− {loaderData.contributors.slice(0, 12).map((person) => (
1369− <Hint key={person.username} label={person.username}>
1370− <Link to={`/${person.username}`}>
1371− <Avatar name={person.username} size={22} />
1372− </Link>
1373− </Hint>
1374− ))}
1375− </div>
1376− )}
1377− </section>
1378− </Card>
1379− )}
1309+ {project && <AboutCard project={project} canChange={canChange} base={base} loaderData={loaderData} />}
13801310
13811311 {member && (
13821312 <Card asChild className="p-5">
14861416 </section>
14871417 </Card>
14881418
1489− {source && (
1490− <Card asChild className="p-5">
1491− <section>
1492− <h2 className="text-sm font-semibold">Clone</h2>
1493− <div className="mt-3">
1494− <CopyLine breakAtSlashes text={`git clone ${cloneUrl(addresses, `${source.repo.namespace}/${source.repo.name}`)}`} />
1495− </div>
1496− </section>
1497− </Card>
1419+ {source && <CloneCard addresses={addresses} path={`${source.repo.namespace}/${source.repo.name}`} />}
1420+ </aside>
1421+ </div>
1422+ </div>
1423+ );
1424+}
1425+
1426+/** The newest commits on the default branch, with who made them. */
1427+function LatestCommits({ commits, base, branch }: { commits: Loaded["commits"]; base: string; branch: string }) {
1428+ if (commits.length === 0) return null;
1429+ return (
1430+ <Panel title={`Latest on ${branch}`} icon={<GitCommitHorizontal size={14} />} all={{ to: `${base}/commits`, label: "All commits" }}>
1431+ <Card asChild divided className="overflow-hidden">
1432+ <ul>
1433+ {commits.map((one) => (
1434+ <li key={one.hash} className="flex items-center gap-3 px-4 py-2.5 text-sm">
1435+ <CommitAvatars commit={one} size={18} max={2} />
1436+ <span className="min-w-0 grow">
1437+ <Link to={`${base}/commit/${one.hash}`} className="block truncate hover:text-accent">
1438+ {one.message}
1439+ </Link>
1440+ <span className="text-xs text-muted">
1441+ <CommitNames commit={one} className="hover:text-fg" />
1442+ </span>
1443+ </span>
1444+ <Link to={`${base}/commit/${one.hash}`} className="hidden shrink-0 font-mono text-xs text-faint hover:text-fg sm:block">
1445+ {one.hash.slice(0, 7)}
1446+ </Link>
1447+ <span className="w-16 shrink-0 text-right text-xs text-faint">
1448+ <TimeAgo at={one.at} />
1449+ </span>
1450+ </li>
1451+ ))}
1452+ </ul>
1453+ </Card>
1454+ </Panel>
1455+ );
1456+}
1457+
1458+/** What the project is: its description, links, kind, latest release, languages and contributors. */
1459+function AboutCard({
1460+ project,
1461+ canChange,
1462+ base,
1463+ loaderData,
1464+}: {
1465+ project: NonNullable<Loaded["project"]>;
1466+ canChange: boolean;
1467+ base: string;
1468+ loaderData: Pick<Loaded, "release" | "languages" | "contributors">;
1469+}) {
1470+ return (
1471+ <Card asChild className="p-5">
1472+ <section>
1473+ <div className="flex items-center justify-between">
1474+ <h2 className="text-sm font-semibold">About</h2>
1475+ {canChange && <AboutEditor project={project} />}
1476+ </div>
1477+ <p className={`mt-2.5 text-sm ${project.description ? "text-fg-soft" : "text-faint"}`}>{project.description ?? "No description."}</p>
1478+ <LinkList links={project.links} className="mt-3" />
1479+ <ul className="mt-4 space-y-2 text-xs text-muted">
1480+ <li className="flex items-center gap-2">
1481+ {project.kind === "library" || project.kind === "tool" ? (
1482+ <PackageGlyph size={13} className="shrink-0 text-faint" />
1483+ ) : project.kind === "docs" ? (
1484+ <BookOpen size={13} className="shrink-0 text-faint" />
1485+ ) : (
1486+ <Rocket size={13} className="shrink-0 text-faint" />
1487+ )}
1488+ <Hint label={project.kindReason.detail}>
1489+ <span>{kindLabel(project)}</span>
1490+ </Hint>
1491+ </li>
1492+ {loaderData.release && (
1493+ <li className="flex items-center gap-2">
1494+ <Tag size={13} className="shrink-0 text-faint" />
1495+ <Link to={`${base}/tags`} className="hover:text-fg">
1496+ <span className="font-mono text-fg-soft">{loaderData.release.name}</span>
1497+ {loaderData.release.at && (
1498+ <span className="text-faint">
1499+ {" · "}
1500+ <TimeAgo at={loaderData.release.at} />
1501+ </span>
1502+ )}
1503+ </Link>
1504+ </li>
1505+ )}
1506+ {loaderData.languages && loaderData.languages.length > 0 && (
1507+ <li className="flex items-center gap-2">
1508+ <Code2 size={13} className="shrink-0 text-faint" />
1509+ <span>
1510+ {loaderData.languages
1511+ .slice(0, 3)
1512+ .map((language) => `${language.name} ${Math.round(language.share * 100)}%`)
1513+ .join(" · ")}
1514+ </span>
1515+ </li>
14981516 )}
1517+ </ul>
1518+ {loaderData.contributors && loaderData.contributors.length > 0 && (
1519+ <div className="mt-4 flex flex-wrap gap-1">
1520+ {loaderData.contributors.slice(0, 12).map((person) => (
1521+ <Hint key={person.username} label={person.username}>
1522+ <Link to={`/${person.username}`}>
1523+ <Avatar name={person.username} size={22} />
1524+ </Link>
1525+ </Hint>
1526+ ))}
1527+ </div>
1528+ )}
1529+ </section>
1530+ </Card>
1531+ );
1532+}
1533+
1534+/** The clone command, with the address this g1t serves. */
1535+function CloneCard({ addresses, path }: { addresses: ReturnType<typeof useAddresses>; path: string }) {
1536+ return (
1537+ <Card asChild className="p-5">
1538+ <section>
1539+ <h2 className="text-sm font-semibold">Clone</h2>
1540+ <div className="mt-3">
1541+ <CopyLine breakAtSlashes text={`git clone ${cloneUrl(addresses, path)}`} />
1542+ </div>
1543+ </section>
1544+ </Card>
1545+ );
1546+}
1547+
1548+/**
1549+ * The overview of a mirror g1t does not lead (standing by, in CI failover
1550+ * or handing back): the remote it copies and when it last did, the facts
1551+ * of the copy, its latest commits, and About. Nothing here starts
1552+ * anything: there is no place it runs, no checklist and no Right now, since
1553+ * work happens on the remote until someone takes over.
1554+ */
1555+function MirrorOverview({
1556+ loaderData,
1557+ base,
1558+ mirror,
1559+ briefs,
1560+ admin,
1561+ source,
1562+ canChange,
1563+ addresses,
1564+}: {
1565+ loaderData: Loaded;
1566+ base: string;
1567+ mirror: RepoMirror;
1568+ briefs: readonly MirrorBrief[];
1569+ admin: boolean;
1570+ source: { repo: { namespace: string; name: string }; defaultBranch?: string | null } | null;
1571+ canChange: boolean;
1572+ addresses: ReturnType<typeof useAddresses>;
1573+}) {
1574+ const { project, commit, checks } = loaderData;
1575+ const branch = source?.defaultBranch ?? "main";
1576+ const synced = leaderOf(briefs)?.syncedAt ?? loaderData.github?.syncedAt ?? null;
1577+ const remote = (
1578+ <a href={mirror.url} target="_blank" rel="noreferrer" className="font-mono text-fg hover:text-accent">
1579+ {mirror.remote}
1580+ </a>
1581+ );
1582+ return (
1583+ <div className="space-y-8">
1584+ <MirrorOverviewNote
1585+ base={base}
1586+ mirror={mirror}
1587+ briefs={briefs}
1588+ admin={admin}
1589+ syncedAt={loaderData.github?.syncedAt}
1590+ lastError={loaderData.github?.lastError}
1591+ />
1592+ {/* The copy: where it comes from, and what it holds. */}
1593+ <section className="overflow-hidden rounded-2xl border border-line bg-surface">
1594+ <div className="flex flex-wrap items-start justify-between gap-4 p-5 sm:p-6">
1595+ <div className="min-w-0">
1596+ <p className="flex items-center gap-2 text-xs font-medium tracking-wide text-muted uppercase">
1597+ <GitBranch size={13} className="text-accent" />
1598+ {mirror.state === "handing_back" ? "Handing back to" : "Mirror of"}
1599+ </p>
1600+ <DeployLink href={mirror.url} className="mt-2 flex items-center gap-1.5 truncate font-mono text-lg font-medium hover:text-accent">
1601+ {mirror.remote}
1602+ <ArrowUpRight size={16} className="shrink-0 text-faint" />
1603+ </DeployLink>
1604+ <p className="mt-1.5 max-w-xl text-sm text-muted">
1605+ {mirror.state === "handing_back" ? (
1606+ <>What was done here is going back to {remote}, branch by branch. Read-only until it is done.</>
1607+ ) : (
1608+ <>
1609+ Every branch and tag of {remote} is copied here as it moves. Nothing runs or opens here on its own;
1610+ {mirror.state === "ci" && " its workflows run on g1t meanwhile;"} the work happens there until someone takes over.
1611+ </>
1612+ )}
1613+ </p>
1614+ </div>
1615+ {admin && mirror.state !== "handing_back" && (
1616+ <ButtonLink to={mirroringSettings(base)} variant="outline" className="shrink-0">
1617+ Mirroring settings
1618+ </ButtonLink>
1619+ )}
1620+ </div>
1621+ <div className="grid grid-cols-2 divide-line border-t border-line text-sm sm:grid-cols-4 sm:divide-x">
1622+ <Stat
1623+ label="Source"
1624+ value={
1625+ <a href={mirror.url} target="_blank" rel="noreferrer" className="inline-flex min-w-0 items-center gap-1.5 font-mono hover:text-accent">
1626+ <Code2 size={12} className="shrink-0 text-faint" />
1627+ <span className="truncate">{mirror.remote}</span>
1628+ </a>
1629+ }
1630+ />
1631+ <Stat
1632+ label="Default branch"
1633+ to={`${base}/commits`}
1634+ value={
1635+ <span className="inline-flex items-center gap-1.5 font-mono">
1636+ <GitBranch size={12} className="text-faint" />
1637+ {branch}
1638+ </span>
1639+ }
1640+ />
1641+ <Stat
1642+ label="Latest commit"
1643+ value={
1644+ commit ? (
1645+ <span className="flex min-w-0 items-center gap-1">
1646+ <Hint label={commit.message.split("\n")[0]}>
1647+ <Link to={`${base}/commit/${commit.hash}`} className="min-w-0 truncate hover:text-accent">
1648+ <span className="font-mono">{commit.hash.slice(0, 7)}</span>{" "}
1649+ <span className="font-normal text-muted">
1650+ <TimeAgo at={commit.authoredAt} />
1651+ </span>
1652+ </Link>
1653+ </Hint>
1654+ <CommitChecksBadge checks={checks} sha={commit.hash} className="size-5" />
1655+ </span>
1656+ ) : (
1657+ "No commits yet"
1658+ )
1659+ }
1660+ />
1661+ <Stat
1662+ label="Synced"
1663+ value={
1664+ synced ? (
1665+ <span className="font-normal text-muted">
1666+ <TimeAgo at={synced} />
1667+ </span>
1668+ ) : (
1669+ "Not yet"
1670+ )
1671+ }
1672+ />
1673+ </div>
1674+ {commit && (
1675+ <p className="truncate border-t border-line px-5 py-2.5 text-xs text-muted sm:px-6">
1676+ <GitCommitHorizontal size={12} className="mr-1.5 inline text-faint" />
1677+ {commit.message.split("\n")[0]}{" "}
1678+ <span className="text-faint">
1679+ · <CommitNames commit={commit} className="hover:text-fg" />
1680+ </span>
1681+ </p>
1682+ )}
1683+ </section>
1684+
1685+ <div className="grid gap-x-8 gap-y-8 lg:grid-cols-[minmax(0,1fr)_20rem]">
1686+ <div className="min-w-0 space-y-8">
1687+ <LatestCommits commits={loaderData.commits} base={base} branch={branch} />
1688+ </div>
1689+ <aside className="space-y-4 text-sm">
1690+ {project && <AboutCard project={project} canChange={canChange} base={base} loaderData={loaderData} />}
1691+ {source && <CloneCard addresses={addresses} path={`${source.repo.namespace}/${source.repo.name}`} />}
14991692 </aside>
15001693 </div>
15011694 </div>
+2−1
44 import { Form, Link, data } from "react-router";
55
66 import type { Route } from "./+types/plan";
7−import { refusal, requireRepo } from "../../lib/access.server";
7+import { refusal, requireRepo, requireWritableRepo } from "../../lib/access.server";
88 import { whyNot } from "../../lib/access";
99 import { page } from "../../lib/meta";
1010 import { Markdown } from "../../components/markdown";
8686 export async function action({ request, params, context }: Route.ActionArgs) {
8787 assertSameOrigin(request);
8888 const user = requireUser(context, request);
89+ await requireWritableRepo(context, params);
8990 const form = await request.formData();
9091 const path = { namespace: params.owner, name: params.repo };
9192 // Opening the issues and putting agents on them: Write and up.
+2−1
55 import type { Plan } from "@g1t/contracts";
66
77 import type { Route } from "./+types/plans";
8−import { refusal, requireRepo } from "../../lib/access.server";
8+import { refusal, requireRepo, requireWritableRepo } from "../../lib/access.server";
99 import { whyNot } from "../../lib/access";
1010 import { page } from "../../lib/meta";
1111 import { ComputeNote, EmptyState, ErrorText, SubmitButton, Textarea, TimeAgo } from "../../components/ui";
4040 export async function action({ request, params, context }: Route.ActionArgs) {
4141 assertSameOrigin(request);
4242 const user = requireUser(context, request);
43+ await requireWritableRepo(context, params);
4344 const form = await request.formData();
4445 const path = { namespace: params.owner, name: params.repo };
4546 // Planning is an agent run: Write and up.
+2−0
1919 import { Card } from "../../components/ui/card";
2020 import { repos, work } from "../../lib/services.server";
2121 import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
22+import { requireWritableRepo } from "../../lib/access.server";
2223
2324 export function meta({ params, ...args }: Route.MetaArgs) {
2425 return page(args, { title: `New pull request · ${params.owner}/${params.repo} · g1t` });
5960 export async function action({ request, params, context }: Route.ActionArgs) {
6061 assertSameOrigin(request);
6162 const user = requireUser(context, request);
63+ await requireWritableRepo(context, params);
6264 const form = await request.formData();
6365 const issue = Number(form.get("issue"));
6466 const result = await work.openPull(
+2−1
77 import { ErrorText, Field, Input, SubmitButton, Textarea } from "../../components/ui";
88 import { Card } from "../../components/ui/card";
99 import { CheckboxOption } from "../../components/ui/checkbox";
10−import { requireRepo } from "../../lib/access.server";
10+import { requireRepo, requireWritableRepo } from "../../lib/access.server";
1111 import { page } from "../../lib/meta";
1212 import { repos } from "../../lib/services.server";
1313 import { assertSameOrigin, requireUser } from "../../lib/session.server";
2929 export async function action({ request, params, context }: Route.ActionArgs) {
3030 assertSameOrigin(request);
3131 const user = requireUser(context, request);
32+ await requireWritableRepo(context, params);
3233 const form = await request.formData();
3334 const text = (key: string) => String(form.get(key) ?? "").trim();
3435 const tagName = text("tag");
+2−1
66 import { redirect } from "react-router";
77
88 import type { Route } from "./+types/security-code-setup";
9−import { refusal } from "../../lib/access.server";
9+import { refusal, requireWritableRepo } from "../../lib/access.server";
1010 import { setupCodeScanning } from "../../lib/security-suite.server";
1111 import { assertSameOrigin, requireUser } from "../../lib/session.server";
1212
1313 export async function action({ request, params, context }: Route.ActionArgs) {
1414 assertSameOrigin(request);
1515 const user = requireUser(context, request);
16+ await requireWritableRepo(context, params);
1617 const refused = await refusal(context, params, "manage_settings");
1718 if (refused) return { error: refused };
1819 const opened = await setupCodeScanning(user, { namespace: params.owner, name: params.repo });
+9−1
2121 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
2222 import { refusal, requireInsider } from "../../lib/access.server";
2323 import { whyNot } from "../../lib/access";
24+import { useRepoMirror } from "../../components/mirror";
25+import { updatesWaitReason } from "../../lib/mirror";
2426 import { alertCapability, countByState, parseAlertState } from "../../lib/security-alerts";
2527 import { severityCounts } from "../../lib/security-suite";
2628
123125 const rescan = useFetcher<{ ok: boolean; error?: string }>();
124126 const upkeep = useFetcher<{ ok: boolean; error?: string }>();
125127 const upkeepOn = upkeep.formData ? upkeep.formData.get("enabled") === "true" : overview.upkeep;
128+ // A mirror g1t does not lead opens nothing on its own: the scan runs and
129+ // the alerts show, but each update waits (lib/mirror.ts `offers`).
130+ const updatesWait = updatesWaitReason(useRepoMirror().repo);
126131 const vulnCounts = countByState(overview.vulnerabilities);
127132 // A link to one alert shows it wherever it stands.
128133 const focus = search.get("finding");
146151 </h2>
147152 <p className="mt-1.5 max-w-2xl text-sm text-muted">
148153 Every package the lockfiles resolve is checked for known vulnerabilities, on every push to the default branch and
149− daily. With security updates on, g1t opens a pull request to upgrade each one that has a fix.
154+ daily.{" "}
155+ {updatesWait ?? "With security updates on, g1t opens a pull request to upgrade each one that has a fix."}
150156 </p>
151157 </div>
152158 {can.run && (
187193 fixes={fixes}
188194 pulls={pulls}
189195 upkeep={overview.upkeep}
196+ updatesWait={updatesWait}
190197 base={base}
191198 action={action}
192199 focus={focus}
208215 <span className="mt-1 block text-sm text-muted">
209216 Open a pull request to upgrade each vulnerable dependency that has a fix. It lands through your branch's
210217 required checks.
218+ {updatesWait && " On this mirror, it takes effect once the repository is taken over or moved to g1t."}
211219 </span>
212220 {upkeep.data?.error && <span className="mt-1 block text-xs text-danger">{upkeep.data.error}</span>}
213221 </span>
+2−1
5353 const pinned = (shortcuts?.pinned ?? []).map((pin) => byId.get(pin.id)).filter((project): project is Project => project != null);
5454 // However many there are: the most active first, archived ones last.
5555 const byActivity = sortProjects(
56− projects.map((project) => ({ ...project, pushedAt: project.pushedAt ?? null, activity: project.activity ?? 0, deploying: false })),
56+ // Deploying and mirroring do not order the list, so neither is read here.
57+ projects.map((project) => ({ ...project, pushedAt: project.pushedAt ?? null, activity: project.activity ?? 0, deploying: false, mirror: null })),
5758 "active",
5859 )
5960 .sort((a, b) => Number(a.archived) - Number(b.archived))
+28−2
22 import { type KeyboardEvent, useEffect, useRef, useState } from "react";
33 import { Form, Link, useLocation, useNavigation, useSubmit } from "react-router";
44
5+import type { MirrorState } from "@g1t/contracts";
6+
57 import type { Route } from "./+types/projects";
68 import { host } from "../../components/deploy";
9+import { MirrorBadge } from "../../components/mirror";
710 import { PinButton } from "../../components/pin-button";
811 import { EmptyState, TimeAgo, notACredential } from "../../components/ui";
912 import { Button } from "../../components/ui/button";
2831 readProjectQuery,
2932 } from "../../lib/project-list";
3033 import { kindLabel, primaryLink } from "../../lib/project-kind";
31−import { deployments, projects } from "../../lib/services.server";
34+import { deployments, mirrors, projects } from "../../lib/services.server";
3235 import { getViewer, roleIn } from "../../lib/session.server";
3336 import { workspaceProjects } from "../../lib/workspace-projects.server";
3437
5356 ]);
5457 const live = new Map((deploys?.ok ? deploys.value : []).map((entry) => [entry.slug, entry]));
5558 const full = new Map((listed.ok ? listed.value : []).map((project) => [project.id, project]));
59+ // Which repositories mirror a remote, and where each mirror stands, so a
60+ // row says it is a mirror and the list can be narrowed to them: one call
61+ // for every repository listed. Without an answer, nothing is marked.
62+ const repoIds = [...new Set((listed.ok ? listed.value : []).flatMap((project) => (project.source.kind === "hosted" ? [project.source.repoId] : [])))];
63+ const briefs = repoIds.length > 0 ? await mirrors.briefs(repoIds).catch(() => []) : [];
64+ const leaders = new Map(briefs.filter((brief) => brief.role === "leader").map((brief) => [brief.repoId, brief]));
5665 const all: Listed[] = (listed.ok ? listed.value : []).map((project) => ({
5766 id: project.id,
5867 slug: project.slug,
6675 pushedAt: project.pushedAt ?? null,
6776 activity: project.activity ?? 0,
6877 deploying: live.get(project.slug)?.enabled ?? false,
78+ mirror: (() => {
79+ const leader = project.source.kind === "hosted" ? leaders.get(project.source.repoId) : undefined;
80+ return leader ? { state: leader.state as MirrorState, remote: leader.name } : null;
81+ })(),
6982 }));
7083 const matched = filterProjects(all, query);
7184 const shown = pageOf(matched, query.page);
94107 /** A filter's select, on the same surface as the other controls. */
95108 const FILTER = "w-auto bg-surface";
96109
97−/** Its language, its kind when it is not an app, and its state, as small words. */
110+/**
111+ * Its language, its kind when it is not an app, whether it mirrors a
112+ * remote (and where the mirror stands), and its state, as small words.
113+ */
98114 function Facts({ project }: { project: Item }) {
99115 const language = project.ecosystem ? LANGUAGE_OF[project.ecosystem] : null;
100116 return (
101117 <>
118+ {project.mirror && (
119+ <MirrorBadge mirror={{ state: project.mirror.state, remote: project.mirror.remote, url: "", since: "" }} briefs={[]} />
120+ )}
102121 {language && <span>{language}</span>}
103122 {project.kind !== "app" && <span>{kindLabel(project)}</span>}
104123 {project.kind === "app" && project.runs === "elsewhere" && <span>Deployed elsewhere</span>}
367386 ...(facets.kind.tool > 0 || query.kind === "tool" ? [{ value: "tool", label: option("Tools", facets.kind.tool) }] : []),
368387 ...(facets.kind.docs > 0 || query.kind === "docs" ? [{ value: "docs", label: option("Docs", facets.kind.docs) }] : []),
369388 ...(facets.kind.other > 0 || query.kind === "other" ? [{ value: "other", label: option("Other", facets.kind.other) }] : []),
389+ // Where they are managed, once a mirror is among them.
390+ ...(facets.mirrors > 0 || query.kind === "mirror" || query.kind === "g1t"
391+ ? [
392+ { value: "mirror", label: option("Mirrors", facets.mirrors) },
393+ { value: "g1t", label: option("On g1t", total - facets.mirrors) },
394+ ]
395+ : []),
370396 ]}
371397 />
372398 {(facets.languages.length > 0 || query.language) && (
+113−0
1010 //! The integrations service keeps the links (`remotes`) and decides; the
1111 //! repos service keeps each repository's [`RepoMirror`], so pushes and
1212 //! merges are refused or allowed without asking anyone.
13+//!
14+//! What a mirror offers on g1t is one rule, [`RepoMirror::offers`], by
15+//! state (`mirrorOffers` in the TypeScript contracts):
16+//!
17+//! | Area | Standing by | CI failover | Taken over | Handing back |
18+//! | --- | --- | --- | --- | --- |
19+//! | `Code`: files, commits, branches, tags, compare, releases (read) | ✓ | ✓ | ✓ | ✓ |
20+//! | `Security`: what scanning found, read-only | ✓ | ✓ | ✓ | ✓ |
21+//! | `Insights`, `Settings` (mirroring settings included) | ✓ | ✓ | ✓ | ✓ |
22+//! | `Workflows`: runs on g1t | with Keep CI warm | ✓ | ✓ | – |
23+//! | `Issues`, `Pulls`, `Agents`, `Deployments` | – | – | ✓ | – |
24+//! | `Updates`: security and dependency updates, upkeep, anything that opens a branch or a pull request on its own | – | – | ✓ | – |
25+//!
26+//! The sidebar lists only what is offered, a page not offered says so in
27+//! place of its list or form, every service refuses the writes, and g1t's
28+//! own automation checks the same rule before it opens anything.
1329
1430 use std::collections::BTreeMap;
1531
93109 pub fn writable(&self) -> bool {
94110 self.state.leads()
95111 }
112+
113+ /// Whether the repository offers `area` now: the table in this
114+ /// module's comment. A mirror g1t has taken over offers everything;
115+ /// one standing by or handing back is a read-only copy (code, what
116+ /// security scanning found, insights, settings). Workflows run in CI
117+ /// failover, and while standing by only when the link keeps CI warm.
118+ pub fn offers(&self, area: RepoArea) -> bool {
119+ if self.writable() || area.read_only() {
120+ return true;
121+ }
122+ match area {
123+ RepoArea::Workflows => self.state == MirrorState::Ci || (self.state == MirrorState::Standby && self.warm),
124+ _ => false,
125+ }
126+ }
127+}
128+
129+/// The parts of a repository a mirror may or may not offer: the sidebar's
130+/// pages, and `Updates` for what g1t does on its own (security and
131+/// dependency updates, upkeep, anything that opens a branch or a pull
132+/// request without being asked).
133+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
134+#[serde(rename_all = "snake_case")]
135+pub enum RepoArea {
136+ Code,
137+ Issues,
138+ Pulls,
139+ Agents,
140+ Workflows,
141+ Deployments,
142+ Security,
143+ Insights,
144+ Settings,
145+ Updates,
96146 }
97147
148+impl RepoArea {
149+ pub const ALL: [RepoArea; 10] = [
150+ RepoArea::Code,
151+ RepoArea::Issues,
152+ RepoArea::Pulls,
153+ RepoArea::Agents,
154+ RepoArea::Workflows,
155+ RepoArea::Deployments,
156+ RepoArea::Security,
157+ RepoArea::Insights,
158+ RepoArea::Settings,
159+ RepoArea::Updates,
160+ ];
161+
162+ /// Whether every mirror offers it, whatever its state: an exact copy, read.
163+ pub fn read_only(self) -> bool {
164+ matches!(self, RepoArea::Code | RepoArea::Security | RepoArea::Insights | RepoArea::Settings)
165+ }
166+}
167+
168+/// Whether a repository with `mirror` offers `area`: everything when it
169+/// leads, else [`RepoMirror::offers`].
170+pub fn offers(mirror: Option<&RepoMirror>, area: RepoArea) -> bool {
171+ mirror.is_none_or(|mirror| mirror.offers(area))
172+}
173+
174+/// Why g1t's own updates wait on a mirror: the sentence the security pages
175+/// and the service share (`mirrorUpdatesWait` in the TypeScript contracts).
176+pub fn updates_wait_message(namespace: &str, name: &str, mirror: &RepoMirror) -> String {
177+ format!(
178+ "Security updates wait until {namespace}/{name} is taken over or moved to g1t. It is a mirror of {remote}, so nothing opens a branch or a pull request here on its own.",
179+ remote = mirror.remote
180+ )
181+}
182+
98183 /// Why a mirror refuses a write, for people and for `git push`.
99184 pub fn mirror_message(namespace: &str, name: &str, mirror: &RepoMirror) -> String {
100185 match mirror.state {
608693 }
609694
610695 #[test]
696+ fn a_mirror_offers_a_read_only_copy_until_it_is_taken_over() {
697+ let mirror = |state| RepoMirror { state, remote: "github.com/acme/web".into(), ..RepoMirror::default() };
698+ let read = [RepoArea::Code, RepoArea::Security, RepoArea::Insights, RepoArea::Settings];
699+ let work = [RepoArea::Issues, RepoArea::Pulls, RepoArea::Agents, RepoArea::Deployments, RepoArea::Updates];
700+ for state in [MirrorState::Standby, MirrorState::Ci, MirrorState::HandingBack] {
701+ for area in read {
702+ assert!(mirror(state).offers(area), "{state:?} {area:?}");
703+ }
704+ for area in work {
705+ assert!(!mirror(state).offers(area), "{state:?} {area:?}");
706+ }
707+ }
708+ for area in RepoArea::ALL {
709+ assert!(mirror(MirrorState::Takeover).offers(area), "{area:?}");
710+ assert!(offers(None, area), "a repository that leads offers {area:?}");
711+ }
712+ // Workflows: CI failover and takeovers, and a standby kept warm.
713+ assert!(!mirror(MirrorState::Standby).offers(RepoArea::Workflows));
714+ assert!(RepoMirror { warm: true, ..mirror(MirrorState::Standby) }.offers(RepoArea::Workflows));
715+ assert!(mirror(MirrorState::Ci).offers(RepoArea::Workflows));
716+ assert!(!mirror(MirrorState::HandingBack).offers(RepoArea::Workflows));
717+ assert!(!RepoMirror { warm: true, ..mirror(MirrorState::HandingBack) }.offers(RepoArea::Workflows));
718+ let message = updates_wait_message("acme", "web", &mirror(MirrorState::Standby));
719+ assert!(message.starts_with("Security updates wait until acme/web is taken over or moved to g1t."));
720+ assert!(message.contains("github.com/acme/web"));
721+ }
722+
723+ #[test]
611724 fn settings_default_to_doing_nothing_on_their_own() {
612725 let settings: MirrorSettings = serde_json::from_str("{}").unwrap();
613726 assert_eq!(settings, MirrorSettings::default());
+17−5
14031403 pub branch: String,
14041404 }
14051405
1406−/// `status_by_id`: whether a repository is archived or deleted, for g1t's
1407−/// own services deciding whether to act on it. An unknown id answers as
1408−/// deleted. Returns `RepoStatus`.
1406+/// `status_by_id`: whether a repository is archived or deleted, and the
1407+/// remote it mirrors, for g1t's own services deciding whether to act on
1408+/// it. An unknown id answers as deleted. Returns `RepoStatus`.
14091409 #[derive(Debug, Serialize, Deserialize)]
14101410 pub struct StatusByIdArgs {
14111411 pub id: String,
14121412 }
14131413
1414−#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1414+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
14151415 pub struct RepoStatus {
14161416 pub archived: bool,
14171417 pub deleted: bool,
1418+ /// Set when it mirrors a remote that leads (see [`crate::mirrors`]).
1419+ #[serde(default, skip_serializing_if = "Option::is_none")]
1420+ pub mirror: Option<crate::mirrors::RepoMirror>,
14181421 }
14191422
14201423 impl RepoStatus {
1421− /// Whether work may start on it: neither archived nor deleted.
1424+ /// Whether work may start on it: neither archived nor deleted. A
1425+ /// mirror is active too: scanning goes on, and so does everything
1426+ /// else that only reads.
14221427 pub fn active(&self) -> bool {
14231428 !self.archived && !self.deleted
14241429 }
1430+
1431+ /// Whether g1t may open branches and pull requests on it of its own
1432+ /// accord (security and dependency updates, upkeep): active, and not
1433+ /// a mirror short of a takeover (`RepoArea::Updates`).
1434+ pub fn takes_updates(&self) -> bool {
1435+ self.active() && crate::mirrors::offers(self.mirror.as_ref(), crate::mirrors::RepoArea::Updates)
1436+ }
14251437 }
14261438
14271439 /// What a person is told when something would change an archived
+47−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { MIRROR_READ_AREAS, type RepoArea, type RepoMirror, mirrorOffers, mirrorUpdatesWait, mirrorWritable } from "./mirrors.ts";
5+
6+const mirror = (state: RepoMirror["state"], warm = false): RepoMirror => ({
7+ state,
8+ remote: "github.com/acme/web",
9+ url: "https://github.com/acme/web",
10+ since: "2026-10-08T10:00:00Z",
11+ warm,
12+});
13+const AREAS: RepoArea[] = ["code", "issues", "pulls", "agents", "workflows", "deployments", "security", "insights", "settings", "updates"];
14+const WORK: RepoArea[] = ["issues", "pulls", "agents", "deployments", "updates"];
15+
16+test("only a takeover takes writes", () => {
17+ assert.equal(mirrorWritable(null), true);
18+ assert.equal(mirrorWritable(undefined), true);
19+ assert.equal(mirrorWritable(mirror("takeover")), true);
20+ for (const state of ["standby", "ci", "handing_back"] as const) assert.equal(mirrorWritable(mirror(state)), false, state);
21+});
22+
23+test("a mirror offers a read-only copy until it is taken over", () => {
24+ for (const state of ["standby", "ci", "handing_back"] as const) {
25+ for (const area of MIRROR_READ_AREAS) assert.ok(mirrorOffers(mirror(state), area), `${state} ${area}`);
26+ for (const area of WORK) assert.ok(!mirrorOffers(mirror(state), area), `${state} ${area}`);
27+ }
28+ for (const area of AREAS) {
29+ assert.ok(mirrorOffers(mirror("takeover"), area), `taken over: ${area}`);
30+ assert.ok(mirrorOffers(null, area), `leads: ${area}`);
31+ }
32+});
33+
34+test("workflows run in CI failover, during a takeover, and on a standby kept warm", () => {
35+ assert.equal(mirrorOffers(mirror("standby"), "workflows"), false);
36+ assert.equal(mirrorOffers(mirror("standby", true), "workflows"), true);
37+ assert.equal(mirrorOffers(mirror("ci"), "workflows"), true);
38+ assert.equal(mirrorOffers(mirror("takeover"), "workflows"), true);
39+ assert.equal(mirrorOffers(mirror("handing_back"), "workflows"), false);
40+ assert.equal(mirrorOffers(mirror("handing_back", true), "workflows"), false, "nothing starts while handing back");
41+});
42+
43+test("why updates wait names the repository and its remote", () => {
44+ const why = mirrorUpdatesWait("acme", "web", mirror("standby"));
45+ assert.ok(why.startsWith("Security updates wait until acme/web is taken over or moved to g1t."));
46+ assert.ok(why.includes("github.com/acme/web"));
47+});
+56−0
88 * a while, then **hands back**), or **move it to g1t** for good, after which
99 * g1t no longer tracks the remote. A repository g1t leads can be **mirrored
1010 * to** any number of followers.
11+ *
12+ * What a mirror offers on g1t is one rule, `mirrorOffers`, by state:
13+ *
14+ * | Area | Standing by | CI failover | Taken over | Handing back |
15+ * | --- | --- | --- | --- | --- |
16+ * | `code`: files, commits, branches, tags, compare, releases (read) | ✓ | ✓ | ✓ | ✓ |
17+ * | `security`: what scanning found, read-only | ✓ | ✓ | ✓ | ✓ |
18+ * | `insights`, `settings` (mirroring settings included) | ✓ | ✓ | ✓ | ✓ |
19+ * | `workflows`: runs on g1t | with Keep CI warm | ✓ | ✓ | – |
20+ * | `issues`, `pulls`, `agents`, `deployments` | – | – | ✓ | – |
21+ * | `updates`: security and dependency updates, upkeep, anything that opens a branch or a pull request on its own | – | – | ✓ | – |
22+ *
23+ * The sidebar lists only what is offered, a page not offered says so in
24+ * place of its list or form, every service refuses the writes, and g1t's
25+ * own automation checks the same rule before it opens anything.
1126 */
1227 import type { ServiceBinding } from "./clients";
1328 import type { User } from "./identity";
3550 return !mirror || mirror.state === "takeover";
3651 }
3752
53+/**
54+ * The parts of a repository a mirror may or may not offer: the sidebar's
55+ * pages, and `updates` for what g1t does on its own (security and
56+ * dependency updates, upkeep, anything that opens a branch or a pull
57+ * request without being asked).
58+ */
59+export type RepoArea =
60+ | "code"
61+ | "issues"
62+ | "pulls"
63+ | "agents"
64+ | "workflows"
65+ | "deployments"
66+ | "security"
67+ | "insights"
68+ | "settings"
69+ | "updates";
70+
71+/** What every mirror offers, whatever its state: an exact copy, read. */
72+export const MIRROR_READ_AREAS: readonly RepoArea[] = ["code", "security", "insights", "settings"];
73+
74+/**
75+ * Whether a repository with `mirror` offers `area` now (the table in this
76+ * module's comment). A repository that leads offers everything, as does a
77+ * mirror g1t has taken over. One standing by or handing back is a
78+ * read-only copy: code, what security scanning found, insights and
79+ * settings. Workflows run in CI failover, and while standing by only when
80+ * the link keeps CI warm.
81+ */
82+export function mirrorOffers(mirror: RepoMirror | null | undefined, area: RepoArea): boolean {
83+ if (mirrorWritable(mirror)) return true;
84+ if (MIRROR_READ_AREAS.includes(area)) return true;
85+ if (area === "workflows") return mirror!.state === "ci" || (mirror!.state === "standby" && mirror!.warm === true);
86+ return false;
87+}
88+
89+/** Why g1t's own updates wait on a mirror: the sentence the security pages and the service share. */
90+export function mirrorUpdatesWait(namespace: string, name: string, mirror: RepoMirror): string {
91+ return `Security updates wait until ${namespace}/${name} is taken over or moved to g1t. It is a mirror of ${mirror.remote}, so nothing opens a branch or a pull request here on its own.`;
92+}
93+
3894 export type RemoteProvider = "github" | "g1t" | "git";
3995 export type RemoteRole = "leader" | "follower";
4096 export type RemoteState = MirrorState | "following" | "stuck";
+6−2
6060 purgeAfter: string;
6161 };
6262
63−/** Whether a repository is archived or deleted, for services deciding whether to act on it. */
64−export type RepoStatus = { archived: boolean; deleted: boolean };
63+/**
64+ * Whether a repository is archived or deleted, and the remote it mirrors,
65+ * for services deciding whether to act on it (`mirrorOffers` in
66+ * `./mirrors` says what a mirror takes).
67+ */
68+export type RepoStatus = { archived: boolean; deleted: boolean; mirror?: RepoMirror | null };
6569
6670 /** The most topics a repository has, and the longest topic. */
6771 export const MAX_TOPICS = 20;
+7−2
673673 .map(|row| row.now))
674674 }
675675
676− /// Whether a repository is archived or deleted; unknown is deleted.
676+ /// Whether a repository is archived or deleted, and the remote it
677+ /// mirrors; unknown is deleted.
677678 pub async fn status(&self, id: &str) -> Result<RepoStatus> {
678679 #[derive(Deserialize)]
679680 struct Row {
681682 archived_at: Option<String>,
682683 #[serde(default)]
683684 deleted_at: Option<String>,
685+ #[serde(default)]
686+ mirror: Option<String>,
684687 }
685688 Ok(self
686689 .db
687− .prepare("SELECT archived_at, deleted_at FROM repos WHERE id = ?")
690+ .prepare("SELECT archived_at, deleted_at, mirror FROM repos WHERE id = ?")
688691 .bind(&[id.into()])?
689692 .first::<Row>(None)
690693 .await?
692695 RepoStatus {
693696 archived: false,
694697 deleted: true,
698+ mirror: None,
695699 },
696700 |row| RepoStatus {
697701 archived: row.archived_at.is_some(),
698702 deleted: row.deleted_at.is_some(),
703+ mirror: row.mirror.as_deref().and_then(|mirror| serde_json::from_str(mirror).ok()),
699704 },
700705 ))
701706 }
+6−4
232232 None => None,
233233 };
234234 // No security updates on an archived (read-only) or deleted
235− // repository; ones in flight for packages no longer vulnerable are
236− // closed either way.
237− let active = self.active(&repo.repo_id).await?;
238− self.security_updates(repo, repo.upkeep != 0 && active, rules.as_ref()).await?;
235+ // repository, nor on a mirror until it is taken over or moved to
236+ // g1t (`g1t_contracts::mirrors`: a standby mirror is an exact copy
237+ // that opens nothing of its own); ones in flight for packages no
238+ // longer vulnerable are closed either way.
239+ let takes_updates = self.takes_updates(repo).await?.is_ok();
240+ self.security_updates(repo, repo.upkeep != 0 && takes_updates, rules.as_ref()).await?;
239241 Ok(None)
240242 }
241243
+25−5
189189 Ok(Outcome::Ok(row))
190190 }
191191
192− /// Whether the repository is neither archived nor deleted. When repos
193− /// cannot say, it is taken as active.
194− async fn active(&self, repo_id: &str) -> Result<bool> {
192+ /// Where the repository stands: archived, deleted, a mirror. When repos
193+ /// cannot say, it is taken as an ordinary active repository.
194+ async fn status(&self, repo_id: &str) -> Result<RepoStatus> {
195195 let status: Result<RepoStatus> =
196196 g1t_kit::call(&self.repos, "status_by_id", &StatusByIdArgs { id: repo_id.to_owned() }).await;
197197 Ok(match status {
198− Ok(status) => status.active(),
198+ Ok(status) => status,
199199 Err(error) => {
200200 worker::console_error!("security: status_by_id {repo_id}: {error}");
201− true
201+ RepoStatus::default()
202202 }
203203 })
204204 }
205205
206+ /// Whether the repository is neither archived nor deleted. Scanning
207+ /// goes on in a mirror: it reads.
208+ async fn active(&self, repo_id: &str) -> Result<bool> {
209+ Ok(self.status(repo_id).await?.active())
210+ }
211+
212+ /// Whether g1t may open branches and pull requests here on its own
213+ /// (security and dependency updates): active, and not a mirror short
214+ /// of a takeover (`g1t_contracts::mirrors`). `Err` says why not.
215+ async fn takes_updates(&self, repo: &RepoRow) -> Result<std::result::Result<(), String>> {
216+ let status = self.status(&repo.repo_id).await?;
217+ if status.takes_updates() {
218+ return Ok(Ok(()));
219+ }
220+ Ok(Err(match &status.mirror {
221+ Some(mirror) if status.active() => g1t_contracts::mirrors::updates_wait_message(&repo.namespace, &repo.name, mirror),
222+ _ => "Updates wait while the repository is archived or deleted.".to_owned(),
223+ }))
224+ }
225+
206226 /// Records a repository named in an event, by id. Forks are not
207227 /// recorded: a pull request's findings belong to its repository.
208228 async fn register_by_id(&self, repo_id: &str) -> Result<Option<RepoRow>> {
+7−5
628628 }
629629
630630 /// Updates whose sandbox never pushed: raising the version did not
631− /// work, so g1t gets an issue for it.
631+ /// work, so g1t gets an issue for it. On a repository that takes no
632+ /// updates now (security updates off, archived, deleted, or a mirror
633+ /// that is not taken over, whose pushes are refused) the update fails
634+ /// with the reason instead: nothing is opened there.
632635 pub async fn stalled_updates(&self) -> Result<()> {
633636 let before = rfc3339(now_ms().saturating_sub(STALLED_MS));
634637 for row in self.store.stalled_updates(&before, 10).await? {
635638 let Some(repo) = self.store.repo(&row.repo_id).await? else { continue };
636− if repo.upkeep == 0 || !self.active(&repo.repo_id).await? {
637− self.store
638− .set_update(&row, UpdateState::Failed, row.pull(), None, Some("The version could not be raised in a sandbox."))
639− .await?;
639+ let waits = if repo.upkeep == 0 { Some("The version could not be raised in a sandbox.".to_owned()) } else { self.takes_updates(&repo).await?.err() };
640+ if let Some(why) = waits {
641+ self.store.set_update(&row, UpdateState::Failed, row.pull(), None, Some(&why)).await?;
640642 continue;
641643 }
642644 self.needs_code(&repo, &row, "The version could not be raised in a sandbox on its own").await?;
+4−2
599599 /// One run of one entry: what it found, as a sentence, or why it could
600600 /// not run.
601601 async fn run_entry(&self, repo: &RepoRow, loaded: &Loaded, entry: &Entry) -> Result<std::result::Result<String, String>> {
602− if !self.active(&repo.repo_id).await? {
603− return Ok(Err("Version updates wait while the repository is archived or deleted.".to_owned()));
602+ // Archived, deleted, or a mirror that is not taken over: nothing
603+ // opens here on its own (`g1t_contracts::mirrors`).
604+ if let Err(why) = self.takes_updates(repo).await? {
605+ return Ok(Err(why.replace("Security updates wait", "Version updates wait")));
604606 }
605607 if entry.open_pull_requests_limit == 0 {
606608 return Ok(Ok("open-pull-requests-limit is 0, so no version updates open.".to_owned()));