A project's overview says what the project is, whether it is running and healthy, and who is working on it right now, and leaves the repository to Code: the Right now board, Active branches, Recent changes, Latest on main, Activity, What this project remembers and Clone are gone, and a single line under production links to Code with the repository's name, default branch and when it was last pushed to.
Under production, the facts are about the project: its latest release (or production's state, where the card does not already show it), how often checks pass, and either how many changes pass on the first try and what is open, or, for people with a role when Observability has data, the last day's requests and error rate with how many errors are open. Live now lists the people and coding agents at work on it, each issue with its claims and sessions, the harness each came from and who it acts for, as Work shows them (the two pages share the list now); with nobody working, people with a role see how to connect Claude Code or any other harness, and anyone else a quiet line. The sidebar has Deployments, an Observability card for people with a role (the last day's requests an hour a bar, latency, and the newest open errors), About, and Health (recent builds, open issues by age). Visitors to a public app on g1t.page see production's address, state and Visit, read from its production environment, where they saw "Deployments are off" before. The page asks for less: no branch comparison, commit log, closed pull requests, merge queue, memory or name lookups, so it settles sooner. The project guide describes the new page.
- Checked:
- the site's type-check, its 884 tests and a production build pass; the overview rendered in headless Chrome with its real layout at 1280 and 390 px, for a member and a signed-out visitor, on flagon-io/g1t (app deployed elsewhere) and flagon-io/lab-api (app on g1t.page) with their live data from g1t.sh, and with sample claims, sessions, errors and traffic for the busy view, since no project has any yet.
| 27 | 27 | | **Branches** | Beside each branch's latest commit | | |
| 28 | 28 | | **Tags** | Beside each tagged commit | | |
| 29 | 29 | | A pull request | Beside its head commit, in the sidebar; its merge box lists them too | | |
| 30 | − | | A project's overview | Beside the latest commit, and each active branch | | |
| 30 | + | | A project's overview | For an app deployed elsewhere that has reported no deployment, beside the latest commit in its production card | | |
| 31 | 31 | ||
| 32 | 32 | A page reads the checks of all its commits at once, after the page itself | |
| 33 | 33 | has loaded: each mark shows a placeholder until they arrive. |
| 47 | 47 | ||
| 48 | 48 | ## The overview | |
| 49 | 49 | ||
| 50 | − | A project's overview is the first page you see. People with a | |
| 51 | − | [role](/guides/access-and-roles/) on its repository see all of it; anyone | |
| 52 | − | else sees what the project shares publicly. | |
| 50 | + | A project's overview is the first page you see. It says what the project | |
| 51 | + | is, whether it is running and healthy, and who is working on it right now. | |
| 52 | + | Its files, branches and commits are on **Code**; its pull requests and the | |
| 53 | + | merge queue on **Pull requests** and **Work**. The overview links to them | |
| 54 | + | rather than repeating them. | |
| 55 | + | ||
| 56 | + | People with a [role](/guides/access-and-roles/) on its repository see all | |
| 57 | + | of it; anyone else sees what the project shares publicly: production, its | |
| 58 | + | checks, who is working on it, its deployments and About. | |
| 53 | 59 | ||
| 54 | 60 | | Part | What it shows | | |
| 55 | 61 | | --- | --- | | |
| ⋯ | |||
| 57 | 63 | | **Production** | [Production's picture](#productions-picture), which opens the live site, beside the rest. For an app [deployed on g1t](/guides/deployments/): its address, the commit it runs and when it went up; **Visit** and **Redeploy**. For an app deployed elsewhere: production's address and **Visit**, with its latest reported production deployment or the default branch's latest commit. For an app nobody has placed yet: one question, where it runs. | | |
| 58 | 64 | | **Packages** | For a library or a tool, in place of Production: the packages its repository publishes, each with its latest version and how to install it, or how to publish a first one. | | |
| 59 | 65 | | **Documentation**, **Homepage** | For docs, where they are read; for anything else, its homepage. | | |
| 60 | − | | **Right now** | Open pull requests moving from working to landed. | | |
| 61 | − | | **Needs you** | What is waiting on a person: a failed production build, a pull request to merge or review, a stuck run. | | |
| 62 | − | | **Active branches** | Branches other than the default, newest first. See [active branches](#active-branches). | | |
| 63 | − | | **Recent changes**, **Latest on main**, **Activity**, **Previews** | What landed, the default branch's latest commits, what happened (pull requests opened, readied and merged, issues opened and closed, checks, reviews, comments, pushes to the default branch and production deploys), and the previews that are up. | | |
| 66 | + | | **Facts** | Under production: its latest release (or, where the card does not already say it, production's state), how often checks pass, and either how many changes pass on the first try and what is open, or, for people with a role when [Observability](/guides/observability/) has data, requests and the error rate over the last day with how many errors are open. | | |
| 67 | + | | **Code** | One line to the repository: its name, default branch and when it was last pushed to, linking to **Code**. | | |
| 68 | + | | **Needs you** | For someone signed in: what is waiting on them, such as a failed production build or a pull request to merge or review. | | |
| 69 | + | | **Live now** | The people and coding agents working on it: each issue with its [claims and sessions](/guides/sessions/), the harness each came from and who it acts for, and sessions on no issue yet. **Work** has everything. With nobody working, people with a role see how to [connect a coding agent](/guides/bring-your-own-agent/). | | |
| 70 | + | | **Previews** | For people with a role: the pull requests' previews that are up. | | |
| 71 | + | | **Deployments** | Each environment's latest deployment and when. | | |
| 72 | + | | **Observability** | For people with a role, when there is data: the last day's requests, an hour a bar, latency, and the newest open errors. | | |
| 64 | 73 | | **About** | Its description, its [links](#links), what it is, and its latest release (its newest tag). People who can change its settings edit the description and links from here. | | |
| 65 | − | | **Health**, **Clone** | How often checks pass (on pull requests, or the project's workflow runs when it ships without them), recent builds and open issues by age; the clone address. | | |
| 74 | + | | **Health** | For people with a role, its recent builds; and its open issues by age. | | |
| 66 | 75 | ||
| 67 | 76 | ### Production's picture | |
| 68 | 77 | ||
| ⋯ | |||
| 188 | 197 | makes it an app deployed on g1t. | |
| 189 | 198 | ||
| 190 | 199 | Changing what it is needs the role that changes the project's settings. | |
| 191 | − | ||
| 192 | − | ### Active branches | |
| 193 | − | ||
| 194 | − | Up to five branches other than the default, the most recently changed | |
| 195 | − | first. Each shows its last commit and who made it, how many commits it is | |
| 196 | − | ahead of the default branch and behind it, and its open pull request, | |
| 197 | − | with its checks, and preview, if it has them. A branch with no pull | |
| 198 | − | request links to opening one, unless it has nothing the default branch | |
| 199 | − | lacks, which says **Nothing to merge**. | |
| 200 | − | ||
| 201 | − | Ahead and behind are exact, merges included. g1t reads up to 1,000 | |
| 202 | − | commits of each history to find where the two meet; a branch that left | |
| 203 | − | the default branch further back than that shows no counts. Ten branches | |
| 204 | − | are read, those with open pull requests first; a project with more says | |
| 205 | − | how many it has. | |
| 206 | 200 | ||
| 207 | 201 | ## Links | |
| 208 | 202 | ||
| 1 | − | import { ArrowDown, ArrowUp, GitBranch, GitPullRequest, Globe } from "lucide-react"; | |
| 2 | − | import { Link } from "react-router"; | |
| 3 | − | ||
| 4 | 1 | import type { Pull } from "@g1t/contracts"; | |
| 5 | 2 | ||
| 6 | 3 | import type { Drift } from "../lib/branches"; | |
| 7 | 4 | import type { CommitPerson } from "../lib/commit-people"; | |
| 8 | − | import { CommitAvatars, CommitNames } from "./commit-person"; | |
| 9 | − | import { CheckBadge } from "./checks"; | |
| 10 | − | import { type ChecksSource, CommitChecksBadge } from "./commit-checks"; | |
| 11 | − | import { host } from "./deploy"; | |
| 12 | − | import { TimeAgo } from "./ui"; | |
| 13 | − | import { Card } from "./ui/card"; | |
| 14 | − | import { Hint } from "./ui/hint"; | |
| 15 | 5 | ||
| 16 | − | function drift({ ahead, behind }: Drift, main: string): string { | |
| 17 | − | return `${ahead} ${ahead === 1 ? "commit" : "commits"} ahead of ${main}, ${behind} behind`; | |
| 18 | − | } | |
| 19 | − | ||
| 6 | + | /** A branch other than the default as Branches lists it: its head, how far it has moved, its pull request and preview. */ | |
| 20 | 7 | export type ActiveBranch = { | |
| 21 | 8 | name: string; | |
| 22 | 9 | /** The commit it points to. */ | |
| ⋯ | |||
| 28 | 15 | pull: { number: number; title: string; checkStatus: Pull["checkStatus"]; draft: boolean; status: Pull["status"] } | null; | |
| 29 | 16 | preview: string | null; | |
| 30 | 17 | }; | |
| 31 | − | ||
| 32 | − | /** Branches other than the default, newest first, with how far each has moved, its head's checks and what is open on it. */ | |
| 33 | − | export function ActiveBranches({ | |
| 34 | − | branches, | |
| 35 | − | base, | |
| 36 | − | main, | |
| 37 | − | checks, | |
| 38 | − | }: { | |
| 39 | − | branches: ActiveBranch[]; | |
| 40 | − | base: string; | |
| 41 | − | main: string; | |
| 42 | − | /** The checks on each branch's head, streamed in. */ | |
| 43 | − | checks?: ChecksSource; | |
| 44 | − | }) { | |
| 45 | − | return ( | |
| 46 | − | <Card asChild divided className="overflow-hidden"> | |
| 47 | − | <ul> | |
| 48 | − | {branches.map((branch) => ( | |
| 49 | − | <li key={branch.name} className="flex flex-wrap items-start gap-x-3 gap-y-1.5 px-4 py-3"> | |
| 50 | − | <GitBranch size={15} className="mt-0.5 shrink-0 text-faint" /> | |
| 51 | − | <span className="min-w-0 grow basis-48"> | |
| 52 | − | <Link | |
| 53 | − | to={`${base}/tree/${encodeURIComponent(branch.name)}`} | |
| 54 | − | className="block truncate font-mono text-[0.8125rem] font-medium hover:text-accent" | |
| 55 | − | > | |
| 56 | − | {branch.name} | |
| 57 | − | </Link> | |
| 58 | − | {branch.commit && ( | |
| 59 | − | <span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted"> | |
| 60 | − | <CommitAvatars commit={branch.commit} size={14} max={2} /> | |
| 61 | − | <span className="shrink-0"> | |
| 62 | − | <CommitNames commit={branch.commit} className="hover:text-fg" /> | |
| 63 | − | </span> | |
| 64 | − | <span className="text-faint">·</span> | |
| 65 | − | <Hint label={branch.commit.message}> | |
| 66 | − | <Link to={`${base}/commit/${branch.commit.hash}`} className="min-w-0 truncate hover:text-fg"> | |
| 67 | − | {branch.commit.message} | |
| 68 | − | </Link> | |
| 69 | − | </Hint> | |
| 70 | − | <CommitChecksBadge checks={checks} sha={branch.commit.hash} className="size-5" /> | |
| 71 | − | <span className="shrink-0 text-faint"> | |
| 72 | − | · <TimeAgo at={branch.commit.at} /> | |
| 73 | − | </span> | |
| 74 | − | </span> | |
| 75 | − | )} | |
| 76 | − | </span> | |
| 77 | − | <span className="ml-6.5 flex shrink-0 flex-wrap items-center gap-2 text-xs sm:ml-0"> | |
| 78 | − | {branch.drift && ( | |
| 79 | − | <Hint label={drift(branch.drift, main)}> | |
| 80 | − | <span className="inline-flex items-center gap-1.5 rounded-md border border-line px-1.5 py-0.5 font-mono tabular-nums text-muted"> | |
| 81 | − | <span className="sr-only">{drift(branch.drift, main)}</span> | |
| 82 | − | <span className="inline-flex items-center gap-0.5" aria-hidden> | |
| 83 | − | <ArrowUp size={11} className={branch.drift.ahead > 0 ? "text-success" : "text-faint"} /> | |
| 84 | − | {branch.drift.ahead} | |
| 85 | − | </span> | |
| 86 | − | <span className="inline-flex items-center gap-0.5" aria-hidden> | |
| 87 | − | <ArrowDown size={11} className={branch.drift.behind > 0 ? "text-warn" : "text-faint"} /> | |
| 88 | − | {branch.drift.behind} | |
| 89 | − | </span> | |
| 90 | − | </span> | |
| 91 | − | </Hint> | |
| 92 | − | )} | |
| 93 | − | {branch.pull ? ( | |
| 94 | − | <Hint label={branch.pull.title}> | |
| 95 | − | <Link | |
| 96 | − | to={`${base}/pull/${branch.pull.number}`} | |
| 97 | − | className="inline-flex items-center gap-1.5 rounded-md border border-line px-1.5 py-0.5 text-muted hover:border-line-strong hover:text-fg" | |
| 98 | − | > | |
| 99 | − | <GitPullRequest size={12} className={branch.pull.draft ? "text-faint" : "text-success"} />#{branch.pull.number} | |
| 100 | − | <span className="sr-only">{branch.pull.title}</span> | |
| 101 | − | <CheckBadge status={branch.pull.checkStatus} /> | |
| 102 | − | </Link> | |
| 103 | − | </Hint> | |
| 104 | − | ) : branch.drift?.ahead === 0 ? ( | |
| 105 | − | // Nothing here that the default branch lacks: no pull request to open. | |
| 106 | − | <Hint label={`Every commit on ${branch.name} is already on ${main}.`}> | |
| 107 | − | <span className="px-1.5 py-0.5 text-faint">Nothing to merge</span> | |
| 108 | − | </Hint> | |
| 109 | − | ) : ( | |
| 110 | − | <Link | |
| 111 | − | to={`${base}/pulls/new?branch=${encodeURIComponent(branch.name)}`} | |
| 112 | − | className="rounded-md px-1.5 py-0.5 text-faint hover:text-fg" | |
| 113 | − | > | |
| 114 | − | Open a pull request | |
| 115 | − | </Link> | |
| 116 | − | )} | |
| 117 | − | {branch.preview && ( | |
| 118 | − | <Hint label={host(branch.preview)}> | |
| 119 | − | <a | |
| 120 | − | href={branch.preview} | |
| 121 | − | className="inline-flex max-w-48 items-center gap-1 truncate rounded-md border border-line px-1.5 py-0.5 text-muted hover:border-line-strong hover:text-fg" | |
| 122 | − | > | |
| 123 | − | <Globe size={12} className="shrink-0 text-accent" /> | |
| 124 | − | Preview | |
| 125 | − | </a> | |
| 126 | − | </Hint> | |
| 127 | − | )} | |
| 128 | − | </span> | |
| 129 | − | </li> | |
| 130 | − | ))} | |
| 131 | − | </ul> | |
| 132 | − | </Card> | |
| 133 | − | ); | |
| 134 | − | } | |
| 1 | 1 | /** | |
| 2 | 2 | * Coordinating work, drawn: claims, harness sessions, dependencies, | |
| 3 | 3 | * integration failures and decision records, as the issue page, the pull | |
| 4 | − | * request page, the Work page and the why panel show them. What each says | |
| 4 | + | * request page, the Work page, a project's overview and the why panel | |
| 5 | + | * show them. What each says | |
| 5 | 6 | * is worked out in lib/coordination.ts. | |
| 6 | 7 | */ | |
| 7 | 8 | import { | |
| ⋯ | |||
| 53 | 54 | evidenceOf, | |
| 54 | 55 | expiresIn, | |
| 55 | 56 | harnessName, | |
| 57 | + | type LiveIssue, | |
| 58 | + | liveByIssue, | |
| 56 | 59 | mergeSessions, | |
| 57 | 60 | numberedParts, | |
| 58 | 61 | sessionTree, | |
| 59 | 62 | } from "../lib/coordination"; | |
| 60 | − | import { Input, SubmitButton, TimeAgo } from "./ui"; | |
| 63 | + | import { CopyLine, EmptyState, Input, SubmitButton, TimeAgo } from "./ui"; | |
| 61 | 64 | import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; | |
| 62 | 65 | import { Avatar } from "./ui/avatar"; | |
| 63 | 66 | import { Badge, type BadgeTone } from "./ui/badge"; | |
| ⋯ | |||
| 750 | 753 | </Popover> | |
| 751 | 754 | ); | |
| 752 | 755 | } | |
| 756 | + | ||
| 757 | + | /** One issue being worked on: its title, then each claim and session on it. */ | |
| 758 | + | export function LiveIssueCard({ group, title, base, now }: { group: LiveIssue; title: string | undefined; base: string; now: number }) { | |
| 759 | + | return ( | |
| 760 | + | <Card asChild divided> | |
| 761 | + | <li> | |
| 762 | + | <div className="flex items-start gap-2.5 px-4 py-3"> | |
| 763 | + | <span className="mt-1"> | |
| 764 | + | <IssueIcon issue={{ state: "open", reason: null }} /> | |
| 765 | + | </span> | |
| 766 | + | <Link to={`${base}/issues/${group.issue}`} prefetch="intent" className="min-w-0 grow font-medium hover:text-accent"> | |
| 767 | + | {title ?? `Issue #${group.issue}`} <span className="font-normal text-faint">#{group.issue}</span> | |
| 768 | + | </Link> | |
| 769 | + | {group.claims.length > 1 && <Badge tone="warn">{group.claims.length} claims</Badge>} | |
| 770 | + | </div> | |
| 771 | + | {group.claims.map((claim) => ( | |
| 772 | + | <div key={claim.id} className="px-4 py-3"> | |
| 773 | + | <ClaimLine claim={claim} now={now} base={base} /> | |
| 774 | + | </div> | |
| 775 | + | ))} | |
| 776 | + | {group.sessions.map((session) => ( | |
| 777 | + | <div key={session.id} className="px-4 py-3"> | |
| 778 | + | <SessionLine session={session} base={base} /> | |
| 779 | + | </div> | |
| 780 | + | ))} | |
| 781 | + | </li> | |
| 782 | + | </Card> | |
| 783 | + | ); | |
| 784 | + | } | |
| 785 | + | ||
| 786 | + | /** | |
| 787 | + | * Who is working right now: each issue with its claims and sessions, the | |
| 788 | + | * most recently active first, then sessions on no issue yet. `limit` caps | |
| 789 | + | * the issues shown, for a page that links to the rest. | |
| 790 | + | */ | |
| 791 | + | export function LiveNowList({ | |
| 792 | + | claims, | |
| 793 | + | sessions, | |
| 794 | + | titles, | |
| 795 | + | base, | |
| 796 | + | now, | |
| 797 | + | limit, | |
| 798 | + | }: { | |
| 799 | + | claims: Claim[]; | |
| 800 | + | sessions: WorkSession[]; | |
| 801 | + | titles: Record<number, string>; | |
| 802 | + | base: string; | |
| 803 | + | now: number; | |
| 804 | + | limit?: number; | |
| 805 | + | }) { | |
| 806 | + | const live = liveByIssue(claims, sessions); | |
| 807 | + | const issues = limit == null ? live.issues : live.issues.slice(0, limit); | |
| 808 | + | return ( | |
| 809 | + | <ol className="space-y-3"> | |
| 810 | + | {issues.map((group) => ( | |
| 811 | + | <LiveIssueCard key={group.issue} group={group} title={titles[group.issue]} base={base} now={now} /> | |
| 812 | + | ))} | |
| 813 | + | {live.loose.length > 0 && ( | |
| 814 | + | <Card asChild divided> | |
| 815 | + | <li> | |
| 816 | + | <p className="px-4 py-3 text-sm text-muted">Not on an issue yet</p> | |
| 817 | + | {live.loose.map((session) => ( | |
| 818 | + | <div key={session.id} className="px-4 py-3"> | |
| 819 | + | <SessionLine session={session} base={base} /> | |
| 820 | + | </div> | |
| 821 | + | ))} | |
| 822 | + | </li> | |
| 823 | + | </Card> | |
| 824 | + | )} | |
| 825 | + | </ol> | |
| 826 | + | ); | |
| 827 | + | } | |
| 828 | + | ||
| 829 | + | /** What to do when nobody is working on a project: bring your own harness. */ | |
| 830 | + | export function StartHere({ title }: { title: string }) { | |
| 831 | + | return ( | |
| 832 | + | <EmptyState title={title}> | |
| 833 | + | <div className="mx-auto mt-3 max-w-lg space-y-3 text-left"> | |
| 834 | + | <p>Install the g1t plugin in Claude Code, and each session shows here as it works:</p> | |
| 835 | + | <CopyLine text="claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git" prompt /> | |
| 836 | + | <CopyLine text="claude plugin install g1t@g1t" prompt /> | |
| 837 | + | <p>From any other harness, or by hand, claim an issue with the g1t CLI:</p> | |
| 838 | + | <CopyLine text="g1t work claim 12" prompt /> | |
| 839 | + | <p> | |
| 840 | + | <a href="https://docs.g1t.sh/guides/bring-your-own-agent/" className="text-fg underline underline-offset-4"> | |
| 841 | + | Connect a coding agent | |
| 842 | + | </a>{" "} | |
| 843 | + | sets up Codex, Cursor and any MCP client;{" "} | |
| 844 | + | <a href="https://docs.g1t.sh/guides/sessions/" className="text-fg underline underline-offset-4"> | |
| 845 | + | Sessions, claims and dependencies | |
| 846 | + | </a>{" "} | |
| 847 | + | says how it works. | |
| 848 | + | </p> | |
| 849 | + | </div> | |
| 850 | + | </EmptyState> | |
| 851 | + | ); | |
| 852 | + | } | |
| 479 | 479 | commented: "commented on", | |
| 480 | 480 | deployed: "deployed production", | |
| 481 | 481 | deploy_failed: "production build failed", | |
| 482 | − | pushed: "pushed to the default branch", | |
| 483 | 482 | }; | |
| 484 | 483 | ||
| 485 | 484 | /** One line of the feed: who, what, and the one thing it was about, by name. */ |
| 1 | 1 | /** | |
| 2 | 2 | * The pieces mission control and a project's overview are made of: panels | |
| 3 | − | * with a "View all", what needs the viewer, the activity feed with its | |
| 4 | − | * filters, the week's pulse with its sparklines, a project's pipeline and | |
| 5 | − | * its deploy history. | |
| 3 | + | * with a "View all", what needs the viewer, the week's pulse with its | |
| 4 | + | * sparklines, and a project's deploy history. | |
| 6 | 5 | */ | |
| 7 | − | import { | |
| 8 | − | ArrowRight, | |
| 9 | − | CircleCheck, | |
| 10 | − | CircleDot, | |
| 11 | − | CircleSlash, | |
| 12 | − | Eye, | |
| 13 | − | GitCommitHorizontal, | |
| 14 | − | GitMerge, | |
| 15 | − | GitPullRequest, | |
| 16 | − | Hand, | |
| 17 | − | MessageSquare, | |
| 18 | − | Play, | |
| 19 | − | Rocket, | |
| 20 | − | Swords, | |
| 21 | − | Terminal, | |
| 22 | − | TimerOff, | |
| 23 | − | TriangleAlert, | |
| 24 | − | UserPlus, | |
| 25 | − | } from "lucide-react"; | |
| 26 | − | import { type ReactNode, useMemo, useState } from "react"; | |
| 6 | + | import { ArrowRight, Eye, GitMerge, Hand, Rocket, Swords, Terminal, TimerOff, TriangleAlert, UserPlus } from "lucide-react"; | |
| 7 | + | import { type ReactNode, useState } from "react"; | |
| 27 | 8 | import { Link } from "react-router"; | |
| 28 | 9 | ||
| 29 | 10 | import type { DeployStatus } from "@g1t/contracts"; | |
| 30 | 11 | ||
| 31 | 12 | import { cn } from "../lib/cn"; | |
| 32 | − | import { type ActivityGroup, type Need, type NeedKind, type Verb, isAgent, sparkPoints } from "../lib/mission"; | |
| 13 | + | import { type Need, type NeedKind, sparkPoints } from "../lib/mission"; | |
| 33 | 14 | import { TimeAgo } from "./ui"; | |
| 34 | − | import { Avatar } from "./ui/avatar"; | |
| 35 | − | import { UserAvatar } from "./user-card"; | |
| 36 | − | import { PersonLink } from "./work"; | |
| 37 | 15 | import { Button } from "./ui/button"; | |
| 38 | 16 | import { Card } from "./ui/card"; | |
| 39 | 17 | import { Hint } from "./ui/hint"; | |
| 40 | − | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| 41 | 18 | ||
| 42 | 19 | // --- Panels ------------------------------------------------------------------- | |
| 43 | 20 | ||
| ⋯ | |||
| 169 | 146 | </Button> | |
| 170 | 147 | )} | |
| 171 | 148 | </> | |
| 172 | − | ); | |
| 173 | − | } | |
| 174 | − | ||
| 175 | − | // --- Activity ----------------------------------------------------------------- | |
| 176 | − | ||
| 177 | − | const VERB: Record<Verb, { icon: ReactNode; tone: string; text: (n: string) => string }> = { | |
| 178 | − | landed: { icon: <GitMerge size={14} />, tone: "text-merged", text: (n) => `landed ${n}` }, | |
| 179 | − | opened_issue: { icon: <CircleDot size={14} />, tone: "text-success", text: (n) => `opened ${n}` }, | |
| 180 | − | closed_issue: { icon: <CircleSlash size={14} />, tone: "text-faint", text: (n) => `closed ${n}` }, | |
| 181 | − | started: { icon: <Play size={14} />, tone: "text-accent", text: (n) => `started on ${n}` }, | |
| 182 | − | ready: { icon: <GitPullRequest size={14} />, tone: "text-info", text: (n) => `marked ${n} ready for review` }, | |
| 183 | − | checks_passed: { icon: <Terminal size={14} />, tone: "text-success", text: (n) => `checks passed on ${n}` }, | |
| 184 | − | checks_failed: { icon: <Terminal size={14} />, tone: "text-danger", text: (n) => `checks failed on ${n}` }, | |
| 185 | − | approved: { icon: <CircleCheck size={14} />, tone: "text-success", text: (n) => `approved ${n}` }, | |
| 186 | − | changes_requested: { icon: <CircleSlash size={14} />, tone: "text-warn", text: (n) => `asked for changes on ${n}` }, | |
| 187 | − | commented: { icon: <MessageSquare size={14} />, tone: "text-muted", text: (n) => `commented on ${n}` }, | |
| 188 | − | deployed: { icon: <Rocket size={14} />, tone: "text-success", text: () => "deployed production" }, | |
| 189 | − | deploy_failed: { icon: <Rocket size={14} />, tone: "text-danger", text: () => "production build failed" }, | |
| 190 | − | pushed: { icon: <GitCommitHorizontal size={14} />, tone: "text-muted", text: () => "pushed to the default branch" }, | |
| 191 | − | }; | |
| 192 | − | ||
| 193 | − | function Refs({ numbers, base }: { numbers: number[]; base: string }) { | |
| 194 | − | const shown = numbers.slice(0, 4); | |
| 195 | − | return ( | |
| 196 | − | <> | |
| 197 | − | {shown.map((number, index) => ( | |
| 198 | − | <span key={number}> | |
| 199 | − | {index > 0 && (index === shown.length - 1 && numbers.length <= 4 ? " and " : ", ")} | |
| 200 | − | <Link to={`${base}/issues/${number}`} prefetch="intent" className="font-medium text-fg hover:underline"> | |
| 201 | − | #{number} | |
| 202 | − | </Link> | |
| 203 | − | </span> | |
| 204 | − | ))} | |
| 205 | − | {numbers.length > 4 && ` and ${numbers.length - 4} more`} | |
| 206 | − | </> | |
| 207 | − | ); | |
| 208 | − | } | |
| 209 | − | ||
| 210 | − | /** One group as a sentence: "landed #4 and #3; started on #5". */ | |
| 211 | − | function GroupLine({ group }: { group: ActivityGroup }) { | |
| 212 | − | const base = `/${group.repo.namespace}/${group.repo.name}`; | |
| 213 | − | return ( | |
| 214 | − | <> | |
| 215 | − | {group.parts.map((part, index) => { | |
| 216 | − | const look = VERB[part.verb]; | |
| 217 | − | const [before, after] = look.text("\u0000").split("\u0000"); | |
| 218 | − | return ( | |
| 219 | − | <span key={part.verb}> | |
| 220 | − | {index > 0 && "; "} | |
| 221 | − | {part.numbers.length > 0 ? ( | |
| 222 | − | <> | |
| 223 | − | {before} | |
| 224 | − | <Refs numbers={part.numbers} base={base} /> | |
| 225 | − | {after} | |
| 226 | − | </> | |
| 227 | − | ) : part.to ? ( | |
| 228 | − | <Link to={part.to} className="hover:text-fg"> | |
| 229 | − | {look.text("")} | |
| 230 | − | </Link> | |
| 231 | − | ) : ( | |
| 232 | − | look.text("") | |
| 233 | − | )} | |
| 234 | − | </span> | |
| 235 | − | ); | |
| 236 | − | })} | |
| 237 | − | </> | |
| 238 | − | ); | |
| 239 | − | } | |
| 240 | − | ||
| 241 | − | /** | |
| 242 | − | * What moved across projects, newest first, a burst of one actor's work | |
| 243 | − | * as one line, filterable by project and by who did it. | |
| 244 | − | */ | |
| 245 | − | export function ActivityFeed({ | |
| 246 | − | groups, | |
| 247 | − | showRepo = true, | |
| 248 | − | limit = 14, | |
| 249 | − | empty, | |
| 250 | − | }: { | |
| 251 | − | groups: ActivityGroup[]; | |
| 252 | − | showRepo?: boolean; | |
| 253 | − | limit?: number; | |
| 254 | − | empty: ReactNode; | |
| 255 | − | }) { | |
| 256 | − | const [project, setProject] = useState("all"); | |
| 257 | − | const [person, setPerson] = useState("all"); | |
| 258 | − | const [more, setMore] = useState(false); | |
| 259 | − | const projects = useMemo( | |
| 260 | − | () => [...new Set(groups.map((g) => `${g.repo.namespace}/${g.repo.name}`))].sort(), | |
| 261 | − | [groups], | |
| 262 | − | ); | |
| 263 | − | const people = useMemo( | |
| 264 | − | () => [...new Set(groups.map((g) => g.actor).filter((a): a is string => a != null))].sort(), | |
| 265 | − | [groups], | |
| 266 | − | ); | |
| 267 | − | const filtered = groups.filter( | |
| 268 | − | (g) => | |
| 269 | − | (project === "all" || `${g.repo.namespace}/${g.repo.name}` === project) && | |
| 270 | − | (person === "all" || g.actor === person), | |
| 271 | − | ); | |
| 272 | − | const shown = more ? filtered : filtered.slice(0, limit); | |
| 273 | − | return ( | |
| 274 | − | <div> | |
| 275 | − | {((showRepo && projects.length > 1) || people.length > 1) && ( | |
| 276 | − | <div className="mb-3 flex flex-wrap items-center gap-2"> | |
| 277 | − | {showRepo && projects.length > 1 && ( | |
| 278 | − | <Select value={project} onValueChange={setProject}> | |
| 279 | − | <SelectTrigger size="sm" className="w-auto max-w-56" aria-label="Project"> | |
| 280 | − | <SelectValue /> | |
| 281 | − | </SelectTrigger> | |
| 282 | − | <SelectContent> | |
| 283 | − | <SelectItem value="all">All projects</SelectItem> | |
| 284 | − | {projects.map((name) => ( | |
| 285 | − | <SelectItem key={name} value={name}> | |
| 286 | − | {name.split("/")[1]} | |
| 287 | − | </SelectItem> | |
| 288 | − | ))} | |
| 289 | − | </SelectContent> | |
| 290 | − | </Select> | |
| 291 | − | )} | |
| 292 | − | {people.length > 1 && ( | |
| 293 | − | <Select value={person} onValueChange={setPerson}> | |
| 294 | − | <SelectTrigger size="sm" className="w-auto max-w-48" aria-label="Who"> | |
| 295 | − | <SelectValue /> | |
| 296 | − | </SelectTrigger> | |
| 297 | − | <SelectContent> | |
| 298 | − | <SelectItem value="all">Anyone</SelectItem> | |
| 299 | − | {people.map((name) => ( | |
| 300 | − | <SelectItem key={name} value={name} icon={<Avatar name={name} image={isAgent(name) ? null : undefined} size={16} />}> | |
| 301 | − | {name} | |
| 302 | − | </SelectItem> | |
| 303 | − | ))} | |
| 304 | − | </SelectContent> | |
| 305 | − | </Select> | |
| 306 | − | )} | |
| 307 | − | </div> | |
| 308 | − | )} | |
| 309 | − | {shown.length === 0 ? ( | |
| 310 | − | <Quiet>{groups.length === 0 ? empty : "Nothing matches these filters."}</Quiet> | |
| 311 | − | ) : ( | |
| 312 | − | <ol className="relative space-y-0.5 before:absolute before:top-3 before:bottom-3 before:left-3.75 before:w-px before:bg-line"> | |
| 313 | − | {shown.map((group) => { | |
| 314 | − | const look = VERB[group.parts[0].verb]; | |
| 315 | − | const agent = group.actor == null || isAgent(group.actor); | |
| 316 | − | return ( | |
| 317 | − | <li key={group.id} className="relative flex items-start gap-3 rounded-lg px-1 py-1.5 text-sm"> | |
| 318 | − | <span | |
| 319 | − | className={cn( | |
| 320 | − | "relative z-10 mt-0.5 flex size-5.5 shrink-0 items-center justify-center rounded-full bg-bg ring-1 ring-line", | |
| 321 | − | look.tone, | |
| 322 | − | )} | |
| 323 | − | > | |
| 324 | − | {look.icon} | |
| 325 | − | </span> | |
| 326 | − | <span className="min-w-0 grow leading-6 text-muted"> | |
| 327 | − | <span className={cn("mr-1.5 inline-flex items-center gap-1.5 align-middle font-medium", agent ? "text-accent" : "text-fg")}> | |
| 328 | − | {agent ? ( | |
| 329 | − | <> | |
| 330 | − | <Avatar name={group.actor ?? "g1t"} image={null} size={16} /> | |
| 331 | − | {group.actor ?? "g1t"} | |
| 332 | − | </> | |
| 333 | − | ) : ( | |
| 334 | − | <> | |
| 335 | − | <UserAvatar username={group.actor!} size={16} /> | |
| 336 | − | <PersonLink name={group.actor!} className="hover:text-accent" /> | |
| 337 | − | </> | |
| 338 | − | )} | |
| 339 | − | </span> | |
| 340 | − | <GroupLine group={group} /> | |
| 341 | − | {showRepo && ( | |
| 342 | − | <Link | |
| 343 | − | to={`/${group.repo.namespace}/${group.repo.name}`} | |
| 344 | − | className="ml-1.5 font-mono text-xs text-faint hover:text-muted" | |
| 345 | − | > | |
| 346 | − | {group.repo.name} | |
| 347 | − | </Link> | |
| 348 | − | )} | |
| 349 | − | {group.count > 2 && <span className="ml-1.5 text-xs text-faint">· {group.count} events</span>} | |
| 350 | − | </span> | |
| 351 | − | <span className="shrink-0 pt-0.5 text-xs text-faint"> | |
| 352 | − | <TimeAgo at={group.at} /> | |
| 353 | − | </span> | |
| 354 | − | </li> | |
| 355 | − | ); | |
| 356 | − | })} | |
| 357 | − | </ol> | |
| 358 | − | )} | |
| 359 | − | {filtered.length > limit && ( | |
| 360 | − | <Button type="button" onClick={() => setMore(!more)} variant="link" size="inline" className="mt-2 text-xs text-muted hover:text-fg font-normal"> | |
| 361 | − | {more ? "Show fewer" : `Show ${filtered.length - limit} more`} | |
| 362 | − | </Button> | |
| 363 | − | )} | |
| 364 | − | </div> | |
| 365 | 149 | ); | |
| 366 | 150 | } | |
| 367 | 151 | ||
| 1 | 1 | /** | |
| 2 | 2 | * A repository's branches as its pages show them: each one's head commit, | |
| 3 | 3 | * how far it has moved from the default branch, the pull request open on | |
| 4 | − | * it with its checks, and its preview. The overview shows the newest few; | |
| 5 | − | * the Branches page shows them all. | |
| 4 | + | * it with its checks, and its preview, as the Branches page shows them. | |
| 6 | 5 | */ | |
| 7 | 6 | import type { Branch, BranchDrifts, Commit, Pull, RepoPath, Viewer } from "@g1t/contracts"; | |
| 8 | 7 |
| 1 | − | import assert from "node:assert/strict"; | |
| 2 | − | import { test } from "node:test"; | |
| 3 | − | ||
| 4 | − | import { distinctFacts, sameFact } from "./memory-facts.ts"; | |
| 5 | − | ||
| 6 | − | test("a fact remembered twice is shown once, the first kept", () => { | |
| 7 | − | const facts = [ | |
| 8 | − | { id: "a", text: "Run tests with npm test." }, | |
| 9 | − | { id: "b", text: "We use pnpm everywhere" }, | |
| 10 | − | { id: "c", text: "run tests with npm test" }, | |
| 11 | − | { id: "d", text: "We use pnpm everywhere." }, | |
| 12 | − | ]; | |
| 13 | − | assert.deepEqual(distinctFacts(facts).map((fact) => fact.id), ["a", "b"]); | |
| 14 | − | assert.deepEqual(distinctFacts([]), []); | |
| 15 | − | }); | |
| 16 | − | ||
| 17 | − | test("near-duplicates collapse: a list that grew, a sentence cut short", () => { | |
| 18 | − | const facts = [ | |
| 19 | − | { id: "a", text: "g1t is a Cargo workspace (apps/api, crates/*, services/actions, services/billing); `cargo test` runs its tests." }, | |
| 20 | − | { id: "b", text: "g1t is a Cargo workspace (apps/api, crates/*, services/actions, services/billing, services/work); `cargo test` runs its tests." }, | |
| 21 | − | { id: "c", text: "Roles. Viewer, commenter, planner and approver map onto" }, | |
| 22 | − | { id: "d", text: "Roles. Viewer, commenter, planner and approver map onto the five repository roles." }, | |
| 23 | − | { id: "e", text: "Use npm to install." }, | |
| 24 | − | { id: "f", text: "Use pnpm to install." }, | |
| 25 | − | ]; | |
| 26 | − | assert.deepEqual(distinctFacts(facts).map((fact) => fact.id), ["a", "c", "e", "f"]); | |
| 27 | − | assert.ok(!sameFact("Run cargo test in the crate you changed.", "Run npm test in the app you changed.")); | |
| 28 | − | assert.ok(!sameFact("use pnpm", "use pnpm in the web app and npm in the docs, which predates it")); | |
| 29 | − | }); |
| 1 | − | /** How alike two facts' words must be (shared over all, as sets) to be one fact. */ | |
| 2 | − | const SAME_WORDS = 0.85; | |
| 3 | − | /** The fewest distinct words a fact needs before it can be found inside another. */ | |
| 4 | − | const CONTAINED_MIN_WORDS = 6; | |
| 5 | − | ||
| 6 | − | /** A fact's words, lowercase, one space apart: case, punctuation and spacing aside. */ | |
| 7 | − | function folded(text: string): string { | |
| 8 | − | return text | |
| 9 | − | .toLowerCase() | |
| 10 | − | .split(/[^\p{L}\p{N}]+/u) | |
| 11 | − | .filter(Boolean) | |
| 12 | − | .join(" "); | |
| 13 | − | } | |
| 14 | − | ||
| 15 | − | /** | |
| 16 | − | * Whether two facts say the same thing, as the work service decides it | |
| 17 | − | * (`same_memory`): the same words; one's words, in order, inside the | |
| 18 | − | * other's; all of one's words (at least six) among the other's; or most of | |
| 19 | − | * their words shared. | |
| 20 | − | */ | |
| 21 | − | export function sameFact(a: string, b: string): boolean { | |
| 22 | − | const [x, y] = [folded(a), folded(b)]; | |
| 23 | − | if (!x || !y) return false; | |
| 24 | − | if (x === y) return true; | |
| 25 | − | const [short, long] = x.length <= y.length ? [x, y] : [y, x]; | |
| 26 | − | if (short.split(" ").length >= 4 && ` ${long} `.includes(` ${short} `)) return true; | |
| 27 | − | const shortWords = new Set(short.split(" ")); | |
| 28 | − | const longWords = new Set(long.split(" ")); | |
| 29 | − | let shared = 0; | |
| 30 | − | for (const word of shortWords) if (longWords.has(word)) shared++; | |
| 31 | − | if (shortWords.size >= CONTAINED_MIN_WORDS && shared === shortWords.size) return true; | |
| 32 | − | const all = new Set([...shortWords, ...longWords]).size; | |
| 33 | − | return all > 0 && shared / all >= SAME_WORDS; | |
| 34 | − | } | |
| 35 | − | ||
| 36 | − | /** | |
| 37 | − | * The same fact remembered twice (two runs that learned it, one saved | |
| 38 | − | * again, or a sentence that grew since) is shown once: the first of them in | |
| 39 | − | * the order given, so a pinned one or the newest wins. Facts match when | |
| 40 | − | * their words do, whatever the case, spacing or punctuation, or when they | |
| 41 | − | * are near enough to be one (see `sameFact`). | |
| 42 | − | */ | |
| 43 | − | export function distinctFacts<T extends { text: string }>(facts: T[]): T[] { | |
| 44 | − | const shown: T[] = []; | |
| 45 | − | for (const fact of facts) { | |
| 46 | − | if (!shown.some((other) => sameFact(other.text, fact.text))) shown.push(fact); | |
| 47 | − | } | |
| 48 | − | return shown; | |
| 49 | − | } |
| 7 | 7 | type ActivityItem, | |
| 8 | 8 | type Need, | |
| 9 | 9 | DELETED_USER, | |
| 10 | − | FEED_EVENT_TYPES, | |
| 11 | − | PROJECT_FEED_EVENT_TYPES, | |
| 12 | 10 | eventItem, | |
| 13 | − | projectFeed, | |
| 14 | − | pushItem, | |
| 15 | 11 | actorIds, | |
| 16 | 12 | ageBuckets, | |
| 17 | 13 | dailyBuckets, | |
| ⋯ | |||
| 24 | 20 | median, | |
| 25 | 21 | nameActor, | |
| 26 | 22 | nextSeen, | |
| 27 | − | pipelineStage, | |
| 28 | − | queuedNumbers, | |
| 29 | 23 | runHealth, | |
| 30 | 24 | rankNeeds, | |
| 31 | 25 | readCookie, | |
| ⋯ | |||
| 193 | 187 | [9, 9], | |
| 194 | 188 | ]); | |
| 195 | 189 | assert.deepEqual(sparkPoints([3], 10, 10, 1), [[5, 1]]); | |
| 196 | − | }); | |
| 197 | − | ||
| 198 | − | test("a pull request's place in the pipeline", () => { | |
| 199 | − | const queued = queuedNumbers([ | |
| 200 | − | { number: 9, state: "waiting" }, | |
| 201 | − | { number: 8, state: "landed" }, | |
| 202 | − | ]); | |
| 203 | − | assert.equal(pipelineStage({ status: "merged", checkStatus: null, number: 1 }, undefined, queued), "landed"); | |
| 204 | − | assert.equal(pipelineStage({ status: "open", checkStatus: "passed", number: 9 }, undefined, queued), "queue"); | |
| 205 | − | assert.equal(pipelineStage({ status: "draft", checkStatus: null, number: 2 }, undefined, queued), "working"); | |
| 206 | − | assert.equal(pipelineStage({ status: "open", checkStatus: "running", number: 3 }, undefined, queued), "checking"); | |
| 207 | − | assert.equal(pipelineStage({ status: "open", checkStatus: "passed", number: 4 }, { kind: "revise" }, queued), "working"); | |
| 208 | − | assert.equal(pipelineStage({ status: "open", checkStatus: "passed", number: 5 }, { kind: "review" }, queued), "reviewing"); | |
| 209 | − | assert.equal(pipelineStage({ status: "open", checkStatus: "failed", number: 6 }, undefined, queued), "reviewing"); | |
| 210 | 190 | }); | |
| 211 | 191 | ||
| 212 | 192 | test("open issues by age", () => { | |
| ⋯ | |||
| 246 | 226 | assert.equal(waitedFor(3 * 24 * 60 - 1), "2 d"); | |
| 247 | 227 | }); | |
| 248 | 228 | ||
| 249 | − | // --- A project's feed ----------------------------------------------------------- | |
| 229 | + | // --- The feed ------------------------------------------------------------------ | |
| 250 | 230 | ||
| 251 | 231 | const REPO = { namespace: "flagon-io", name: "g1t" }; | |
| 252 | 232 | const event = (type: string, data: Record<string, unknown>, id = `evt_${type}`, actor: string | null = "usr_ada"): G1tEvent => | |
| ⋯ | |||
| 257 | 237 | deployment: { id: "dep_9", environment: production ? "production" : "preview", production_environment: production }, | |
| 258 | 238 | deploymentStatus: { state }, | |
| 259 | 239 | }); | |
| 260 | − | ||
| 261 | − | test("the feed asks the log only for the kinds it shows, and pushes for one project", () => { | |
| 262 | − | for (const type of ["session.appended", "queue.changed", "pull.updated", "pull.mergecheck", "workflow.completed"]) { | |
| 263 | − | assert.ok(!(PROJECT_FEED_EVENT_TYPES as readonly string[]).includes(type), type); | |
| 264 | − | } | |
| 265 | − | assert.ok((PROJECT_FEED_EVENT_TYPES as readonly string[]).includes("git.push")); | |
| 266 | − | assert.ok(!(FEED_EVENT_TYPES as readonly string[]).includes("git.push")); | |
| 267 | − | // Every kind asked for makes a line of some events. | |
| 268 | − | for (const type of FEED_EVENT_TYPES) assert.ok(type.includes("."), type); | |
| 269 | − | }); | |
| 270 | 240 | ||
| 271 | 241 | test("a production deploy that finished is a line, linked to it on the current path", () => { | |
| 272 | 242 | const up = eventItem(deployment("success"), REPO); | |
| ⋯ | |||
| 275 | 245 | assert.equal(eventItem(deployment("failure"), REPO)?.verb, "deploy_failed"); | |
| 276 | 246 | assert.equal(eventItem(deployment("in_progress"), REPO), null); | |
| 277 | 247 | assert.equal(eventItem(deployment("success", false), REPO), null); | |
| 278 | − | }); | |
| 279 | − | ||
| 280 | − | test("a project's feed has its pushes to the default branch, but not the ones that only landed a pull request", () => { | |
| 281 | − | const events = [ | |
| 282 | − | event("pull.merged", { pullId: "pul_1", repoId: "rep_1", number: 5, commit: "aaaaaaa1111" }, "evt_1"), | |
| 283 | − | event("git.push", { repoId: "rep_1", ref: "refs/heads/main", after: "aaaaaaa1111", defaultBranch: true }, "evt_2"), | |
| 284 | − | event("git.push", { repoId: "rep_1", ref: "refs/heads/main", after: "bbbbbbb2222", defaultBranch: true }, "evt_3"), | |
| 285 | − | event("git.push", { repoId: "rep_1", ref: "refs/heads/topic", after: "ccccccc3333", defaultBranch: false }, "evt_4"), | |
| 286 | − | event("session.appended", { repoId: "rep_1", number: 5 }, "evt_5"), | |
| 287 | − | ]; | |
| 288 | − | const feed = projectFeed(events, REPO); | |
| 289 | − | assert.deepEqual( | |
| 290 | − | feed.map((item) => [item.verb, item.text ?? item.number]), | |
| 291 | − | [ | |
| 292 | − | ["landed", 5], | |
| 293 | − | ["pushed", "bbbbbbb"], | |
| 294 | − | ], | |
| 295 | − | ); | |
| 296 | − | assert.equal(feed[1]?.to, "/flagon-io/g1t/commit/bbbbbbb2222"); | |
| 297 | − | assert.equal(pushItem(events[4]!, REPO, new Set()), null); | |
| 298 | 248 | }); | |
| 1 | 1 | /** | |
| 2 | 2 | * The arithmetic behind mission control and a project's overview: when the | |
| 3 | 3 | * viewer was last here, what moved since, how a burst of one person's work | |
| 4 | − | * reads as one line, the week's pulse, and where each pull request stands | |
| 5 | − | * in its way to landing. Pure, so it is tested on its own; it imports only | |
| 6 | − | * types. | |
| 4 | + | * reads as one line, the week's pulse, and how healthy a project is. Pure, | |
| 5 | + | * so it is tested on its own; it imports only types. | |
| 7 | 6 | */ | |
| 8 | − | import type { G1tEvent, Pull, QueueEntry, RepoPath } from "@g1t/contracts"; | |
| 7 | + | import type { G1tEvent, RepoPath } from "@g1t/contracts"; | |
| 9 | 8 | ||
| 10 | 9 | /** A run as the arithmetic here reads it: its kind, status and times. */ | |
| 11 | 10 | type RunLike = { kind: string; status: string; updatedAt: string }; | |
| ⋯ | |||
| 90 | 89 | | "changes_requested" | |
| 91 | 90 | | "commented" | |
| 92 | 91 | | "deployed" | |
| 93 | − | | "deploy_failed" | |
| 94 | − | | "pushed"; | |
| 92 | + | | "deploy_failed"; | |
| 95 | 93 | ||
| 96 | 94 | /** | |
| 97 | 95 | * The event types `eventItem` makes a line of. Ask the log for these only: | |
| ⋯ | |||
| 111 | 109 | "deployment_status.created", | |
| 112 | 110 | ] as const satisfies readonly G1tEvent["type"][]; | |
| 113 | 111 | ||
| 114 | − | /** With pushes to the default branch too, for one project's feed. */ | |
| 115 | − | export const PROJECT_FEED_EVENT_TYPES = [...FEED_EVENT_TYPES, "git.push"] as const satisfies readonly G1tEvent["type"][]; | |
| 116 | − | ||
| 117 | 112 | /** One thing that moved, as the feed shows it. */ | |
| 118 | 113 | export type ActivityItem = { | |
| 119 | 114 | id: string; | |
| ⋯ | |||
| 177 | 172 | } | |
| 178 | 173 | } | |
| 179 | 174 | ||
| 180 | − | /** | |
| 181 | − | * A push to the default branch as a feed line, or null for any other push | |
| 182 | − | * and for one that only landed a pull request (`merged`: the commits pull | |
| 183 | − | * requests' merges made), which already has its own line. | |
| 184 | − | */ | |
| 185 | − | export function pushItem(event: G1tEvent, repo: RepoPath, merged: ReadonlySet<string>): ActivityItem | null { | |
| 186 | − | if (event.type !== "git.push" || !event.data.defaultBranch || !event.data.ref.startsWith("refs/heads/")) return null; | |
| 187 | − | const after = event.data.after; | |
| 188 | − | if (!after || /^0+$/.test(after) || merged.has(after)) return null; | |
| 189 | − | return { | |
| 190 | − | id: event.id, | |
| 191 | − | at: Date.parse(event.time), | |
| 192 | − | repo, | |
| 193 | − | actor: event.actor, | |
| 194 | − | verb: "pushed", | |
| 195 | − | number: null, | |
| 196 | − | text: after.slice(0, 7), | |
| 197 | − | to: `/${repo.namespace}/${repo.name}/commit/${after}`, | |
| 198 | − | }; | |
| 199 | − | } | |
| 200 | − | ||
| 201 | − | /** One project's events as its feed: every line `eventItem` makes, and its people's pushes. */ | |
| 202 | − | export function projectFeed(events: G1tEvent[], repo: RepoPath): ActivityItem[] { | |
| 203 | − | const merged = new Set(events.flatMap((event) => (event.type === "pull.merged" ? [event.data.commit] : []))); | |
| 204 | − | return events.flatMap((event) => eventItem(event, repo) ?? pushItem(event, repo, merged) ?? []); | |
| 205 | − | } | |
| 206 | − | ||
| 207 | 175 | /** Accounts that act for g1t itself, named in the log by fixed ids. */ | |
| 208 | 176 | const G1T_ACTORS: Record<string, string> = { usr_g1t_agent: "g1t", g1t_policy: "g1t" }; | |
| 209 | 177 | /** Who an account the lookup no longer knows was. */ | |
| ⋯ | |||
| 438 | 406 | const y = max > 0 ? height - pad - (Math.max(0, value) / max) * (height - pad * 2) : height - pad; | |
| 439 | 407 | return [Math.round(x * 100) / 100, Math.round(y * 100) / 100]; | |
| 440 | 408 | }); | |
| 441 | − | } | |
| 442 | − | ||
| 443 | − | // --- A project's pipeline --------------------------------------------------- | |
| 444 | − | ||
| 445 | − | export type PipelineStage = "working" | "checking" | "reviewing" | "queue" | "landed"; | |
| 446 | − | ||
| 447 | − | export const PIPELINE: { stage: PipelineStage; label: string }[] = [ | |
| 448 | − | { stage: "working", label: "Working" }, | |
| 449 | − | { stage: "checking", label: "Checking" }, | |
| 450 | − | { stage: "reviewing", label: "Reviewing" }, | |
| 451 | − | { stage: "queue", label: "Queue" }, | |
| 452 | − | { stage: "landed", label: "Landed" }, | |
| 453 | − | ]; | |
| 454 | − | ||
| 455 | − | /** | |
| 456 | − | * Where a pull request stands on its way to landing, from what a list of | |
| 457 | − | * pull requests says about it, the run at work on it, the merge queue, and | |
| 458 | − | * for one g1t sees through, its lifecycle (waiting on its checks). | |
| 459 | − | */ | |
| 460 | − | export function pipelineStage( | |
| 461 | − | pull: Pick<Pull, "status" | "checkStatus" | "number">, | |
| 462 | − | run: Pick<RunLike, "kind"> | undefined, | |
| 463 | − | queued: Set<number>, | |
| 464 | − | lifecycle?: { stage: string } | null, | |
| 465 | − | ): PipelineStage { | |
| 466 | − | if (pull.status === "merged") return "landed"; | |
| 467 | − | if (queued.has(pull.number)) return "queue"; | |
| 468 | − | if (run) { | |
| 469 | − | if (run.kind === "checks") return "checking"; | |
| 470 | − | if (run.kind === "review") return "reviewing"; | |
| 471 | − | if (run.kind === "queue") return "queue"; | |
| 472 | − | return "working"; | |
| 473 | − | } | |
| 474 | − | if (pull.status === "draft") return "working"; | |
| 475 | − | if (lifecycle?.stage === "checking") return "checking"; | |
| 476 | − | if (pull.checkStatus === "queued" || pull.checkStatus === "running") return "checking"; | |
| 477 | − | return "reviewing"; | |
| 478 | − | } | |
| 479 | − | ||
| 480 | − | /** The numbers of the pull requests waiting or being tested in a merge queue. */ | |
| 481 | − | export function queuedNumbers(entries: Pick<QueueEntry, "number" | "state">[]): Set<number> { | |
| 482 | − | return new Set(entries.filter((e) => e.state === "waiting" || e.state === "testing" || e.state === "passed").map((e) => e.number)); | |
| 483 | 409 | } | |
| 484 | 410 | ||
| 485 | 411 | /** Open issues by how long they have been open. */ | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | 4 | import { workOwner } from "../../../../packages/contracts/src/work.ts"; | |
| 5 | − | import { madeByG1t, openedBy } from "./opened-by.ts"; | |
| 5 | + | import { openedBy } from "./opened-by.ts"; | |
| 6 | 6 | ||
| 7 | 7 | const syntaqx = { id: "usr_1", username: "syntaqx" }; | |
| 8 | 8 | const g1t = { id: "usr_g1t_agent", username: "g1t", kind: "agent" as const }; | |
| 9 | 9 | ||
| 10 | 10 | test("a pull request g1t made shows g1t, requested by whoever asked", () => { | |
| 11 | − | const pull = { agent: "g1t", branch: null, author: g1t, requestedBy: syntaqx }; | |
| 12 | − | assert.equal(madeByG1t(pull), true); | |
| 11 | + | const pull = { author: g1t, requestedBy: syntaqx }; | |
| 13 | 12 | assert.deepEqual(openedBy(pull), { name: "g1t", requestedBy: "syntaqx" }); | |
| 14 | 13 | // It is theirs to manage, as their own would be. | |
| 15 | 14 | assert.equal(workOwner(pull).id, "usr_1"); | |
| ⋯ | |||
| 18 | 17 | test("anything else shows its author", () => { | |
| 19 | 18 | // Someone's own agent. | |
| 20 | 19 | assert.deepEqual(openedBy({ author: syntaqx, requestedBy: null }), { name: "syntaqx", requestedBy: null }); | |
| 21 | − | // A branch pushed to the repository, even with g1t named on it. | |
| 22 | − | assert.equal(madeByG1t({ agent: "g1t", branch: "fix-login" }), false); | |
| 23 | 20 | assert.equal(workOwner({ author: syntaqx, requestedBy: null }).id, "usr_1"); | |
| 24 | 21 | }); | |
| 25 | 22 | ||
| 4 | 4 | export const G1T = "g1t"; | |
| 5 | 5 | ||
| 6 | 6 | /** | |
| 7 | − | * Whether g1t's own agent made a pull request: in a fork of its own, not | |
| 8 | − | * on a branch someone pushed. | |
| 9 | − | */ | |
| 10 | − | export function madeByG1t(pull: Pick<Pull, "agent" | "branch">): boolean { | |
| 11 | − | return pull.agent === G1T && !pull.branch; | |
| 12 | − | } | |
| 13 | − | ||
| 14 | − | /** | |
| 15 | 7 | * Who opened an issue or a pull request, and who asked g1t for it, as | |
| 16 | 8 | * stored: g1t for what it made or filed, with the person it was for as | |
| 17 | 9 | * `requestedBy`; anyone else's, theirs alone. Who may change it is the |
| 35 | 35 | assert.equal(tagOf(null), null); | |
| 36 | 36 | }); | |
| 37 | 37 | ||
| 38 | − | test("the files and overview pages follow refs, checks, releases and deployments", () => { | |
| 38 | + | test("a project's overview follows what it runs, who is working on it and what may need someone, not every push", () => { | |
| 39 | + | const overview = repoPages.overview(); | |
| 40 | + | for (const c of [ | |
| 41 | + | change("deployment_status.created"), | |
| 42 | + | change("status.created", { sha: "abc" }), | |
| 43 | + | change("release.published"), | |
| 44 | + | change("work.claimed", { number: 3 }), | |
| 45 | + | change("session.started"), | |
| 46 | + | change("session.progressed"), | |
| 47 | + | change("pull.ready", { number: 4 }), | |
| 48 | + | change("review.completed", { number: 4 }), | |
| 49 | + | ]) { | |
| 50 | + | assert.equal(overview(c), true, c.kind); | |
| 51 | + | } | |
| 52 | + | assert.equal(overview(push("feature")), false); | |
| 53 | + | assert.equal(overview(change("comment.created", { number: 3 })), false); | |
| 54 | + | }); | |
| 55 | + | ||
| 56 | + | test("the files page follows refs, checks, releases and deployments", () => { | |
| 39 | 57 | const files = repoPages.files(); | |
| 40 | 58 | for (const c of [push("main"), push("other"), change("git.deleted", { ref: "refs/heads/old" }), change("status.created", { sha: "abc" }), change("release.published"), change("deployment_status.created")]) { | |
| 41 | 59 | assert.equal(files(c), true, c.kind); |
| 76 | 76 | /** What each kind of repository page shows. */ | |
| 77 | 77 | export const repoPages = { | |
| 78 | 78 | /** | |
| 79 | − | * Files, the overview and a folder: any ref moving (the files and latest | |
| 79 | + | * Files and a folder: any ref moving (the files and latest | |
| 80 | 80 | * commit, the branch menu, recent pushes), checks on commits, releases | |
| 81 | 81 | * and deployments. | |
| 82 | 82 | */ | |
| 83 | 83 | files(): RepoMatch { | |
| 84 | 84 | return (change) => isRefChange(change) || isCheck(change) || ["release", "deployment", "deployment_status"].includes(family(change)); | |
| 85 | 85 | }, | |
| 86 | + | /** | |
| 87 | + | * A project's overview: what it runs and how it is doing (deployments, | |
| 88 | + | * checks, releases), who is working on it (claims and sessions), and the | |
| 89 | + | * pull requests that may need someone (opened, readied, reviewed, merged). | |
| 90 | + | */ | |
| 91 | + | overview(): RepoMatch { | |
| 92 | + | return (change) => | |
| 93 | + | isCheck(change) || | |
| 94 | + | ["release", "deployment", "deployment_status", "work", "pull", "review"].includes(family(change)) || | |
| 95 | + | SESSION_KINDS.includes(change.kind); | |
| 96 | + | }, | |
| 86 | 97 | /** Commits: pushes to the branch shown, and checks on commits. */ | |
| 87 | 98 | commits(branch: string | null): RepoMatch { | |
| 88 | 99 | return (change) => (branch == null ? change.kind === "git.push" && !tagOf(change.ref) : isPushTo(change, branch)) || isCheck(change); |
| 2 | 2 | import type { ReactNode } from "react"; | |
| 3 | 3 | import { Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | − | import type { EventType, Pull, WorkSession } from "@g1t/contracts"; | |
| 5 | + | import type { EventType, Pull } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/work"; | |
| 8 | − | import { CoordinationFeed, ClaimLine, SessionLine } from "../../components/coordination"; | |
| 8 | + | import { CoordinationFeed, LiveNowList, StartHere } from "../../components/coordination"; | |
| 9 | 9 | import { QueueTrain } from "../../components/merge-queue"; | |
| 10 | − | import { CopyLine, EmptyState, TimeAgo } from "../../components/ui"; | |
| 10 | + | import { EmptyState, TimeAgo } from "../../components/ui"; | |
| 11 | 11 | import { Avatar } from "../../components/ui/avatar"; | |
| 12 | − | import { Badge } from "../../components/ui/badge"; | |
| 13 | 12 | import { Card } from "../../components/ui/card"; | |
| 14 | − | import { IssueIcon, PullIcon } from "../../components/work-icons"; | |
| 13 | + | import { PullIcon } from "../../components/work-icons"; | |
| 15 | 14 | import { requireRepo } from "../../lib/access.server"; | |
| 16 | − | import { COORDINATION_TYPES, type LiveIssue, feedLine, liveByIssue } from "../../lib/coordination"; | |
| 15 | + | import { COORDINATION_TYPES, feedLine } from "../../lib/coordination"; | |
| 17 | 16 | import { page } from "../../lib/meta"; | |
| 18 | 17 | import { useRefreshWhile } from "../../lib/refresh"; | |
| 19 | 18 | import { repoPages } from "../../lib/repo-live"; | |
| ⋯ | |||
| 66 | 65 | }; | |
| 67 | 66 | } | |
| 68 | 67 | ||
| 69 | − | function LiveIssueCard({ group, title, base, now }: { group: LiveIssue; title: string | undefined; base: string; now: number }) { | |
| 70 | − | return ( | |
| 71 | − | <Card asChild divided> | |
| 72 | − | <li> | |
| 73 | − | <div className="flex items-start gap-2.5 px-4 py-3"> | |
| 74 | − | <span className="mt-1"> | |
| 75 | − | <IssueIcon issue={{ state: "open", reason: null }} /> | |
| 76 | − | </span> | |
| 77 | − | <Link to={`${base}/issues/${group.issue}`} prefetch="intent" className="min-w-0 grow font-medium hover:text-accent"> | |
| 78 | − | {title ?? `Issue #${group.issue}`} <span className="font-normal text-faint">#{group.issue}</span> | |
| 79 | − | </Link> | |
| 80 | − | {group.claims.length > 1 && <Badge tone="warn">{group.claims.length} claims</Badge>} | |
| 81 | − | </div> | |
| 82 | − | {group.claims.map((claim) => ( | |
| 83 | − | <div key={claim.id} className="px-4 py-3"> | |
| 84 | − | <ClaimLine claim={claim} now={now} base={base} /> | |
| 85 | − | </div> | |
| 86 | − | ))} | |
| 87 | − | {group.sessions.map((session) => ( | |
| 88 | − | <div key={session.id} className="px-4 py-3"> | |
| 89 | − | <SessionLine session={session} base={base} /> | |
| 90 | − | </div> | |
| 91 | − | ))} | |
| 92 | − | </li> | |
| 93 | − | </Card> | |
| 94 | − | ); | |
| 95 | − | } | |
| 96 | − | ||
| 97 | − | /** What to do when nothing is being worked on: bring a harness. */ | |
| 98 | − | function StartHere() { | |
| 99 | − | return ( | |
| 100 | − | <EmptyState title="Nobody is working on this repository right now"> | |
| 101 | − | <div className="mx-auto mt-3 max-w-lg space-y-3 text-left"> | |
| 102 | − | <p>Install the g1t plugin in Claude Code, and each session shows here as it works:</p> | |
| 103 | − | <CopyLine text="claude plugin marketplace add https://g1t.sh/flagon-io/g1t.git" prompt /> | |
| 104 | − | <CopyLine text="claude plugin install g1t@g1t" prompt /> | |
| 105 | − | <p>From any other harness, or by hand, claim an issue with the g1t CLI:</p> | |
| 106 | − | <CopyLine text="g1t work claim 12" prompt /> | |
| 107 | − | <p> | |
| 108 | − | <a href="https://docs.g1t.sh/guides/sessions/" className="text-fg underline underline-offset-4"> | |
| 109 | − | Sessions, claims and dependencies | |
| 110 | − | </a>{" "} | |
| 111 | − | says how it works. | |
| 112 | − | </p> | |
| 113 | − | </div> | |
| 114 | − | </EmptyState> | |
| 115 | − | ); | |
| 116 | − | } | |
| 117 | − | ||
| 118 | 68 | function Section({ icon, title, aside, children }: { icon: ReactNode; title: string; aside?: ReactNode; children: ReactNode }) { | |
| 119 | 69 | return ( | |
| 120 | 70 | <section className="mt-10"> | |
| ⋯ | |||
| 166 | 116 | const { claims, sessions, queue, feed, titles, landed, defaultBranch } = loaderData; | |
| 167 | 117 | const base = `/${params.owner}/${params.repo}`; | |
| 168 | 118 | const now = Date.now(); | |
| 169 | − | const live = liveByIssue(claims, sessions); | |
| 170 | 119 | const working = claims.length > 0 || sessions.length > 0; | |
| 171 | 120 | // Claims, sessions, overlaps, integration results and merges as they happen. | |
| 172 | 121 | useRepoLive({ match: repoPages.work() }); | |
| ⋯ | |||
| 194 | 143 | } | |
| 195 | 144 | > | |
| 196 | 145 | {!working ? ( | |
| 197 | − | <StartHere /> | |
| 146 | + | <StartHere title="Nobody is working on this repository right now" /> | |
| 198 | 147 | ) : ( | |
| 199 | − | <ol className="space-y-3"> | |
| 200 | − | {live.issues.map((group) => ( | |
| 201 | − | <LiveIssueCard key={group.issue} group={group} title={titles[group.issue]} base={base} now={now} /> | |
| 202 | − | ))} | |
| 203 | − | {live.loose.length > 0 && ( | |
| 204 | − | <Card asChild divided> | |
| 205 | − | <li> | |
| 206 | − | <p className="px-4 py-3 text-sm text-muted">Not on an issue yet</p> | |
| 207 | − | {live.loose.map((session: WorkSession) => ( | |
| 208 | − | <div key={session.id} className="px-4 py-3"> | |
| 209 | − | <SessionLine session={session} base={base} /> | |
| 210 | − | </div> | |
| 211 | − | ))} | |
| 212 | − | </li> | |
| 213 | − | </Card> | |
| 214 | − | )} | |
| 215 | − | </ol> | |
| 148 | + | <LiveNowList claims={claims} sessions={sessions} titles={titles} base={base} now={now} /> | |
| 216 | 149 | )} | |
| 217 | 150 | </Section> | |
| 218 | 151 | ||
Comments
Sign in to comment.