Skip to content

Commit

Demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed

- A closed or merged pull request shows its head's checks in the conversation, job by job with Details, read-only: nothing to re-run and nothing about holding a merge (ChecksSection `settled`). - Pull requests for the same issue are compared under "Other attempts at #N": this one and up to four others, each with its state, head checks, review standing, size and shared files. Streamed, read only when the issue has more than one pull request (lib/attempts.ts). - Merge queue: the empty state names the rule Require the merge queue under Settings -> Rules and links there; the Settings button goes to Settings -> Rules; an entry's checks link to its merge_group run(s). - The overview's Clone box wraps the address after a `/` instead of scrolling it out of sight (CopyLine `breakAtSlashes`). - A project's Activity asks the event log only for the kinds the feed shows. The newest 150 events were mostly session steps and merge checks, which eventItem drops, so a busy repository showed "Nothing has happened here yet". The feed also shows pushes to the default branch (not those that only landed a pull request) and production deploys from deployment_status.created, wherever they ran. Mission control asks for the same kinds and takes its deploy lines from the log too. - open_run records the repository's current path, looked up by id, rather than the path the runner named, so new runs never carry an old owner. - The Outcomes example brief no longer suggests --shout. - Docs: pull requests (settled checks, other attempts), merge queue, outcomes, projects, concepts overview.

syntaqxcommitted Parent82db908Browse files
20 files+631−620/20 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>
+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))),