Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

## [Unreleased]

### Added

- Adds the ability to get autolinks for branches using branch name [#3547](https://github.com/gitkraken/vscode-gitlens/issues/3547)

## [16.0.2] - 2024-11-18

### Changed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19602,7 +19602,7 @@
"update-dts:main": "pushd \"src/@types\" && pnpx @vscode/dts main && popd",
"update-emoji": "node ./scripts/generateEmojiShortcodeMap.mjs",
"update-licenses": "node ./scripts/generateLicenses.mjs",
"pretest": "pnpm run build:tests",
"//pretest": "pnpm run build:tests",
"vscode:prepublish": "pnpm run bundle"
},
"dependencies": {
Expand Down
51 changes: 51 additions & 0 deletions src/autolinks/__tests__/autolinks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import * as assert from 'assert';
import { suite, test } from 'mocha';
import { map } from '../../system/iterable';
import type { Autolink, RefSet } from '../autolinks';
import { Autolinks } from '../autolinks';

const mockRefSets = (prefixes: string[] = ['']): RefSet[] =>
prefixes.map(prefix => [
{ domain: 'test', icon: '1', id: '1', name: 'test' },
[
{
alphanumeric: false,
ignoreCase: false,
prefix: prefix,
title: 'test',
url: 'test/<num>',
description: 'test',
},
],
]);

function assertAutolinks(actual: Map<string, Autolink>, expected: Array<string>): void {
assert.deepEqual([...map(actual.values(), x => x.url)], expected);
}

suite('Autolinks Test Suite', () => {
test('Branch name autolinks', () => {
assertAutolinks(Autolinks._getBranchAutolinks('123', mockRefSets()), ['test/123']);
assertAutolinks(Autolinks._getBranchAutolinks('feature/123', mockRefSets()), ['test/123']);
assertAutolinks(Autolinks._getBranchAutolinks('feature/PRE-123', mockRefSets()), ['test/123']);
assertAutolinks(Autolinks._getBranchAutolinks('123.2', mockRefSets()), ['test/123', 'test/2']);
assertAutolinks(Autolinks._getBranchAutolinks('123', mockRefSets(['PRE-'])), []);
assertAutolinks(Autolinks._getBranchAutolinks('feature/123', mockRefSets(['PRE-'])), []);
assertAutolinks(Autolinks._getBranchAutolinks('feature/2-fa/123', mockRefSets([''])), ['test/123', 'test/2']);
assertAutolinks(Autolinks._getBranchAutolinks('feature/2-fa/123', mockRefSets([''])), ['test/123', 'test/2']);
// 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
assertAutolinks(Autolinks._getBranchAutolinks('feature/2-fa/3', mockRefSets([''])), ['test/2', 'test/3']);
assertAutolinks(Autolinks._getBranchAutolinks('feature/PRE-123', mockRefSets(['PRE-'])), ['test/123']);
assertAutolinks(Autolinks._getBranchAutolinks('feature/PRE-123.2', mockRefSets(['PRE-'])), ['test/123']);
assertAutolinks(Autolinks._getBranchAutolinks('feature/3-123-PRE-123', mockRefSets(['PRE-'])), ['test/123']);
assertAutolinks(
Autolinks._getBranchAutolinks('feature/3-123-PRE-123', mockRefSets(['', 'PRE-'])),

['test/123', 'test/3'],
);
});

test('Commit message autolinks', () => {
assertAutolinks(Autolinks._getAutolinks('test message 123 sd', mockRefSets()), ['test/123']);
});
});
203 changes: 156 additions & 47 deletions src/autolinks.ts → src/autolinks/autolinks.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,30 @@
import type { ConfigurationChangeEvent } from 'vscode';
import { Disposable } from 'vscode';
import { GlyphChars } from './constants';
import type { IntegrationId } from './constants.integrations';
import { IssueIntegrationId } from './constants.integrations';
import type { Container } from './container';
import type { IssueOrPullRequest } from './git/models/issue';
import { getIssueOrPullRequestHtmlIcon, getIssueOrPullRequestMarkdownIcon } from './git/models/issue';
import type { GitRemote } from './git/models/remote';
import type { ProviderReference } from './git/models/remoteProvider';
import type { ResourceDescriptor } from './plus/integrations/integration';
import { fromNow } from './system/date';
import { debug } from './system/decorators/log';
import { encodeUrl } from './system/encoding';
import { join, map } from './system/iterable';
import { Logger } from './system/logger';
import { escapeMarkdown } from './system/markdown';
import type { MaybePausedResult } from './system/promise';
import { capitalize, encodeHtmlWeak, escapeRegex, getSuperscript } from './system/string';
import { configuration } from './system/vscode/configuration';
import { GlyphChars } from '../constants';
import type { IntegrationId } from '../constants.integrations';
import { IssueIntegrationId } from '../constants.integrations';
import type { Container } from '../container';
import type { IssueOrPullRequest } from '../git/models/issue';
import { getIssueOrPullRequestHtmlIcon, getIssueOrPullRequestMarkdownIcon } from '../git/models/issue';
import type { GitRemote } from '../git/models/remote';
import type { ProviderReference } from '../git/models/remoteProvider';
import type { ResourceDescriptor } from '../plus/integrations/integration';
import { fromNow } from '../system/date';
import { debug } from '../system/decorators/log';
import { encodeUrl } from '../system/encoding';
import { join, map } from '../system/iterable';
import { Logger } from '../system/logger';
import { escapeMarkdown } from '../system/markdown';
import type { MaybePausedResult } from '../system/promise';
import { capitalize, encodeHtmlWeak, escapeRegex, getSuperscript } from '../system/string';
import { configuration } from '../system/vscode/configuration';

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

const numRegex = /<num>/g;

export type AutolinkType = 'issue' | 'pullrequest';
export type AutolinkReferenceType = 'commitMessage' | 'branchName';

export interface AutolinkReference {
/** Short prefix to match to generate autolinks for the external resource */
Expand All @@ -37,13 +38,15 @@ export interface AutolinkReference {
readonly title: string | undefined;

readonly type?: AutolinkType;
readonly referenceType?: AutolinkReferenceType;
readonly description?: string;
readonly descriptor?: ResourceDescriptor;
}

export interface Autolink extends AutolinkReference {
provider?: ProviderReference;
id: string;
index: number;

tokenize?:
| ((
Expand Down Expand Up @@ -78,6 +81,7 @@ export function serializeAutolink(value: Autolink): Autolink {
}
: undefined,
id: value.id,
index: value.index,
prefix: value.prefix,
url: value.url,
alphanumeric: value.alphanumeric,
Expand Down Expand Up @@ -105,6 +109,7 @@ export interface CacheableAutolinkReference extends AutolinkReference {
messageHtmlRegex?: RegExp;
messageMarkdownRegex?: RegExp;
messageRegex?: RegExp;
branchNameRegex?: RegExp;
}

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

export type RefSet = [
ProviderReference | undefined,
(AutolinkReference | DynamicAutolinkReference)[] | CacheableAutolinkReference[],
];

export class Autolinks implements Disposable {
protected _disposable: Disposable | undefined;
private _references: CacheableAutolinkReference[] = [];
Expand Down Expand Up @@ -162,30 +172,11 @@ export class Autolinks implements Disposable {
}
}

async getAutolinks(message: string, remote?: GitRemote): Promise<Map<string, Autolink>>;
async getAutolinks(
message: string,
remote: GitRemote,
// eslint-disable-next-line @typescript-eslint/unified-signatures
options?: { excludeCustom?: boolean },
): Promise<Map<string, Autolink>>;
@debug<Autolinks['getAutolinks']>({
args: {
0: '<message>',
1: false,
},
})
async getAutolinks(
message: string,
remote?: GitRemote,
options?: { excludeCustom?: boolean },
): Promise<Map<string, Autolink>> {
const refsets: [
ProviderReference | undefined,
(AutolinkReference | DynamicAutolinkReference)[] | CacheableAutolinkReference[],
][] = [];
// Connected integration autolinks
await Promise.allSettled(
/**
* put connected integration autolinks to mutable refsets
*/
private async collectIntegrationAutolinks(refsets: RefSet[]) {
return Promise.allSettled(
supportedAutolinkIntegrations.map(async integrationId => {
const integration = await this.container.integrations.get(integrationId);
// Don't check for integration access, as we want to allow autolinks to always be generated
Expand All @@ -195,8 +186,10 @@ export class Autolinks implements Disposable {
}
}),
);
}

// Remote-specific autolinks and remote integration autolinks
/** put remote-specific autolinks and remote integration autolinks to mutable refsets */
private async collectRemoteAutolinks(remote: GitRemote | undefined, refsets: RefSet[]) {
if (remote?.provider != null) {
const autoLinks = [];
// Don't check for integration access, as we want to allow autolinks to always be generated
Expand All @@ -212,15 +205,124 @@ export class Autolinks implements Disposable {
refsets.push([remote.provider, autoLinks]);
}
}
}

// Custom-configured autolinks
if (this._references.length && (remote?.provider == null || !options?.excludeCustom)) {
/** put custom-configured autolinks to mutable refsets */
private collectCustomAutolinks(remote: GitRemote | undefined, refsets: RefSet[]) {
if (this._references.length && remote?.provider == null) {
refsets.push([undefined, this._references]);
}
}

/**
* it should always return non-0 result that means a probability of the autolink `b` is more relevant of the autolink `a`
*/
private static compareAutolinks(a: Autolink, b: Autolink) {
// consider that if the number is in the start, it's the most relevant link
if (b.index === 0) {
return 1;
}
if (a.index === 0) {
return -1;
}
// maybe it worths to use some weight function instead.
return b.prefix.length - a.prefix.length || b.id.length - a.id.length || -(b.index - a.index);
}

private async getRefsets(remote?: GitRemote, options?: { excludeCustom?: boolean }) {
const refsets: RefSet[] = [];
await this.collectIntegrationAutolinks(refsets);
await this.collectRemoteAutolinks(remote, refsets);
if (!options?.excludeCustom) {
this.collectCustomAutolinks(remote, refsets);
}
return refsets;
}

/**
* returns sorted list of autolinks. the first is matched as the most relevant
*/
async getBranchAutolinks(
branchName: string,
remote?: GitRemote,
options?: { excludeCustom?: boolean },
): Promise<Map<string, Autolink>> {
const refsets = await this.getRefsets(remote, options);
if (refsets.length === 0) return emptyAutolinkMap;

return Autolinks._getBranchAutolinks(branchName, refsets);
}

static _getBranchAutolinks(branchName: string, refsets: Readonly<RefSet[]>) {
const autolinks = new Map<string, Autolink>();

let match;
let num;
for (const [provider, refs] of refsets) {
for (const ref of refs) {
if (!isCacheable(ref)) {
continue;
}
if (ref.type === 'pullrequest' || (ref.referenceType && ref.referenceType !== 'branchName')) {
continue;
}

ensureCachedRegex(ref, 'plaintext');
const matches = branchName.matchAll(ref.branchNameRegex);
do {
match = matches.next();
if (!match.value?.groups) break;

num = match?.value?.groups.issueKeyNumber;
let index = match.value.index;
const linkUrl = ref.url?.replace(numRegex, num);
// strange case (I would say synthetic), but if we parse the link twice, use the most relevant of them
if (autolinks.has(linkUrl)) {
index = Math.min(index, autolinks.get(linkUrl)!.index);
}
autolinks.set(linkUrl, {
...ref,
provider: provider,
id: num,
index: index,
url: linkUrl,
title: ref.title?.replace(numRegex, num),
description: ref.description?.replace(numRegex, num),
descriptor: ref.descriptor,
});
} while (!match.done);
}
}

return new Map([...autolinks.entries()].sort((a, b) => this.compareAutolinks(a[1], b[1])));
}

async getAutolinks(message: string, remote?: GitRemote): Promise<Map<string, Autolink>>;
async getAutolinks(
message: string,
remote: GitRemote,
// eslint-disable-next-line @typescript-eslint/unified-signatures
options?: { excludeCustom?: boolean },
): Promise<Map<string, Autolink>>;
@debug<Autolinks['getAutolinks']>({
args: {
0: '<message>',
1: false,
},
})
async getAutolinks(
message: string,
remote?: GitRemote,
options?: { excludeCustom?: boolean },
): Promise<Map<string, Autolink>> {
const refsets = await this.getRefsets(remote, options);
if (refsets.length === 0) return emptyAutolinkMap;

return Autolinks._getAutolinks(message, refsets);
}

static _getAutolinks(message: string, refsets: Readonly<RefSet[]>) {
const autolinks = new Map<string, Autolink>();
let match;
let num;
for (const [provider, refs] of refsets) {
Expand All @@ -236,13 +338,14 @@ export class Autolinks implements Disposable {

do {
match = ref.messageRegex.exec(message);
if (match == null) break;
if (!match) break;

[, , , num] = match;

autolinks.set(num, {
provider: provider,
id: num,
index: match.index,
prefix: ref.prefix,
url: ref.url?.replace(numRegex, num),
alphanumeric: ref.alphanumeric,
Expand Down Expand Up @@ -625,7 +728,7 @@ function ensureCachedRegex(
function ensureCachedRegex(
ref: CacheableAutolinkReference,
outputFormat: 'plaintext',
): asserts ref is RequireSome<CacheableAutolinkReference, 'messageRegex'>;
): asserts ref is RequireSome<CacheableAutolinkReference, 'messageRegex' | 'branchNameRegex'>;
function ensureCachedRegex(ref: CacheableAutolinkReference, outputFormat: 'html' | 'markdown' | 'plaintext') {
// Regexes matches the ref prefix followed by a token (e.g. #1234)
if (outputFormat === 'markdown' && ref.messageMarkdownRegex == null) {
Expand All @@ -646,6 +749,12 @@ function ensureCachedRegex(ref: CacheableAutolinkReference, outputFormat: 'html'
`(^|\\s|\\(|\\[|\\{)(${escapeRegex(ref.prefix)}(${ref.alphanumeric ? '\\w' : '\\d'}+))\\b`,
ref.ignoreCase ? 'gi' : 'g',
);
ref.branchNameRegex = new RegExp(
`(^|\\-|_|\\.|\\/)(?<prefix>${ref.prefix})(?<issueKeyNumber>${
ref.alphanumeric ? '\\w' : '\\d'
}+)(?=$|\\-|_|\\.|\\/)`,
'gi',
);
Comment on lines +759 to +764
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for my own learning/benefit, can you explain how you formulated this regex?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an easy regex playground service called https://regex101.com/, I always test the regexes there before I put them to the code.
Speaking about the current regex, here I expect that the link is wrapped between any star/end (split) symbols (expecting one of [-,_,.,/]). The both start/end symbols are the same except of regex symbols ^ and $. Then I expect that the issue key is a concatenation of the prefix and some non-zero count (+) of numbers (\d) or alphanumeric (\w)

}

return true;
Expand Down
1 change: 1 addition & 0 deletions src/autolinks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './autolinks';
Loading
Loading