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