Skip to content
Source
Sign inSign up

thread-pr-presentation.ts

201 lines6.8 KB

1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import type {
3 PullRequestSummary,
4 ThreadPullRequestKey,
5 ThreadPullRequestLink,
6 VcsStatusResult,
7} from "@t3tools/contracts";
8import {
9 sourceControlClients,
10 type ChangeRequestTerminology,
11} from "@t3tools/client-runtime/source-control-clients";
12​
13import {
14 legacyThreadPullRequestKey,
15 resolveThreadCurrentPullRequestLink,
16 resolveThreadPullRequestBadge,
17 threadPullRequestKeyOf,
18} from "@t3tools/client-runtime/thread-pull-requests";
19​
20export type ThreadPr = NonNullable<VcsStatusResult["pr"]>;
21​
22export interface ThreadPrPresentation {
23 readonly number: number;
24 readonly state: ThreadPr["state"] | null;
25 readonly kind: "pull-request" | "stack";
26 readonly others: number;
27 readonly isDraft: boolean;
28 readonly updatedAt: string | null;
29 readonly url: string;
30 readonly changeRequest: ChangeRequestTerminology;
31 readonly label: string;
32 readonly accessibilityLabel: string;
33 readonly textClassName: string;
35​
36const PR_STATE_TEXT_CLASS: Record<ThreadPr["state"], string> = {
37 open: "text-adaptive-emerald-600-400",
38 merged: "text-adaptive-violet-600-400",
39 closed: "text-foreground-muted",
40};
41​
42export function presentThreadPr(
43 pr: ThreadPr,
44 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,
45): ThreadPrPresentation {
46 const { changeRequest } = sourceControlClients.get(provider?.kind);
47 const isDraft = pr.state === "open" && pr.isDraft === true;
48 return {
49 kind: "pull-request",
50 others: 0,
51 number: pr.number,
52 state: pr.state,
53 isDraft,
54 updatedAt: pr.updatedAt ?? null,
55 url: pr.url,
56 changeRequest,
57 label: String(pr.number),
58 accessibilityLabel: `#${pr.number} ${changeRequest.singular} ${isDraft ? "draft" : pr.state}`,
59 textClassName: isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[pr.state],
60 };
62​
63export function presentThreadLinkedPullRequests(
64 links: ReadonlyArray<ThreadPullRequestLink>,
65): ThreadPrPresentation | null {
66 const link = resolveThreadCurrentPullRequestLink(links);
67 const badge = resolveThreadPullRequestBadge(links);
68 if (link === null || badge === null) return null;
69 const { changeRequest } =
70 sourceControlClients.findByChangeRequestUrl(link.url) ?? sourceControlClients.get(undefined);
71 const snapshot = link.snapshot;
72 const linkedCount = badge.kind === "pull-request" && badge.others > 0 ? badge.others + 1 : null;
73 const isMultiple = badge.kind === "stack" || linkedCount !== null;
74 const state = isMultiple
75 ? badge.state === "draft"
76 ? "open"
77 : badge.state
78 : (snapshot?.state ?? null);
79 const isDraft = isMultiple
80 ? badge.state === "draft"
81 : snapshot?.isDraft === true && state === "open";
82 const label =
83 badge.kind === "stack"
84 ? String(badge.layers)
85 : linkedCount !== null
86 ? `+${linkedCount}`
87 : String(link.number);
88 return {
89 kind: badge.kind,
90 others: badge.kind === "pull-request" ? badge.others : 0,
91 number: link.number,
92 state,
93 isDraft,
94 updatedAt: snapshot?.updatedAt ?? null,
95 url: link.url,
96 changeRequest,
97 label,
98 accessibilityLabel:
99 badge.kind === "stack"
100 ? `${badge.layers} ${changeRequest.singular}s in stack, ${isDraft ? "draft" : (state ?? "status pending")}`
101 : linkedCount !== null
102 ? `${linkedCount} linked ${changeRequest.plural}, overall ${badge.state}`
103 : `#${link.number} ${changeRequest.singular} ${state === null ? "status pending" : isDraft ? "draft" : state}`,
104 textClassName:
105 state === null || isDraft
106 ? "text-foreground-muted"
107 : isMultiple && state === "closed"
108 ? "text-adaptive-rose-600-400"
109 : PR_STATE_TEXT_CLASS[state],
110 };
112​
113export function resolveThreadPrSource(
114 thread: Pick<EnvironmentThreadShell, "pullRequests" | "linkedPullRequest" | "branchPullRequest">,
115 /** False until the server config loads; the stored references serve until then. */
116 supportsSnapshots: boolean,
117) {
118 const linkedPresentation = supportsSnapshots
119 ? presentThreadLinkedPullRequests(thread.pullRequests)
120 : null;
121 const pullRequestRef =
122 linkedPresentation !== null
123 ? null
124 : ((supportsSnapshots
125 ? thread.branchPullRequest
126 : (thread.linkedPullRequest ?? thread.branchPullRequest)) ?? null);
127 return { linkedPresentation, pullRequestRef };
129​
130export function resolveThreadPrBadgeAction(pr: ThreadPrPresentation) {
131 return pr.kind === "stack" || pr.others > 0
132 ? { kind: "list" as const, accessibilityLabel: `Show ${pr.accessibilityLabel}` }
133 : { kind: "open" as const, accessibilityLabel: `Open ${pr.accessibilityLabel}` };
135​
136/** Records `value` as the newest entry, evicting the least recently answered beyond `limit`. */
137export function rememberBounded<V>(
138 current: ReadonlyMap<string, V>,
139 key: string,
140 value: V,
141 limit: number,
142): ReadonlyMap<string, V> {
143 const next = new Map(current);
144 next.delete(key);
145 next.set(key, value);
146 for (const oldest of next.keys()) {
147 if (next.size <= limit) break;
148 next.delete(oldest);
149 }
150 return next;
152​
153/** The newest summary held for `pullRequest`, matched strictly by host, repository and number. */
154export function knownPullRequestSummary(
155 pullRequest: ThreadPullRequestKey,
156 threads: Iterable<Pick<EnvironmentThreadShell, "projectId" | "pullRequests">>,
157 summaries: Iterable<PullRequestSummary>,
158): PullRequestSummary | undefined {
159 const key = threadPullRequestKeyOf(pullRequest);
160 let newest:
161 | { readonly summary: PullRequestSummary; readonly updatedAt: number; readonly readAt: number }
162 | undefined;
163 // Checks and reviews change without moving `updatedAt`, so equal versions prefer the later read.
164 const consider = (summary: PullRequestSummary, readAt = Number.NEGATIVE_INFINITY) => {
165 const updatedAt = Date.parse(summary.updatedAt);
166 if (
167 newest === undefined ||
168 updatedAt > newest.updatedAt ||
169 (updatedAt === newest.updatedAt && readAt > newest.readAt)
170 ) {
171 newest = { summary, updatedAt, readAt };
172 }
173 };
174 for (const summary of summaries) {
175 if (threadPullRequestKeyOf(legacyThreadPullRequestKey(summary)) === key) {
176 consider(summary, summary.observedAt);
177 }
178 }
179 for (const thread of threads) {
180 for (const link of thread.pullRequests) {
181 const snapshot = link.snapshot;
182 if (snapshot?.updatedAt == null || threadPullRequestKeyOf(link) !== key) continue;
183 consider(
184 {
185 ...snapshot,
186 updatedAt: snapshot.updatedAt,
187 provider: (
188 sourceControlClients.findByChangeRequestUrl(link.url) ??
189 sourceControlClients.get(undefined)
190 ).kind,
191 projectId: thread.projectId,
192 repository: link.repository,
193 number: link.number,
194 url: link.url,
195 },
196 Date.parse(snapshot.syncedAt),
197 );
198 }
199 }
200 return newest?.summary;