Open History to see the last commit
1import type {
2 PullRequestActor,
3 PullRequestCapabilities,
4 PullRequestFilterOptions,
5 PullRequestReferenceCandidate,
6 PullRequestReferenceSigil,
7} from "@t3tools/contracts";
8
9import { replaceTextRange } from "./composerTrigger.ts";
10
11export type PullRequestComposerTrigger = {
12 readonly query: string;
13 readonly rangeStart: number;
14 readonly rangeEnd: number;
15} & (
16 | { readonly kind: "mention"; readonly sigil: "@" }
17 | { readonly kind: "reference"; readonly sigil: PullRequestReferenceSigil }
18);
19
20// Mentions cover GitHub logins (letters, digits, hyphens) and GitLab usernames (also `_` and `.`).
21const TOKEN = /^(?:@([A-Za-z0-9_][A-Za-z0-9_.-]*)?|([#!])([\p{L}\p{N}_-]*))$/u;
22
23/** What a pull request's host can complete; absent fields complete nothing. */
24export type PullRequestCompletionSupport = Pick<
25 PullRequestCapabilities,
26 "mentions" | "referenceSigils"
27>;
28
29/**
30 * The `@login`, `#123` or `!123` being typed at the caret, when the caret sits at its end and the
31 * host completes that sigil.
32 */
33export function detectPullRequestComposerTrigger(
34 text: string,
35 cursor: number,
36 support: PullRequestCompletionSupport,
37): PullRequestComposerTrigger | null {
38 const end = Math.max(0, Math.min(text.length, cursor));
39 const next = text[end];
40 if (next !== undefined && !/\s/.test(next)) return null;
41 let start = end;
42 while (start > 0 && !/\s/.test(text[start - 1] ?? "")) start -= 1;
43 const match = TOKEN.exec(text.slice(start, end));
44 if (!match) return null;
45 const sigil = match[2];
46 if (sigil === "#" || sigil === "!") {
47 return support.referenceSigils?.includes(sigil)
48 ? { kind: "reference", sigil, query: match[3] ?? "", rangeStart: start, rangeEnd: end }
49 : null;
50 }
51 return support.mentions === true
52 ? { kind: "mention", sigil: "@", query: match[1] ?? "", rangeStart: start, rangeEnd: end }
53 : null;
54}
55
56/** Replaces the trigger with `token` and leaves the caret past the space after it. */
57export function insertPullRequestCompletion(
58 text: string,
59 trigger: PullRequestComposerTrigger,
60 token: string,
61): { text: string; cursor: number } {
62 const followedBySpace = /[ \t]/.test(text[trigger.rangeEnd] ?? "");
63 if (!followedBySpace) {
64 return replaceTextRange(text, trigger.rangeStart, trigger.rangeEnd, `${token} `);
65 }
66 const inserted = replaceTextRange(text, trigger.rangeStart, trigger.rangeEnd, token);
67 return { ...inserted, cursor: inserted.cursor + 1 };
68}
69
70/** The key a debounce delays: the trigger's sigil and its host search, or `null` with no trigger. */
71export function pullRequestCompletionKey(
72 trigger: Pick<PullRequestComposerTrigger, "sigil" | "query"> | null,
73): string | null {
74 return trigger === null ? null : `${trigger.sigil}${trigger.query.slice(0, 100)}`;
75}
76
77/**
78 * The search to send for `trigger` given a debounced key. Until a key for this sigil settles, the
79 * host is asked for its unsearched list, which narrows locally, so a burst of typing sends one query.
80 */
81export function debouncedPullRequestCompletionSearch(
82 trigger: Pick<PullRequestComposerTrigger, "sigil">,
83 debouncedKey: string | null,
84): string | undefined {
85 return debouncedKey?.startsWith(trigger.sigil)
86 ? debouncedKey.slice(trigger.sigil.length) || undefined
87 : undefined;
88}
89
90export function narrowPullRequestMentions<A extends { login: string; name: string | null }>(
91 users: ReadonlyArray<A>,
92 typed: string,
93 limit: number,
94): A[] {
95 const needle = typed.toLowerCase();
96 return users
97 .filter(
98 (user) =>
99 user.login.toLowerCase().includes(needle) ||
100 (user.name?.toLowerCase().includes(needle) ?? false),
101 )
102 .slice(0, limit);
103}
104
105/** Candidates the typed sigil links, by number prefix or title. */
106export function narrowPullRequestReferences<
107 A extends { reference: string; number: number; title: string },
108>(
109 candidates: ReadonlyArray<A>,
110 trigger: Pick<PullRequestComposerTrigger, "sigil" | "query">,
111 limit: number,
112): A[] {
113 const needle = trigger.query.toLowerCase();
114 return candidates
115 .filter(
116 (candidate) =>
117 candidate.reference.startsWith(trigger.sigil) &&
118 (String(candidate.number).startsWith(needle) ||
119 candidate.title.toLowerCase().includes(needle)),
120 )
121 .slice(0, limit);
122}
123
124export type PullRequestCompletion =
125 | { readonly kind: "mention"; readonly token: string; readonly actor: PullRequestActor }
126 | {
127 readonly kind: "reference";
128 readonly token: string;
129 readonly candidate: PullRequestReferenceCandidate;
130 };
131
132/** The host's options for `trigger`, narrowed to what is typed now. */
133export function pullRequestCompletions(
134 trigger: PullRequestComposerTrigger | null,
135 options: Pick<PullRequestFilterOptions, "authors" | "references"> | null,
136 limit: number,
137): ReadonlyArray<PullRequestCompletion> {
138 if (trigger === null) return [];
139 return trigger.kind === "mention"
140 ? narrowPullRequestMentions(options?.authors ?? [], trigger.query, limit).map((actor) => ({
141 kind: "mention",
142 token: `@${actor.login}`,
143 actor,
144 }))
145 : narrowPullRequestReferences(options?.references ?? [], trigger, limit).map((candidate) => ({
146 kind: "reference",
147 token: candidate.reference,
148 candidate,
149 }));
150}