Skip to content

Commit

Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md

syntaqxcommitted Parents8e414f77d45580Browse files
21 files+674−820/21 viewed
+2−1
205205
206206 Merging one pull request at a time keeps every merge clean as text, but two
207207 changes can merge without a conflict and still break each other. A
208−repository that turns on **Merge through a queue** tests each pull request
208+repository whose rules include **Require the merge queue** (**Settings →
209+Rules**) tests each pull request
209210 together with the ones ahead of it, and `main` only moves to a state whose
210211 required checks passed.
211212 See [merge queue](/guides/merge-queue/).
+1−1
164164 | --- | --- |
165165 | State | **Waiting**, **Testing** or **Passed**. |
166166 | Tested as | `main` and the pull requests merged into it, such as `main + #41 + #44`. |
167−| Checks | How its state's checks stand. |
167+| Checks | How its state's checks stand, linked to the `merge_group` workflow run on the tested commit (each run by name when there are several). |
168168 | Who | The agent or person who made the pull request, and who queued it. |
169169 | Commit | The tested state's commit. |
170170
+4−4
2727 3. Choose **Plan it**.
2828
2929 ```text
30−The greeter should support a --lang flag for Spanish and French, a --shout
31−flag that upper-cases the greeting, and a --version flag. Each should be
32−documented in the README and covered by tests.
30+The greeter should greet in Italian with --lang it and in Portuguese with
31+--lang pt. Each language should be documented in the README and covered by
32+tests.
3333 ```
3434
3535 An agent reads the repository in a sandbox and writes the plan. This takes
145145 curl -X POST https://api.g1t.sh/repos/acme/greeter/plans \
146146 -H "Authorization: Bearer $G1T_TOKEN" \
147147 -H "Content-Type: application/json" \
148− -d '{"brief": "The greeter should support a --shout flag, documented and tested."}'
148+ -d '{"brief": "The greeter should greet in Italian with --lang it, documented and tested."}'
149149
150150 # 2. Read it until status is "ready".
151151 curl https://api.g1t.sh/repos/acme/greeter/plans/pln_01… \
+1−1
6161 | **Right now** | Agents at work, and open pull requests moving from working to landed. |
6262 | **Needs you** | What is waiting on a person: a failed production build, a pull request to merge or review, a stuck run. |
6363 | **Active branches** | Branches other than the default, newest first. See [active branches](#active-branches). |
64−| **Recent changes**, **Latest on main**, **Activity**, **Previews** | What landed, the default branch's latest commits, everything that happened, and the previews that are up. |
64+| **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. |
6565 | **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. |
6666 | **Health**, **Dependencies**, **Clone** | How often checks pass, recent builds and open issues by age; what it uses and what uses it; the clone address. |
6767
+16−0
4646 A workflow job's **Details** opens its log on the run's page, where every
4747 step's output is kept.
4848
49+A closed or merged pull request keeps a checks section in its conversation:
50+its head commit's workflow runs, job by job, as they ended, each with
51+**Details** to its log. It is there to read; nothing in it can be re-run,
52+and it says nothing about merging.
53+
54+### Other attempts at the same issue
55+
56+When an issue has more than one pull request, for example because two
57+agents each tried it, every one of them shows **Other attempts at #N**: the
58+pull requests for that issue side by side, this one first. Each row has its
59+state (open, merged, or closed because another was merged instead), its
60+head commit's checks, where its review stands, its size, and the files it
61+changes that this one changes too. Pull requests for the same issue are
62+alternatives, so they are not listed under **Other work is changing the
63+same files**; that box is for work on other issues, which will collide.
64+
4965 ### Running them again
5066
5167 People with the Write [role](/guides/access-and-roles/) or higher can press
+106−0
1+/**
2+ * The pull requests for one issue, side by side, on each of them: how each
3+ * stands, how its checks went, where its review is and how big it is, so
4+ * two agents' attempts at the same issue can be compared and one chosen.
5+ */
6+import { CircleCheck, CircleDashed, CircleSlash, GitPullRequestArrow, MessageSquare } from "lucide-react";
7+import { Suspense } from "react";
8+import { Await, Link } from "react-router";
9+
10+import { type Attempt, attemptOutcome } from "../lib/attempts";
11+import { checksTally } from "../lib/commit-checks";
12+import { CheckStateIcon } from "./commit-checks";
13+import { ChangeSize } from "./work";
14+import { PullIcon } from "./work-icons";
15+import { SkeletonLine } from "./ui/skeleton";
16+
17+function ReviewIcon({ state }: { state: Attempt["review"]["state"] }) {
18+ if (state === "approved") return <CircleCheck size={14} className="shrink-0 text-success" />;
19+ if (state === "changes_requested") return <CircleSlash size={14} className="shrink-0 text-danger" />;
20+ if (state === "requested") return <MessageSquare size={14} className="shrink-0 text-warn" />;
21+ return <CircleDashed size={14} className="shrink-0 text-faint" />;
22+}
23+
24+function AttemptRow({ attempt, base }: { attempt: Attempt; base: string }) {
25+ const merged = attempt.status === "merged";
26+ return (
27+ <li className={`px-4 py-3 ${attempt.current ? "bg-raised/40" : ""}`}>
28+ <div className="flex min-w-0 items-center gap-2">
29+ <PullIcon status={attempt.status} size={15} />
30+ {attempt.current ? (
31+ <span className="min-w-0 truncate font-medium">
32+ {attempt.title} <span className="font-normal text-faint">#{attempt.number}</span>
33+ </span>
34+ ) : (
35+ <Link to={`${base}/pull/${attempt.number}`} className="min-w-0 truncate font-medium hover:underline">
36+ {attempt.title} <span className="font-normal text-faint">#{attempt.number}</span>
37+ </Link>
38+ )}
39+ {attempt.current && (
40+ <span className="shrink-0 rounded-full border border-line px-1.5 py-px text-[0.625rem] text-faint">This one</span>
41+ )}
42+ <span className="ml-auto shrink-0">
43+ <ChangeSize files={attempt.files} />
44+ </span>
45+ </div>
46+ <dl className="mt-2 grid gap-x-4 gap-y-1.5 text-xs sm:grid-cols-3">
47+ <div className="flex min-w-0 items-center gap-1.5">
48+ <dt className="sr-only">State</dt>
49+ <dd className={`truncate ${merged ? "font-medium text-merged" : "text-muted"}`}>
50+ {attemptOutcome(attempt)} · by {attempt.author}
51+ </dd>
52+ </div>
53+ <div className="flex min-w-0 items-center gap-1.5">
54+ <dt className="sr-only">Checks</dt>
55+ <CheckStateIcon state={attempt.checks?.state ?? "none"} size={14} />
56+ <dd className="truncate text-muted">{attempt.checks && attempt.checks.total > 0 ? checksTally(attempt.checks) : "No checks"}</dd>
57+ </div>
58+ <div className="flex min-w-0 items-center gap-1.5">
59+ <dt className="sr-only">Review</dt>
60+ <ReviewIcon state={attempt.review.state} />
61+ <dd className="truncate text-muted">{attempt.review.text}</dd>
62+ </div>
63+ </dl>
64+ {!attempt.current && attempt.shared.length > 0 && (
65+ <p className="mt-1.5 truncate font-mono text-xs text-faint">Also changes {attempt.shared.join(", ")}</p>
66+ )}
67+ </li>
68+ );
69+}
70+
71+/**
72+ * Every pull request for issue `issue`, this one first: streamed, so the
73+ * page shows before the others are read. Nothing while there are no others.
74+ */
75+export function AttemptsBox({ attempts, issue, base }: { attempts: Promise<Attempt[]>; issue: number; base: string }) {
76+ return (
77+ <Suspense
78+ fallback={
79+ <div className="mt-4 rounded-xl border border-line bg-surface px-4 py-3" aria-busy>
80+ <SkeletonLine className="text-sm" barClassName="w-48" />
81+ </div>
82+ }
83+ >
84+ <Await resolve={attempts} errorElement={null}>
85+ {(found) =>
86+ found.filter((attempt) => !attempt.current).length > 0 && (
87+ <section aria-label={`Other attempts at #${issue}`} className="mt-4 overflow-hidden rounded-xl border border-line bg-surface text-sm">
88+ <h3 className="flex items-center gap-2.5 px-4 pt-3 font-medium">
89+ <GitPullRequestArrow size={16} className="shrink-0 text-info" />
90+ Other attempts at #{issue}
91+ </h3>
92+ <p className="px-4 pt-0.5 pb-2 text-xs text-faint">
93+ Pull requests for the same issue. Merging one closes the others.
94+ </p>
95+ <ul className="divide-y divide-line border-t border-line">
96+ {found.map((attempt) => (
97+ <AttemptRow key={attempt.number} attempt={attempt} base={base} />
98+ ))}
99+ </ul>
100+ </section>
101+ )
102+ }
103+ </Await>
104+ </Suspense>
105+ );
106+}
+10−2
321321 settingsUrl,
322322 error,
323323 loading = false,
324+ settled = false,
324325 }: {
325326 /** Set when the merge queue took it out, or for a run from before checks were workflows. */
326327 run: CheckRun | null;
338339 error?: string | null;
339340 /** The workflow runs' jobs are still being read: their rows wait, busy. */
340341 loading?: boolean;
342+ /**
343+ * The pull request is closed or merged: its head's checks as they ended,
344+ * to read only. Nothing is said of holding a merge.
345+ */
346+ settled?: boolean;
341347 }) {
342348 if (!run && statuses.length === 0 && required.length === 0) return null;
343349
374380 .join(", ");
375381 const sub = summary
376382 ? [summary.sub, others && `all checks: ${others}`].filter(Boolean).join(" · ")
377− : [others, "none are required, so none hold the merge"].filter(Boolean).join(" · ");
383+ : settled
384+ ? [others, pull.headCommit && `on ${pull.headCommit.slice(0, 7)}`].filter(Boolean).join(" · ")
385+ : [others, "none are required, so none hold the merge"].filter(Boolean).join(" · ");
378386 const standing: Standing = summary
379387 ? summary.standing
380388 : failed > 0
403411 <div className="min-w-0 grow">
404412 <p className="text-sm font-medium">{headline}</p>
405413 {sub && <p className="mt-0.5 text-xs text-muted">{sub}</p>}
406− {!summary && settingsUrl && (
414+ {!summary && !settled && settingsUrl && (
407415 <p className="mt-0.5 text-xs text-muted">
408416 <Link to={settingsUrl} className="text-fg hover:underline">
409417 Choose required checks
+1−0
576576 asked: "was asked about",
577577 deployed: "deployed production",
578578 deploy_failed: "production build failed",
579+ pushed: "pushed to the default branch",
579580 learned: "learned",
580581 };
581582
+2−0
1212 CircleSlash,
1313 CreditCard,
1414 Eye,
15+ GitCommitHorizontal,
1516 GitMerge,
1617 GitPullRequest,
1718 Hand,
182183 asked: { icon: <MessageCircleQuestion size={14} />, tone: "text-accent", text: (n) => `asked the agent on ${n}` },
183184 deployed: { icon: <Rocket size={14} />, tone: "text-success", text: () => "deployed production" },
184185 deploy_failed: { icon: <Rocket size={14} />, tone: "text-danger", text: () => "production build failed" },
186+ pushed: { icon: <GitCommitHorizontal size={14} />, tone: "text-muted", text: () => "pushed to the default branch" },
185187 learned: { icon: <Brain size={14} />, tone: "text-accent", text: () => "learned" },
186188 };
187189
+22−4
11 import { Check, Copy, LoaderCircle } from "lucide-react";
2−import { type ComponentProps, type ReactNode, useState } from "react";
2+import { type ComponentProps, Fragment, type ReactNode, useState } from "react";
33 import { Link, type LinkProps, NavLink, useLocation, useNavigation } from "react-router";
44
55 import { isWaitingMessage, linkPaths } from "../../lib/compute";
358358 text,
359359 prompt,
360360 disabled,
361+ breakAtSlashes,
361362 }: {
362363 text: string;
363364 prompt?: boolean;
364365 /** Shown, so it is clear what will be there, but not yet usable: dimmed, with no copy. */
365366 disabled?: boolean;
367+ /**
368+ * For a narrow box: an address longer than the line may wrap after a
369+ * `/`, so all of it shows, rather than scroll.
370+ */
371+ breakAtSlashes?: boolean;
366372 }) {
367373 const [copied, setCopied] = useState(false);
368374 return (
378384 {prompt && <span className="mr-2 text-faint select-none">$</span>}
379385 {text.split(/(\s+)/).map((part, index) =>
380386 /\S/.test(part) ? (
381− <span key={index} className="whitespace-nowrap">
382− {part}
383− </span>
387+ breakAtSlashes ? (
388+ // Each piece whole, with a chance to wrap after each `/`.
389+ <span key={index}>
390+ {part.split(/(?<=\/)(?!\/)/).map((piece, at) => (
391+ <Fragment key={at}>
392+ {at > 0 && <wbr />}
393+ <span className="whitespace-nowrap">{piece}</span>
394+ </Fragment>
395+ ))}
396+ </span>
397+ ) : (
398+ <span key={index} className="whitespace-nowrap">
399+ {part}
400+ </span>
401+ )
384402 ) : (
385403 part
386404 ),
+58−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { Overlap } from "@g1t/contracts";
5+
6+import { MAX_ATTEMPTS, attemptOutcome, attemptReview, otherAttempts, sharedPaths, splitOverlaps } from "./attempts.ts";
7+
8+const overlap = (number: number, issue: number | null): Overlap => ({ number, title: `#${number}`, issue, paths: ["src/lib.rs"] });
9+
10+test("overlaps for the same issue are alternatives; the rest are collisions", () => {
11+ const { collisions, alternatives } = splitOverlaps([overlap(3, 2), overlap(4, 7), overlap(5, null)], 2);
12+ assert.deepEqual(alternatives.map((o) => o.number), [3]);
13+ assert.deepEqual(collisions.map((o) => o.number), [4, 5]);
14+ // A pull request for no issue has no alternatives.
15+ assert.deepEqual(splitOverlaps([overlap(5, null)], null).alternatives, []);
16+});
17+
18+test("the other attempts are the issue's other pull requests, newest first, a few at most", () => {
19+ const pulls = [3, 9, 4, 12, 7, 8].map((number) => ({ number }));
20+ const others = otherAttempts(pulls, 9);
21+ assert.deepEqual(others, [12, 8, 7, 4]);
22+ assert.equal(others.length, MAX_ATTEMPTS);
23+ assert.deepEqual(otherAttempts([{ number: 9 }], 9), []);
24+});
25+
26+test("shared paths are the files both change", () => {
27+ assert.deepEqual(
28+ sharedPaths([{ path: "README.md" }, { path: "src/lib.rs" }, { path: "src/main.rs" }], [{ path: "src/main.rs" }, { path: "README.md" }]),
29+ ["README.md", "src/main.rs"],
30+ );
31+});
32+
33+const said = (username: string, verdict: "approve" | "request_changes" | null) => ({
34+ author: { id: `usr_${username}`, username },
35+ verdict,
36+});
37+
38+test("a review stands at each reviewer's latest verdict, changes asked for first", () => {
39+ assert.deepEqual(attemptReview([said("g1t", "approve")], []), { state: "approved", text: "Approved by g1t" });
40+ assert.deepEqual(attemptReview([said("g1t", "request_changes"), said("g1t", "approve"), said("ada", "approve")], []), {
41+ state: "approved",
42+ text: "Approved by g1t, ada",
43+ });
44+ assert.deepEqual(attemptReview([said("g1t", "approve"), said("ada", "request_changes")], ["sam"]), {
45+ state: "changes_requested",
46+ text: "Changes requested by ada",
47+ });
48+ assert.deepEqual(attemptReview([said("ada", null)], ["g1t"]), { state: "requested", text: "Review requested from g1t" });
49+ assert.deepEqual(attemptReview([], []), { state: "none", text: "No review yet" });
50+});
51+
52+test("an attempt's outcome says which was merged instead", () => {
53+ assert.equal(attemptOutcome({ status: "merged", supersededBy: null }), "Merged");
54+ assert.equal(attemptOutcome({ status: "closed", supersededBy: 5 }), "Closed · #5 was merged instead");
55+ assert.equal(attemptOutcome({ status: "closed", supersededBy: null }), "Closed without merging");
56+ assert.equal(attemptOutcome({ status: "draft", supersededBy: null }), "Draft · in progress");
57+ assert.equal(attemptOutcome({ status: "open", supersededBy: null }), "Open");
58+});
+88−0
1+/**
2+ * The other pull requests for the same issue, as a pull request shows them
3+ * beside itself: alternatives to compare, not collisions to avoid. Each
4+ * says how it stands, how its head commit's checks went, and where its
5+ * review is, so two agents' attempts at one issue can be weighed side by
6+ * side (routes/repo/pull.tsx).
7+ */
8+import type { ChangedFile, Comment, CommitChecks, Overlap, Pull, PullStatus } from "@g1t/contracts";
9+
10+/** How many other attempts a pull request reads, newest first. */
11+export const MAX_ATTEMPTS = 4;
12+
13+export type AttemptReview = {
14+ state: "approved" | "changes_requested" | "requested" | "none";
15+ text: string;
16+};
17+
18+/** One pull request for the issue: this one, or another. */
19+export type Attempt = {
20+ number: number;
21+ title: string;
22+ status: PullStatus;
23+ /** The pull request merged instead of it, when one was. */
24+ supersededBy: number | null;
25+ /** Who opened it, or `g1t`. */
26+ author: string;
27+ headCommit: string | null;
28+ files: ChangedFile[];
29+ updatedAt: string;
30+ /** Whether it is the pull request being looked at. */
31+ current: boolean;
32+ /** The files it changes that the one being looked at changes too. */
33+ shared: string[];
34+ /** Its head commit's checks; null when nothing reported on it. */
35+ checks: CommitChecks | null;
36+ review: AttemptReview;
37+};
38+
39+/**
40+ * A pull request's overlaps, split: those for other issues will collide
41+ * with it; those for the same issue are alternatives to it, and expected
42+ * to change the same files.
43+ */
44+export function splitOverlaps(overlaps: Overlap[], issue: number | null): { collisions: Overlap[]; alternatives: Overlap[] } {
45+ const same = (other: Overlap) => issue != null && other.issue === issue;
46+ return { collisions: overlaps.filter((other) => !same(other)), alternatives: overlaps.filter(same) };
47+}
48+
49+/** The other pull requests of an issue worth showing beside `number`: newest first, at most a few. */
50+export function otherAttempts(pulls: Pick<Pull, "number">[], number: number): number[] {
51+ return pulls
52+ .map((pull) => pull.number)
53+ .filter((other) => other !== number)
54+ .sort((a, b) => b - a)
55+ .slice(0, MAX_ATTEMPTS);
56+}
57+
58+/** The paths both lists of changed files name, in the order of the first. */
59+export function sharedPaths(mine: Pick<ChangedFile, "path">[], theirs: Pick<ChangedFile, "path">[]): string[] {
60+ const other = new Set(theirs.map((file) => file.path));
61+ return mine.map((file) => file.path).filter((path) => other.has(path));
62+}
63+
64+/**
65+ * Where a pull request's review stands: each reviewer's latest verdict,
66+ * changes asked for first; otherwise whose review was asked for.
67+ */
68+export function attemptReview(comments: Pick<Comment, "author" | "verdict">[], reviewers: string[]): AttemptReview {
69+ const latest = new Map<string, NonNullable<Comment["verdict"]>>();
70+ for (const comment of comments) if (comment.verdict) latest.set(comment.author.username, comment.verdict);
71+ const by = (verdict: NonNullable<Comment["verdict"]>) =>
72+ [...latest].filter(([, given]) => given === verdict).map(([reviewer]) => reviewer);
73+ const blocking = by("request_changes");
74+ if (blocking.length > 0) return { state: "changes_requested", text: `Changes requested by ${blocking.join(", ")}` };
75+ const approving = by("approve");
76+ if (approving.length > 0) return { state: "approved", text: `Approved by ${approving.join(", ")}` };
77+ if (reviewers.length > 0) return { state: "requested", text: `Review requested from ${reviewers.join(", ")}` };
78+ return { state: "none", text: "No review yet" };
79+}
80+
81+/** What became of an attempt, in a few words. */
82+export function attemptOutcome(attempt: Pick<Attempt, "status" | "supersededBy">): string {
83+ if (attempt.status === "merged") return "Merged";
84+ if (attempt.supersededBy != null) return `Closed · #${attempt.supersededBy} was merged instead`;
85+ if (attempt.status === "closed") return "Closed without merging";
86+ if (attempt.status === "draft") return "Draft · in progress";
87+ return "Open";
88+}
+58−0
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4+import type { G1tEvent } from "@g1t/contracts";
5+
46 import {
57 type ActivityItem,
68 type Need,
79 DELETED_USER,
10+ FEED_EVENT_TYPES,
11+ PROJECT_FEED_EVENT_TYPES,
12+ eventItem,
13+ projectFeed,
14+ pushItem,
815 actorIds,
916 ageBuckets,
1017 agentHours,
258265 assert.equal(waitedFor(24 * 60), "1 d");
259266 assert.equal(waitedFor(3 * 24 * 60 - 1), "2 d");
260267 });
268+
269+// --- A project's feed -----------------------------------------------------------
270+
271+const REPO = { namespace: "flagon-io", name: "g1t" };
272+const event = (type: string, data: Record<string, unknown>, id = `evt_${type}`, actor: string | null = "usr_ada"): G1tEvent =>
273+ ({ id, type, source: "test", time: "2026-10-08T12:00:00Z", repoId: "rep_1", actor, data }) as unknown as G1tEvent;
274+const deployment = (state: string, production = true) =>
275+ event("deployment_status.created", {
276+ repoId: "rep_1",
277+ deployment: { id: "dep_9", environment: production ? "production" : "preview", production_environment: production },
278+ deploymentStatus: { state },
279+ });
280+
281+test("the feed asks the log only for the kinds it shows, and pushes for one project", () => {
282+ for (const type of ["session.appended", "queue.changed", "pull.updated", "pull.mergecheck", "workflow.completed"]) {
283+ assert.ok(!(PROJECT_FEED_EVENT_TYPES as readonly string[]).includes(type), type);
284+ }
285+ assert.ok((PROJECT_FEED_EVENT_TYPES as readonly string[]).includes("git.push"));
286+ assert.ok(!(FEED_EVENT_TYPES as readonly string[]).includes("git.push"));
287+ // Every kind asked for makes a line of some events.
288+ for (const type of FEED_EVENT_TYPES) assert.ok(type.includes("."), type);
289+});
290+
291+test("a production deploy that finished is a line, linked to it on the current path", () => {
292+ const up = eventItem(deployment("success"), REPO);
293+ assert.equal(up?.verb, "deployed");
294+ assert.equal(up?.to, "/flagon-io/g1t/deployments/dep_9");
295+ assert.equal(eventItem(deployment("failure"), REPO)?.verb, "deploy_failed");
296+ assert.equal(eventItem(deployment("in_progress"), REPO), null);
297+ assert.equal(eventItem(deployment("success", false), REPO), null);
298+});
299+
300+test("a project's feed has its pushes to the default branch, but not the ones that only landed a pull request", () => {
301+ const events = [
302+ event("pull.merged", { pullId: "pul_1", repoId: "rep_1", number: 5, commit: "aaaaaaa1111" }, "evt_1"),
303+ event("git.push", { repoId: "rep_1", ref: "refs/heads/main", after: "aaaaaaa1111", defaultBranch: true }, "evt_2"),
304+ event("git.push", { repoId: "rep_1", ref: "refs/heads/main", after: "bbbbbbb2222", defaultBranch: true }, "evt_3"),
305+ event("git.push", { repoId: "rep_1", ref: "refs/heads/topic", after: "ccccccc3333", defaultBranch: false }, "evt_4"),
306+ event("session.appended", { repoId: "rep_1", number: 5 }, "evt_5"),
307+ ];
308+ const feed = projectFeed(events, REPO);
309+ assert.deepEqual(
310+ feed.map((item) => [item.verb, item.text ?? item.number]),
311+ [
312+ ["landed", 5],
313+ ["pushed", "bbbbbbb"],
314+ ],
315+ );
316+ assert.equal(feed[1]?.to, "/flagon-io/g1t/commit/bbbbbbb2222");
317+ assert.equal(pushItem(events[4]!, REPO, new Set()), null);
318+});
+64−0
104104 | "asked"
105105 | "deployed"
106106 | "deploy_failed"
107+ | "pushed"
107108 | "learned";
108109
110+/**
111+ * The event types `eventItem` makes a line of. Ask the log for these only:
112+ * a repository's newest events are mostly ones no line is made of (session
113+ * steps, queue and merge-check changes), which would otherwise fill the
114+ * page and leave nothing to show.
115+ */
116+export const FEED_EVENT_TYPES = [
117+ "pull.merged",
118+ "issue.opened",
119+ "issue.closed",
120+ "pull.opened",
121+ "pull.ready",
122+ "checks.completed",
123+ "review.completed",
124+ "comment.created",
125+ "agent.asked",
126+ "deployment_status.created",
127+] as const satisfies readonly G1tEvent["type"][];
128+
129+/** With pushes to the default branch too, for one project's feed. */
130+export const PROJECT_FEED_EVENT_TYPES = [...FEED_EVENT_TYPES, "git.push"] as const satisfies readonly G1tEvent["type"][];
131+
109132 /** One thing that moved, as the feed shows it. */
110133 export type ActivityItem = {
111134 id: string;
148171 return { ...base, verb: "commented", number: event.data.number };
149172 case "agent.asked":
150173 return { ...base, verb: "asked", number: event.data.number };
174+ case "deployment_status.created": {
175+ // Production, once it is up or has failed: g1t.page builds, g1t
176+ // Actions jobs and deployments reported through the API alike.
177+ const { deployment, deploymentStatus } = event.data;
178+ if (!deployment.production_environment) return null;
179+ const state = deploymentStatus.state;
180+ if (state !== "success" && state !== "failure" && state !== "error") return null;
181+ return {
182+ ...base,
183+ verb: state === "success" ? "deployed" : "deploy_failed",
184+ number: null,
185+ to: `/${repo.namespace}/${repo.name}/deployments/${deployment.id}`,
186+ };
187+ }
151188 default:
152189 return null;
153190 }
154191 }
155192
193+/**
194+ * A push to the default branch as a feed line, or null for any other push
195+ * and for one that only landed a pull request (`merged`: the commits pull
196+ * requests' merges made), which already has its own line.
197+ */
198+export function pushItem(event: G1tEvent, repo: RepoPath, merged: ReadonlySet<string>): ActivityItem | null {
199+ if (event.type !== "git.push" || !event.data.defaultBranch || !event.data.ref.startsWith("refs/heads/")) return null;
200+ const after = event.data.after;
201+ if (!after || /^0+$/.test(after) || merged.has(after)) return null;
202+ return {
203+ id: event.id,
204+ at: Date.parse(event.time),
205+ repo,
206+ actor: event.actor,
207+ verb: "pushed",
208+ number: null,
209+ text: after.slice(0, 7),
210+ to: `/${repo.namespace}/${repo.name}/commit/${after}`,
211+ };
212+}
213+
214+/** One project's events as its feed: every line `eventItem` makes, and its people's pushes. */
215+export function projectFeed(events: G1tEvent[], repo: RepoPath): ActivityItem[] {
216+ const merged = new Set(events.flatMap((event) => (event.type === "pull.merged" ? [event.data.commit] : [])));
217+ return events.flatMap((event) => eventItem(event, repo) ?? pushItem(event, repo, merged) ?? []);
218+}
219+
156220 /** Accounts that act for g1t itself, named in the log by fixed ids. */
157221 const G1T_ACTORS: Record<string, string> = { usr_g1t_agent: "g1t", g1t_policy: "g1t" };
158222 /** Who an account the lookup no longer knows was. */
+4−15
2626 agentHours,
2727 dailyBuckets,
2828 eventItem,
29+ FEED_EVENT_TYPES,
2930 greetingFor,
3031 groupActivity,
3132 hourIn,
233234 .slice(0, MAX_PROJECTS);
234235 const [batch, logs, pushes] = await Promise.all([
235236 work.pullsForRepos(chosen.map((repo) => repo.id), viewer, PULL_PAGE).catch(() => []),
236− Promise.all(chosen.map((repo) => eventLog.list({ repoId: repo.id, limit: EVENTS_PER_PROJECT }).catch(() => null))),
237+ Promise.all(chosen.map((repo) => eventLog.list({ repoId: repo.id, types: [...FEED_EVENT_TYPES], limit: EVENTS_PER_PROJECT }).catch(() => null))),
237238 // People's pushes straight to the default branch, which no pull
238239 // request counts: the commits each brought, for the week.
239240 Promise.all(chosen.map((repo) => directCommits(repo, viewer).catch(() => []))),
599600 if (item) items.push(item);
600601 }
601602 }
602− for (const entry of overviewList ?? []) {
603− const latest = entry.latest;
604− const served = latest?.status === "ready" || latest?.status === "replaced";
605− if (!slug || latest?.kind !== "production" || (!served && latest.status !== "failed")) continue;
606− items.push({
607− id: `deploy:${latest.id}`,
608− at: Date.parse(latest.finishedAt ?? latest.createdAt),
609− repo: { namespace: slug, name: entry.slug },
610− actor: latest.createdBy,
611− verb: served ? "deployed" : "deploy_failed",
612− number: null,
613− to: `/${slug}/${entry.slug}/deployments/${latest.id}`,
614− });
615− }
603+ // Production deploys come from the log too (deployment_status.created),
604+ // wherever they ran: g1t.page, g1t Actions or the API.
616605 for (const memory of okOr(memories ?? null)?.workspace ?? []) {
617606 const repo = memory.repo ?? memory.source.repo;
618607 if (!repo || !slug) continue;
+9−6
6464 TIME,
6565 actorIds,
6666 ageBuckets,
67− eventItem,
6867 firstPassRate,
6968 groupActivity,
7069 nameActor,
7170 passRate,
7271 pipelineStage,
72+ PROJECT_FEED_EVENT_TYPES,
73+ projectFeed,
7374 queuedNumbers,
7475 rankNeeds,
7576 stuckMinutes,
159160 const libraryRepoP = Promise.all([planP, repoP]).then(([plan, repo]) => (plan === "release" && repo?.ok ? repo.value : null));
160161 const packagesP = libraryRepoP.then((repo) => (repo ? soft(packages.list(params.owner, viewer, { repoId: repo.id })) : null));
161162 const workflowsP = planP.then((plan) => (plan && plan !== "production" ? forMembers(() => actions.workflows(path, viewer)) : null));
162− const eventsP = repoP.then((repo) => (repo?.ok ? soft(eventLog.list({ repoId: repo.value.id, limit: 150 })) : null));
163+ // Only the kinds the feed shows: the newest events are mostly session
164+ // steps and merge checks, which would otherwise crowd out everything.
165+ const eventsP = repoP.then((repo) =>
166+ repo?.ok ? soft(eventLog.list({ repoId: repo.value.id, types: [...PROJECT_FEED_EVENT_TYPES], limit: 150 })) : null,
167+ );
163168 const minePullsP = repoP.then((repo) =>
164169 repo?.ok && viewer
165170 ? soft(work.listActivePulls(viewer)).then((list) => (list ?? []).filter((item) => item.pull.repoId === repo.value.id))
337342
338343 // --- Activity and health -------------------------------------------------------
339344 const eventList: G1tEvent[] = recent ?? [];
340− const logged = eventList
341− .map((event) => eventItem(event, path))
342− .filter((item): item is ActivityItem => item != null);
345+ const logged: ActivityItem[] = projectFeed(eventList, path);
343346 // The log names people by account id: their usernames, in one lookup.
344347 const ids = actorIds(logged.map((item) => item.actor));
345348 const names = ids.length > 0 ? await soft(identity.usernames(ids)) : {};
14961499 <section className="rounded-xl border border-line bg-surface p-5">
14971500 <h2 className="text-sm font-semibold">Clone</h2>
14981501 <div className="mt-3">
1499− <CopyLine text={`git clone ${cloneUrl(addresses, `${source.repo.namespace}/${source.repo.name}`)}`} />
1502+ <CopyLine breakAtSlashes text={`git clone ${cloneUrl(addresses, `${source.repo.namespace}/${source.repo.name}`)}`} />
15001503 </div>
15011504 </section>
15021505 )}
+1−1
7676 rows={7}
7777 required
7878 placeholder={
79− "The outcome, in your own words. For example:\n\nThe greeter should support a --lang flag for Spanish and French, a --shout flag that upper-cases the greeting, and a --version flag. Each should be documented in the README and covered by tests."
79+ "The outcome, in your own words. For example:\n\nThe greeter should greet in Italian with --lang it and in Portuguese with --lang pt. Each language should be documented in the README and covered by tests."
8080 }
8181 />
8282 <div className="flex flex-wrap items-center gap-3">
+82−5
3030
3131 import {
3232 type Capability,
33+ type Comment,
3334 type Comparison,
3435 type Deployment,
3536 type Job,
3637 type LiveApp,
38+ type Pull,
39+ type RepoPath,
3740 type SessionEntry,
3841 type Viewer,
3942 pullComparison,
6972 import { TabStrip } from "../../components/ui/tab-strip";
7073 import { WorkflowStatuses } from "../../components/actions";
7174 import { AddCiPrompt } from "../../components/add-ci";
75+import { AttemptsBox } from "../../components/attempts";
76+import { type Attempt, attemptReview, otherAttempts, sharedPaths, splitOverlaps } from "../../lib/attempts";
7277 import {
7378 CommentForm,
7479 CommentList,
176181 // The jobs of each workflow run on its head, to list checks job by job.
177182 // Read as the viewer: a run they cannot see is listed by its status alone.
178183 // Streamed: the checks show by status first, then job by job.
184+ // A closed or merged one lists its head's checks too, to read only.
179185 const runIds = [...new Set((found.value.statuses ?? []).map(runIdOf).filter((id) => id != null))].slice(0, 10);
180186 const workflowJobs =
181− tab === "conversation" && pull.status === "open" && runIds.length > 0
187+ tab === "conversation" && pull.status !== "draft" && runIds.length > 0
182188 ? Promise.all(runIds.map((id) => actions.run(path, viewer, id).catch(() => null))).then((runs) => {
183189 const jobs: Record<string, Job[]> = {};
184190 for (const run of runs) if (run?.ok) jobs[run.value.run.id] = run.value.jobs;
209215 : false,
210216 ]);
211217 const affects = used.ok ? used.value.usedBy : [];
218+ // Every pull request for its issue, to compare: streamed, and read only
219+ // when there is more than this one.
220+ const attempts =
221+ tab === "conversation" && pull.issue != null && (found.value.issue?.pullCount ?? 0) > 1
222+ ? attemptsFor(path, viewer, pull, found.value.comments).catch(() => [] as Attempt[])
223+ : null;
212224 const [labels, milestones] = await Promise.all([labelsFound, milestonesFound]);
213225 const repoDefault = repo.ok ? repo.value.defaultBranch : "main";
214226 // The default branch's protection covers pull requests into it only.
226238 // The branch it merges into: Write and up.
227239 canChangeBase: can.push,
228240 workflowJobs,
241+ attempts,
229242 tab,
230243 session: session?.ok ? session.value : [],
231244 // An empty comparison if it could not be made.
275288 }
276289
277290 /**
291+ * This pull request and the latest few others for its issue, each with its
292+ * head's checks and where its review stands, this one first.
293+ */
294+async function attemptsFor(path: RepoPath, viewer: Viewer, pull: Pull, comments: Comment[]): Promise<Attempt[]> {
295+ const found = await work.getIssue(path, pull.issue!, viewer);
296+ if (!found.ok) return [];
297+ const others = otherAttempts(found.value.pulls, pull.number).flatMap(
298+ (number) => found.value.pulls.find((other) => other.number === number) ?? [],
299+ );
300+ const [details, checks] = await Promise.all([
301+ Promise.all(others.map((other) => work.getPull(path, other.number, viewer).catch(() => null))),
302+ commitChecksFor(path, viewer, [pull.headCommit, ...others.map((other) => other.headCommit)]),
303+ ]);
304+ const attempt = (one: Pull, said: Comment[], current: boolean): Attempt => ({
305+ number: one.number,
306+ title: one.title,
307+ status: one.status,
308+ supersededBy: one.supersededBy,
309+ author: openedBy(one).name,
310+ headCommit: one.headCommit,
311+ files: one.files,
312+ updatedAt: one.updatedAt,
313+ current,
314+ shared: current ? [] : sharedPaths(one.files, pull.files),
315+ checks: (one.headCommit && checks?.[one.headCommit]) || null,
316+ review: attemptReview(said, one.reviewers),
317+ });
318+ return [
319+ attempt(pull, comments, true),
320+ ...others.map((other, index) => {
321+ const detail = details[index];
322+ return attempt(other, detail?.ok ? detail.value.comments : [], false);
323+ }),
324+ ];
325+}
326+
327+/**
278328 * Where this pull request is live: its preview (or its latest build, while
279329 * one is going or after one failed), and the previews of the projects
280330 * that use this one, built against it.
612662 conflicts = [],
613663 earlierChecks = [],
614664 workflowJobs,
665+ attempts,
615666 affects,
616667 preview,
617668 build,
660711 loading={loading}
661712 />
662713 );
714+ // A closed or merged pull request's checks, as they ended: to read, with
715+ // nothing to re-run and nothing that holds a merge.
716+ const settledChecks = (jobs: Record<string, Job[]>, loading = false) => (
717+ <ChecksSection
718+ run={null}
719+ required={[]}
720+ statuses={statuses}
721+ jobs={jobs}
722+ pull={pull}
723+ base={base}
724+ earlier={[]}
725+ canRerunWorkflows={false}
726+ settled
727+ loading={loading}
728+ />
729+ );
663730 const addresses = useAddresses();
664731 const remote = pull.fork
665732 ? cloneUrl(addresses, `${pull.fork.namespace}/${pull.fork.name}`)
675742 const unchecked = requiredChecks.some((check) => check.state !== "success") || checks?.status === "failed";
676743 const requiredFailed = requiredChecks.some((check) => check.state === "failure") || checks?.status === "failed";
677744 // Pull requests for other issues changing the same files will conflict;
678− // ones for the same issue are alternatives, and expected to.
679− const collisions = overlaps.filter((other) => other.issue == null || other.issue !== pull.issue);
745+ // ones for the same issue are alternatives, and expected to: those are
746+ // compared under their own heading.
747+ const { collisions } = splitOverlaps(overlaps, pull.issue);
680748 const review = {
681749 changesUrl: here + "?tab=changes",
682750 // Nobody reviews their own pull request, nor one g1t made for them.
9621030 ))}
9631031 </ul>
9641032 )}
1033+ {attempts && pull.issue != null && <AttemptsBox attempts={attempts} issue={pull.issue} base={base} />}
9651034 {collisions.length > 0 && active && (
9661035 <div className="mt-4 rounded-xl border border-line bg-surface px-4 py-3 text-sm">
9671036 <p className="flex items-center gap-2.5 font-medium">
11061175 </div>
11071176 )}
11081177
1178+ {(pull.status === "merged" || pull.status === "closed") && statuses.length > 0 && (
1179+ <StatusBox>
1180+ <Suspense fallback={settledChecks({}, true)}>
1181+ <Await resolve={workflowJobs}>{(jobs) => settledChecks(jobs)}</Await>
1182+ </Suspense>
1183+ </StatusBox>
1184+ )}
1185+
11091186 {pull.status === "open" && (
11101187 <StatusBox>
11111188 {/* Checks by status at once; job by job when the runs are read. */}
13811458 </div>
13821459
13831460 <aside className={tab === "changes" ? "hidden" : "space-y-6"}>
1384− {/* An open pull request shows its checks in full in the merge box. */}
1385− {pull.status !== "open" && <WorkflowStatuses statuses={statuses} />}
1461+ {/* Other than a draft, a pull request shows its checks in full in the conversation. */}
1462+ {pull.status === "draft" && <WorkflowStatuses statuses={statuses} />}
13861463 {affects.length > 0 && (
13871464 <section>
13881465 <h3 className="flex items-center gap-1.5 text-sm font-medium">
+85−16
88 Minus,
99 X,
1010 } from "lucide-react";
11−import { Link } from "react-router";
11+import { Suspense } from "react";
12+import { Await, Link } from "react-router";
1213
13−import type { QueueEntry, QueueState } from "@g1t/contracts";
14+import type { QueueEntry, QueueState, RepoPath, Viewer } from "@g1t/contracts";
1415
1516 import type { Route } from "./+types/queue";
1617 import { page } from "../../lib/meta";
1718 import { Avatar, ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
18−import { work } from "../../lib/services.server";
19+import { actions, work } from "../../lib/services.server";
1920 import { getViewer, unwrap } from "../../lib/session.server";
2021 import { accessFor, repoFor } from "../../lib/access.server";
2122 import { useRefreshWhile } from "../../lib/refresh";
2223
2324
25+/** A merge_group workflow run on a combined state. */
26+type GroupRun = { id: string; name: string };
27+
2428 export function meta({ params, ...args }: Route.MetaArgs) {
2529 return page(args, { title: `Merge queue · ${params.owner}/${params.repo} · g1t` });
2630 }
2933 const viewer = getViewer(context);
3034 const path = { namespace: params.owner, name: params.repo };
3135 const [queue, repo] = await Promise.all([work.queue(path, viewer), repoFor(context, params)]);
36+ const view = unwrap(queue);
3237 return {
33− queue: unwrap(queue),
38+ queue: view,
39+ // The merge_group runs on each combined state, to link its checks to:
40+ // streamed, so the queue shows at once.
41+ groupRuns: mergeGroupRuns(path, viewer, [...view.active, ...view.recent]),
3442 defaultBranch: unwrap(repo).defaultBranch,
3543 // Turning the queue on is a setting: Maintain and up.
3644 member: repo.ok && accessFor(viewer, repo.value).can.manage_settings,
3745 };
3846 }
3947
48+/** The merge_group runs on each entry's combined state, by commit; empty where none could be read. */
49+function mergeGroupRuns(path: RepoPath, viewer: Viewer, entries: QueueEntry[]): Promise<Record<string, GroupRun[]>> {
50+ const commits = [...new Set(entries.map((entry) => entry.combinedCommit).filter((sha) => sha != null))].slice(0, 20);
51+ return Promise.all(
52+ commits.map((sha) =>
53+ actions
54+ .runs(path, viewer, { sha, event: "merge_group", limit: 10 })
55+ .then((found) => [sha, found.ok ? found.value.map((run) => ({ id: run.id, name: run.name })) : []] as const)
56+ .catch(() => [sha, []] as const),
57+ ),
58+ ).then((found) => Object.fromEntries(found));
59+}
60+
4061 const STATE: Record<QueueState, { label: string; tone: string; icon: React.ReactNode }> = {
4162 waiting: { label: "Waiting", tone: "text-muted border-line", icon: <CircleDashed size={13} /> },
4263 testing: {
81102 );
82103 }
83104
84−function Entry({ entry, base, branch, position }: { entry: QueueEntry; base: string; branch: string; position?: number }) {
105+/**
106+ * How an entry's checks went, linked to the merge_group run on its combined
107+ * state; with several runs, each is named and linked after the count.
108+ */
109+function GroupChecks({ entry, base, runs }: { entry: QueueEntry; base: string; runs: GroupRun[] }) {
85110 const ran = entry.results.length;
86111 const passed = entry.results.filter((result) => result.passed).length;
112+ const [first, ...more] = runs;
113+ const tone = passed === ran ? "text-success" : "text-danger";
114+ const count = ran > 0 ? `${passed}/${ran} checks passed` : first ? "merge_group run" : null;
115+ if (!count) return null;
87116 return (
117+ <span className="flex flex-wrap items-center gap-x-2">
118+ {first ? (
119+ <Link to={`${base}/actions/runs/${first.id}`} className={`underline-offset-2 hover:underline ${ran > 0 ? tone : "text-muted"}`}>
120+ {count}
121+ {more.length > 0 && ` · ${first.name}`}
122+ </Link>
123+ ) : (
124+ <span className={tone}>{count}</span>
125+ )}
126+ {more.map((run) => (
127+ <Link key={run.id} to={`${base}/actions/runs/${run.id}`} className="text-muted underline-offset-2 hover:text-fg hover:underline">
128+ {run.name}
129+ </Link>
130+ ))}
131+ </span>
132+ );
133+}
134+
135+function Entry({
136+ entry,
137+ base,
138+ branch,
139+ position,
140+ groupRuns,
141+}: {
142+ entry: QueueEntry;
143+ base: string;
144+ branch: string;
145+ position?: number;
146+ groupRuns: Promise<Record<string, GroupRun[]>>;
147+}) {
148+ const runsOf = (found: Record<string, GroupRun[]>) => (entry.combinedCommit ? (found[entry.combinedCommit] ?? []) : []);
149+ return (
88150 <li className="relative pl-10">
89151 {/* Its place on the rail. */}
90152 <span
110172 {(entry.state === "testing" || entry.state === "passed" || entry.state === "failed") && (
111173 <TestedAs entry={entry} branch={branch} />
112174 )}
113− {ran > 0 && (
114− <span className={passed === ran ? "text-success" : "text-danger"}>
115− {passed}/{ran} checks passed
116− </span>
117− )}
175+ <Suspense fallback={<GroupChecks entry={entry} base={base} runs={[]} />}>
176+ <Await resolve={groupRuns} errorElement={<GroupChecks entry={entry} base={base} runs={[]} />}>
177+ {(found) => <GroupChecks entry={entry} base={base} runs={runsOf(found)} />}
178+ </Await>
179+ </Suspense>
118180 <span className="flex items-center gap-1.5">
119181 <Avatar name={entry.agent} size={14} />
120182 {entry.agent}
146208 }
147209
148210 export default function Queue({ loaderData, params }: Route.ComponentProps) {
149− const { queue, defaultBranch, member } = loaderData;
211+ const { queue, defaultBranch, member, groupRuns } = loaderData;
150212 const base = `/${params.owner}/${params.repo}`;
151213 const moving = queue.active.length > 0;
152214 useRefreshWhile(moving);
168230 </p>
169231 </div>
170232 {member && (
171− <ButtonLink to={`${base}/settings`}>{queue.enabled ? "Settings" : "Turn it on"}</ButtonLink>
233+ <ButtonLink to={`${base}/settings/rules`}>{queue.enabled ? "Settings" : "Turn it on"}</ButtonLink>
172234 )}
173235 </div>
174236
175237 {!queue.enabled && queue.active.length === 0 && queue.recent.length === 0 ? (
176238 <div className="mt-8">
177239 <EmptyState title="This repository merges directly">
178− Turn on <b>Merge through a queue</b> in the repository's settings, and merging a
179− pull request adds it here instead of changing {defaultBranch} at once.
240+ Add the rule <b>Require the merge queue</b> for {defaultBranch} under{" "}
241+ {member ? (
242+ <Link to={`${base}/settings/rules`} className="text-fg underline underline-offset-2">
243+ Settings → Rules
244+ </Link>
245+ ) : (
246+ "Settings → Rules"
247+ )}
248+ , and merging a pull request adds it here instead of changing {defaultBranch} at once.
180249 </EmptyState>
181250 </div>
182251 ) : (
199268 )}
200269 </li>
201270 {queue.active.map((entry, index) => (
202− <Entry key={entry.id} entry={entry} base={base} branch={defaultBranch} position={index + 1} />
271+ <Entry key={entry.id} entry={entry} base={base} branch={defaultBranch} position={index + 1} groupRuns={groupRuns} />
203272 ))}
204273 {queue.active.length === 0 && (
205274 <li className="pl-10 text-sm text-muted">Nothing is waiting. Merged pull requests appear here.</li>
212281 <h3 className="text-sm font-medium">Recently</h3>
213282 <ol className="mt-3 space-y-3">
214283 {queue.recent.map((entry) => (
215− <Entry key={entry.id} entry={entry} base={base} branch={defaultBranch} />
284+ <Entry key={entry.id} entry={entry} base={base} branch={defaultBranch} groupRuns={groupRuns} />
216285 ))}
217286 </ol>
218287 </section>
+43−20
1919 - Write the three issues for section 3 in a scratch file so they can be
2020 pasted (titles below). Agents take one to three minutes each: start them,
2121 talk over sections 4 and 5, and come back.
22−- In `flagon-io/hello` Settings → Branches and merging, check **Merge through
23− a queue** and **Merge automatically when ready** are on, so section 7's pull
24− requests enter the queue by themselves. (`automation-lab` is set to wait
25− for a person, so do not record there.)
22+- In `flagon-io/hello` **Settings → Rules**, check the ruleset for `main`
23+ has **Require the merge queue**, and in Settings check **Merge
24+ automatically when ready** is on, so section 7's pull requests enter the
25+ queue by themselves. (`automation-lab` is set to wait for a person, so do
26+ not record there.)
27+- In the same settings, **Ask a person before merging low-confidence
28+ changes** is on for `hello`. Decide before recording: leave it on and
29+ say so if a low-confidence change waits for you in section 7, or turn it
30+ off for the take so every green, reviewed pull request lands by itself.
31+ Turn it back on afterwards.
2632 - Write the brief for section 4 in the scratch file too.
33+- Mark the inbox's error notifications read, and check the latest
34+ **Deploy** run on `flagon-io/g1t` is green, since section 9 ends there.
35+- Load every page the script visits once before recording. Cold loads take
36+ 1.4 to 2.7 seconds; warm ones 0.4 to 0.9.
2737 - Do not deploy the runner while agents work.
2838
2939 ## 1. The problem (30 seconds)
5666 > `pull_request` and `merge_group` events. Storage is Cloudflare Artifacts;
5767 > every job runs in its own Cloudflare Container.
5868
59−- Actions tab: the runs, by event. Open one and show the steps and the log.
69+- **Workflows** tab: the runs, by event. Open one and show the steps and the log.
6070
6171 ## 3. Many issues, an agent on each (1 minute 30 seconds)
6272
6373 Issues tab.
6474
6575 - Create three issues quickly, pasting them in:
76+ - **Add a --repeat-sep flag**: `--repeat-sep <s>` prints the greetings
77+ `--times` repeats on one line, separated by `<s>`, documented in the
78+ README options table.
6679 - **Add a --sparkle flag** that ends the greeting with a sparkle emoji.
67− - **Greet in German** with `--lang de`.
68− - **Explain in the README what happens with no name.**
80+ - **Greet in German** with `--lang de` (`Hallo, NAME!`). Today `--lang de`
81+ is refused with an error, and a test says so; that test changes too.
82+- All three change `src/lib.rs`, `src/main.rs` and `README.md`, so once
83+ the agents have pushed, each pull request shows the others under **Other
84+ work is changing the same files**.
6985 - Tick all three and press **Assign to g1t**. Say there is nothing
7086 else to choose: no number of agents, no model. Each issue gets an agent of
7187 its own and g1t routes the work; every session opens by naming the model
86102
87103 Issues tab, then **Outcomes**.
88104
89−- Paste a brief, such as: *The greeter should support `--shout`, which
90− upper-cases the greeting, documented in the README and covered by tests.*
91− Press **Plan it**. In about twenty seconds an agent has read the
105+- Paste a brief, such as: *The greeter should greet in Italian with
106+ `--lang it` (`Ciao, NAME!`) and in Portuguese with `--lang pt`
107+ (`Olá, NAME!`). Each language should be documented in the README and
108+ covered by tests.* (`hello` speaks `en`, `es` and `fr` today.) Press
109+ **Plan it**. In about twenty seconds an agent has read the
92110 repository and proposed issues: what each must make true, the files each
93111 will touch, and which has to land before which.
94112 - Press **Open these and assign g1t**. The first issue starts at once. Come
104122 Open issue **#80, CI: fail when a flag is missing from the README**, and its
105123 pull request **#81**.
106124
107−- An agent wrote this CI step. Changes tab: the shell step it added to
125+- An agent wrote this CI step. **Files changed**: the shell step it added to
108126 `ci.yml`. It went through review and the merge queue like any change.
109127
110128 Open issue **#84, Add a --reverse flag**, and its pull request **#85**.
126144 Open **Say goodbye too** (#4) and its pull request **Add a farewell** (#9).
127145
128146 - This one was pushed as a branch by a person, the way you already work.
129−- **Checks failed.** Expand `cargo test` and show the output. Changes tab:
130− the reviewer's comment sits on the faulty line. The agent's pull request
131− for the same issue, #10, passed and was merged; #9 was closed.
147+- **Checks failed.** A closed pull request keeps its checks: open
148+ `cargo test`'s **Details** and show the log. The agent's pull request for
149+ the same issue, #10, passed and was merged; #9 says "#10 was merged
150+ instead".
132151
133−Open **Greet in Spanish and French** (#2).
152+Open **Greet in Spanish and French** (#2), then either of its pull requests.
134153
135−- Two pull requests for one issue, side by side: checks, size of the change,
136− who reviewed. Open one and show **Other work is changing the same files**.
154+- **Other attempts at #2**: two agents' pull requests for one issue, side
155+ by side, each with its state, its checks, where its review stands and the
156+ size of the change. Reviews come from @g1t, the one name all of g1t's own
157+ work carries.
158+- Then, on one of section 3's pull requests: **Other work is changing the
159+ same files**, naming the other two and the files they share.
137160
138161 > This is the overlap radar. g1t says so while the work is still going on,
139162 > not at the end as a merge conflict. Agents see the same thing through the
140163 > API, which is how the agents in sections 3 and 4 were told about each other.
141164
142165 Open **A blank name greets nobody** (#1): closed, saying which pull request
143−resolved it; the other is marked superseded.
166+resolved it; the other says which one was merged instead.
144167
145168 Open **Add a --both flag** (#88) and its pull request **#89**, then **Rename
146169 hail() and part() to greet() and farewell()** (#86, pull request **#87**).
165188
166189 ## 7. The merge queue (1 minute 15 seconds)
167190
168−Back to the pull requests from section 3. Their checks have passed and a g1t
169−agent has reviewed them.
191+Back to the pull requests from section 3. Their checks have passed and
192+@g1t has reviewed them.
170193
171194 - With auto-merge on, they enter the **Merge queue** on their own. Open it.
172195
+17−6
223223
224224 pub(crate) async fn open_run(&self, a: OpenRunArgs) -> Result<Outcome<AgentRunTicket>> {
225225 // The runner is trusted: it names the repository it is starting a
226− // sandbox in, whoever the sandbox acts as.
227− let (repo_id, namespace) = match &a.pull_id {
226+ // sandbox in, whoever the sandbox acts as. The run records where
227+ // the repository is now, looked up by its id, not the path it was
228+ // named by, which may be from before a transfer or a rename.
229+ let (repo_id, path) = match &a.pull_id {
228230 Some(pull_id) => match self.pull_by_id(pull_id).await? {
229− Some(pull) => (pull.repo_id, a.repo.namespace.to_lowercase()),
231+ Some(pull) => {
232+ let now: Option<RepoPath> = g1t_kit::call(
233+ &self.repos,
234+ "path_by_id",
235+ &g1t_contracts::repos::PathByIdArgs { id: pull.repo_id.clone() },
236+ )
237+ .await?;
238+ (pull.repo_id, now.unwrap_or_else(|| a.repo.clone()))
239+ }
230240 None => return Ok(Outcome::fail(FailureCode::NotFound, "Pull request not found.")),
231241 },
232242 None => match self.repo(&a.repo, &member_of(&a.actor, &a.repo.namespace)).await? {
233− Outcome::Ok(repo) => (repo.id, repo.namespace.to_lowercase()),
243+ Outcome::Ok(repo) => (repo.id, RepoPath { namespace: repo.namespace, name: repo.name }),
234244 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
235245 },
236246 };
247+ let namespace = path.namespace.to_lowercase();
237248 // Nothing new starts on an archived or deleted repository.
238249 if !self.repo_active(&repo_id).await? {
239250 return Ok(Outcome::fail(
240251 FailureCode::Forbidden,
241− format!("{}/{} is archived or deleted, so nothing new starts on it.", a.repo.namespace, a.repo.name),
252+ format!("{}/{} is archived or deleted, so nothing new starts on it.", path.namespace, path.name),
242253 ));
243254 }
244255 let now = now_ms();
260271 id.as_str().into(),
261272 namespace.into(),
262273 repo_id.into(),
263− format!("{}/{}", a.repo.namespace, a.repo.name).into(),
274+ format!("{}/{}", path.namespace, path.name).into(),
264275 a.number.filter(|n| *n > 0).map_or(JsValue::NULL, JsValue::from),
265276 optional(&a.pull_id),
266277 optional(&a.title.map(|title| one_line(&title, 200))),