Skip to content

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

g1t.sh

Commit

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.
syntaqxcommitted Parent1f5404eBrowse files
17 files+836−15600/17 viewed
+1−1
2727 | **Branches** | Beside each branch's latest commit |
2828 | **Tags** | Beside each tagged commit |
2929 | 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 |
3131
3232 A page reads the checks of all its commits at once, after the page itself
3333 has loaded: each mark shows a placeholder until they arrive.
+17−23
4747
4848 ## The overview
4949
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.
5359
5460 | Part | What it shows |
5561 | --- | --- |
5763 | **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. |
5864 | **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. |
5965 | **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. |
6473 | **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. |
6675
6776 ### Production's picture
6877
188197 makes it an app deployed on g1t.
189198
190199 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.
206200
207201 ## Links
208202
+1−118
1−import { ArrowDown, ArrowUp, GitBranch, GitPullRequest, Globe } from "lucide-react";
2−import { Link } from "react-router";
3−
41 import type { Pull } from "@g1t/contracts";
52
63 import type { Drift } from "../lib/branches";
74 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";
155
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. */
207 export type ActiveBranch = {
218 name: string;
229 /** The commit it points to. */
2815 pull: { number: number; title: string; checkStatus: Pull["checkStatus"]; draft: boolean; status: Pull["status"] } | null;
2916 preview: string | null;
3017 };
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−}
+102−2
11 /**
22 * Coordinating work, drawn: claims, harness sessions, dependencies,
33 * 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
56 * is worked out in lib/coordination.ts.
67 */
78 import {
5354 evidenceOf,
5455 expiresIn,
5556 harnessName,
57+ type LiveIssue,
58+ liveByIssue,
5659 mergeSessions,
5760 numberedParts,
5861 sessionTree,
5962 } from "../lib/coordination";
60−import { Input, SubmitButton, TimeAgo } from "./ui";
63+import { CopyLine, EmptyState, Input, SubmitButton, TimeAgo } from "./ui";
6164 import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
6265 import { Avatar } from "./ui/avatar";
6366 import { Badge, type BadgeTone } from "./ui/badge";
750753 </Popover>
751754 );
752755 }
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+}
+0−1
479479 commented: "commented on",
480480 deployed: "deployed production",
481481 deploy_failed: "production build failed",
482− pushed: "pushed to the default branch",
483482 };
484483
485484 /** One line of the feed: who, what, and the one thing it was about, by name. */
+5−221
11 /**
22 * 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.
65 */
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";
278 import { Link } from "react-router";
289
2910 import type { DeployStatus } from "@g1t/contracts";
3011
3112 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";
3314 import { TimeAgo } from "./ui";
34−import { Avatar } from "./ui/avatar";
35−import { UserAvatar } from "./user-card";
36−import { PersonLink } from "./work";
3715 import { Button } from "./ui/button";
3816 import { Card } from "./ui/card";
3917 import { Hint } from "./ui/hint";
40−import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
4118
4219 // --- Panels -------------------------------------------------------------------
4320
169146 </Button>
170147 )}
171148 </>
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>
365149 );
366150 }
367151
+1−2
11 /**
22 * A repository's branches as its pages show them: each one's head commit,
33 * 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.
65 */
76 import type { Branch, BranchDrifts, Commit, Pull, RepoPath, Viewer } from "@g1t/contracts";
87
+0−29
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−});
+0−49
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−}
+1−51
77 type ActivityItem,
88 type Need,
99 DELETED_USER,
10− FEED_EVENT_TYPES,
11− PROJECT_FEED_EVENT_TYPES,
1210 eventItem,
13− projectFeed,
14− pushItem,
1511 actorIds,
1612 ageBuckets,
1713 dailyBuckets,
2420 median,
2521 nameActor,
2622 nextSeen,
27− pipelineStage,
28− queuedNumbers,
2923 runHealth,
3024 rankNeeds,
3125 readCookie,
193187 [9, 9],
194188 ]);
195189 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");
210190 });
211191
212192 test("open issues by age", () => {
246226 assert.equal(waitedFor(3 * 24 * 60 - 1), "2 d");
247227 });
248228
249−// --- A project's feed -----------------------------------------------------------
229+// --- The feed ------------------------------------------------------------------
250230
251231 const REPO = { namespace: "flagon-io", name: "g1t" };
252232 const event = (type: string, data: Record<string, unknown>, id = `evt_${type}`, actor: string | null = "usr_ada"): G1tEvent =>
257237 deployment: { id: "dep_9", environment: production ? "production" : "preview", production_environment: production },
258238 deploymentStatus: { state },
259239 });
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−});
270240
271241 test("a production deploy that finished is a line, linked to it on the current path", () => {
272242 const up = eventItem(deployment("success"), REPO);
275245 assert.equal(eventItem(deployment("failure"), REPO)?.verb, "deploy_failed");
276246 assert.equal(eventItem(deployment("in_progress"), REPO), null);
277247 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);
298248 });
+4−78
11 /**
22 * The arithmetic behind mission control and a project's overview: when the
33 * 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.
76 */
8−import type { G1tEvent, Pull, QueueEntry, RepoPath } from "@g1t/contracts";
7+import type { G1tEvent, RepoPath } from "@g1t/contracts";
98
109 /** A run as the arithmetic here reads it: its kind, status and times. */
1110 type RunLike = { kind: string; status: string; updatedAt: string };
9089 | "changes_requested"
9190 | "commented"
9291 | "deployed"
93− | "deploy_failed"
94− | "pushed";
92+ | "deploy_failed";
9593
9694 /**
9795 * The event types `eventItem` makes a line of. Ask the log for these only:
111109 "deployment_status.created",
112110 ] as const satisfies readonly G1tEvent["type"][];
113111
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−
117112 /** One thing that moved, as the feed shows it. */
118113 export type ActivityItem = {
119114 id: string;
177172 }
178173 }
179174
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−
207175 /** Accounts that act for g1t itself, named in the log by fixed ids. */
208176 const G1T_ACTORS: Record<string, string> = { usr_g1t_agent: "g1t", g1t_policy: "g1t" };
209177 /** Who an account the lookup no longer knows was. */
438406 const y = max > 0 ? height - pad - (Math.max(0, value) / max) * (height - pad * 2) : height - pad;
439407 return [Math.round(x * 100) / 100, Math.round(y * 100) / 100];
440408 });
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));
483409 }
484410
485411 /** Open issues by how long they have been open. */
+2−5
22 import { test } from "node:test";
33
44 import { workOwner } from "../../../../packages/contracts/src/work.ts";
5−import { madeByG1t, openedBy } from "./opened-by.ts";
5+import { openedBy } from "./opened-by.ts";
66
77 const syntaqx = { id: "usr_1", username: "syntaqx" };
88 const g1t = { id: "usr_g1t_agent", username: "g1t", kind: "agent" as const };
99
1010 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 };
1312 assert.deepEqual(openedBy(pull), { name: "g1t", requestedBy: "syntaqx" });
1413 // It is theirs to manage, as their own would be.
1514 assert.equal(workOwner(pull).id, "usr_1");
1817 test("anything else shows its author", () => {
1918 // Someone's own agent.
2019 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);
2320 assert.equal(workOwner({ author: syntaqx, requestedBy: null }).id, "usr_1");
2421 });
2522
+0−8
44 export const G1T = "g1t";
55
66 /**
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−/**
157 * Who opened an issue or a pull request, and who asked g1t for it, as
168 * stored: g1t for what it made or filed, with the person it was for as
179 * `requestedBy`; anyone else's, theirs alone. Who may change it is the
+19−1
3535 assert.equal(tagOf(null), null);
3636 });
3737
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", () => {
3957 const files = repoPages.files();
4058 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")]) {
4159 assert.equal(files(c), true, c.kind);
+12−1
7676 /** What each kind of repository page shows. */
7777 export const repoPages = {
7878 /**
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
8080 * commit, the branch menu, recent pushes), checks on commits, releases
8181 * and deployments.
8282 */
8383 files(): RepoMatch {
8484 return (change) => isRefChange(change) || isCheck(change) || ["release", "deployment", "deployment_status"].includes(family(change));
8585 },
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+ },
8697 /** Commits: pushes to the branch shown, and checks on commits. */
8798 commits(branch: string | null): RepoMatch {
8899 return (change) => (branch == null ? change.kind === "git.push" && !tagOf(change.ref) : isPushTo(change, branch)) || isCheck(change);
+664−896
A large diff (1,809 lines) is not shown until you ask for it.
+7−74
22 import type { ReactNode } from "react";
33 import { Link } from "react-router";
44
5−import type { EventType, Pull, WorkSession } from "@g1t/contracts";
5+import type { EventType, Pull } from "@g1t/contracts";
66
77 import type { Route } from "./+types/work";
8−import { CoordinationFeed, ClaimLine, SessionLine } from "../../components/coordination";
8+import { CoordinationFeed, LiveNowList, StartHere } from "../../components/coordination";
99 import { QueueTrain } from "../../components/merge-queue";
10−import { CopyLine, EmptyState, TimeAgo } from "../../components/ui";
10+import { EmptyState, TimeAgo } from "../../components/ui";
1111 import { Avatar } from "../../components/ui/avatar";
12−import { Badge } from "../../components/ui/badge";
1312 import { Card } from "../../components/ui/card";
14−import { IssueIcon, PullIcon } from "../../components/work-icons";
13+import { PullIcon } from "../../components/work-icons";
1514 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";
1716 import { page } from "../../lib/meta";
1817 import { useRefreshWhile } from "../../lib/refresh";
1918 import { repoPages } from "../../lib/repo-live";
6665 };
6766 }
6867
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−
11868 function Section({ icon, title, aside, children }: { icon: ReactNode; title: string; aside?: ReactNode; children: ReactNode }) {
11969 return (
12070 <section className="mt-10">
166116 const { claims, sessions, queue, feed, titles, landed, defaultBranch } = loaderData;
167117 const base = `/${params.owner}/${params.repo}`;
168118 const now = Date.now();
169− const live = liveByIssue(claims, sessions);
170119 const working = claims.length > 0 || sessions.length > 0;
171120 // Claims, sessions, overlaps, integration results and merges as they happen.
172121 useRepoLive({ match: repoPages.work() });
194143 }
195144 >
196145 {!working ? (
197− <StartHere />
146+ <StartHere title="Nobody is working on this repository right now" />
198147 ) : (
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} />
216149 )}
217150 </Section>
218151

Comments

Sign in to comment.