Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
184 changes: 149 additions & 35 deletions src/annotations/autolinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export interface CacheableAutolinkReference extends AutolinkReference {
messageHtmlRegex?: RegExp;
messageMarkdownRegex?: RegExp;
messageRegex?: RegExp;
branchNameRegex?: RegExp;
}

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

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

type ComparingAutolinkSet = {
/** the place where the autolink is found from start-like symbol (/|_) */
index: number;
/** the place where the autolink is found from start */
startIndex: number;
autolink: Autolink;
};

export class Autolinks implements Disposable {
protected _disposable: Disposable | undefined;
private _references: CacheableAutolinkReference[] = [];
Expand Down Expand Up @@ -155,30 +169,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 await Promise.allSettled(
supportedAutolinkIntegrations.map(async integrationId => {
const integration = await this.container.integrations.get(integrationId);
const autoLinks = await integration.autolinks();
Expand All @@ -187,8 +182,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 = [];
const integrationAutolinks = await (await remote.getIntegration())?.autolinks();
Expand All @@ -203,11 +200,122 @@ 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 compareAutolinks(a: ComparingAutolinkSet, b: ComparingAutolinkSet) {
// 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.autolink.prefix.length - a.autolink.prefix.length ||
-(b.startIndex - a.startIndex) ||
-(b.index - a.index)
);
}

/**
* returns sorted list of autolinks. the first is matched as the most relevant
*/
async getBranchAutolinks(
branchName: string,
remote?: GitRemote,
options?: { excludeCustom: boolean },
): Promise<undefined | Autolink[]> {
const refsets: RefSet[] = [];
await this.collectIntegrationAutolinks(refsets);
await this.collectRemoteAutolinks(remote, refsets);
if (!options?.excludeCustom) this.collectCustomAutolinks(remote, refsets);
if (refsets.length === 0) return undefined;

let autolinks = new Map<string, ComparingAutolinkSet>();

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, {
index,
// TODO: calc the distance from the nearest start-like symbol
startIndex: 0,
autolink: {
provider: provider,
id: num,
prefix: ref.prefix,
url: linkUrl,
title: ref.title?.replace(numRegex, num),

type: ref.type,
description: ref.description?.replace(numRegex, num),
descriptor: ref.descriptor,
},
});
} while (!match.done);
}
}

return [...autolinks.values()]
.flat()
.sort(this.compareAutolinks)
.map(x => x.autolink);
}

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; isBranchName?: boolean },
): Promise<Map<string, Autolink>> {
const refsets: RefSet[] = [];
await this.collectIntegrationAutolinks(refsets);
await this.collectRemoteAutolinks(remote, refsets);
if (!options?.excludeCustom) this.collectCustomAutolinks(remote, refsets);
if (refsets.length === 0) return emptyAutolinkMap;

const autolinks = new Map<string, Autolink>();
Expand All @@ -227,9 +335,9 @@ export class Autolinks implements Disposable {

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

[, , , num] = match;
num = match.groups.issueKeyNumber;

autolinks.set(num, {
provider: provider,
Expand Down Expand Up @@ -615,27 +723,33 @@ 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) {
// Extra `\\\\` in `\\\\\\[` is because the markdown is escaped
ref.messageMarkdownRegex = new RegExp(
`(^|\\s|\\(|\\[|\\{)(${escapeRegex(encodeHtmlWeak(escapeMarkdown(ref.prefix)))}(${
ref.alphanumeric ? '\\w' : '\\d'
}+))\\b`,
`(^|\\s|\\(|\\[|\\{)(?<issueKeyNumber>${escapeRegex(
encodeHtmlWeak(escapeMarkdown(ref.prefix)),
)}(?<issueKeyNumber>${ref.alphanumeric ? '\\w' : '\\d'}+))\\b`,
ref.ignoreCase ? 'gi' : 'g',
);
} else if (outputFormat === 'html' && ref.messageHtmlRegex == null) {
ref.messageHtmlRegex = new RegExp(
`(^|\\s|\\(|\\[|\\{)(${escapeRegex(encodeHtmlWeak(ref.prefix))}(${ref.alphanumeric ? '\\w' : '\\d'}+))\\b`,
`(^|\\s|\\(|\\[|\\{)(${escapeRegex(encodeHtmlWeak(ref.prefix))}(?<issueKeyNumber>${
ref.alphanumeric ? '\\w' : '\\d'
}+))\\b`,
ref.ignoreCase ? 'gi' : 'g',
);
} else if (ref.messageRegex == null) {
ref.messageRegex = new RegExp(
`(^|\\s|\\(|\\[|\\{)(${escapeRegex(ref.prefix)}(${ref.alphanumeric ? '\\w' : '\\d'}+))\\b`,
`(^|\\s|\\(|\\[|\\{)(${escapeRegex(ref.prefix)}(?<issueKeyNumber>${ref.alphanumeric ? '\\w' : '\\d'}+))\\b`,
ref.ignoreCase ? 'gi' : 'g',
);
ref.branchNameRegex = new RegExp(
`(^|\\-|_|\\.|\\/)(?<prefix>${ref.prefix})(?<issueKeyNumber>\\d+)($|\\-|_|\\.|\\/)`,
'gi',
);
}

return true;
Expand Down
4 changes: 4 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ export interface Config {

export type AnnotationsToggleMode = 'file' | 'window';
export type AutolinkType = 'issue' | 'pullrequest';
export type AutolinkReferenceType = 'message' | 'branchName';

export interface AutolinkReference {
readonly prefix: string;
Expand All @@ -261,6 +262,9 @@ export interface AutolinkReference {
readonly alphanumeric?: boolean;
readonly ignoreCase?: boolean;

/** used to split some autolinks logic, consider that default undefined value means that the autolink is applicable for all reference types */
readonly referenceType?: AutolinkReferenceType;

readonly type?: AutolinkType;
readonly description?: string;
readonly descriptor?: ResourceDescriptor;
Expand Down
9 changes: 7 additions & 2 deletions src/git/remotes/azure-devops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,20 @@ export class AzureDevOpsRemote extends RemoteProvider {
this.project = repoProject;
}

protected override get issueLinkPattern(): string {
const workUrl = this.baseUrl.replace(gitRegex, '/');
return `${workUrl}/_workitems/edit/<num>`;
}

private _autolinks: (AutolinkReference | DynamicAutolinkReference)[] | undefined;
override get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] {
if (this._autolinks === undefined) {
// Strip off any `_git` part from the repo url
const workUrl = this.baseUrl.replace(gitRegex, '/');
this._autolinks = [
...super.autolinks,
{
prefix: '#',
url: `${workUrl}/_workitems/edit/<num>`,
url: this.issueLinkPattern,
title: `Open Work Item #<num> on ${this.name}`,

type: 'issue',
Expand Down
7 changes: 6 additions & 1 deletion src/git/remotes/bitbucket-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@ export class BitbucketServerRemote extends RemoteProvider {
super(domain, path, protocol, name, custom);
}

protected override get issueLinkPattern(): string {
return `${this.baseUrl}/issues/<num>`;
}

private _autolinks: (AutolinkReference | DynamicAutolinkReference)[] | undefined;
override get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] {
if (this._autolinks === undefined) {
this._autolinks = [
...super.autolinks,
{
prefix: 'issue #',
url: `${this.baseUrl}/issues/<num>`,
url: this.issueLinkPattern,
title: `Open Issue #<num> on ${this.name}`,

type: 'issue',
Expand Down
7 changes: 6 additions & 1 deletion src/git/remotes/bitbucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@ export class BitbucketRemote extends RemoteProvider {
super(domain, path, protocol, name, custom);
}

protected override get issueLinkPattern(): string {
return `${this.baseUrl}/issues/<num>`;
}

private _autolinks: (AutolinkReference | DynamicAutolinkReference)[] | undefined;
override get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] {
if (this._autolinks === undefined) {
this._autolinks = [
...super.autolinks,
{
prefix: 'issue #',
url: `${this.baseUrl}/issues/<num>`,
url: this.issueLinkPattern,
title: `Open Issue #<num> on ${this.name}`,

type: 'issue',
Expand Down
5 changes: 5 additions & 0 deletions src/git/remotes/custom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export class CustomRemote extends RemoteProvider {
this.urls = urls;
}

protected override get issueLinkPattern(): string {
// TODO: if it's ok, think about passing issue link to cfg.urls or using optional
throw new Error('Method not implemented.');
}

get id(): RemoteProviderId {
return 'custom';
}
Expand Down
5 changes: 5 additions & 0 deletions src/git/remotes/gerrit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export class GerritRemote extends RemoteProvider {
super(domain, path, protocol, name, custom);
}

protected override get issueLinkPattern(): string {
// TODO: if it's ok, think about passing issue link to cfg.urls or using optional
throw new Error('Method not implemented.');
}

private _autolinks: (AutolinkReference | DynamicAutolinkReference)[] | undefined;
override get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] {
if (this._autolinks === undefined) {
Expand Down
7 changes: 6 additions & 1 deletion src/git/remotes/gitea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,18 @@ export class GiteaRemote extends RemoteProvider {
super(domain, path, protocol, name, custom);
}

protected override get issueLinkPattern(): string {
return `${this.baseUrl}/issues/<num>`;
}

private _autolinks: (AutolinkReference | DynamicAutolinkReference)[] | undefined;
override get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] {
if (this._autolinks === undefined) {
this._autolinks = [
...super.autolinks,
{
prefix: '#',
url: `${this.baseUrl}/issues/<num>`,
url: this.issueLinkPattern,
title: `Open Issue #<num> on ${this.name}`,

type: 'issue',
Expand Down
Loading