1import type { PullRequestListFilters } from "@t3tools/contracts";
2
3const REVIEW_VALUES: Record<string, PullRequestListFilters["review"]> = {
4 approved: "approved",
5 changes_requested: "changes-requested",
6 "changes-requested": "changes-requested",
7 required: "review-required",
8 "review-required": "review-required",
9 none: "none",
10};
11const CHECKS_VALUES: Record<string, PullRequestListFilters["checks"]> = {
12 success: "passing",
13 passing: "passing",
14 failure: "failing",
15 failing: "failing",
16};
17
18const QUERY_TOKEN = /(?:[^\s"]|"[^"]*")+/g;
19const MAX_QUALIFIER_VALUES = 10;
20const MAX_QUALIFIER_LENGTH = 200;
21
22function qualifierValue(raw: string): string {
23 return raw.replaceAll('"', "").trim();
24}
25
26function splitQualifierList(raw: string): string[] {
27 return (raw.match(/(?:[^,"]|"[^"]*")+/g) ?? [])
28 .map((part) => qualifierValue(part))
29 .filter((part) => part.length > 0);
30}
31
32/** `me` and `@me` both name the viewer; logins are case-insensitive. */
33function authorKey(login: string): string {
34 return login.toLowerCase().replace(/^@me$/, "me");
35}
36
37function boundedNames(names: ReadonlyArray<string>): string[] {
38 return names
39 .slice(0, MAX_QUALIFIER_VALUES)
40 .map((name) => name.slice(0, MAX_QUALIFIER_LENGTH).trim())
41 .filter((name) => name.length > 0);
42}
43
44export function parsePullRequestQuery(raw: string): {
45 readonly text: string;
46 readonly filters: PullRequestListFilters;
47} {
48 const text: string[] = [];
49 const labels: string[][] = [];
50 const excludedLabels: string[] = [];
51 // `null` once two different logins are named: GitHub ORs them, the contract carries one.
52 let author: string | null | undefined;
53 let draft: PullRequestListFilters["draft"];
54 let review: PullRequestListFilters["review"];
55 let checks: PullRequestListFilters["checks"];
56 for (const [token] of raw.matchAll(QUERY_TOKEN)) {
57 const qualifier = /^(-?)([A-Za-z][A-Za-z0-9_-]*):(.*)$/.exec(token);
58 const value = qualifier === null ? "" : qualifierValue(qualifier[3] ?? "");
59 const negated = qualifier?.[1] === "-";
60 switch (value.length === 0 ? "" : (qualifier?.[2]?.toLowerCase() ?? "")) {
61 case "label": {
62 const names = boundedNames(splitQualifierList(qualifier?.[3] ?? ""));
63 if (names.length === 0) break;
64 if (negated) excludedLabels.push(...names);
65 else labels.push(names);
66 continue;
67 }
68 case "author": {
69 if (negated) break;
70 const login = value.slice(0, MAX_QUALIFIER_LENGTH).trim();
71 if (author === undefined) author = login;
72 else if (author === null || authorKey(author) !== authorKey(login)) author = null;
73 continue;
74 }
75 case "draft":
76 if (value.toLowerCase() !== "true" && value.toLowerCase() !== "false") break;
77 draft = (value.toLowerCase() === "true") !== negated ? "only" : "hide";
78 continue;
79 case "review": {
80 const decision = negated ? undefined : REVIEW_VALUES[value.toLowerCase()];
81 if (decision === undefined) break;
82 review = decision;
83 continue;
84 }
85 case "status":
86 case "checks": {
87 const state = negated ? undefined : CHECKS_VALUES[value.toLowerCase()];
88 if (state === undefined) break;
89 checks = state;
90 continue;
91 }
92 // GitHub's own search qualifiers, not labels: the list sets scope and order itself.
93 case "is":
94 case "archived":
95 case "state":
96 case "repo":
97 case "org":
98 case "user":
99 case "sort":
100 continue;
101 case "":
102 break;
103 default:
104 if (!value.startsWith("/")) {
105 const names = boundedNames(
106 splitQualifierList(qualifier?.[3] ?? "").map((name) =>
107 name.includes(":") ? name : `${qualifier?.[2] ?? ""}:${name}`,
108 ),
109 );
110 if (names.length === 0) break;
111 if (negated) excludedLabels.push(...names);
112 else labels.push(names);
113 continue;
114 }
115 }
116 text.push(token);
117 }
118 return {
119 text: text.join(" "),
120 filters: {
121 ...(labels.length === 0 ? {} : { labels: labels.slice(0, MAX_QUALIFIER_VALUES) }),
122 ...(excludedLabels.length === 0
123 ? {}
124 : { excludedLabels: excludedLabels.slice(0, MAX_QUALIFIER_VALUES) }),
125 ...(typeof author === "string" ? { author } : {}),
126 ...(draft === undefined ? {} : { draft }),
127 ...(review === undefined ? {} : { review }),
128 ...(checks === undefined ? {} : { checks }),
129 },
130 };
131}
132
133/**
134 * The Draft menu shows a typed `draft:` qualifier, so picking another value has to take the
135 * qualifier out of the query: left in, it would keep outranking whatever the menu stores.
136 */
137export function applyPullRequestDraftFilterChange(
138 query: string,
139 menuFilters: PullRequestListFilters,
140 next: PullRequestListFilters,
141) {
142 const typedDraft = parsePullRequestQuery(query).filters.draft;
143 if (next.draft === (typedDraft ?? menuFilters.draft))
144 return { query, filters: { ...next, draft: menuFilters.draft } };
145 return {
146 query:
147 typedDraft === undefined
148 ? query
149 : [...query.matchAll(QUERY_TOKEN)]
150 .map(([token]) => token)
151 .filter((token) => parsePullRequestQuery(token).filters.draft === undefined)
152 .join(" "),
153 filters: next,
154 };
155}