Skip to content

Commit 3aff925

Browse files
committed
Add branchName autolinks
1 parent 3d8749a commit 3aff925

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
const autoLinks = await integration.autolinks();
@@ -194,8 +191,10 @@ export class Autolinks implements Disposable {
194191
}
195192
}),
196193
);
194+
}
197195

198-
// Remote-specific autolinks and remote integration autolinks
196+
/** put remote-specific autolinks and remote integration autolinks to mutable refsets */
197+
private async collectRemoteAutolinks(remote: GitRemote | undefined, refsets: RefSet[]) {
199198
if (remote?.provider != null) {
200199
const autoLinks = [];
201200
const integrationAutolinks = await (await remote.getIntegration())?.autolinks();
@@ -210,15 +209,136 @@ export class Autolinks implements Disposable {
210209
refsets.push([remote.provider, autoLinks]);
211210
}
212211
}
212+
}
213213

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

220-
const autolinks = new Map<string, Autolink>();
337+
return Autolinks._getAutolinks(message, refsets);
338+
}
221339

340+
static _getAutolinks(message: string, refsets: Readonly<RefSet[]>) {
341+
const autolinks = new Map<string, Autolink>();
222342
let match;
223343
let num;
224344
for (const [provider, refs] of refsets) {
@@ -234,7 +354,7 @@ export class Autolinks implements Disposable {
234354

235355
do {
236356
match = ref.messageRegex.exec(message);
237-
if (match == null) break;
357+
if (!match) break;
238358

239359
[, , , num] = match;
240360

@@ -623,7 +743,7 @@ function ensureCachedRegex(
623743
function ensureCachedRegex(
624744
ref: CacheableAutolinkReference,
625745
outputFormat: 'plaintext',
626-
): asserts ref is RequireSome<CacheableAutolinkReference, 'messageRegex'>;
746+
): asserts ref is RequireSome<CacheableAutolinkReference, 'messageRegex' | 'branchNameRegex'>;
627747
function ensureCachedRegex(ref: CacheableAutolinkReference, outputFormat: 'html' | 'markdown' | 'plaintext') {
628748
// Regexes matches the ref prefix followed by a token (e.g. #1234)
629749
if (outputFormat === 'markdown' && ref.messageMarkdownRegex == null) {
@@ -644,6 +764,12 @@ function ensureCachedRegex(ref: CacheableAutolinkReference, outputFormat: 'html'
644764
`(^|\\s|\\(|\\[|\\{)(${escapeRegex(ref.prefix)}(${ref.alphanumeric ? '\\w' : '\\d'}+))\\b`,
645765
ref.ignoreCase ? 'gi' : 'g',
646766
);
767+
ref.branchNameRegex = new RegExp(
768+
`(^|\\-|_|\\.|\\/)(?<prefix>${ref.prefix})(?<issueKeyNumber>${
769+
ref.alphanumeric ? '\\w' : '\\d'
770+
}+)(?=$|\\-|_|\\.|\\/)`,
771+
'gi',
772+
);
647773
}
648774

649775
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)