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.
44 files+1120−2160/44 viewed
| 7 | 7 | for every project until you turn them on. Once they are on, every branch | |
| 8 | 8 | with a pull request gets its own live preview, linked on the pull request, | |
| 9 | 9 | 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. | |
| 11 | 13 | ||
| 12 | 14 | Apps run on Cloudflare Workers, on `g1t.page`: | |
| 13 | 15 |
| 13 | 13 | ||
| 14 | 14 | | The repository on g1t is | Who leads | What works on g1t | | |
| 15 | 15 | | --- | --- | --- | | |
| 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. | | |
| 18 | 18 | | A mirror **taken over** | g1t, for now | Everything: pushes, pull requests, issues, agents, workflows. | | |
| 19 | 19 | | **Mirrored to** other hosts | g1t | Everything. Each push is sent to the followers. | | |
| 20 | 20 | ||
| ⋯ | |||
| 39 | 39 | `refs/g1t/replaced/<branch>/<time>` for at least 30 days. Nothing a mirror | |
| 40 | 40 | held is lost silently. | |
| 41 | 41 | ||
| 42 | − | ## What a mirror standing by refuses | |
| 42 | + | ## What a standby mirror offers | |
| 43 | 43 | ||
| 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: | |
| 46 | 48 | ||
| 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 | – | – | ✓ | – | | |
| 56 | 60 | ||
| 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: | |
| 58 | 86 | ||
| 59 | 87 | ```text | |
| 60 | 88 | remote: acme/web is a mirror of github.com/acme/web, so it is read-only here. | |
| ⋯ | |||
| 62 | 90 | ! [remote rejected] main -> main | |
| 63 | 91 | ``` | |
| 64 | 92 | ||
| 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. | |
| 67 | 98 | ||
| 68 | 99 | ## When the remote stops answering | |
| 69 | 100 | ||
| 291 | 291 | and the [merge queue](/guides/merge-queue/), like any other. When it | |
| 292 | 292 | merges, the next scan finds the vulnerability fixed. | |
| 293 | 293 | ||
| 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 | + | ||
| 294 | 299 | Each alert shows its update's status and links the pull request: | |
| 295 | 300 | ||
| 296 | 301 | | Status | Meaning | | |
| 297 | 302 | | --- | --- | | |
| 303 | + | | Waiting | The repository is a mirror that g1t does not lead. The update opens once it is taken over or moved to g1t. | | |
| 298 | 304 | | Making the change | The sandbox is raising the version. | | |
| 299 | 305 | | Open | The pull request is open. | | |
| 300 | 306 | | Merged | The pull request merged. | |
| 628 | 628 | are for members of the workspace. A run for an outside collaborator is | |
| 629 | 629 | told the project's memory, never the workspace's. | |
| 630 | 630 | ||
| 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 | + | ||
| 631 | 636 | Agents cost g1t real money, so they run for paid workspaces. A free | |
| 632 | 637 | workspace has the whole forge, and two ways to try agents: | |
| 633 | 638 |
| 5 | 5 | import type { RepoMirror } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import { ConfirmDialog } from "./repo-lifecycle"; | |
| 8 | − | import { ButtonLink, SubmitButton, TimeAgo } from "./ui"; | |
| 8 | + | import { ButtonLink, EmptyState, SubmitButton, TimeAgo } from "./ui"; | |
| 9 | 9 | import { Badge } from "./ui/badge"; | |
| 10 | 10 | import { Button } from "./ui/button"; | |
| 11 | 11 | import { Hint } from "./ui/hint"; | |
| 12 | 12 | import { | |
| 13 | + | MIRRORING_DOCS, | |
| 13 | 14 | type MirrorBrief, | |
| 14 | 15 | type MirroredRepo, | |
| 15 | 16 | followersOf, | |
| ⋯ | |||
| 304 | 305 | } | |
| 305 | 306 | ||
| 306 | 307 | /** | |
| 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 | + | /** | |
| 307 | 356 | * On the repository's overview, one quiet line when no banner says it: a | |
| 308 | 357 | * mirror standing by, or the remotes that follow it, and when it last synced. | |
| 309 | 358 | */ | |
| 19 | 19 | import { type ReactNode, useEffect, useRef, useState } from "react"; | |
| 20 | 20 | import { Link, useLocation, useNavigate, useSubmit } from "react-router"; | |
| 21 | 21 | ||
| 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"; | |
| 23 | 23 | ||
| 24 | 24 | import { appIcon, pinnedApp } from "./apps"; | |
| 25 | 25 | import { CountBadge, ShortcutsDialog } from "./rail"; | |
| ⋯ | |||
| 533 | 533 | can, | |
| 534 | 534 | settings, | |
| 535 | 535 | counts, | |
| 536 | + | mirror, | |
| 536 | 537 | always = false, | |
| 537 | 538 | }: { | |
| 538 | 539 | /** `/<namespace>/<name>`. */ | |
| ⋯ | |||
| 542 | 543 | /** Whether they see its settings (lib/access.ts `seesSettings`), as the sidebar decides. */ | |
| 543 | 544 | settings?: boolean; | |
| 544 | 545 | 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; | |
| 545 | 548 | /** On a computer too: for a visitor, who has no sidebar to list them. */ | |
| 546 | 549 | always?: boolean; | |
| 547 | 550 | }) { | |
| 548 | 551 | const { pathname } = useLocation(); | |
| 549 | 552 | const rest = pathname.toLowerCase().startsWith(base.toLowerCase()) ? pathname.slice(base.length) : ""; | |
| 550 | 553 | 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"), | |
| 552 | 555 | ...(settings ?? member ? (["settings"] as const) : []), | |
| 553 | 556 | ]; | |
| 554 | 557 | const pill = (current: boolean) => | |
| 19 | 19 | KeyRound, | |
| 20 | 20 | Loader, | |
| 21 | 21 | Package, | |
| 22 | + | PauseCircle, | |
| 22 | 23 | ShieldAlert, | |
| 23 | 24 | ShieldCheck, | |
| 24 | 25 | } from "lucide-react"; | |
| ⋯ | |||
| 540 | 541 | closed: <GitPullRequestClosed size={13} className="text-danger" />, | |
| 541 | 542 | }; | |
| 542 | 543 | ||
| 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 | + | }) { | |
| 545 | 556 | const meta = UPDATE_STATES[update.state]; | |
| 546 | 557 | 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 | + | } | |
| 547 | 574 | const about: Record<SecurityUpdate["state"], ReactNode> = { | |
| 548 | 575 | requested: <>A sandbox is raising {name} to {update.target}.</>, | |
| 549 | 576 | open: <>It raises {name} to {update.target} and lands through your branch's required checks.</>, | |
| ⋯ | |||
| 605 | 632 | fixes, | |
| 606 | 633 | pulls, | |
| 607 | 634 | upkeep, | |
| 635 | + | updatesWait, | |
| 608 | 636 | base, | |
| 609 | 637 | action, | |
| 610 | 638 | focus, | |
| ⋯ | |||
| 618 | 646 | pulls: Record<number, PullInfo>; | |
| 619 | 647 | /** Whether security updates are on. */ | |
| 620 | 648 | upkeep: boolean; | |
| 649 | + | /** Why no update opens now: the repository is a mirror g1t does not lead (lib/mirror.ts). */ | |
| 650 | + | updatesWait?: string | null; | |
| 621 | 651 | base: string; | |
| 622 | 652 | action: string; | |
| 623 | 653 | focus: string | null; | |
| ⋯ | |||
| 648 | 678 | fixes={fixes} | |
| 649 | 679 | pulls={pulls} | |
| 650 | 680 | upkeep={upkeep} | |
| 681 | + | updatesWait={updatesWait} | |
| 651 | 682 | base={base} | |
| 652 | 683 | action={action} | |
| 653 | 684 | focus={focus} | |
| ⋯ | |||
| 664 | 695 | fixes, | |
| 665 | 696 | pulls, | |
| 666 | 697 | upkeep, | |
| 698 | + | updatesWait, | |
| 667 | 699 | base, | |
| 668 | 700 | action, | |
| 669 | 701 | focus, | |
| ⋯ | |||
| 674 | 706 | fixes: Record<number, UpgradeFix>; | |
| 675 | 707 | pulls: Record<number, PullInfo>; | |
| 676 | 708 | upkeep: boolean; | |
| 709 | + | updatesWait?: string | null; | |
| 677 | 710 | base: string; | |
| 678 | 711 | action: string; | |
| 679 | 712 | focus: string | null; | |
| ⋯ | |||
| 701 | 734 | </p> | |
| 702 | 735 | </div> | |
| 703 | 736 | </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} />} | |
| 705 | 738 | {issue != null && ( | |
| 706 | 739 | <div className="mt-2 sm:ml-7"> | |
| 707 | 740 | <FixLink issue={issue} fix={fixes[issue]} base={base} /> | |
| ⋯ | |||
| 716 | 749 | showManifest={manifests.length > 1} | |
| 717 | 750 | activity={activity} | |
| 718 | 751 | upkeep={upkeep} | |
| 752 | + | updatesWait={updatesWait} | |
| 719 | 753 | base={base} | |
| 720 | 754 | action={action} | |
| 721 | 755 | focused={vuln.id === focus} | |
| ⋯ | |||
| 733 | 767 | showManifest, | |
| 734 | 768 | activity, | |
| 735 | 769 | upkeep, | |
| 770 | + | updatesWait, | |
| 736 | 771 | base, | |
| 737 | 772 | action, | |
| 738 | 773 | focused, | |
| ⋯ | |||
| 742 | 777 | showManifest: boolean; | |
| 743 | 778 | activity: AlertActivity[]; | |
| 744 | 779 | upkeep: boolean; | |
| 780 | + | updatesWait?: string | null; | |
| 745 | 781 | base: string; | |
| 746 | 782 | action: string; | |
| 747 | 783 | focused: boolean; | |
| ⋯ | |||
| 790 | 826 | </p> | |
| 791 | 827 | <p className="mt-1 text-muted"> | |
| 792 | 828 | 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."} | |
| 796 | 834 | </p> | |
| 797 | 835 | <div className="mt-2 flex flex-wrap items-center gap-2"> | |
| 798 | 836 | <a href={osv} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-fg-soft hover:text-fg hover:underline"> | |
| 2 | 2 | import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| 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"; | |
| 6 | 6 | ||
| 7 | 7 | import { InMain } from "./landmark"; | |
| 8 | 8 | import { CommandPalette, type PaletteCommand, PaletteKey, usePaletteShortcut } from "./command-palette"; | |
| ⋯ | |||
| 782 | 782 | settings?: boolean; | |
| 783 | 783 | /** What they may do there, once the repository's page loads. */ | |
| 784 | 784 | can?: Abilities; | |
| 785 | + | /** The remote it mirrors, once the repository's page loads: a mirror lists only what it offers. */ | |
| 786 | + | mirror?: RepoMirror | null; | |
| 785 | 787 | issues?: number; | |
| 786 | 788 | pulls?: number; | |
| 787 | 789 | }; | |
| ⋯ | |||
| 811 | 813 | back: { to: string; label: string }; | |
| 812 | 814 | }) { | |
| 813 | 815 | 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)); | |
| 816 | 819 | const { current: atOverview, pending: toOverview } = useRowState(base, { end: true }); | |
| 817 | 820 | return ( | |
| 818 | 821 | <nav aria-label={`${repo.namespace}/${repo.name}`} className={PANEL}> | |
| ⋯ | |||
| 841 | 844 | <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} />}> | |
| 842 | 845 | Code | |
| 843 | 846 | </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 | + | )} | |
| 856 | 868 | </NavRows> | |
| 857 | 869 | <Rule /> | |
| 858 | 870 | <NavRows> | |
| ⋯ | |||
| 861 | 873 | Deployments | |
| 862 | 874 | </SidebarLink> | |
| 863 | 875 | ) : 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 | + | )} | |
| 867 | 881 | {shows.has("security") ? ( | |
| 868 | 882 | <SidebarLink to={`${base}/security`} also={soonPaths(base, "Security")} icon={<ShieldCheck size={15} />}> | |
| 869 | 883 | Security | |
| ⋯ | |||
| 1107 | 1121 | : null; | |
| 1108 | 1122 | // The viewer's role there, from the repository's page once it has loaded. | |
| 1109 | 1123 | 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 } | |
| 1111 | 1125 | | undefined; | |
| 1112 | 1126 | const menuRepo: MenuRepo | null = | |
| 1113 | 1127 | 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 } | |
| 1115 | 1129 | : guessed; | |
| 1116 | 1130 | ||
| 1117 | 1131 | // The way from Code's list to the one shown. | |
| 9 | 9 | can, | |
| 10 | 10 | granted, | |
| 11 | 11 | httpStatus, | |
| 12 | + | mirrorWritable, | |
| 12 | 13 | needs, | |
| 13 | 14 | permission, | |
| 14 | 15 | } from "@g1t/contracts"; | |
| 15 | 16 | ||
| 16 | 17 | import type { ViewerAccess } from "./access"; | |
| 18 | + | import { mirrorReason } from "./mirror"; | |
| 17 | 19 | import { projects, repos, work } from "./services.server"; | |
| 18 | 20 | import { getViewer } from "./session.server"; | |
| 19 | 21 | ||
| ⋯ | |||
| 147 | 149 | const refused = await refusal(context, params, capability); | |
| 148 | 150 | if (refused) throw data(refused, { status: 403 }); | |
| 149 | 151 | } | |
| 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 | + | } | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 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"; | |
| 5 | 7 | ||
| 6 | 8 | 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 | + | }); | |
| 7 | 16 | const member = { signedIn: true, workspace: true }; | |
| 8 | 17 | const newcomer = { signedIn: true, workspace: false }; | |
| 9 | 18 | ||
| ⋯ | |||
| 63 | 72 | assert.ok(visitor.includes("deployments") && projectPages(true).includes("deployments")); | |
| 64 | 73 | }); | |
| 65 | 74 | ||
| 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 | + | ||
| 66 | 117 | test("the phone's project strip lights the page a path is under", () => { | |
| 67 | 118 | assert.equal(projectPageAt(""), "overview"); | |
| 68 | 119 | assert.equal(projectPageAt("blob/main/README.md"), "code"); | |
| 13 | 13 | * Explore, Docs and signing in, the page at full width, and the footer. | |
| 14 | 14 | */ | |
| 15 | 15 | ||
| 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"; | |
| 17 | 19 | ||
| 18 | 20 | export type Frame = "standalone" | "public" | "app"; | |
| 19 | 21 | ||
| ⋯ | |||
| 78 | 80 | export const PAGE_NEEDS: Partial<Record<ProjectPage, Capability>> = { security: "push" }; | |
| 79 | 81 | ||
| 80 | 82 | /** | |
| 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 | + | /** | |
| 81 | 101 | * A project's menu, in order. Security and Settings are for people with a | |
| 82 | 102 | * role on its repository, and a page in `PAGE_NEEDS` only for those whose | |
| 83 | 103 | * 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. | |
| 85 | 109 | */ | |
| 86 | − | export function projectPages(member: boolean, can?: Partial<Abilities>): ProjectPage[] { | |
| 110 | + | export function projectPages(member: boolean, can?: Partial<Abilities>, mirror?: RepoMirror | null): ProjectPage[] { | |
| 87 | 111 | const pages: (ProjectPage | false)[] = [ | |
| 88 | 112 | "code", | |
| 89 | 113 | "issues", | |
| ⋯ | |||
| 98 | 122 | member && "settings", | |
| 99 | 123 | ]; | |
| 100 | 124 | 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]), | |
| 102 | 127 | ); | |
| 103 | 128 | } | |
| 104 | 129 | ||
| 105 | 130 | /** | |
| 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 | + | /** | |
| 106 | 147 | * A project's pages as the phone's strip of tabs under its name names them | |
| 107 | 148 | * (routes/repo/layout.tsx): the sidebar's words, and the paths under the | |
| 108 | 149 | * project at which each is the current one. | |
| 2 | 2 | // Each needs the Triage role, which work checks again. | |
| 3 | 3 | ||
| 4 | 4 | import type { MilestoneResult } from "../components/milestones"; | |
| 5 | − | import { refusal } from "./access.server"; | |
| 5 | + | import { refusal, requireWritableRepo } from "./access.server"; | |
| 6 | 6 | import { work } from "./services.server"; | |
| 7 | 7 | import { assertSameOrigin, requireUser } from "./session.server"; | |
| 8 | 8 | ||
| ⋯ | |||
| 12 | 12 | export async function milestoneAction(request: Request, params: RepoParams, context: Context): Promise<MilestoneResult> { | |
| 13 | 13 | assertSameOrigin(request); | |
| 14 | 14 | const user = requireUser(context, request); | |
| 15 | + | await requireWritableRepo(context, params); | |
| 15 | 16 | const form = await request.formData(); | |
| 16 | 17 | const intent = String(form.get("intent") ?? ""); | |
| 17 | 18 | const number = Number(form.get("number")) || undefined; | |
| 3 | 3 | ||
| 4 | 4 | import type { HandbackPlan, RepoMirror } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 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"; | |
| 7 | 14 | ||
| 8 | 15 | import { | |
| 9 | 16 | clampMinutes, | |
| ⋯ | |||
| 13 | 20 | mirrorBanner, | |
| 14 | 21 | mirrorReason, | |
| 15 | 22 | mirrorSettingsFrom, | |
| 23 | + | offers, | |
| 16 | 24 | shortSha, | |
| 17 | 25 | TAKE_OVER_MINUTES, | |
| 26 | + | updatesWaitReason, | |
| 18 | 27 | workflowReason, | |
| 19 | 28 | writable, | |
| 20 | 29 | } from "./mirror.ts"; | |
| ⋯ | |||
| 29 | 38 | const leader = (reachable: boolean) => ({ repoId: "r", role: "leader" as const, name: "github.com/acme/web", state: "standby" as const, reachable }); | |
| 30 | 39 | const follower = (name: string) => ({ repoId: "r", role: "follower" as const, name, state: "following" as const, reachable: true }); | |
| 31 | 40 | ||
| 41 | + | const AREAS: RepoArea[] = ["code", "issues", "pulls", "agents", "workflows", "deployments", "security", "insights", "settings", "updates"]; | |
| 42 | + | ||
| 32 | 43 | test("the copies agree with the contracts", () => { | |
| 33 | 44 | 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))); | |
| 35 | 52 | } | |
| 36 | 53 | assert.deepEqual([...TAKE_OVER_MINUTES], [...TAKE_OVER_AFTER_MINUTES]); | |
| 37 | 54 | }); | |
| 38 | 55 | ||
| 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 | + | ||
| 39 | 74 | test("a read-only mirror says why its buttons are off, and where to take over", () => { | |
| 40 | 75 | assert.equal( | |
| 41 | 76 | mirrorReason(repo("standby")), | |
| 15 | 15 | return !mirror || mirror.state === "takeover"; | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 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 | + | ||
| 18 | 45 | /** As `TAKE_OVER_AFTER_MINUTES` in the contracts: 5 minutes to a day. */ | |
| 19 | 46 | export const TAKE_OVER_MINUTES = [5, 24 * 60] as const; | |
| 20 | 47 |
| 28 | 28 | pushedAt: null, | |
| 29 | 29 | activity: 0, | |
| 30 | 30 | deploying: false, | |
| 31 | + | mirror: null, | |
| 31 | 32 | ...more, | |
| 32 | 33 | }; | |
| 33 | 34 | } | |
| ⋯ | |||
| 85 | 86 | assert.deepEqual(slugs({ deployments: true }), ["site"]); | |
| 86 | 87 | }); | |
| 87 | 88 | ||
| 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 | + | ||
| 88 | 105 | test("search matches every word, in the name, slug or description, names that start with it first", () => { | |
| 89 | 106 | const list = [ | |
| 90 | 107 | project("docs-site", { name: "Docs site", description: "The API reference" }), | |
| 5 | 5 | * filtered list can be shared, bookmarked and gone back to. | |
| 6 | 6 | */ | |
| 7 | 7 | ||
| 8 | − | import type { Project, ProjectEcosystem, ProjectKind } from "@g1t/contracts"; | |
| 8 | + | import type { MirrorState, Project, ProjectEcosystem, ProjectKind } from "@g1t/contracts"; | |
| 9 | 9 | ||
| 10 | 10 | export type ProjectSort = "updated" | "name" | "active" | "pushed"; | |
| 11 | 11 | export type ProjectView = "list" | "grid"; | |
| 12 | 12 | ||
| 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 | + | ||
| 13 | 20 | export type ProjectQuery = { | |
| 14 | 21 | /** Words in the name, slug or description. */ | |
| 15 | 22 | q: string; | |
| 16 | 23 | visibility: "all" | "public" | "private"; | |
| 17 | − | kind: "all" | ProjectKind; | |
| 24 | + | kind: ProjectKindFilter; | |
| 18 | 25 | /** A language, from the manifests at the project's root. */ | |
| 19 | 26 | language: string | null; | |
| 20 | 27 | /** Only projects with Deployments on. */ | |
| ⋯ | |||
| 58 | 65 | python: "Python", | |
| 59 | 66 | }; | |
| 60 | 67 | ||
| 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 | + | */ | |
| 62 | 75 | export type Listed = Pick< | |
| 63 | 76 | Project, | |
| 64 | 77 | "id" | "slug" | "name" | "description" | "private" | "archived" | "kind" | "ecosystem" | "updatedAt" | "pushedAt" | "activity" | |
| 65 | − | > & { deploying: boolean }; | |
| 78 | + | > & { deploying: boolean; mirror: ListedMirror | null }; | |
| 66 | 79 | ||
| 67 | 80 | const one = <T extends string>(value: string | null, allowed: readonly T[], fallback: T): T => | |
| 68 | 81 | (allowed as readonly string[]).includes(value ?? "") ? (value as T) : fallback; | |
| ⋯ | |||
| 73 | 86 | return { | |
| 74 | 87 | q: (params.get("q") ?? "").trim().slice(0, 100), | |
| 75 | 88 | 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"), | |
| 77 | 90 | language: params.get("language")?.trim() || null, | |
| 78 | 91 | deployments: params.get("deployments") === "on", | |
| 79 | 92 | archived: one(params.get("archived"), ["hide", "include", "only"] as const, "hide"), | |
| ⋯ | |||
| 128 | 141 | (project) => | |
| 129 | 142 | (query.archived === "include" || (query.archived === "only") === project.archived) && | |
| 130 | 143 | (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)) && | |
| 132 | 146 | (!query.language || (project.ecosystem != null && LANGUAGE_OF[project.ecosystem] === query.language)) && | |
| 133 | 147 | (!query.deployments || project.deploying) && | |
| 134 | 148 | (words.length === 0 || matches(project, words)), | |
| ⋯ | |||
| 170 | 184 | languages: { name: string; count: number }[]; | |
| 171 | 185 | deploying: number; | |
| 172 | 186 | archived: number; | |
| 187 | + | /** Projects whose repository mirrors a remote; the rest are managed on g1t. */ | |
| 188 | + | mirrors: number; | |
| 173 | 189 | }; | |
| 174 | 190 | ||
| 175 | 191 | /** How many of the workspace's projects each filter would show, before any is chosen. */ | |
| 176 | 192 | export function facetsOf(projects: Listed[]): Facets { | |
| 177 | 193 | 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 | + | }; | |
| 179 | 202 | for (const project of projects) { | |
| 180 | 203 | facets.visibility[project.private ? "private" : "public"]++; | |
| 181 | 204 | facets.kind[project.kind]++; | |
| 182 | 205 | if (project.deploying) facets.deploying++; | |
| 206 | + | if (project.mirror) facets.mirrors++; | |
| 183 | 207 | if (project.archived) facets.archived++; | |
| 184 | 208 | if (project.ecosystem) { | |
| 185 | 209 | const language = LANGUAGE_OF[project.ecosystem]; | |
| 2 | 2 | import { readFileSync } from "node:fs"; | |
| 3 | 3 | import { test } from "node:test"; | |
| 4 | 4 | ||
| 5 | + | import type { RepoMirror } from "@g1t/contracts"; | |
| 6 | + | ||
| 7 | + | import { tabsFor } from "./project-nav.ts"; | |
| 5 | 8 | import { ROADMAP } from "./roadmap.ts"; | |
| 6 | 9 | ||
| 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 | + | ||
| 7 | 26 | test("code is not docs: a project never gets a Docs tab", () => { | |
| 8 | 27 | // Docs is its own workspace mode, filtered by project there. | |
| 9 | 28 | assert.equal(ROADMAP.some((item) => item.section !== "Workspace" && /docs/i.test(item.key)), false); |
| 4 | 4 | * across its top, the way GitHub and Vercel do. Soon tabs come from the | |
| 5 | 5 | * roadmap and open its page for them, under the same tabs. | |
| 6 | 6 | */ | |
| 7 | − | import type { Abilities, Capability } from "@g1t/contracts"; | |
| 7 | + | import type { Abilities, Capability, RepoMirror } from "@g1t/contracts"; | |
| 8 | 8 | ||
| 9 | − | import { type RoadmapItem, roadmapIn } from "./roadmap"; | |
| 9 | + | import { type RepoArea, offers } from "./mirror.ts"; | |
| 10 | + | import { type RoadmapItem, roadmapIn } from "./roadmap.ts"; | |
| 10 | 11 | ||
| 11 | 12 | export type Tab = { | |
| 12 | 13 | label: string; | |
| ⋯ | |||
| 26 | 27 | ||
| 27 | 28 | export type Section = { | |
| 28 | 29 | 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; | |
| 29 | 32 | tabs: Tab[]; | |
| 30 | 33 | }; | |
| 31 | 34 | ||
| ⋯ | |||
| 36 | 39 | export const SECTIONS: Section[] = [ | |
| 37 | 40 | { | |
| 38 | 41 | key: "Code", | |
| 42 | + | area: "code", | |
| 39 | 43 | tabs: [ | |
| 40 | 44 | { label: "Files", path: "code", also: ["tree", "blob"] }, | |
| 41 | 45 | { label: "Commits", path: "commits", also: ["commit"] }, | |
| ⋯ | |||
| 48 | 52 | }, | |
| 49 | 53 | { | |
| 50 | 54 | key: "Issues", | |
| 55 | + | area: "issues", | |
| 51 | 56 | tabs: [ | |
| 52 | 57 | { label: "Issues", path: "issues" }, | |
| 53 | 58 | { label: "Outcomes", path: "plans" }, | |
| ⋯ | |||
| 57 | 62 | }, | |
| 58 | 63 | { | |
| 59 | 64 | key: "Agents", | |
| 65 | + | area: "agents", | |
| 60 | 66 | tabs: [ | |
| 61 | 67 | { label: "At work", path: "agents" }, | |
| 62 | 68 | { label: "Sessions", path: "sessions" }, | |
| ⋯ | |||
| 66 | 72 | }, | |
| 67 | 73 | { | |
| 68 | 74 | key: "Deployments", | |
| 75 | + | area: "deployments", | |
| 69 | 76 | tabs: [{ label: "Deployments", path: "deployments" }, ...soon("Deployments")], | |
| 70 | 77 | }, | |
| 71 | − | { key: "Observability", tabs: soon("Observability") }, | |
| 78 | + | { key: "Observability", area: "deployments", tabs: soon("Observability") }, | |
| 72 | 79 | { | |
| 73 | 80 | key: "Security", | |
| 81 | + | area: "security", | |
| 74 | 82 | tabs: [ | |
| 75 | 83 | { label: "Overview", path: "security", members: true, needs: "security_alerts", exact: true }, | |
| 76 | 84 | { label: "Secret scanning", path: "security/secret-scanning", members: true, needs: "security_alerts" }, | |
| ⋯ | |||
| 84 | 92 | }, | |
| 85 | 93 | { | |
| 86 | 94 | key: "Insights", | |
| 95 | + | area: "insights", | |
| 87 | 96 | tabs: [ | |
| 88 | 97 | { label: "Contributors", path: "contributors" }, | |
| 89 | 98 | { label: "Activity", path: "activity" }, | |
| ⋯ | |||
| 96 | 105 | /** Pull requests and the merge queue share a page's tabs too. */ | |
| 97 | 106 | export const PULLS: Section = { | |
| 98 | 107 | key: "Code", | |
| 108 | + | area: "pulls", | |
| 99 | 109 | tabs: [ | |
| 100 | 110 | { label: "Pull requests", path: "pulls", also: ["pull"] }, | |
| 101 | 111 | { label: "Merge queue", path: "queue" }, | |
| ⋯ | |||
| 108 | 118 | ||
| 109 | 119 | /** | |
| 110 | 120 | * 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). | |
| 112 | 123 | */ | |
| 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 { | |
| 114 | 125 | for (const section of [...SECTIONS, PULLS]) { | |
| 126 | + | if (!offers(mirror, section.area)) continue; | |
| 115 | 127 | const visible = section.tabs.filter( | |
| 116 | 128 | (tab) => (member || !tab.members) && (!tab.needs || !can || Boolean(can[tab.needs])), | |
| 117 | 129 | ); | |
| 7 | 7 | ||
| 8 | 8 | import type { Route } from "./+types/add-ci"; | |
| 9 | 9 | import { addCi } from "../../lib/add-ci.server"; | |
| 10 | − | import { refusal } from "../../lib/access.server"; | |
| 10 | + | import { refusal, requireWritableRepo } from "../../lib/access.server"; | |
| 11 | 11 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 12 | 12 | ||
| 13 | 13 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 14 | 14 | assertSameOrigin(request); | |
| 15 | 15 | const user = requireUser(context, request); | |
| 16 | + | await requireWritableRepo(context, params); | |
| 16 | 17 | const refused = await refusal(context, params, "push"); | |
| 17 | 18 | if (refused) return { error: refused }; | |
| 18 | 19 | const added = await addCi(user, { namespace: params.owner, name: params.repo }); |
| 30 | 30 | import { neverDeploys } from "../../lib/project-kind"; | |
| 31 | 31 | import { billing, deployments, projects } from "../../lib/services.server"; | |
| 32 | 32 | 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"; | |
| 34 | 34 | import { whyNot } from "../../lib/access"; | |
| 35 | 35 | import { | |
| 36 | 36 | type ListFilter, | |
| ⋯ | |||
| 101 | 101 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 102 | 102 | assertSameOrigin(request); | |
| 103 | 103 | const user = requireUser(context, request); | |
| 104 | + | await requireWritableRepo(context, params); | |
| 104 | 105 | const form = await request.formData(); | |
| 105 | 106 | const ref = { workspace: params.owner, slug: params.repo }; | |
| 106 | 107 | const intent = form.get("intent"); | |
| 15 | 15 | import { LabelChip } from "../../components/labels"; | |
| 16 | 16 | import { integrations, work } from "../../lib/services.server"; | |
| 17 | 17 | import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server"; | |
| 18 | − | import { accessTo } from "../../lib/access.server"; | |
| 18 | + | import { accessTo, requireWritableRepo } from "../../lib/access.server"; | |
| 19 | 19 | import { computeNoteFor } from "../../lib/compute.server"; | |
| 20 | 20 | import { delegateForm, issuePath, notStarted } from "../../lib/delegate"; | |
| 21 | 21 | ||
| ⋯ | |||
| 56 | 56 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 57 | 57 | assertSameOrigin(request); | |
| 58 | 58 | const user = requireUser(context, request); | |
| 59 | + | await requireWritableRepo(context, params); | |
| 59 | 60 | const form = await request.formData(); | |
| 60 | 61 | if (form.get("intent") === "import") { | |
| 61 | 62 | const imported = await integrations.import( | |
| 24 | 24 | import { isWaitingMessage } from "../../lib/compute"; | |
| 25 | 25 | import { work } from "../../lib/services.server"; | |
| 26 | 26 | 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"; | |
| 28 | 28 | ||
| 29 | 29 | /** As many sandboxes as one request may start. */ | |
| 30 | 30 | const MAX_ASSIGNED_AT_ONCE = 10; | |
| ⋯ | |||
| 66 | 66 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 67 | 67 | assertSameOrigin(request); | |
| 68 | 68 | const user = requireUser(context, request); | |
| 69 | + | await requireWritableRepo(context, params); | |
| 69 | 70 | const path = { namespace: params.owner, name: params.repo }; | |
| 70 | 71 | const numbers = (await request.formData()) | |
| 71 | 72 | .getAll("issue") | |
| 20 | 20 | } from "../../components/ui/alert-dialog"; | |
| 21 | 21 | import { Button } from "../../components/ui/button"; | |
| 22 | 22 | import { Card } from "../../components/ui/card"; | |
| 23 | − | import { refusal, requireRepo } from "../../lib/access.server"; | |
| 23 | + | import { refusal, requireRepo, requireWritableRepo } from "../../lib/access.server"; | |
| 24 | 24 | import { matchLabels, tidyColor } from "../../lib/labels"; | |
| 25 | 25 | import { page } from "../../lib/meta"; | |
| 26 | 26 | import { work } from "../../lib/services.server"; | |
| ⋯ | |||
| 41 | 41 | export async function action({ request, params, context }: Route.ActionArgs): Promise<ActionData> { | |
| 42 | 42 | assertSameOrigin(request); | |
| 43 | 43 | const user = requireUser(context, request); | |
| 44 | + | await requireWritableRepo(context, params); | |
| 44 | 45 | const form = await request.formData(); | |
| 45 | 46 | const intent = String(form.get("intent") ?? ""); | |
| 46 | 47 | const name = String(form.get("name") ?? ""); | |
| 19 | 19 | import { PinButton } from "../../components/pin-button"; | |
| 20 | 20 | import { StarButton } from "../../components/star-button"; | |
| 21 | 21 | 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"; | |
| 23 | 24 | import type { MirrorBrief } from "../../lib/mirror"; | |
| 24 | 25 | import { WelcomeBanner } from "../../components/welcome"; | |
| 25 | 26 | import { clearWelcome, welcomes } from "../../lib/invites"; | |
| ⋯ | |||
| 196 | 197 | // The project's own description, else the repository's as it is now. | |
| 197 | 198 | const description = (project && !project.descriptionInherited ? project.description : null) ?? repo.description; | |
| 198 | 199 | 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); | |
| 200 | 206 | // 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); | |
| 202 | 208 | // Everyone, signed in or not, finds the project's pages in the sidebar; | |
| 203 | 209 | // the page shows its name, and the views of the page it is on as tabs. | |
| 204 | 210 | return ( | |
| ⋯ | |||
| 230 | 236 | {!filesPage && <Topics topics={repo.topics} />} | |
| 231 | 237 | {/* A phone, or a visitor: the project's pages, which the sidebar lists for someone signed in. */} | |
| 232 | 238 | <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} /> | |
| 234 | 240 | </div> | |
| 235 | 241 | {tabs && ( | |
| 236 | 242 | <div className="mt-3"> | |
| ⋯ | |||
| 263 | 269 | className="mb-6" | |
| 264 | 270 | /> | |
| 265 | 271 | )} | |
| 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 | + | )} | |
| 267 | 277 | </div> | |
| 268 | 278 | </> | |
| 269 | 279 | ); | |
| 7 | 7 | import { ReviewQueue, reviewAction } from "../../components/context"; | |
| 8 | 8 | import { agents, memoryReview } from "../../lib/services.server"; | |
| 9 | 9 | import { assertSameOrigin, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 10 | − | import { requireRepo } from "../../lib/access.server"; | |
| 10 | + | import { requireRepo, requireWritableRepo } from "../../lib/access.server"; | |
| 11 | 11 | ||
| 12 | 12 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 13 | 13 | return page(args, { title: `Memory · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 36 | 36 | export async function action({ params, context, request }: Route.ActionArgs) { | |
| 37 | 37 | assertSameOrigin(request); | |
| 38 | 38 | const user = requireUser(context, request); | |
| 39 | + | await requireWritableRepo(context, params); | |
| 39 | 40 | const form = await request.formData(); | |
| 40 | 41 | return ( | |
| 41 | 42 | (await reviewAction(memoryReview, user, params.owner, form)) ?? | |
| 32 | 32 | type PackageSummary, | |
| 33 | 33 | type Project, | |
| 34 | 34 | type Pull, | |
| 35 | + | type RepoMirror, | |
| 35 | 36 | RUN_KIND_LABEL, | |
| 36 | 37 | isActiveRun, | |
| 37 | 38 | workOwner, | |
| ⋯ | |||
| 51 | 52 | import { ProductionShot } from "../../components/production-shot"; | |
| 52 | 53 | import { GithubLinkStrip } from "../../components/github"; | |
| 53 | 54 | import { MirrorOverviewNote, useRepoMirror } from "../../components/mirror"; | |
| 55 | + | import { type MirrorBrief, leaderOf, mirroringSettings, writable } from "../../lib/mirror"; | |
| 54 | 56 | import { distinctFacts } from "../../lib/memory-facts"; | |
| 55 | 57 | import { githubApp } from "../../lib/github.server"; | |
| 56 | 58 | import { ButtonLink, CopyLine, SubmitButton, TimeAgo } from "../../components/ui"; | |
| ⋯ | |||
| 97 | 99 | import { actions, agents, deployments, events as eventLog, identity, packages, projects, repos, work } from "../../lib/services.server"; | |
| 98 | 100 | import { madeByG1t } from "../../lib/opened-by"; | |
| 99 | 101 | 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"; | |
| 101 | 103 | import { shotVersion } from "./production-screenshot"; | |
| 102 | 104 | import { DeploymentsPanel } from "../../components/deployments-panel"; | |
| 103 | 105 | import { environmentUrl, productionEnvironment } from "../../lib/deployments"; | |
| ⋯ | |||
| 497 | 499 | return saved.ok ? { notice: "Saved." } : { error: saved.error.message }; | |
| 498 | 500 | } | |
| 499 | 501 | // Deploying is compute. Syncing with GitHub is under Settings → Mirroring. | |
| 502 | + | await requireWritableRepo(context, params); | |
| 500 | 503 | const refused = await refusal(context, params, "run"); | |
| 501 | 504 | if (refused) return { error: refused }; | |
| 502 | 505 | const started = await deployments.redeploy(user, { workspace: params.owner, slug: params.repo }, null); | |
| ⋯ | |||
| 817 | 820 | const ages = health.ages; | |
| 818 | 821 | const oldest = ages ? Math.max(1, ...ages.map((a) => a.count)) : 1; | |
| 819 | 822 | ||
| 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 | + | ||
| 820 | 843 | return ( | |
| 821 | 844 | <div className="space-y-8"> | |
| 822 | 845 | {loaderData.github?.mode === "import" ? ( | |
| ⋯ | |||
| 1237 | 1260 | )} | |
| 1238 | 1261 | </Panel> | |
| 1239 | 1262 | ||
| 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"} /> | |
| 1271 | 1264 | ||
| 1272 | 1265 | <Panel title="Activity" icon={<ActivityIcon size={14} />}> | |
| 1273 | 1266 | {loaderData.eventsLoaded ? ( | |
| ⋯ | |||
| 1313 | 1306 | <aside className="space-y-4 text-sm"> | |
| 1314 | 1307 | {/* Deployments panel (deployments-panel.tsx): each environment's latest deployment. */} | |
| 1315 | 1308 | <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} />} | |
| 1380 | 1310 | ||
| 1381 | 1311 | {member && ( | |
| 1382 | 1312 | <Card asChild className="p-5"> | |
| ⋯ | |||
| 1486 | 1416 | </section> | |
| 1487 | 1417 | </Card> | |
| 1488 | 1418 | ||
| 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> | |
| 1498 | 1516 | )} | |
| 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}`} />} | |
| 1499 | 1692 | </aside> | |
| 1500 | 1693 | </div> | |
| 1501 | 1694 | </div> | |
| 4 | 4 | import { Form, Link, data } from "react-router"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/plan"; | |
| 7 | − | import { refusal, requireRepo } from "../../lib/access.server"; | |
| 7 | + | import { refusal, requireRepo, requireWritableRepo } from "../../lib/access.server"; | |
| 8 | 8 | import { whyNot } from "../../lib/access"; | |
| 9 | 9 | import { page } from "../../lib/meta"; | |
| 10 | 10 | import { Markdown } from "../../components/markdown"; | |
| ⋯ | |||
| 86 | 86 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 87 | 87 | assertSameOrigin(request); | |
| 88 | 88 | const user = requireUser(context, request); | |
| 89 | + | await requireWritableRepo(context, params); | |
| 89 | 90 | const form = await request.formData(); | |
| 90 | 91 | const path = { namespace: params.owner, name: params.repo }; | |
| 91 | 92 | // Opening the issues and putting agents on them: Write and up. | |
| 5 | 5 | import type { Plan } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/plans"; | |
| 8 | − | import { refusal, requireRepo } from "../../lib/access.server"; | |
| 8 | + | import { refusal, requireRepo, requireWritableRepo } from "../../lib/access.server"; | |
| 9 | 9 | import { whyNot } from "../../lib/access"; | |
| 10 | 10 | import { page } from "../../lib/meta"; | |
| 11 | 11 | import { ComputeNote, EmptyState, ErrorText, SubmitButton, Textarea, TimeAgo } from "../../components/ui"; | |
| ⋯ | |||
| 40 | 40 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 41 | 41 | assertSameOrigin(request); | |
| 42 | 42 | const user = requireUser(context, request); | |
| 43 | + | await requireWritableRepo(context, params); | |
| 43 | 44 | const form = await request.formData(); | |
| 44 | 45 | const path = { namespace: params.owner, name: params.repo }; | |
| 45 | 46 | // Planning is an agent run: Write and up. | |
| 19 | 19 | import { Card } from "../../components/ui/card"; | |
| 20 | 20 | import { repos, work } from "../../lib/services.server"; | |
| 21 | 21 | import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server"; | |
| 22 | + | import { requireWritableRepo } from "../../lib/access.server"; | |
| 22 | 23 | ||
| 23 | 24 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 24 | 25 | return page(args, { title: `New pull request · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 59 | 60 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 60 | 61 | assertSameOrigin(request); | |
| 61 | 62 | const user = requireUser(context, request); | |
| 63 | + | await requireWritableRepo(context, params); | |
| 62 | 64 | const form = await request.formData(); | |
| 63 | 65 | const issue = Number(form.get("issue")); | |
| 64 | 66 | const result = await work.openPull( | |
| 7 | 7 | import { ErrorText, Field, Input, SubmitButton, Textarea } from "../../components/ui"; | |
| 8 | 8 | import { Card } from "../../components/ui/card"; | |
| 9 | 9 | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 10 | − | import { requireRepo } from "../../lib/access.server"; | |
| 10 | + | import { requireRepo, requireWritableRepo } from "../../lib/access.server"; | |
| 11 | 11 | import { page } from "../../lib/meta"; | |
| 12 | 12 | import { repos } from "../../lib/services.server"; | |
| 13 | 13 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| ⋯ | |||
| 29 | 29 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 30 | 30 | assertSameOrigin(request); | |
| 31 | 31 | const user = requireUser(context, request); | |
| 32 | + | await requireWritableRepo(context, params); | |
| 32 | 33 | const form = await request.formData(); | |
| 33 | 34 | const text = (key: string) => String(form.get(key) ?? "").trim(); | |
| 34 | 35 | const tagName = text("tag"); | |
| 6 | 6 | import { redirect } from "react-router"; | |
| 7 | 7 | ||
| 8 | 8 | import type { Route } from "./+types/security-code-setup"; | |
| 9 | − | import { refusal } from "../../lib/access.server"; | |
| 9 | + | import { refusal, requireWritableRepo } from "../../lib/access.server"; | |
| 10 | 10 | import { setupCodeScanning } from "../../lib/security-suite.server"; | |
| 11 | 11 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 12 | 12 | ||
| 13 | 13 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 14 | 14 | assertSameOrigin(request); | |
| 15 | 15 | const user = requireUser(context, request); | |
| 16 | + | await requireWritableRepo(context, params); | |
| 16 | 17 | const refused = await refusal(context, params, "manage_settings"); | |
| 17 | 18 | if (refused) return { error: refused }; | |
| 18 | 19 | const opened = await setupCodeScanning(user, { namespace: params.owner, name: params.repo }); |
| 21 | 21 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 22 | 22 | import { refusal, requireInsider } from "../../lib/access.server"; | |
| 23 | 23 | import { whyNot } from "../../lib/access"; | |
| 24 | + | import { useRepoMirror } from "../../components/mirror"; | |
| 25 | + | import { updatesWaitReason } from "../../lib/mirror"; | |
| 24 | 26 | import { alertCapability, countByState, parseAlertState } from "../../lib/security-alerts"; | |
| 25 | 27 | import { severityCounts } from "../../lib/security-suite"; | |
| 26 | 28 | ||
| ⋯ | |||
| 123 | 125 | const rescan = useFetcher<{ ok: boolean; error?: string }>(); | |
| 124 | 126 | const upkeep = useFetcher<{ ok: boolean; error?: string }>(); | |
| 125 | 127 | 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); | |
| 126 | 131 | const vulnCounts = countByState(overview.vulnerabilities); | |
| 127 | 132 | // A link to one alert shows it wherever it stands. | |
| 128 | 133 | const focus = search.get("finding"); | |
| ⋯ | |||
| 146 | 151 | </h2> | |
| 147 | 152 | <p className="mt-1.5 max-w-2xl text-sm text-muted"> | |
| 148 | 153 | 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."} | |
| 150 | 156 | </p> | |
| 151 | 157 | </div> | |
| 152 | 158 | {can.run && ( | |
| ⋯ | |||
| 187 | 193 | fixes={fixes} | |
| 188 | 194 | pulls={pulls} | |
| 189 | 195 | upkeep={overview.upkeep} | |
| 196 | + | updatesWait={updatesWait} | |
| 190 | 197 | base={base} | |
| 191 | 198 | action={action} | |
| 192 | 199 | focus={focus} | |
| ⋯ | |||
| 208 | 215 | <span className="mt-1 block text-sm text-muted"> | |
| 209 | 216 | Open a pull request to upgrade each vulnerable dependency that has a fix. It lands through your branch's | |
| 210 | 217 | required checks. | |
| 218 | + | {updatesWait && " On this mirror, it takes effect once the repository is taken over or moved to g1t."} | |
| 211 | 219 | </span> | |
| 212 | 220 | {upkeep.data?.error && <span className="mt-1 block text-xs text-danger">{upkeep.data.error}</span>} | |
| 213 | 221 | </span> | |
| 53 | 53 | const pinned = (shortcuts?.pinned ?? []).map((pin) => byId.get(pin.id)).filter((project): project is Project => project != null); | |
| 54 | 54 | // However many there are: the most active first, archived ones last. | |
| 55 | 55 | 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 })), | |
| 57 | 58 | "active", | |
| 58 | 59 | ) | |
| 59 | 60 | .sort((a, b) => Number(a.archived) - Number(b.archived)) |
| 2 | 2 | import { type KeyboardEvent, useEffect, useRef, useState } from "react"; | |
| 3 | 3 | import { Form, Link, useLocation, useNavigation, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| 5 | + | import type { MirrorState } from "@g1t/contracts"; | |
| 6 | + | ||
| 5 | 7 | import type { Route } from "./+types/projects"; | |
| 6 | 8 | import { host } from "../../components/deploy"; | |
| 9 | + | import { MirrorBadge } from "../../components/mirror"; | |
| 7 | 10 | import { PinButton } from "../../components/pin-button"; | |
| 8 | 11 | import { EmptyState, TimeAgo, notACredential } from "../../components/ui"; | |
| 9 | 12 | import { Button } from "../../components/ui/button"; | |
| ⋯ | |||
| 28 | 31 | readProjectQuery, | |
| 29 | 32 | } from "../../lib/project-list"; | |
| 30 | 33 | import { kindLabel, primaryLink } from "../../lib/project-kind"; | |
| 31 | − | import { deployments, projects } from "../../lib/services.server"; | |
| 34 | + | import { deployments, mirrors, projects } from "../../lib/services.server"; | |
| 32 | 35 | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 33 | 36 | import { workspaceProjects } from "../../lib/workspace-projects.server"; | |
| 34 | 37 | ||
| ⋯ | |||
| 53 | 56 | ]); | |
| 54 | 57 | const live = new Map((deploys?.ok ? deploys.value : []).map((entry) => [entry.slug, entry])); | |
| 55 | 58 | 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])); | |
| 56 | 65 | const all: Listed[] = (listed.ok ? listed.value : []).map((project) => ({ | |
| 57 | 66 | id: project.id, | |
| 58 | 67 | slug: project.slug, | |
| ⋯ | |||
| 66 | 75 | pushedAt: project.pushedAt ?? null, | |
| 67 | 76 | activity: project.activity ?? 0, | |
| 68 | 77 | 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 | + | })(), | |
| 69 | 82 | })); | |
| 70 | 83 | const matched = filterProjects(all, query); | |
| 71 | 84 | const shown = pageOf(matched, query.page); | |
| ⋯ | |||
| 94 | 107 | /** A filter's select, on the same surface as the other controls. */ | |
| 95 | 108 | const FILTER = "w-auto bg-surface"; | |
| 96 | 109 | ||
| 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 | + | */ | |
| 98 | 114 | function Facts({ project }: { project: Item }) { | |
| 99 | 115 | const language = project.ecosystem ? LANGUAGE_OF[project.ecosystem] : null; | |
| 100 | 116 | return ( | |
| 101 | 117 | <> | |
| 118 | + | {project.mirror && ( | |
| 119 | + | <MirrorBadge mirror={{ state: project.mirror.state, remote: project.mirror.remote, url: "", since: "" }} briefs={[]} /> | |
| 120 | + | )} | |
| 102 | 121 | {language && <span>{language}</span>} | |
| 103 | 122 | {project.kind !== "app" && <span>{kindLabel(project)}</span>} | |
| 104 | 123 | {project.kind === "app" && project.runs === "elsewhere" && <span>Deployed elsewhere</span>} | |
| ⋯ | |||
| 367 | 386 | ...(facets.kind.tool > 0 || query.kind === "tool" ? [{ value: "tool", label: option("Tools", facets.kind.tool) }] : []), | |
| 368 | 387 | ...(facets.kind.docs > 0 || query.kind === "docs" ? [{ value: "docs", label: option("Docs", facets.kind.docs) }] : []), | |
| 369 | 388 | ...(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 | + | : []), | |
| 370 | 396 | ]} | |
| 371 | 397 | /> | |
| 372 | 398 | {(facets.languages.length > 0 || query.language) && ( | |
| 10 | 10 | //! The integrations service keeps the links (`remotes`) and decides; the | |
| 11 | 11 | //! repos service keeps each repository's [`RepoMirror`], so pushes and | |
| 12 | 12 | //! 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. | |
| 13 | 29 | ||
| 14 | 30 | use std::collections::BTreeMap; | |
| 15 | 31 | ||
| ⋯ | |||
| 93 | 109 | pub fn writable(&self) -> bool { | |
| 94 | 110 | self.state.leads() | |
| 95 | 111 | } | |
| 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, | |
| 96 | 146 | } | |
| 97 | 147 | ||
| 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 | + | ||
| 98 | 183 | /// Why a mirror refuses a write, for people and for `git push`. | |
| 99 | 184 | pub fn mirror_message(namespace: &str, name: &str, mirror: &RepoMirror) -> String { | |
| 100 | 185 | match mirror.state { | |
| ⋯ | |||
| 608 | 693 | } | |
| 609 | 694 | ||
| 610 | 695 | #[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] | |
| 611 | 724 | fn settings_default_to_doing_nothing_on_their_own() { | |
| 612 | 725 | let settings: MirrorSettings = serde_json::from_str("{}").unwrap(); | |
| 613 | 726 | assert_eq!(settings, MirrorSettings::default()); | |
| 1403 | 1403 | pub branch: String, | |
| 1404 | 1404 | } | |
| 1405 | 1405 | ||
| 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`. | |
| 1409 | 1409 | #[derive(Debug, Serialize, Deserialize)] | |
| 1410 | 1410 | pub struct StatusByIdArgs { | |
| 1411 | 1411 | pub id: String, | |
| 1412 | 1412 | } | |
| 1413 | 1413 | ||
| 1414 | − | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 1414 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 1415 | 1415 | pub struct RepoStatus { | |
| 1416 | 1416 | pub archived: bool, | |
| 1417 | 1417 | 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>, | |
| 1418 | 1421 | } | |
| 1419 | 1422 | ||
| 1420 | 1423 | 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. | |
| 1422 | 1427 | pub fn active(&self) -> bool { | |
| 1423 | 1428 | !self.archived && !self.deleted | |
| 1424 | 1429 | } | |
| 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 | + | } | |
| 1425 | 1437 | } | |
| 1426 | 1438 | ||
| 1427 | 1439 | /// What a person is told when something would change an archived |
| 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 | + | }); |
| 8 | 8 | * a while, then **hands back**), or **move it to g1t** for good, after which | |
| 9 | 9 | * g1t no longer tracks the remote. A repository g1t leads can be **mirrored | |
| 10 | 10 | * 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. | |
| 11 | 26 | */ | |
| 12 | 27 | import type { ServiceBinding } from "./clients"; | |
| 13 | 28 | import type { User } from "./identity"; | |
| ⋯ | |||
| 35 | 50 | return !mirror || mirror.state === "takeover"; | |
| 36 | 51 | } | |
| 37 | 52 | ||
| 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 | + | ||
| 38 | 94 | export type RemoteProvider = "github" | "g1t" | "git"; | |
| 39 | 95 | export type RemoteRole = "leader" | "follower"; | |
| 40 | 96 | export type RemoteState = MirrorState | "following" | "stuck"; | |
| 60 | 60 | purgeAfter: string; | |
| 61 | 61 | }; | |
| 62 | 62 | ||
| 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 }; | |
| 65 | 69 | ||
| 66 | 70 | /** The most topics a repository has, and the longest topic. */ | |
| 67 | 71 | export const MAX_TOPICS = 20; |
| 673 | 673 | .map(|row| row.now)) | |
| 674 | 674 | } | |
| 675 | 675 | ||
| 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. | |
| 677 | 678 | pub async fn status(&self, id: &str) -> Result<RepoStatus> { | |
| 678 | 679 | #[derive(Deserialize)] | |
| 679 | 680 | struct Row { | |
| ⋯ | |||
| 681 | 682 | archived_at: Option<String>, | |
| 682 | 683 | #[serde(default)] | |
| 683 | 684 | deleted_at: Option<String>, | |
| 685 | + | #[serde(default)] | |
| 686 | + | mirror: Option<String>, | |
| 684 | 687 | } | |
| 685 | 688 | Ok(self | |
| 686 | 689 | .db | |
| 687 | − | .prepare("SELECT archived_at, deleted_at FROM repos WHERE id = ?") | |
| 690 | + | .prepare("SELECT archived_at, deleted_at, mirror FROM repos WHERE id = ?") | |
| 688 | 691 | .bind(&[id.into()])? | |
| 689 | 692 | .first::<Row>(None) | |
| 690 | 693 | .await? | |
| ⋯ | |||
| 692 | 695 | RepoStatus { | |
| 693 | 696 | archived: false, | |
| 694 | 697 | deleted: true, | |
| 698 | + | mirror: None, | |
| 695 | 699 | }, | |
| 696 | 700 | |row| RepoStatus { | |
| 697 | 701 | archived: row.archived_at.is_some(), | |
| 698 | 702 | deleted: row.deleted_at.is_some(), | |
| 703 | + | mirror: row.mirror.as_deref().and_then(|mirror| serde_json::from_str(mirror).ok()), | |
| 699 | 704 | }, | |
| 700 | 705 | )) | |
| 701 | 706 | } | |
| 232 | 232 | None => None, | |
| 233 | 233 | }; | |
| 234 | 234 | // 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?; | |
| 239 | 241 | Ok(None) | |
| 240 | 242 | } | |
| 241 | 243 |
| 189 | 189 | Ok(Outcome::Ok(row)) | |
| 190 | 190 | } | |
| 191 | 191 | ||
| 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> { | |
| 195 | 195 | let status: Result<RepoStatus> = | |
| 196 | 196 | g1t_kit::call(&self.repos, "status_by_id", &StatusByIdArgs { id: repo_id.to_owned() }).await; | |
| 197 | 197 | Ok(match status { | |
| 198 | − | Ok(status) => status.active(), | |
| 198 | + | Ok(status) => status, | |
| 199 | 199 | Err(error) => { | |
| 200 | 200 | worker::console_error!("security: status_by_id {repo_id}: {error}"); | |
| 201 | − | true | |
| 201 | + | RepoStatus::default() | |
| 202 | 202 | } | |
| 203 | 203 | }) | |
| 204 | 204 | } | |
| 205 | 205 | ||
| 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 | + | ||
| 206 | 226 | /// Records a repository named in an event, by id. Forks are not | |
| 207 | 227 | /// recorded: a pull request's findings belong to its repository. | |
| 208 | 228 | async fn register_by_id(&self, repo_id: &str) -> Result<Option<RepoRow>> { |
| 628 | 628 | } | |
| 629 | 629 | ||
| 630 | 630 | /// 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. | |
| 632 | 635 | pub async fn stalled_updates(&self) -> Result<()> { | |
| 633 | 636 | let before = rfc3339(now_ms().saturating_sub(STALLED_MS)); | |
| 634 | 637 | for row in self.store.stalled_updates(&before, 10).await? { | |
| 635 | 638 | 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?; | |
| 640 | 642 | continue; | |
| 641 | 643 | } | |
| 642 | 644 | self.needs_code(&repo, &row, "The version could not be raised in a sandbox on its own").await?; |
| 599 | 599 | /// One run of one entry: what it found, as a sentence, or why it could | |
| 600 | 600 | /// not run. | |
| 601 | 601 | 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"))); | |
| 604 | 606 | } | |
| 605 | 607 | if entry.open_pull_requests_limit == 0 { | |
| 606 | 608 | return Ok(Ok("open-pull-requests-limit is 0, so no version updates open.".to_owned())); |