Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md
21 files+674−820/21 viewed
| 205 | 205 | ||
| 206 | 206 | Merging one pull request at a time keeps every merge clean as text, but two | |
| 207 | 207 | 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 | |
| 209 | 210 | together with the ones ahead of it, and `main` only moves to a state whose | |
| 210 | 211 | required checks passed. | |
| 211 | 212 | See [merge queue](/guides/merge-queue/). |
| 164 | 164 | | --- | --- | | |
| 165 | 165 | | State | **Waiting**, **Testing** or **Passed**. | | |
| 166 | 166 | | 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). | | |
| 168 | 168 | | Who | The agent or person who made the pull request, and who queued it. | | |
| 169 | 169 | | Commit | The tested state's commit. | | |
| 170 | 170 |
| 27 | 27 | 3. Choose **Plan it**. | |
| 28 | 28 | ||
| 29 | 29 | ```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. | |
| 33 | 33 | ``` | |
| 34 | 34 | ||
| 35 | 35 | An agent reads the repository in a sandbox and writes the plan. This takes | |
| ⋯ | |||
| 145 | 145 | curl -X POST https://api.g1t.sh/repos/acme/greeter/plans \ | |
| 146 | 146 | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 147 | 147 | -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."}' | |
| 149 | 149 | ||
| 150 | 150 | # 2. Read it until status is "ready". | |
| 151 | 151 | curl https://api.g1t.sh/repos/acme/greeter/plans/pln_01… \ | |
| 61 | 61 | | **Right now** | Agents at work, and open pull requests moving from working to landed. | | |
| 62 | 62 | | **Needs you** | What is waiting on a person: a failed production build, a pull request to merge or review, a stuck run. | | |
| 63 | 63 | | **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. | | |
| 65 | 65 | | **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. | | |
| 66 | 66 | | **Health**, **Dependencies**, **Clone** | How often checks pass, recent builds and open issues by age; what it uses and what uses it; the clone address. | | |
| 67 | 67 |
| 46 | 46 | A workflow job's **Details** opens its log on the run's page, where every | |
| 47 | 47 | step's output is kept. | |
| 48 | 48 | ||
| 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 | + | ||
| 49 | 65 | ### Running them again | |
| 50 | 66 | ||
| 51 | 67 | People with the Write [role](/guides/access-and-roles/) or higher can press |
| 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 | + | } |
| 321 | 321 | settingsUrl, | |
| 322 | 322 | error, | |
| 323 | 323 | loading = false, | |
| 324 | + | settled = false, | |
| 324 | 325 | }: { | |
| 325 | 326 | /** Set when the merge queue took it out, or for a run from before checks were workflows. */ | |
| 326 | 327 | run: CheckRun | null; | |
| ⋯ | |||
| 338 | 339 | error?: string | null; | |
| 339 | 340 | /** The workflow runs' jobs are still being read: their rows wait, busy. */ | |
| 340 | 341 | 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; | |
| 341 | 347 | }) { | |
| 342 | 348 | if (!run && statuses.length === 0 && required.length === 0) return null; | |
| 343 | 349 | ||
| ⋯ | |||
| 374 | 380 | .join(", "); | |
| 375 | 381 | const sub = summary | |
| 376 | 382 | ? [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(" · "); | |
| 378 | 386 | const standing: Standing = summary | |
| 379 | 387 | ? summary.standing | |
| 380 | 388 | : failed > 0 | |
| ⋯ | |||
| 403 | 411 | <div className="min-w-0 grow"> | |
| 404 | 412 | <p className="text-sm font-medium">{headline}</p> | |
| 405 | 413 | {sub && <p className="mt-0.5 text-xs text-muted">{sub}</p>} | |
| 406 | − | {!summary && settingsUrl && ( | |
| 414 | + | {!summary && !settled && settingsUrl && ( | |
| 407 | 415 | <p className="mt-0.5 text-xs text-muted"> | |
| 408 | 416 | <Link to={settingsUrl} className="text-fg hover:underline"> | |
| 409 | 417 | Choose required checks | |
| 576 | 576 | asked: "was asked about", | |
| 577 | 577 | deployed: "deployed production", | |
| 578 | 578 | deploy_failed: "production build failed", | |
| 579 | + | pushed: "pushed to the default branch", | |
| 579 | 580 | learned: "learned", | |
| 580 | 581 | }; | |
| 581 | 582 |
| 12 | 12 | CircleSlash, | |
| 13 | 13 | CreditCard, | |
| 14 | 14 | Eye, | |
| 15 | + | GitCommitHorizontal, | |
| 15 | 16 | GitMerge, | |
| 16 | 17 | GitPullRequest, | |
| 17 | 18 | Hand, | |
| ⋯ | |||
| 182 | 183 | asked: { icon: <MessageCircleQuestion size={14} />, tone: "text-accent", text: (n) => `asked the agent on ${n}` }, | |
| 183 | 184 | deployed: { icon: <Rocket size={14} />, tone: "text-success", text: () => "deployed production" }, | |
| 184 | 185 | 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" }, | |
| 185 | 187 | learned: { icon: <Brain size={14} />, tone: "text-accent", text: () => "learned" }, | |
| 186 | 188 | }; | |
| 187 | 189 | ||
| 1 | 1 | 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"; | |
| 3 | 3 | import { Link, type LinkProps, NavLink, useLocation, useNavigation } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { isWaitingMessage, linkPaths } from "../../lib/compute"; | |
| ⋯ | |||
| 358 | 358 | text, | |
| 359 | 359 | prompt, | |
| 360 | 360 | disabled, | |
| 361 | + | breakAtSlashes, | |
| 361 | 362 | }: { | |
| 362 | 363 | text: string; | |
| 363 | 364 | prompt?: boolean; | |
| 364 | 365 | /** Shown, so it is clear what will be there, but not yet usable: dimmed, with no copy. */ | |
| 365 | 366 | 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; | |
| 366 | 372 | }) { | |
| 367 | 373 | const [copied, setCopied] = useState(false); | |
| 368 | 374 | return ( | |
| ⋯ | |||
| 378 | 384 | {prompt && <span className="mr-2 text-faint select-none">$</span>} | |
| 379 | 385 | {text.split(/(\s+)/).map((part, index) => | |
| 380 | 386 | /\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 | + | ) | |
| 384 | 402 | ) : ( | |
| 385 | 403 | part | |
| 386 | 404 | ), | |
| 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 | + | }); |
| 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 | + | } |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | + | import type { G1tEvent } from "@g1t/contracts"; | |
| 5 | + | ||
| 4 | 6 | import { | |
| 5 | 7 | type ActivityItem, | |
| 6 | 8 | type Need, | |
| 7 | 9 | DELETED_USER, | |
| 10 | + | FEED_EVENT_TYPES, | |
| 11 | + | PROJECT_FEED_EVENT_TYPES, | |
| 12 | + | eventItem, | |
| 13 | + | projectFeed, | |
| 14 | + | pushItem, | |
| 8 | 15 | actorIds, | |
| 9 | 16 | ageBuckets, | |
| 10 | 17 | agentHours, | |
| ⋯ | |||
| 258 | 265 | assert.equal(waitedFor(24 * 60), "1 d"); | |
| 259 | 266 | assert.equal(waitedFor(3 * 24 * 60 - 1), "2 d"); | |
| 260 | 267 | }); | |
| 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 | + | }); | |
| 104 | 104 | | "asked" | |
| 105 | 105 | | "deployed" | |
| 106 | 106 | | "deploy_failed" | |
| 107 | + | | "pushed" | |
| 107 | 108 | | "learned"; | |
| 108 | 109 | ||
| 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 | + | ||
| 109 | 132 | /** One thing that moved, as the feed shows it. */ | |
| 110 | 133 | export type ActivityItem = { | |
| 111 | 134 | id: string; | |
| ⋯ | |||
| 148 | 171 | return { ...base, verb: "commented", number: event.data.number }; | |
| 149 | 172 | case "agent.asked": | |
| 150 | 173 | 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 | + | } | |
| 151 | 188 | default: | |
| 152 | 189 | return null; | |
| 153 | 190 | } | |
| 154 | 191 | } | |
| 155 | 192 | ||
| 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 | + | ||
| 156 | 220 | /** Accounts that act for g1t itself, named in the log by fixed ids. */ | |
| 157 | 221 | const G1T_ACTORS: Record<string, string> = { usr_g1t_agent: "g1t", g1t_policy: "g1t" }; | |
| 158 | 222 | /** Who an account the lookup no longer knows was. */ | |
| 26 | 26 | agentHours, | |
| 27 | 27 | dailyBuckets, | |
| 28 | 28 | eventItem, | |
| 29 | + | FEED_EVENT_TYPES, | |
| 29 | 30 | greetingFor, | |
| 30 | 31 | groupActivity, | |
| 31 | 32 | hourIn, | |
| ⋯ | |||
| 233 | 234 | .slice(0, MAX_PROJECTS); | |
| 234 | 235 | const [batch, logs, pushes] = await Promise.all([ | |
| 235 | 236 | 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))), | |
| 237 | 238 | // People's pushes straight to the default branch, which no pull | |
| 238 | 239 | // request counts: the commits each brought, for the week. | |
| 239 | 240 | Promise.all(chosen.map((repo) => directCommits(repo, viewer).catch(() => []))), | |
| ⋯ | |||
| 599 | 600 | if (item) items.push(item); | |
| 600 | 601 | } | |
| 601 | 602 | } | |
| 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. | |
| 616 | 605 | for (const memory of okOr(memories ?? null)?.workspace ?? []) { | |
| 617 | 606 | const repo = memory.repo ?? memory.source.repo; | |
| 618 | 607 | if (!repo || !slug) continue; | |
| 64 | 64 | TIME, | |
| 65 | 65 | actorIds, | |
| 66 | 66 | ageBuckets, | |
| 67 | − | eventItem, | |
| 68 | 67 | firstPassRate, | |
| 69 | 68 | groupActivity, | |
| 70 | 69 | nameActor, | |
| 71 | 70 | passRate, | |
| 72 | 71 | pipelineStage, | |
| 72 | + | PROJECT_FEED_EVENT_TYPES, | |
| 73 | + | projectFeed, | |
| 73 | 74 | queuedNumbers, | |
| 74 | 75 | rankNeeds, | |
| 75 | 76 | stuckMinutes, | |
| ⋯ | |||
| 159 | 160 | const libraryRepoP = Promise.all([planP, repoP]).then(([plan, repo]) => (plan === "release" && repo?.ok ? repo.value : null)); | |
| 160 | 161 | const packagesP = libraryRepoP.then((repo) => (repo ? soft(packages.list(params.owner, viewer, { repoId: repo.id })) : null)); | |
| 161 | 162 | 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 | + | ); | |
| 163 | 168 | const minePullsP = repoP.then((repo) => | |
| 164 | 169 | repo?.ok && viewer | |
| 165 | 170 | ? soft(work.listActivePulls(viewer)).then((list) => (list ?? []).filter((item) => item.pull.repoId === repo.value.id)) | |
| ⋯ | |||
| 337 | 342 | ||
| 338 | 343 | // --- Activity and health ------------------------------------------------------- | |
| 339 | 344 | 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); | |
| 343 | 346 | // The log names people by account id: their usernames, in one lookup. | |
| 344 | 347 | const ids = actorIds(logged.map((item) => item.actor)); | |
| 345 | 348 | const names = ids.length > 0 ? await soft(identity.usernames(ids)) : {}; | |
| ⋯ | |||
| 1496 | 1499 | <section className="rounded-xl border border-line bg-surface p-5"> | |
| 1497 | 1500 | <h2 className="text-sm font-semibold">Clone</h2> | |
| 1498 | 1501 | <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}`)}`} /> | |
| 1500 | 1503 | </div> | |
| 1501 | 1504 | </section> | |
| 1502 | 1505 | )} | |
| 76 | 76 | rows={7} | |
| 77 | 77 | required | |
| 78 | 78 | 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." | |
| 80 | 80 | } | |
| 81 | 81 | /> | |
| 82 | 82 | <div className="flex flex-wrap items-center gap-3"> |
| 30 | 30 | ||
| 31 | 31 | import { | |
| 32 | 32 | type Capability, | |
| 33 | + | type Comment, | |
| 33 | 34 | type Comparison, | |
| 34 | 35 | type Deployment, | |
| 35 | 36 | type Job, | |
| 36 | 37 | type LiveApp, | |
| 38 | + | type Pull, | |
| 39 | + | type RepoPath, | |
| 37 | 40 | type SessionEntry, | |
| 38 | 41 | type Viewer, | |
| 39 | 42 | pullComparison, | |
| ⋯ | |||
| 69 | 72 | import { TabStrip } from "../../components/ui/tab-strip"; | |
| 70 | 73 | import { WorkflowStatuses } from "../../components/actions"; | |
| 71 | 74 | import { AddCiPrompt } from "../../components/add-ci"; | |
| 75 | + | import { AttemptsBox } from "../../components/attempts"; | |
| 76 | + | import { type Attempt, attemptReview, otherAttempts, sharedPaths, splitOverlaps } from "../../lib/attempts"; | |
| 72 | 77 | import { | |
| 73 | 78 | CommentForm, | |
| 74 | 79 | CommentList, | |
| ⋯ | |||
| 176 | 181 | // The jobs of each workflow run on its head, to list checks job by job. | |
| 177 | 182 | // Read as the viewer: a run they cannot see is listed by its status alone. | |
| 178 | 183 | // 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. | |
| 179 | 185 | const runIds = [...new Set((found.value.statuses ?? []).map(runIdOf).filter((id) => id != null))].slice(0, 10); | |
| 180 | 186 | const workflowJobs = | |
| 181 | − | tab === "conversation" && pull.status === "open" && runIds.length > 0 | |
| 187 | + | tab === "conversation" && pull.status !== "draft" && runIds.length > 0 | |
| 182 | 188 | ? Promise.all(runIds.map((id) => actions.run(path, viewer, id).catch(() => null))).then((runs) => { | |
| 183 | 189 | const jobs: Record<string, Job[]> = {}; | |
| 184 | 190 | for (const run of runs) if (run?.ok) jobs[run.value.run.id] = run.value.jobs; | |
| ⋯ | |||
| 209 | 215 | : false, | |
| 210 | 216 | ]); | |
| 211 | 217 | 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; | |
| 212 | 224 | const [labels, milestones] = await Promise.all([labelsFound, milestonesFound]); | |
| 213 | 225 | const repoDefault = repo.ok ? repo.value.defaultBranch : "main"; | |
| 214 | 226 | // The default branch's protection covers pull requests into it only. | |
| ⋯ | |||
| 226 | 238 | // The branch it merges into: Write and up. | |
| 227 | 239 | canChangeBase: can.push, | |
| 228 | 240 | workflowJobs, | |
| 241 | + | attempts, | |
| 229 | 242 | tab, | |
| 230 | 243 | session: session?.ok ? session.value : [], | |
| 231 | 244 | // An empty comparison if it could not be made. | |
| ⋯ | |||
| 275 | 288 | } | |
| 276 | 289 | ||
| 277 | 290 | /** | |
| 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 | + | /** | |
| 278 | 328 | * Where this pull request is live: its preview (or its latest build, while | |
| 279 | 329 | * one is going or after one failed), and the previews of the projects | |
| 280 | 330 | * that use this one, built against it. | |
| ⋯ | |||
| 612 | 662 | conflicts = [], | |
| 613 | 663 | earlierChecks = [], | |
| 614 | 664 | workflowJobs, | |
| 665 | + | attempts, | |
| 615 | 666 | affects, | |
| 616 | 667 | preview, | |
| 617 | 668 | build, | |
| ⋯ | |||
| 660 | 711 | loading={loading} | |
| 661 | 712 | /> | |
| 662 | 713 | ); | |
| 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 | + | ); | |
| 663 | 730 | const addresses = useAddresses(); | |
| 664 | 731 | const remote = pull.fork | |
| 665 | 732 | ? cloneUrl(addresses, `${pull.fork.namespace}/${pull.fork.name}`) | |
| ⋯ | |||
| 675 | 742 | const unchecked = requiredChecks.some((check) => check.state !== "success") || checks?.status === "failed"; | |
| 676 | 743 | const requiredFailed = requiredChecks.some((check) => check.state === "failure") || checks?.status === "failed"; | |
| 677 | 744 | // 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); | |
| 680 | 748 | const review = { | |
| 681 | 749 | changesUrl: here + "?tab=changes", | |
| 682 | 750 | // Nobody reviews their own pull request, nor one g1t made for them. | |
| ⋯ | |||
| 962 | 1030 | ))} | |
| 963 | 1031 | </ul> | |
| 964 | 1032 | )} | |
| 1033 | + | {attempts && pull.issue != null && <AttemptsBox attempts={attempts} issue={pull.issue} base={base} />} | |
| 965 | 1034 | {collisions.length > 0 && active && ( | |
| 966 | 1035 | <div className="mt-4 rounded-xl border border-line bg-surface px-4 py-3 text-sm"> | |
| 967 | 1036 | <p className="flex items-center gap-2.5 font-medium"> | |
| ⋯ | |||
| 1106 | 1175 | </div> | |
| 1107 | 1176 | )} | |
| 1108 | 1177 | ||
| 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 | + | ||
| 1109 | 1186 | {pull.status === "open" && ( | |
| 1110 | 1187 | <StatusBox> | |
| 1111 | 1188 | {/* Checks by status at once; job by job when the runs are read. */} | |
| ⋯ | |||
| 1381 | 1458 | </div> | |
| 1382 | 1459 | ||
| 1383 | 1460 | <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} />} | |
| 1386 | 1463 | {affects.length > 0 && ( | |
| 1387 | 1464 | <section> | |
| 1388 | 1465 | <h3 className="flex items-center gap-1.5 text-sm font-medium"> | |
| 8 | 8 | Minus, | |
| 9 | 9 | X, | |
| 10 | 10 | } from "lucide-react"; | |
| 11 | − | import { Link } from "react-router"; | |
| 11 | + | import { Suspense } from "react"; | |
| 12 | + | import { Await, Link } from "react-router"; | |
| 12 | 13 | ||
| 13 | − | import type { QueueEntry, QueueState } from "@g1t/contracts"; | |
| 14 | + | import type { QueueEntry, QueueState, RepoPath, Viewer } from "@g1t/contracts"; | |
| 14 | 15 | ||
| 15 | 16 | import type { Route } from "./+types/queue"; | |
| 16 | 17 | import { page } from "../../lib/meta"; | |
| 17 | 18 | import { Avatar, ButtonLink, EmptyState, TimeAgo } from "../../components/ui"; | |
| 18 | − | import { work } from "../../lib/services.server"; | |
| 19 | + | import { actions, work } from "../../lib/services.server"; | |
| 19 | 20 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 20 | 21 | import { accessFor, repoFor } from "../../lib/access.server"; | |
| 21 | 22 | import { useRefreshWhile } from "../../lib/refresh"; | |
| 22 | 23 | ||
| 23 | 24 | ||
| 25 | + | /** A merge_group workflow run on a combined state. */ | |
| 26 | + | type GroupRun = { id: string; name: string }; | |
| 27 | + | ||
| 24 | 28 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 25 | 29 | return page(args, { title: `Merge queue · ${params.owner}/${params.repo} · g1t` }); | |
| 26 | 30 | } | |
| ⋯ | |||
| 29 | 33 | const viewer = getViewer(context); | |
| 30 | 34 | const path = { namespace: params.owner, name: params.repo }; | |
| 31 | 35 | const [queue, repo] = await Promise.all([work.queue(path, viewer), repoFor(context, params)]); | |
| 36 | + | const view = unwrap(queue); | |
| 32 | 37 | 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]), | |
| 34 | 42 | defaultBranch: unwrap(repo).defaultBranch, | |
| 35 | 43 | // Turning the queue on is a setting: Maintain and up. | |
| 36 | 44 | member: repo.ok && accessFor(viewer, repo.value).can.manage_settings, | |
| 37 | 45 | }; | |
| 38 | 46 | } | |
| 39 | 47 | ||
| 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 | + | ||
| 40 | 61 | const STATE: Record<QueueState, { label: string; tone: string; icon: React.ReactNode }> = { | |
| 41 | 62 | waiting: { label: "Waiting", tone: "text-muted border-line", icon: <CircleDashed size={13} /> }, | |
| 42 | 63 | testing: { | |
| ⋯ | |||
| 81 | 102 | ); | |
| 82 | 103 | } | |
| 83 | 104 | ||
| 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[] }) { | |
| 85 | 110 | const ran = entry.results.length; | |
| 86 | 111 | 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; | |
| 87 | 116 | 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 ( | |
| 88 | 150 | <li className="relative pl-10"> | |
| 89 | 151 | {/* Its place on the rail. */} | |
| 90 | 152 | <span | |
| ⋯ | |||
| 110 | 172 | {(entry.state === "testing" || entry.state === "passed" || entry.state === "failed") && ( | |
| 111 | 173 | <TestedAs entry={entry} branch={branch} /> | |
| 112 | 174 | )} | |
| 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> | |
| 118 | 180 | <span className="flex items-center gap-1.5"> | |
| 119 | 181 | <Avatar name={entry.agent} size={14} /> | |
| 120 | 182 | {entry.agent} | |
| ⋯ | |||
| 146 | 208 | } | |
| 147 | 209 | ||
| 148 | 210 | export default function Queue({ loaderData, params }: Route.ComponentProps) { | |
| 149 | − | const { queue, defaultBranch, member } = loaderData; | |
| 211 | + | const { queue, defaultBranch, member, groupRuns } = loaderData; | |
| 150 | 212 | const base = `/${params.owner}/${params.repo}`; | |
| 151 | 213 | const moving = queue.active.length > 0; | |
| 152 | 214 | useRefreshWhile(moving); | |
| ⋯ | |||
| 168 | 230 | </p> | |
| 169 | 231 | </div> | |
| 170 | 232 | {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> | |
| 172 | 234 | )} | |
| 173 | 235 | </div> | |
| 174 | 236 | ||
| 175 | 237 | {!queue.enabled && queue.active.length === 0 && queue.recent.length === 0 ? ( | |
| 176 | 238 | <div className="mt-8"> | |
| 177 | 239 | <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. | |
| 180 | 249 | </EmptyState> | |
| 181 | 250 | </div> | |
| 182 | 251 | ) : ( | |
| ⋯ | |||
| 199 | 268 | )} | |
| 200 | 269 | </li> | |
| 201 | 270 | {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} /> | |
| 203 | 272 | ))} | |
| 204 | 273 | {queue.active.length === 0 && ( | |
| 205 | 274 | <li className="pl-10 text-sm text-muted">Nothing is waiting. Merged pull requests appear here.</li> | |
| ⋯ | |||
| 212 | 281 | <h3 className="text-sm font-medium">Recently</h3> | |
| 213 | 282 | <ol className="mt-3 space-y-3"> | |
| 214 | 283 | {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} /> | |
| 216 | 285 | ))} | |
| 217 | 286 | </ol> | |
| 218 | 287 | </section> | |
| 19 | 19 | - Write the three issues for section 3 in a scratch file so they can be | |
| 20 | 20 | pasted (titles below). Agents take one to three minutes each: start them, | |
| 21 | 21 | 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. | |
| 26 | 32 | - 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. | |
| 27 | 37 | - Do not deploy the runner while agents work. | |
| 28 | 38 | ||
| 29 | 39 | ## 1. The problem (30 seconds) | |
| ⋯ | |||
| 56 | 66 | > `pull_request` and `merge_group` events. Storage is Cloudflare Artifacts; | |
| 57 | 67 | > every job runs in its own Cloudflare Container. | |
| 58 | 68 | ||
| 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. | |
| 60 | 70 | ||
| 61 | 71 | ## 3. Many issues, an agent on each (1 minute 30 seconds) | |
| 62 | 72 | ||
| 63 | 73 | Issues tab. | |
| 64 | 74 | ||
| 65 | 75 | - 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. | |
| 66 | 79 | - **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**. | |
| 69 | 85 | - Tick all three and press **Assign to g1t**. Say there is nothing | |
| 70 | 86 | else to choose: no number of agents, no model. Each issue gets an agent of | |
| 71 | 87 | its own and g1t routes the work; every session opens by naming the model | |
| ⋯ | |||
| 86 | 102 | ||
| 87 | 103 | Issues tab, then **Outcomes**. | |
| 88 | 104 | ||
| 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 | |
| 92 | 110 | repository and proposed issues: what each must make true, the files each | |
| 93 | 111 | will touch, and which has to land before which. | |
| 94 | 112 | - Press **Open these and assign g1t**. The first issue starts at once. Come | |
| ⋯ | |||
| 104 | 122 | Open issue **#80, CI: fail when a flag is missing from the README**, and its | |
| 105 | 123 | pull request **#81**. | |
| 106 | 124 | ||
| 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 | |
| 108 | 126 | `ci.yml`. It went through review and the merge queue like any change. | |
| 109 | 127 | ||
| 110 | 128 | Open issue **#84, Add a --reverse flag**, and its pull request **#85**. | |
| ⋯ | |||
| 126 | 144 | Open **Say goodbye too** (#4) and its pull request **Add a farewell** (#9). | |
| 127 | 145 | ||
| 128 | 146 | - 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". | |
| 132 | 151 | ||
| 133 | − | Open **Greet in Spanish and French** (#2). | |
| 152 | + | Open **Greet in Spanish and French** (#2), then either of its pull requests. | |
| 134 | 153 | ||
| 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. | |
| 137 | 160 | ||
| 138 | 161 | > This is the overlap radar. g1t says so while the work is still going on, | |
| 139 | 162 | > not at the end as a merge conflict. Agents see the same thing through the | |
| 140 | 163 | > API, which is how the agents in sections 3 and 4 were told about each other. | |
| 141 | 164 | ||
| 142 | 165 | 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. | |
| 144 | 167 | ||
| 145 | 168 | Open **Add a --both flag** (#88) and its pull request **#89**, then **Rename | |
| 146 | 169 | hail() and part() to greet() and farewell()** (#86, pull request **#87**). | |
| ⋯ | |||
| 165 | 188 | ||
| 166 | 189 | ## 7. The merge queue (1 minute 15 seconds) | |
| 167 | 190 | ||
| 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. | |
| 170 | 193 | ||
| 171 | 194 | - With auto-merge on, they enter the **Merge queue** on their own. Open it. | |
| 172 | 195 | ||
| 223 | 223 | ||
| 224 | 224 | pub(crate) async fn open_run(&self, a: OpenRunArgs) -> Result<Outcome<AgentRunTicket>> { | |
| 225 | 225 | // 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 { | |
| 228 | 230 | 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 | + | } | |
| 230 | 240 | None => return Ok(Outcome::fail(FailureCode::NotFound, "Pull request not found.")), | |
| 231 | 241 | }, | |
| 232 | 242 | 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 }), | |
| 234 | 244 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 235 | 245 | }, | |
| 236 | 246 | }; | |
| 247 | + | let namespace = path.namespace.to_lowercase(); | |
| 237 | 248 | // Nothing new starts on an archived or deleted repository. | |
| 238 | 249 | if !self.repo_active(&repo_id).await? { | |
| 239 | 250 | return Ok(Outcome::fail( | |
| 240 | 251 | 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), | |
| 242 | 253 | )); | |
| 243 | 254 | } | |
| 244 | 255 | let now = now_ms(); | |
| ⋯ | |||
| 260 | 271 | id.as_str().into(), | |
| 261 | 272 | namespace.into(), | |
| 262 | 273 | repo_id.into(), | |
| 263 | − | format!("{}/{}", a.repo.namespace, a.repo.name).into(), | |
| 274 | + | format!("{}/{}", path.namespace, path.name).into(), | |
| 264 | 275 | a.number.filter(|n| *n > 0).map_or(JsValue::NULL, JsValue::from), | |
| 265 | 276 | optional(&a.pull_id), | |
| 266 | 277 | optional(&a.title.map(|title| one_line(&title, 200))), | |