Skip to content

Commit 871a584

Browse files
committed
Add branchName autolinks
1 parent 352262d commit 871a584

File tree

13 files changed

+321
-60
lines changed

13 files changed

+321
-60
lines changed
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import * as assert from 'assert';
2+
import { suite, test } from 'mocha';
3+
import type { RefSet } from '../autolinks';
4+
import { Autolinks } from '../autolinks';
5+
6+
const mockRefSets = (prefixes: string[] = ['']): RefSet[] =>
7+
prefixes.map(prefix => [
8+
{ domain: 'test', icon: '1', id: '1', name: 'test' },
9+
[
10+
{
11+
alphanumeric: false,
12+
ignoreCase: false,
13+
prefix: prefix,
14+
title: 'test',
15+
url: 'test/<num>',
16+
description: 'test',
17+
},
18+
],
19+
]);
20+
21+
suite('Autolinks Test Suite', () => {
22+
test('Branch name autolinks', () => {
23+
assert.deepEqual(
24+
Autolinks._getBranchAutolinks('123', mockRefSets()).map(x => x.url),
25+
['test/123'],
26+
);
27+
assert.deepEqual(
28+
Autolinks._getBranchAutolinks('feature/123', mockRefSets()).map(x => x.url),
29+
['test/123'],
30+
);
31+
assert.deepEqual(
32+
Autolinks._getBranchAutolinks('feature/PRE-123', mockRefSets()).map(x => x.url),
33+
['test/123'],
34+
);
35+
assert.deepEqual(
36+
Autolinks._getBranchAutolinks('123.2', mockRefSets()).map(x => x.url),
37+
['test/123', 'test/2'],
38+
);
39+
assert.deepEqual(
40+
Autolinks._getBranchAutolinks('123', mockRefSets(['PRE-'])).map(x => x.url),
41+
[],
42+
);
43+
assert.deepEqual(
44+
Autolinks._getBranchAutolinks('feature/123', mockRefSets(['PRE-'])).map(x => x.url),
45+
[],
46+
);
47+
assert.deepEqual(
48+
Autolinks._getBranchAutolinks('feature/PRE-123', mockRefSets(['PRE-'])).map(x => x.url),
49+
['test/123'],
50+
);
51+
assert.deepEqual(
52+
Autolinks._getBranchAutolinks('feature/PRE-123.2', mockRefSets(['PRE-'])).map(x => x.url),
53+
['test/123'],
54+
);
55+
assert.deepEqual(
56+
Autolinks._getBranchAutolinks('feature/3-123-PRE-123', mockRefSets(['PRE-'])).map(x => x.url),
57+
['test/123'],
58+
);
59+
assert.deepEqual(
60+
Autolinks._getBranchAutolinks('feature/3-123-PRE-123', mockRefSets(['', 'PRE-'])).map(x => x.url),
61+
['test/123', 'test/3'],
62+
);
63+
});
64+
65+
test('Commit message autolinks', () => {
66+
assert.deepEqual(
67+
[...Autolinks._getAutolinks('test message 123 sd', mockRefSets()).values()].map(x => x.url),
68+
['test/123'],
69+
);
70+
});
71+
});

src/autolinks.ts renamed to src/autolinks/autolinks.ts

Lines changed: 175 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,30 @@
11
import type { ConfigurationChangeEvent } from 'vscode';
22
import { Disposable } from 'vscode';
3-
import { GlyphChars } from './constants';
4-
import type { IntegrationId } from './constants.integrations';
5-
import { IssueIntegrationId } from './constants.integrations';
6-
import type { Container } from './container';
7-
import type { IssueOrPullRequest } from './git/models/issue';
8-
import { getIssueOrPullRequestHtmlIcon, getIssueOrPullRequestMarkdownIcon } from './git/models/issue';
9-
import type { GitRemote } from './git/models/remote';
10-
import type { ProviderReference } from './git/models/remoteProvider';
11-
import type { ResourceDescriptor } from './plus/integrations/integration';
12-
import { fromNow } from './system/date';
13-
import { debug } from './system/decorators/log';
14-
import { encodeUrl } from './system/encoding';
15-
import { join, map } from './system/iterable';
16-
import { Logger } from './system/logger';
17-
import { escapeMarkdown } from './system/markdown';
18-
import type { MaybePausedResult } from './system/promise';
19-
import { capitalize, encodeHtmlWeak, escapeRegex, getSuperscript } from './system/string';
20-
import { configuration } from './system/vscode/configuration';
3+
import { GlyphChars } from '../constants';
4+
import type { IntegrationId } from '../constants.integrations';
5+
import { IssueIntegrationId } from '../constants.integrations';
6+
import type { Container } from '../container';
7+
import type { IssueOrPullRequest } from '../git/models/issue';
8+
import { getIssueOrPullRequestHtmlIcon, getIssueOrPullRequestMarkdownIcon } from '../git/models/issue';
9+
import type { GitRemote } from '../git/models/remote';
10+
import type { ProviderReference } from '../git/models/remoteProvider';
11+
import type { ResourceDescriptor } from '../plus/integrations/integration';
12+
import { fromNow } from '../system/date';
13+
import { debug } from '../system/decorators/log';
14+
import { encodeUrl } from '../system/encoding';
15+
import { join, map } from '../system/iterable';
16+
import { Logger } from '../system/logger';
17+
import { escapeMarkdown } from '../system/markdown';
18+
import type { MaybePausedResult } from '../system/promise';
19+
import { capitalize, encodeHtmlWeak, escapeRegex, getSuperscript } from '../system/string';
20+
import { configuration } from '../system/vscode/configuration';
2121

2222
const emptyAutolinkMap = Object.freeze(new Map<string, Autolink>());
2323

2424
const numRegex = /<num>/g;
2525

2626
export type AutolinkType = 'issue' | 'pullrequest';
27+
export type AutolinkReferenceType = 'commitMessage' | 'branchName';
2728

2829
export interface AutolinkReference {
2930
/** Short prefix to match to generate autolinks for the external resource */
@@ -37,6 +38,7 @@ export interface AutolinkReference {
3738
readonly title: string | undefined;
3839

3940
readonly type?: AutolinkType;
41+
readonly referenceType?: AutolinkReferenceType;
4042
readonly description?: string;
4143
readonly descriptor?: ResourceDescriptor;
4244
}
@@ -105,6 +107,7 @@ export interface CacheableAutolinkReference extends AutolinkReference {
105107
messageHtmlRegex?: RegExp;
106108
messageMarkdownRegex?: RegExp;
107109
messageRegex?: RegExp;
110+
branchNameRegex?: RegExp;
108111
}
109112

110113
export interface DynamicAutolinkReference {
@@ -123,14 +126,27 @@ export interface DynamicAutolinkReference {
123126

124127
export const supportedAutolinkIntegrations = [IssueIntegrationId.Jira];
125128

126-
function isDynamic(ref: AutolinkReference | DynamicAutolinkReference): ref is DynamicAutolinkReference {
129+
export function isDynamic(ref: AutolinkReference | DynamicAutolinkReference): ref is DynamicAutolinkReference {
127130
return !('prefix' in ref) && !('url' in ref);
128131
}
129132

130133
function isCacheable(ref: AutolinkReference | DynamicAutolinkReference): ref is CacheableAutolinkReference {
131134
return 'prefix' in ref && ref.prefix != null && 'url' in ref && ref.url != null;
132135
}
133136

137+
export type RefSet = [
138+
ProviderReference | undefined,
139+
(AutolinkReference | DynamicAutolinkReference)[] | CacheableAutolinkReference[],
140+
];
141+
142+
type ComparingAutolinkSet = {
143+
/** the place where the autolink is found from start-like symbol (/|_) */
144+
index: number;
145+
/** the place where the autolink is found from start */
146+
startIndex: number;
147+
autolink: Autolink;
148+
};
149+
134150
export class Autolinks implements Disposable {
135151
protected _disposable: Disposable | undefined;
136152
private _references: CacheableAutolinkReference[] = [];
@@ -162,30 +178,11 @@ export class Autolinks implements Disposable {
162178
}
163179
}
164180

165-
async getAutolinks(message: string, remote?: GitRemote): Promise<Map<string, Autolink>>;
166-
async getAutolinks(
167-
message: string,
168-
remote: GitRemote,
169-
// eslint-disable-next-line @typescript-eslint/unified-signatures
170-
options?: { excludeCustom?: boolean },
171-
): Promise<Map<string, Autolink>>;
172-
@debug<Autolinks['getAutolinks']>({
173-
args: {
174-
0: '<message>',
175-
1: false,
176-
},
177-
})
178-
async getAutolinks(
179-
message: string,
180-
remote?: GitRemote,
181-
options?: { excludeCustom?: boolean },
182-
): Promise<Map<string, Autolink>> {
183-
const refsets: [
184-
ProviderReference | undefined,
185-
(AutolinkReference | DynamicAutolinkReference)[] | CacheableAutolinkReference[],
186-
][] = [];
187-
// Connected integration autolinks
188-
await Promise.allSettled(
181+
/**
182+
* put connected integration autolinks to mutable refsets
183+
*/
184+
private async collectIntegrationAutolinks(refsets: RefSet[]) {
185+
return Promise.allSettled(
189186
supportedAutolinkIntegrations.map(async integrationId => {
190187
const integration = await this.container.integrations.get(integrationId);
191188
// Don't check for integration access, as we want to allow autolinks to always be generated
@@ -195,8 +192,10 @@ export class Autolinks implements Disposable {
195192
}
196193
}),
197194
);
195+
}
198196

199-
// Remote-specific autolinks and remote integration autolinks
197+
/** put remote-specific autolinks and remote integration autolinks to mutable refsets */
198+
private async collectRemoteAutolinks(remote: GitRemote | undefined, refsets: RefSet[]) {
200199
if (remote?.provider != null) {
201200
const autoLinks = [];
202201
// Don't check for integration access, as we want to allow autolinks to always be generated
@@ -212,15 +211,136 @@ export class Autolinks implements Disposable {
212211
refsets.push([remote.provider, autoLinks]);
213212
}
214213
}
214+
}
215215

216-
// Custom-configured autolinks
217-
if (this._references.length && (remote?.provider == null || !options?.excludeCustom)) {
216+
/** put custom-configured autolinks to mutable refsets */
217+
private collectCustomAutolinks(remote: GitRemote | undefined, refsets: RefSet[]) {
218+
if (this._references.length && remote?.provider == null) {
218219
refsets.push([undefined, this._references]);
219220
}
221+
}
222+
223+
/**
224+
* it should always return non-0 result that means a probability of the autolink `b` is more relevant of the autolink `a`
225+
*/
226+
private static compareAutolinks(a: ComparingAutolinkSet, b: ComparingAutolinkSet) {
227+
// consider that if the number is in the start, it's the most relevant link
228+
if (b.index === 0) {
229+
return 1;
230+
}
231+
if (a.index === 0) {
232+
return -1;
233+
}
234+
// maybe it worths to use some weight function instead.
235+
return (
236+
b.autolink.prefix.length - a.autolink.prefix.length ||
237+
-(b.startIndex - a.startIndex) ||
238+
-(b.index - a.index)
239+
);
240+
}
241+
242+
/**
243+
* returns sorted list of autolinks. the first is matched as the most relevant
244+
*/
245+
async getBranchAutolinks(
246+
branchName: string,
247+
remote?: GitRemote,
248+
options?: { excludeCustom: boolean },
249+
): Promise<undefined | Autolink[]> {
250+
const refsets: RefSet[] = [];
251+
await this.collectIntegrationAutolinks(refsets);
252+
await this.collectRemoteAutolinks(remote, refsets);
253+
if (!options?.excludeCustom) {
254+
this.collectCustomAutolinks(remote, refsets);
255+
}
256+
if (refsets.length === 0) return undefined;
257+
258+
return Autolinks._getBranchAutolinks(branchName, refsets);
259+
}
260+
261+
static _getBranchAutolinks(branchName: string, refsets: Readonly<RefSet[]>) {
262+
const autolinks = new Map<string, ComparingAutolinkSet>();
263+
264+
let match;
265+
let num;
266+
for (const [provider, refs] of refsets) {
267+
for (const ref of refs) {
268+
if (!isCacheable(ref)) {
269+
continue;
270+
}
271+
if (ref.type === 'pullrequest' || (ref.referenceType && ref.referenceType !== 'branchName')) {
272+
continue;
273+
}
274+
275+
ensureCachedRegex(ref, 'plaintext');
276+
const matches = branchName.matchAll(ref.branchNameRegex);
277+
do {
278+
match = matches.next();
279+
if (!match.value?.groups) break;
280+
281+
num = match?.value?.groups.issueKeyNumber;
282+
let index = match.value.index;
283+
const linkUrl = ref.url?.replace(numRegex, num);
284+
// strange case (I would say synthetic), but if we parse the link twice, use the most relevant of them
285+
if (autolinks.has(linkUrl)) {
286+
index = Math.min(index, autolinks.get(linkUrl)!.index);
287+
}
288+
autolinks.set(linkUrl, {
289+
index: index,
290+
// TODO: calc the distance from the nearest start-like symbol
291+
startIndex: 0,
292+
autolink: {
293+
...ref,
294+
provider: provider,
295+
id: num,
296+
297+
url: linkUrl,
298+
title: ref.title?.replace(numRegex, num),
299+
description: ref.description?.replace(numRegex, num),
300+
descriptor: ref.descriptor,
301+
},
302+
});
303+
} while (!match.done);
304+
}
305+
}
306+
307+
return [...autolinks.values()]
308+
.flat()
309+
.sort(this.compareAutolinks)
310+
.map(x => x.autolink);
311+
}
312+
313+
async getAutolinks(message: string, remote?: GitRemote): Promise<Map<string, Autolink>>;
314+
async getAutolinks(
315+
message: string,
316+
remote: GitRemote,
317+
// eslint-disable-next-line @typescript-eslint/unified-signatures
318+
options?: { excludeCustom?: boolean },
319+
): Promise<Map<string, Autolink>>;
320+
@debug<Autolinks['getAutolinks']>({
321+
args: {
322+
0: '<message>',
323+
1: false,
324+
},
325+
})
326+
async getAutolinks(
327+
message: string,
328+
remote?: GitRemote,
329+
options?: { excludeCustom?: boolean; isBranchName?: boolean },
330+
): Promise<Map<string, Autolink>> {
331+
const refsets: RefSet[] = [];
332+
await this.collectIntegrationAutolinks(refsets);
333+
await this.collectRemoteAutolinks(remote, refsets);
334+
if (!options?.excludeCustom) {
335+
this.collectCustomAutolinks(remote, refsets);
336+
}
220337
if (refsets.length === 0) return emptyAutolinkMap;
221338

222-
const autolinks = new Map<string, Autolink>();
339+
return Autolinks._getAutolinks(message, refsets);
340+
}
223341

342+
static _getAutolinks(message: string, refsets: Readonly<RefSet[]>) {
343+
const autolinks = new Map<string, Autolink>();
224344
let match;
225345
let num;
226346
for (const [provider, refs] of refsets) {
@@ -236,7 +356,7 @@ export class Autolinks implements Disposable {
236356

237357
do {
238358
match = ref.messageRegex.exec(message);
239-
if (match == null) break;
359+
if (!match) break;
240360

241361
[, , , num] = match;
242362

@@ -625,7 +745,7 @@ function ensureCachedRegex(
625745
function ensureCachedRegex(
626746
ref: CacheableAutolinkReference,
627747
outputFormat: 'plaintext',
628-
): asserts ref is RequireSome<CacheableAutolinkReference, 'messageRegex'>;
748+
): asserts ref is RequireSome<CacheableAutolinkReference, 'messageRegex' | 'branchNameRegex'>;
629749
function ensureCachedRegex(ref: CacheableAutolinkReference, outputFormat: 'html' | 'markdown' | 'plaintext') {
630750
// Regexes matches the ref prefix followed by a token (e.g. #1234)
631751
if (outputFormat === 'markdown' && ref.messageMarkdownRegex == null) {
@@ -646,6 +766,12 @@ function ensureCachedRegex(ref: CacheableAutolinkReference, outputFormat: 'html'
646766
`(^|\\s|\\(|\\[|\\{)(${escapeRegex(ref.prefix)}(${ref.alphanumeric ? '\\w' : '\\d'}+))\\b`,
647767
ref.ignoreCase ? 'gi' : 'g',
648768
);
769+
ref.branchNameRegex = new RegExp(
770+
`(^|\\-|_|\\.|\\/)(?<prefix>${ref.prefix})(?<issueKeyNumber>${
771+
ref.alphanumeric ? '\\w' : '\\d'
772+
}+)(?=$|\\-|_|\\.|\\/)`,
773+
'gi',
774+
);
649775
}
650776

651777
return true;

src/autolinks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './autolinks';

0 commit comments

Comments
 (0)