Skip to content

Commit fb1f067

Browse files
Add branchName autolinks (#3644)
* Add branchName autolinks * update changelog * Fix review notes * Fixes comment, fixes commit message autolinks, makes index optional * Fixes spacing --------- Co-authored-by: Ramin Tadayon <[email protected]>
1 parent b2ed2b1 commit fb1f067

File tree

14 files changed

+294
-58
lines changed

14 files changed

+294
-58
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- Adds the ability to get autolinks for branches using branch name [#3547](https://github.com/gitkraken/vscode-gitlens/issues/3547)
12+
913
## [16.0.2] - 2024-11-18
1014

1115
### Changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import * as assert from 'assert';
2+
import { suite, test } from 'mocha';
3+
import { map } from '../../system/iterable';
4+
import type { Autolink, RefSet } from '../autolinks';
5+
import { Autolinks } from '../autolinks';
6+
7+
const mockRefSets = (prefixes: string[] = ['']): RefSet[] =>
8+
prefixes.map(prefix => [
9+
{ domain: 'test', icon: '1', id: '1', name: 'test' },
10+
[
11+
{
12+
alphanumeric: false,
13+
ignoreCase: false,
14+
prefix: prefix,
15+
title: 'test',
16+
url: 'test/<num>',
17+
description: 'test',
18+
},
19+
],
20+
]);
21+
22+
function assertAutolinks(actual: Map<string, Autolink>, expected: Array<string>): void {
23+
assert.deepEqual([...map(actual.values(), x => x.url)], expected);
24+
}
25+
26+
suite('Autolinks Test Suite', () => {
27+
test('Branch name autolinks', () => {
28+
assertAutolinks(Autolinks._getBranchAutolinks('123', mockRefSets()), ['test/123']);
29+
assertAutolinks(Autolinks._getBranchAutolinks('feature/123', mockRefSets()), ['test/123']);
30+
assertAutolinks(Autolinks._getBranchAutolinks('feature/PRE-123', mockRefSets()), ['test/123']);
31+
assertAutolinks(Autolinks._getBranchAutolinks('123.2', mockRefSets()), ['test/123', 'test/2']);
32+
assertAutolinks(Autolinks._getBranchAutolinks('123', mockRefSets(['PRE-'])), []);
33+
assertAutolinks(Autolinks._getBranchAutolinks('feature/123', mockRefSets(['PRE-'])), []);
34+
assertAutolinks(Autolinks._getBranchAutolinks('feature/2-fa/123', mockRefSets([''])), ['test/123', 'test/2']);
35+
assertAutolinks(Autolinks._getBranchAutolinks('feature/2-fa/123', mockRefSets([''])), ['test/123', 'test/2']);
36+
// incorrectly solved case, maybe it worths to compare the blocks length so that the less block size (without possible link) is more likely a link
37+
assertAutolinks(Autolinks._getBranchAutolinks('feature/2-fa/3', mockRefSets([''])), ['test/2', 'test/3']);
38+
assertAutolinks(Autolinks._getBranchAutolinks('feature/PRE-123', mockRefSets(['PRE-'])), ['test/123']);
39+
assertAutolinks(Autolinks._getBranchAutolinks('feature/PRE-123.2', mockRefSets(['PRE-'])), ['test/123']);
40+
assertAutolinks(Autolinks._getBranchAutolinks('feature/3-123-PRE-123', mockRefSets(['PRE-'])), ['test/123']);
41+
assertAutolinks(
42+
Autolinks._getBranchAutolinks('feature/3-123-PRE-123', mockRefSets(['', 'PRE-'])),
43+
44+
['test/123', 'test/3'],
45+
);
46+
});
47+
48+
test('Commit message autolinks', () => {
49+
assertAutolinks(Autolinks._getAutolinks('test message 123 sd', mockRefSets()), ['test/123']);
50+
});
51+
});

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

Lines changed: 164 additions & 48 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,13 +38,15 @@ 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
}
4345

4446
export interface Autolink extends AutolinkReference {
4547
provider?: ProviderReference;
4648
id: string;
49+
index?: number;
4750

4851
tokenize?:
4952
| ((
@@ -78,6 +81,7 @@ export function serializeAutolink(value: Autolink): Autolink {
7881
}
7982
: undefined,
8083
id: value.id,
84+
index: value.index,
8185
prefix: value.prefix,
8286
url: value.url,
8387
alphanumeric: value.alphanumeric,
@@ -105,6 +109,7 @@ export interface CacheableAutolinkReference extends AutolinkReference {
105109
messageHtmlRegex?: RegExp;
106110
messageMarkdownRegex?: RegExp;
107111
messageRegex?: RegExp;
112+
branchNameRegex?: RegExp;
108113
}
109114

110115
export interface DynamicAutolinkReference {
@@ -131,6 +136,11 @@ function isCacheable(ref: AutolinkReference | DynamicAutolinkReference): ref is
131136
return 'prefix' in ref && ref.prefix != null && 'url' in ref && ref.url != null;
132137
}
133138

139+
export type RefSet = [
140+
ProviderReference | undefined,
141+
(AutolinkReference | DynamicAutolinkReference)[] | CacheableAutolinkReference[],
142+
];
143+
134144
export class Autolinks implements Disposable {
135145
protected _disposable: Disposable | undefined;
136146
private _references: CacheableAutolinkReference[] = [];
@@ -162,30 +172,11 @@ export class Autolinks implements Disposable {
162172
}
163173
}
164174

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(
175+
/**
176+
* put connected integration autolinks to mutable refsets
177+
*/
178+
private async collectIntegrationAutolinks(refsets: RefSet[]) {
179+
return Promise.allSettled(
189180
supportedAutolinkIntegrations.map(async integrationId => {
190181
const integration = await this.container.integrations.get(integrationId);
191182
// Don't check for integration access, as we want to allow autolinks to always be generated
@@ -195,8 +186,10 @@ export class Autolinks implements Disposable {
195186
}
196187
}),
197188
);
189+
}
198190

199-
// Remote-specific autolinks and remote integration autolinks
191+
/** put remote-specific autolinks and remote integration autolinks to mutable refsets */
192+
private async collectRemoteAutolinks(remote: GitRemote | undefined, refsets: RefSet[]) {
200193
if (remote?.provider != null) {
201194
const autoLinks = [];
202195
// Don't check for integration access, as we want to allow autolinks to always be generated
@@ -212,20 +205,136 @@ export class Autolinks implements Disposable {
212205
refsets.push([remote.provider, autoLinks]);
213206
}
214207
}
208+
}
215209

216-
// Custom-configured autolinks
217-
if (this._references.length && (remote?.provider == null || !options?.excludeCustom)) {
210+
/** put custom-configured autolinks to mutable refsets */
211+
private collectCustomAutolinks(remote: GitRemote | undefined, refsets: RefSet[]) {
212+
if (this._references.length && remote?.provider == null) {
218213
refsets.push([undefined, this._references]);
219214
}
215+
}
216+
217+
/**
218+
* it should always return non-0 result that means a probability of the autolink `b` is more relevant of the autolink `a`
219+
*/
220+
private static compareAutolinks(a: Autolink, b: Autolink) {
221+
// consider that if the number is in the start, it's the most relevant link
222+
if (b.index === 0) {
223+
return 1;
224+
}
225+
if (a.index === 0) {
226+
return -1;
227+
}
228+
229+
// maybe it worths to use some weight function instead.
230+
return (
231+
b.prefix.length - a.prefix.length ||
232+
b.id.length - a.id.length ||
233+
(b.index != null && a.index != null ? -(b.index - a.index) : 0)
234+
);
235+
}
236+
237+
private async getRefsets(remote?: GitRemote, options?: { excludeCustom?: boolean }) {
238+
const refsets: RefSet[] = [];
239+
await this.collectIntegrationAutolinks(refsets);
240+
await this.collectRemoteAutolinks(remote, refsets);
241+
if (!options?.excludeCustom) {
242+
this.collectCustomAutolinks(remote, refsets);
243+
}
244+
return refsets;
245+
}
246+
247+
/**
248+
* returns sorted list of autolinks. the first is matched as the most relevant
249+
*/
250+
async getBranchAutolinks(
251+
branchName: string,
252+
remote?: GitRemote,
253+
options?: { excludeCustom?: boolean },
254+
): Promise<Map<string, Autolink>> {
255+
const refsets = await this.getRefsets(remote, options);
220256
if (refsets.length === 0) return emptyAutolinkMap;
221257

258+
return Autolinks._getBranchAutolinks(branchName, refsets);
259+
}
260+
261+
static _getBranchAutolinks(branchName: string, refsets: Readonly<RefSet[]>) {
222262
const autolinks = new Map<string, Autolink>();
223263

224264
let match;
225265
let num;
226266
for (const [provider, refs] of refsets) {
227267
for (const ref of refs) {
228-
if (!isCacheable(ref)) {
268+
if (
269+
!isCacheable(ref) ||
270+
ref.type === 'pullrequest' ||
271+
(ref.referenceType && ref.referenceType !== 'branchName')
272+
) {
273+
continue;
274+
}
275+
276+
ensureCachedRegex(ref, 'plaintext');
277+
const matches = branchName.matchAll(ref.branchNameRegex);
278+
do {
279+
match = matches.next();
280+
if (!match.value?.groups) break;
281+
282+
num = match?.value?.groups.issueKeyNumber;
283+
let index = match.value.index;
284+
const linkUrl = ref.url?.replace(numRegex, num);
285+
// strange case (I would say synthetic), but if we parse the link twice, use the most relevant of them
286+
const existingIndex = autolinks.get(linkUrl)?.index;
287+
if (existingIndex != null) {
288+
index = Math.min(index, existingIndex);
289+
}
290+
autolinks.set(linkUrl, {
291+
...ref,
292+
provider: provider,
293+
id: num,
294+
index: index,
295+
url: linkUrl,
296+
title: ref.title?.replace(numRegex, num),
297+
description: ref.description?.replace(numRegex, num),
298+
descriptor: ref.descriptor,
299+
});
300+
} while (!match.done);
301+
}
302+
}
303+
304+
return new Map([...autolinks.entries()].sort((a, b) => this.compareAutolinks(a[1], b[1])));
305+
}
306+
307+
async getAutolinks(message: string, remote?: GitRemote): Promise<Map<string, Autolink>>;
308+
async getAutolinks(
309+
message: string,
310+
remote: GitRemote,
311+
// eslint-disable-next-line @typescript-eslint/unified-signatures
312+
options?: { excludeCustom?: boolean },
313+
): Promise<Map<string, Autolink>>;
314+
@debug<Autolinks['getAutolinks']>({
315+
args: {
316+
0: '<message>',
317+
1: false,
318+
},
319+
})
320+
async getAutolinks(
321+
message: string,
322+
remote?: GitRemote,
323+
options?: { excludeCustom?: boolean },
324+
): Promise<Map<string, Autolink>> {
325+
const refsets = await this.getRefsets(remote, options);
326+
if (refsets.length === 0) return emptyAutolinkMap;
327+
328+
return Autolinks._getAutolinks(message, refsets);
329+
}
330+
331+
static _getAutolinks(message: string, refsets: Readonly<RefSet[]>) {
332+
const autolinks = new Map<string, Autolink>();
333+
let match;
334+
let num;
335+
for (const [provider, refs] of refsets) {
336+
for (const ref of refs) {
337+
if (!isCacheable(ref) || (ref.referenceType && ref.referenceType !== 'commitMessage')) {
229338
if (isDynamic(ref)) {
230339
ref.parse(message, autolinks);
231340
}
@@ -236,13 +345,14 @@ export class Autolinks implements Disposable {
236345

237346
do {
238347
match = ref.messageRegex.exec(message);
239-
if (match == null) break;
348+
if (!match) break;
240349

241350
[, , , num] = match;
242351

243352
autolinks.set(num, {
244353
provider: provider,
245354
id: num,
355+
index: match.index,
246356
prefix: ref.prefix,
247357
url: ref.url?.replace(numRegex, num),
248358
alphanumeric: ref.alphanumeric,
@@ -625,7 +735,7 @@ function ensureCachedRegex(
625735
function ensureCachedRegex(
626736
ref: CacheableAutolinkReference,
627737
outputFormat: 'plaintext',
628-
): asserts ref is RequireSome<CacheableAutolinkReference, 'messageRegex'>;
738+
): asserts ref is RequireSome<CacheableAutolinkReference, 'messageRegex' | 'branchNameRegex'>;
629739
function ensureCachedRegex(ref: CacheableAutolinkReference, outputFormat: 'html' | 'markdown' | 'plaintext') {
630740
// Regexes matches the ref prefix followed by a token (e.g. #1234)
631741
if (outputFormat === 'markdown' && ref.messageMarkdownRegex == null) {
@@ -646,6 +756,12 @@ function ensureCachedRegex(ref: CacheableAutolinkReference, outputFormat: 'html'
646756
`(^|\\s|\\(|\\[|\\{)(${escapeRegex(ref.prefix)}(${ref.alphanumeric ? '\\w' : '\\d'}+))\\b`,
647757
ref.ignoreCase ? 'gi' : 'g',
648758
);
759+
ref.branchNameRegex = new RegExp(
760+
`(^|\\-|_|\\.|\\/)(?<prefix>${ref.prefix})(?<issueKeyNumber>${
761+
ref.alphanumeric ? '\\w' : '\\d'
762+
}+)(?=$|\\-|_|\\.|\\/)`,
763+
'gi',
764+
);
649765
}
650766

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