Skip to content
94 linesCodeBlameRaw
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 */
8import type { ChangedFile, Comment, CommitChecks, Overlap, Pull, PullStatus } from "@g1t/contracts";
9
10/** How many other attempts a pull request reads, newest first. */
11export const MAX_ATTEMPTS = 4;
12
13export 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. */
19export 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 */
44export 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. */
50export 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. */
59export 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 */
68export function attemptReview(
69 comments: (Pick<Comment, "author" | "verdict"> & Partial<Pick<Comment, "agent" | "advisory">>)[],
70 reviewers: string[],
71): AttemptReview {
72 const latest = new Map<string, NonNullable<Comment["verdict"]>>();
73 // An agent's review is advisory: it neither approves nor holds anything up.
74 for (const comment of comments) {
75 if (comment.verdict && !comment.agent && !comment.advisory) latest.set(comment.author.username, comment.verdict);
76 }
77 const by = (verdict: NonNullable<Comment["verdict"]>) =>
78 [...latest].filter(([, given]) => given === verdict).map(([reviewer]) => reviewer);
79 const blocking = by("request_changes");
80 if (blocking.length > 0) return { state: "changes_requested", text: `Changes requested by ${blocking.join(", ")}` };
81 const approving = by("approve");
82 if (approving.length > 0) return { state: "approved", text: `Approved by ${approving.join(", ")}` };
83 if (reviewers.length > 0) return { state: "requested", text: `Review requested from ${reviewers.join(", ")}` };
84 return { state: "none", text: "No review yet" };
85}
86
87/** What became of an attempt, in a few words. */
88export function attemptOutcome(attempt: Pick<Attempt, "status" | "supersededBy">): string {
89 if (attempt.status === "merged") return "Merged";
90 if (attempt.supersededBy != null) return `Closed · #${attempt.supersededBy} was merged instead`;
91 if (attempt.status === "closed") return "Closed without merging";
92 if (attempt.status === "draft") return "Draft · in progress";
93 return "Open";
94}