Skip to content
88 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(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. */
82export 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}