1import { md5, sha1 } from "@noble/hashes/legacy";
2import { sha256 } from "@noble/hashes/sha2";
3import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils";
4
5export interface PullRequestAnchorRange {
6 readonly start: number;
7 readonly side: "left" | "right";
8 readonly end: number;
9 readonly endSide: "left" | "right";
10}
11
12export type PullRequestAnchor =
13 | { readonly kind: "code" }
14 | { readonly kind: "commits" }
15 | { readonly kind: "checks" }
16 | {
17 readonly kind: "diff";
18 readonly digest: string;
19 readonly range: PullRequestAnchorRange | null;
20 }
21 | { readonly kind: "timeline"; readonly anchor: string }
22 | { readonly kind: "summary"; readonly anchor: string };
23
24function pullRequestUrlHash(url: string | null | undefined): string {
25 if (!url) return "";
26 try {
27 return decodeURIComponent(new URL(url).hash.slice(1));
28 } catch {
29 return "";
30 }
31}
32
33function normalizePullRequestCommentAnchor(anchor: string): string {
34 return anchor.replace(
35 /^(?:r|discussion-diff-)(\d+)(?:[LR]\d+(?:-[LR]\d+)?)?$/i,
36 "discussion_r$1",
37 );
38}
39
40// GitHub pull request tabs and their GitLab merge request equivalents.
41function pullRequestPathTab(pathname: string): PullRequestAnchor | null {
42 const tab = /\/(?:pull|-\/merge_requests)\/\d+\/([a-z]+)(?:\/(.*))?$/i.exec(pathname);
43 const rest = tab?.[2] ?? "";
44 switch (tab?.[1]?.toLowerCase()) {
45 case "files":
46 case "changes":
47 case "diffs":
48 return { kind: "code" };
49 case "commits":
50 if (rest === "") return { kind: "commits" };
51 return /^[a-f0-9]{40}\/?$/i.test(rest) ? { kind: "code" } : null;
52 case "checks":
53 case "pipelines":
54 return rest === "" ? { kind: "checks" } : null;
55 default:
56 return null;
57 }
58}
59
60export function parsePullRequestAnchor(url: string | null | undefined): PullRequestAnchor | null {
61 let parsed: URL;
62 try {
63 parsed = new URL(url ?? "");
64 } catch {
65 return null;
66 }
67 const fallback = pullRequestPathTab(parsed.pathname);
68 const anchor = pullRequestUrlHash(url);
69 if (!anchor) return fallback;
70 // GitHub line ranges may cross diff sides; file digests were MD5 before SHA-256.
71 const diff = /^diff-([a-f0-9]+)(?:([LR])(\d+)(?:-([LR])(\d+))?)?$/i.exec(anchor);
72 if (diff) {
73 const start = Number(diff[3]);
74 const end = Number(diff[5] ?? diff[3]);
75 if (
76 diff[2] &&
77 (!Number.isSafeInteger(start) || start < 1 || !Number.isSafeInteger(end) || end < 1)
78 )
79 return fallback;
80 return {
81 kind: "diff",
82 digest: (diff[1] ?? "").toLowerCase(),
83 range: diff[2]
84 ? {
85 start,
86 side: diff[2].toUpperCase() === "L" ? "left" : "right",
87 end,
88 endSide: (diff[4] ?? diff[2]).toUpperCase() === "L" ? "left" : "right",
89 }
90 : null,
91 };
92 }
93 // GitLab: SHA-1 of the path, then old and new line counters. A removed line still carries a
94 // new counter, so the new side wins unless the file has none (a 0 for a deleted file).
95 const gitlab = /^([a-f0-9]{40})(?:_(\d+)_(\d+))?$/i.exec(anchor);
96 if (gitlab) {
97 const digest = (gitlab[1] ?? "").toLowerCase();
98 if (!gitlab[2]) return { kind: "diff", digest, range: null };
99 const newLine = Number(gitlab[3]);
100 const oldLine = Number(gitlab[2]);
101 const side = newLine >= 1 ? "right" : "left";
102 const line = side === "right" ? newLine : oldLine;
103 if (!Number.isSafeInteger(line) || line < 1) return fallback;
104 return { kind: "diff", digest, range: { start: line, side, end: line, endSide: side } };
105 }
106 if (
107 /^(?:(?:issuecomment-|pullrequestreview-|discussion_r|r|commitcomment-|event-|note_|comment-)\d+|discussion-diff-\d+(?:[LR]\d+(?:-[LR]\d+)?)?)$/i.test(
108 anchor,
109 )
110 ) {
111 return { kind: "timeline", anchor: normalizePullRequestCommentAnchor(anchor) };
112 }
113 return fallback ?? { kind: "summary", anchor };
114}
115
116export function pullRequestAnchorMatchesUrl(
117 anchor: string,
118 url: string | null | undefined,
119): boolean {
120 return normalizePullRequestCommentAnchor(pullRequestUrlHash(url)) === anchor;
121}
122
123interface AnchoredComment {
124 readonly url: string | null;
125 readonly createdAt: string;
126 readonly reviewUrl?: string | null | undefined;
127}
128
129/**
130 * A review with no text of its own appears only through the line comments it posted, so its link
131 * resolves to the earliest of those. Any other anchor, or a review shown in its own right, is kept.
132 */
133export function resolvePullRequestReviewAnchor(
134 anchor: string,
135 comments: ReadonlyArray<Pick<AnchoredComment, "url">>,
136 reviewThreads: ReadonlyArray<{ readonly comments: ReadonlyArray<AnchoredComment> }>,
137): string {
138 if (!/^pullrequestreview-\d+$/i.test(anchor)) return anchor;
139 if (comments.some((comment) => pullRequestAnchorMatchesUrl(anchor, comment.url))) return anchor;
140 // The earliest comment the review posted; `toSorted` is missing from Hermes.
141 let posted: AnchoredComment | undefined;
142 for (const comment of reviewThreads.flatMap((thread) => thread.comments)) {
143 if (comment.url === null || !pullRequestAnchorMatchesUrl(anchor, comment.reviewUrl)) continue;
144 if (posted === undefined || comment.createdAt < posted.createdAt) posted = comment;
145 }
146 const own = posted ? normalizePullRequestCommentAnchor(pullRequestUrlHash(posted.url)) : "";
147 return own || anchor;
148}
149
150export function pullRequestDiffPathMatches(digest: string, path: string): boolean {
151 const bytes = utf8ToBytes(path);
152 const hash = digest.length === 32 ? md5 : digest.length === 40 ? sha1 : sha256;
153 return digest === bytesToHex(hash(bytes));
154}
155
156export function pullRequestLinkCommit(url: string | null | undefined): string | null {
157 if (!url) return null;
158 try {
159 return /\/pull\/\d+\/commits\/([a-f0-9]{40})\/?$/i.exec(new URL(url).pathname)?.[1] ?? null;
160 } catch {
161 return null;
162 }
163}