1import type {
2 PullRequestActivity,
3 PullRequestCheck,
4 PullRequestDetail,
5 PullRequestListEntry,
6} from "@t3tools/contracts";
7import type { AppSymbolName } from "../../components/AppSymbol";
8
9const STATES = {
10 open: {
11 label: "Open",
12 icon: "arrow.triangle.pull",
13 tint: "accent-adaptive-emerald-600-400",
14 text: "text-adaptive-emerald-600-400",
15 background: "bg-adaptive-emerald-500-a12-a16",
16 },
17 merged: {
18 label: "Merged",
19 icon: "arrow.triangle.merge",
20 tint: "accent-adaptive-violet-600-400",
21 text: "text-adaptive-violet-600-400",
22 background: "bg-adaptive-violet-500-a12-a16",
23 },
24 closed: {
25 label: "Closed",
26 icon: "git.pull-request.closed",
27 tint: "accent-danger-foreground",
28 text: "text-danger-foreground",
29 background: "bg-danger",
30 },
31 draft: {
32 label: "Draft",
33 icon: "git.pull-request.draft",
34 tint: "accent-icon-muted",
35 text: "text-foreground-muted",
36 background: "bg-grouped-card",
37 },
38} satisfies Record<
39 string,
40 { label: string; icon: AppSymbolName; tint: string; text: string; background: string }
41>;
42
43export function pullRequestStatus(pr: Pick<PullRequestListEntry, "state" | "isDraft">) {
44 return STATES[pr.isDraft && pr.state === "open" ? "draft" : pr.state];
45}
46
47export function checkStatus(check: PullRequestCheck) {
48 switch (check.status) {
49 case "success":
50 return {
51 label: "Passed",
52 icon: "checkmark.circle",
53 tint: "accent-adaptive-emerald-600-400",
54 } as const;
55 case "failure":
56 return {
57 label: "Failed",
58 icon: "exclamationmark.circle",
59 tint: "accent-danger-foreground",
60 } as const;
61 case "action-required":
62 return {
63 label: "Action required",
64 icon: "exclamationmark.circle",
65 tint: "accent-warning-foreground",
66 } as const;
67 case "pending":
68 return { label: "Pending", icon: "clock", tint: "accent-warning-foreground" } as const;
69 case "cancelled":
70 return { label: "Cancelled", icon: "minus", tint: "accent-icon-muted" } as const;
71 case "skipped":
72 return { label: "Skipped", icon: "minus", tint: "accent-icon-muted" } as const;
73 case "neutral":
74 return { label: "Neutral", icon: "minus", tint: "accent-icon-muted" } as const;
75 }
76}
77
78export function pullRequestChecksSummary(pr: Pick<PullRequestDetail, "checks">) {
79 const failed = pr.checks.filter((check) => check.status === "failure").length;
80 const required = pr.checks.filter((check) => check.status === "action-required").length;
81 const pending = pr.checks.filter((check) => check.status === "pending").length;
82 const passed = pr.checks.filter((check) => check.status === "success").length;
83 const count = pr.checks.length ? `${passed} of ${pr.checks.length}` : undefined;
84 if (failed)
85 return {
86 label: `${failed} ${failed === 1 ? "check" : "checks"} failing`,
87 title: `${failed} ${failed === 1 ? "check" : "checks"} failing`,
88 count,
89 icon: "exclamationmark.circle",
90 tint: "accent-danger-foreground",
91 } as const;
92 if (required)
93 return {
94 label: `${required} need attention`,
95 title: "Checks need attention",
96 count: `${required} awaiting action`,
97 icon: "exclamationmark.circle",
98 tint: "accent-warning-foreground",
99 } as const;
100 if (pending)
101 return {
102 label: `${pending} pending`,
103 title: "Checks running",
104 count: `${pending} pending`,
105 icon: "clock",
106 tint: "accent-warning-foreground",
107 } as const;
108 if (passed)
109 return {
110 label: `${passed} of ${pr.checks.length} passed`,
111 title: passed === pr.checks.length ? "All checks passed" : "Checks completed",
112 count,
113 icon: "checkmark.circle",
114 tint: "accent-adaptive-emerald-600-400",
115 } as const;
116 return {
117 label: pr.checks.length ? `${pr.checks.length} completed` : "No checks reported",
118 title: pr.checks.length ? "Checks completed" : "No checks reported",
119 count,
120 icon: "minus",
121 tint: "accent-icon-muted",
122 } as const;
123}
124
125/** A reviewer's latest verdict replaces their earlier approval or dismissal. */
126export function pullRequestReviewSummary(
127 pr: Pick<PullRequestDetail, "reviewers">,
128 activity: Pick<PullRequestActivity, "comments" | "commits"> | null,
129) {
130 const latest = new Map<string, PullRequestActivity["comments"][number]>();
131 const comments = new Map<string, PullRequestActivity["comments"][number]>();
132 for (const review of activity?.comments ?? []) {
133 if (review.kind !== "review" || !review.reviewState) continue;
134 const state = review.reviewState.toUpperCase().replaceAll("-", "_");
135 const key = review.author?.login ?? review.id;
136 if (state === "COMMENTED") {
137 const previous = comments.get(key);
138 if (!previous || Date.parse(review.createdAt) >= Date.parse(previous.createdAt))
139 comments.set(key, { ...review, reviewState: state });
140 continue;
141 }
142 if (!["APPROVED", "CHANGES_REQUESTED", "DISMISSED"].includes(state)) continue;
143 const previous = latest.get(key);
144 if (!previous || Date.parse(review.createdAt) >= Date.parse(previous.createdAt))
145 latest.set(key, { ...review, reviewState: state });
146 }
147 const reviews = [
148 ...[...latest.values()].filter((review) => review.reviewState !== "DISMISSED"),
149 ...[...comments.entries()].filter(([key]) => !latest.has(key)).map(([, review]) => review),
150 ];
151 const requested = reviews.filter((review) => review.reviewState === "CHANGES_REQUESTED");
152 const newestCommit = Math.max(
153 0,
154 ...(activity?.commits.map((commit) => Date.parse(commit.committedDate)) ?? []),
155 );
156 const approved = reviews.filter(
157 (review) => review.reviewState === "APPROVED" && Date.parse(review.createdAt) >= newestCommit,
158 );
159 if (requested.length)
160 return {
161 title: "Changes requested",
162 count: `${requested.length} ${requested.length === 1 ? "reviewer" : "reviewers"}`,
163 icon: "xmark.circle.fill",
164 tint: "accent-danger-foreground",
165 reviews,
166 } as const;
167 if (approved.length)
168 return {
169 title:
170 approved.length === 1 && approved[0]?.author
171 ? `Approved by ${approved[0].author.login}`
172 : "Reviews approved",
173 count: `${approved.length} approved`,
174 icon: "checkmark.circle",
175 tint: "accent-adaptive-emerald-600-400",
176 reviews,
177 } as const;
178 const commented = reviews.filter((review) => review.reviewState === "COMMENTED");
179 if (commented.length && !pr.reviewers.length)
180 return {
181 title: "Reviews",
182 count: `${commented.length} commented`,
183 icon: "bubble.left",
184 tint: "accent-icon-muted",
185 reviews,
186 } as const;
187 return {
188 title: pr.reviewers.length ? "Awaiting review" : "Request a review",
189 count: pr.reviewers.length ? `${pr.reviewers.length} requested` : "None assigned",
190 icon: "person.2",
191 tint: "accent-icon-muted",
192 reviews,
193 } as const;
194}
195
196export function pullRequestBranchSummary(
197 pr: Pick<PullRequestDetail, "mergeability" | "baseComparison" | "behindBy" | "baseBranch">,
198) {
199 if (pr.mergeability === "conflicting") return "Has conflicts";
200 if (pr.baseComparison === "behind") return `${pr.behindBy ?? ""} behind ${pr.baseBranch}`.trim();
201 if (pr.baseComparison === "up-to-date") return "Up to date";
202 return pr.mergeability === "mergeable" ? "No conflicts" : "Not reported";
203}