-
Notifications
You must be signed in to change notification settings - Fork 16
Expand file tree
/
Copy pathextractors.ts
More file actions
322 lines (280 loc) · 10.2 KB
/
Copy pathextractors.ts
File metadata and controls
322 lines (280 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
import { verbose } from "./log";
import { CommitContext } from "./types";
const MAX_KEY_LENGTH = 7;
/**
* Linear's API types `pullRequestReferences[].number` as a GraphQL `Int`
* (signed 32-bit). A `#NNN` token whose value exceeds this cannot be a real
* GitHub PR number and would cause the entire release sync to be rejected,
* so we filter such tokens out at extraction time.
*/
const MAX_PR_NUMBER = 2_147_483_647;
/**
* Regex for matching issue identifiers with proper word boundaries.
* Matches the same patterns as Linear's issue identifier detection.
*
* - `(?:^|\b|(?<=_))` - start boundary (includes underscore as word boundary)
* - `(\w{1,7})` - team key (1-7 word characters)
* - `-` - literal hyphen
* - `([0-9]{1,9})` - issue number (1-9 digits)
* - `(?:$|\b|(?=_))` - end boundary (includes underscore as word boundary)
* - `(?!\.\d)` - negative lookahead to exclude version suffixes like "1.57.0"
*/
const ISSUE_IDENTIFIER_REGEX = new RegExp(
`(?:^|\\b|(?<=_))((\\w{1,${MAX_KEY_LENGTH}})-([0-9]{1,9}))(?:$|\\b|(?=_))(?!\\.\\d)`,
"gi",
);
const LINEAR_ISSUE_URL_REGEX = /https?:\/\/linear\.app\/[\w-]+\/issue\/(\w{1,7}-[0-9]{1,9})(?:\/[\w-]*)*/gi;
function normalizeLinearUrls(text: string): string {
return text.replace(LINEAR_ISSUE_URL_REGEX, "$1");
}
/** Magic words that indicate a commit is closing/fixing an issue. Matches Linear's detection. */
const CLOSING_WORDS = [
"close",
"closes",
"closed",
"closing",
"fix",
"fixes",
"fixed",
"fixing",
"resolve",
"resolves",
"resolved",
"resolving",
"complete",
"completes",
"completed",
"completing",
"implement",
"implements",
"implemented",
"implementing",
];
/** Magic phrases that indicate a commit contributes to an issue. Matches Linear's detection. */
const CONTRIBUTING_PHRASES = [
"ref",
"refs",
"references",
"part of",
"related to",
"relates to",
"contributes to",
"towards",
"toward",
];
/**
* Core issue ID pattern without word boundaries — used inside the magic word
* composite regex where surrounding context already provides boundaries.
*/
const ISSUE_ID_CORE = `\\w{1,${MAX_KEY_LENGTH}}-[0-9]{1,9}(?!\\.\\d)`;
/**
* Build a regex that matches magic words followed by one or more issue identifiers.
* Pattern per line, matching Linear's detection:
* \b(magic_words)[\s:]+(ISSUE_ID(([,\s]|\band\b|&)+ISSUE_ID)*)
*/
const MAGIC_WORD_REGEX = new RegExp(
`\\b(${[...CLOSING_WORDS, ...CONTRIBUTING_PHRASES].join("|")})[\\s:]+(${ISSUE_ID_CORE}(?:(?:[\\s,]|\\band\\b|&)+${ISSUE_ID_CORE})*)`,
"gi",
);
type IdentifierMatch = {
identifier: string;
rawIdentifier: string;
};
function parseMatch(match: RegExpExecArray): IdentifierMatch | undefined {
const [, rawIdentifier, teamKey, numberString] = match;
// Reject leading zeros (e.g., LIN-0004)
if (!rawIdentifier || !teamKey || !numberString || Number(numberString).toString().length !== numberString.length) {
return;
}
return {
rawIdentifier,
identifier: `${teamKey.toUpperCase()}-${Number(numberString)}`,
};
}
function matchAllIdentifiers(text: string): IdentifierMatch[] {
const regex = new RegExp(ISSUE_IDENTIFIER_REGEX.source, "gi");
const results: IdentifierMatch[] = [];
let match;
while ((match = regex.exec(text)) !== null) {
const parsed = parseMatch(match);
if (parsed) {
results.push(parsed);
}
}
return results;
}
/**
* Extract issue identifiers from text only when preceded by a magic word.
* Processes text line-by-line, matching Linear's detection behavior.
*/
function matchMagicWordIdentifiers(text: string): IdentifierMatch[] {
const results: IdentifierMatch[] = [];
const lines = text.split(/\r?\n/);
for (let line of lines) {
line = normalizeLinearUrls(line);
const regex = new RegExp(MAGIC_WORD_REGEX.source, "gi");
let match;
while ((match = regex.exec(line)) !== null) {
// match[2] contains the captured issue keys portion (one or more IDs)
const issueKeysPortion = match[2];
if (issueKeysPortion) {
const identifiers = matchAllIdentifiers(issueKeysPortion);
results.push(...identifiers);
}
}
}
return results;
}
export type ExtractedIdentifier = {
identifier: string;
source: "branch_name" | "commit_message";
};
export function extractLinearIssueIdentifiersForCommit(commit: CommitContext): ExtractedIdentifier[] {
if (!commit) {
return [];
}
// Odd depth = the commit is undoing previous work (a revert), so we must not
// count its identifiers as "added". Even depth = revert-of-revert (re-add).
const { depth: branchDepth, inner: strippedBranch } = parseRevertBranch(commit.branchName ?? "");
if (branchDepth % 2 === 1) {
verbose(`Skipping revert branch "${commit.branchName}" (depth ${branchDepth}) for commit ${commit.sha}`);
return [];
}
const { depth: messageDepth } = parseRevertMessage(commit.message ?? "");
if (messageDepth % 2 === 1) {
verbose(`Skipping revert message (depth ${messageDepth}) for commit ${commit.sha}`);
return [];
}
const found = new Map<string, ExtractedIdentifier>();
if (strippedBranch.length > 0) {
for (const match of matchAllIdentifiers(strippedBranch)) {
if (!found.has(match.identifier)) {
found.set(match.identifier, { identifier: match.identifier, source: "branch_name" });
}
}
}
// Commit message: only extract when preceded by a magic word
const message = commit.message ?? "";
if (message.length > 0) {
for (const match of matchMagicWordIdentifiers(message)) {
if (!found.has(match.identifier)) {
found.set(match.identifier, { identifier: match.identifier, source: "commit_message" });
}
}
}
return Array.from(found.values());
}
export function extractPullRequestNumbersForCommit(commit: CommitContext): number[] {
if (!commit) {
return [];
}
const message = commit.message ?? "";
// Skip reverts - they reference the original PR, not a new one
if (/^Revert "/i.test(message)) {
verbose(`Skipping revert commit ${commit.sha} with message: "${message}"`);
return [];
}
// Revert merge commits reference the original PR number, not a new one.
// Even depth (revert-of-revert) falls through to normal extraction.
if (getRevertBranchDepth(commit.branchName) % 2 === 1) {
verbose(`Skipping revert merge commit ${commit.sha}`);
return [];
}
const prNumbers: number[] = [];
const pushIfValid = (raw: string, source: string): void => {
const number = Number.parseInt(raw, 10);
if (number > MAX_PR_NUMBER) {
verbose(
`Ignoring #${raw} in commit ${commit.sha} (${source}): exceeds max PR number ${MAX_PR_NUMBER}, likely not a GitHub PR reference`,
);
return;
}
verbose(`Found PR number ${number} in commit ${commit.sha} (${source}): "${message}"`);
prNumbers.push(number);
};
// GitHub squash: "Title (#123)" - must be at end of title (first line)
const title = message.split(/\r?\n/)[0] ?? "";
const squashMatch = title.match(/\(#(\d+)\)$/);
if (squashMatch) {
pushIfValid(squashMatch[1]!, "squash format");
}
// GitHub merge: "Merge pull request #123 from ..." - must be at start
const mergeMatch = message.match(/^Merge pull request #(\d+)/i);
if (mergeMatch) {
pushIfValid(mergeMatch[1]!, "merge format");
}
// Only use fallback if no matches from squash/merge formats
if (prNumbers.length === 0) {
for (const match of message.matchAll(/#(\d+)/g)) {
pushIfValid(match[1]!, "message scan");
}
}
return [...new Set(prNumbers)];
}
function parseRevertBranch(branchName: string): {
depth: number;
inner: string;
} {
// Full refs can have org/ prefixes (e.g. "org/revert-571-..."), strip to the revert pattern.
// Non-greedy so we stop at the first revert-N- match, not the last (preserves nested depth).
let name = branchName.replace(/^.*?\/(?=revert-\d+-)/i, "");
let depth = 0;
while (/^revert-\d+-/i.test(name)) {
name = name.replace(/^revert-\d+-/i, "");
depth++;
}
return { depth, inner: name };
}
/**
* Strip revert-N- prefixes from a branch name and count nesting depth.
* e.g. "revert-572-revert-571-romain/bac-39" → { depth: 2, inner: "romain/bac-39" }
*/
export function getRevertBranchDepth(branchName: string | null | undefined): number {
if (!branchName) return 0;
return parseRevertBranch(branchName).depth;
}
function parseRevertMessage(message: string): { depth: number; inner: string } {
let text = message;
let depth = 0;
while (/^Revert "/i.test(text)) {
const match = text.match(/^Revert "(.+)"(.*)$/s);
if (!match) break;
text = match[1]!;
depth++;
}
return { depth, inner: text };
}
/**
* Unwrap Revert "..." layers from a commit message and count nesting depth.
* e.g. 'Revert "Revert "DRIVE-320: Fix""' → { depth: 2, inner: "DRIVE-320: Fix" }
*/
export function getRevertMessageDepth(message: string | null | undefined): number {
if (!message) return 0;
return parseRevertMessage(message).depth;
}
/** Extract identifiers being reverted. Returns [] if not an odd-depth revert. */
export function extractRevertedIssueIdentifiersForCommit(commit: CommitContext): ExtractedIdentifier[] {
if (!commit) return [];
const { depth: branchDepth, inner: originalBranch } = parseRevertBranch(commit.branchName ?? "");
const { depth: messageDepth, inner: innerMessage } = parseRevertMessage(commit.message ?? "");
// At least one of branch/message must have odd depth (i.e., be a revert) to extract
if (branchDepth % 2 === 0 && messageDepth % 2 === 0) return [];
const found = new Map<string, ExtractedIdentifier>();
if (branchDepth % 2 === 1) {
for (const match of matchAllIdentifiers(originalBranch)) {
if (!found.has(match.identifier)) {
found.set(match.identifier, { identifier: match.identifier, source: "branch_name" });
}
}
}
// Use magic-word gating on the inner message, same as the add path, to avoid
// false positives from generic word-number tokens (e.g. "Bump v1-2 to v1-3").
if (messageDepth % 2 === 1) {
for (const match of matchMagicWordIdentifiers(innerMessage)) {
if (!found.has(match.identifier)) {
found.set(match.identifier, { identifier: match.identifier, source: "commit_message" });
}
}
}
return Array.from(found.values());
}