diff --git a/CHANGELOG.md b/CHANGELOG.md index 45fdeae16be63..b9020a69fb575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/autolinks/__tests__/autolinks.test.ts b/src/autolinks/__tests__/autolinks.test.ts new file mode 100644 index 0000000000000..f23a5f20472f1 --- /dev/null +++ b/src/autolinks/__tests__/autolinks.test.ts @@ -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/', + description: 'test', + }, + ], + ]); + +function assertAutolinks(actual: Map, expected: Array): 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']); + }); +}); diff --git a/src/autolinks.ts b/src/autolinks/autolinks.ts similarity index 79% rename from src/autolinks.ts rename to src/autolinks/autolinks.ts index 50658d6f4c4f6..16d8dedbf691d 100644 --- a/src/autolinks.ts +++ b/src/autolinks/autolinks.ts @@ -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()); const numRegex = //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 */ @@ -37,6 +38,7 @@ export interface AutolinkReference { readonly title: string | undefined; readonly type?: AutolinkType; + readonly referenceType?: AutolinkReferenceType; readonly description?: string; readonly descriptor?: ResourceDescriptor; } @@ -44,6 +46,7 @@ export interface AutolinkReference { export interface Autolink extends AutolinkReference { provider?: ProviderReference; id: string; + index?: number; tokenize?: | (( @@ -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, @@ -105,6 +109,7 @@ export interface CacheableAutolinkReference extends AutolinkReference { messageHtmlRegex?: RegExp; messageMarkdownRegex?: RegExp; messageRegex?: RegExp; + branchNameRegex?: RegExp; } export interface DynamicAutolinkReference { @@ -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[] = []; @@ -162,30 +172,11 @@ export class Autolinks implements Disposable { } } - async getAutolinks(message: string, remote?: GitRemote): Promise>; - async getAutolinks( - message: string, - remote: GitRemote, - // eslint-disable-next-line @typescript-eslint/unified-signatures - options?: { excludeCustom?: boolean }, - ): Promise>; - @debug({ - args: { - 0: '', - 1: false, - }, - }) - async getAutolinks( - message: string, - remote?: GitRemote, - options?: { excludeCustom?: boolean }, - ): Promise> { - 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 @@ -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 @@ -212,20 +205,136 @@ 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 != null && a.index != null ? -(b.index - a.index) : 0) + ); + } + + 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> { + const refsets = await this.getRefsets(remote, options); if (refsets.length === 0) return emptyAutolinkMap; + return Autolinks._getBranchAutolinks(branchName, refsets); + } + + static _getBranchAutolinks(branchName: string, refsets: Readonly) { const autolinks = new Map(); let match; let num; for (const [provider, refs] of refsets) { for (const ref of refs) { - if (!isCacheable(ref)) { + if ( + !isCacheable(ref) || + 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 + const existingIndex = autolinks.get(linkUrl)?.index; + if (existingIndex != null) { + index = Math.min(index, existingIndex); + } + 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>; + async getAutolinks( + message: string, + remote: GitRemote, + // eslint-disable-next-line @typescript-eslint/unified-signatures + options?: { excludeCustom?: boolean }, + ): Promise>; + @debug({ + args: { + 0: '', + 1: false, + }, + }) + async getAutolinks( + message: string, + remote?: GitRemote, + options?: { excludeCustom?: boolean }, + ): Promise> { + const refsets = await this.getRefsets(remote, options); + if (refsets.length === 0) return emptyAutolinkMap; + + return Autolinks._getAutolinks(message, refsets); + } + + static _getAutolinks(message: string, refsets: Readonly) { + const autolinks = new Map(); + let match; + let num; + for (const [provider, refs] of refsets) { + for (const ref of refs) { + if (!isCacheable(ref) || (ref.referenceType && ref.referenceType !== 'commitMessage')) { if (isDynamic(ref)) { ref.parse(message, autolinks); } @@ -236,13 +345,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, @@ -625,7 +735,7 @@ function ensureCachedRegex( function ensureCachedRegex( ref: CacheableAutolinkReference, outputFormat: 'plaintext', -): asserts ref is RequireSome; +): asserts ref is RequireSome; 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) { @@ -646,6 +756,12 @@ function ensureCachedRegex(ref: CacheableAutolinkReference, outputFormat: 'html' `(^|\\s|\\(|\\[|\\{)(${escapeRegex(ref.prefix)}(${ref.alphanumeric ? '\\w' : '\\d'}+))\\b`, ref.ignoreCase ? 'gi' : 'g', ); + ref.branchNameRegex = new RegExp( + `(^|\\-|_|\\.|\\/)(?${ref.prefix})(?${ + ref.alphanumeric ? '\\w' : '\\d' + }+)(?=$|\\-|_|\\.|\\/)`, + 'gi', + ); } return true; diff --git a/src/autolinks/index.ts b/src/autolinks/index.ts new file mode 100644 index 0000000000000..f6cfd71f23208 --- /dev/null +++ b/src/autolinks/index.ts @@ -0,0 +1 @@ +export * from './autolinks'; diff --git a/src/git/remotes/azure-devops.ts b/src/git/remotes/azure-devops.ts index c53d5872290d9..39c74fd39f9d7 100644 --- a/src/git/remotes/azure-devops.ts +++ b/src/git/remotes/azure-devops.ts @@ -55,15 +55,20 @@ export class AzureDevOpsRemote extends RemoteProvider { this.project = repoProject; } + protected override get issueLinkPattern(): string { + const workUrl = this.baseUrl.replace(gitRegex, '/'); + return `${workUrl}/_workitems/edit/`; + } + 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/`, + url: this.issueLinkPattern, alphanumeric: false, ignoreCase: false, title: `Open Work Item # on ${this.name}`, diff --git a/src/git/remotes/bitbucket-server.ts b/src/git/remotes/bitbucket-server.ts index f976c1a1be3be..3426f35e04c14 100644 --- a/src/git/remotes/bitbucket-server.ts +++ b/src/git/remotes/bitbucket-server.ts @@ -15,13 +15,18 @@ export class BitbucketServerRemote extends RemoteProvider { super(domain, path, protocol, name, custom); } + protected override get issueLinkPattern(): string { + return `${this.baseUrl}/issues/`; + } + private _autolinks: (AutolinkReference | DynamicAutolinkReference)[] | undefined; override get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] { if (this._autolinks === undefined) { this._autolinks = [ + ...super.autolinks, { prefix: 'issue #', - url: `${this.baseUrl}/issues/`, + url: this.issueLinkPattern, alphanumeric: false, ignoreCase: true, title: `Open Issue # on ${this.name}`, diff --git a/src/git/remotes/bitbucket.ts b/src/git/remotes/bitbucket.ts index 1efc4050e79c3..a42a62d3ccb1a 100644 --- a/src/git/remotes/bitbucket.ts +++ b/src/git/remotes/bitbucket.ts @@ -15,13 +15,18 @@ export class BitbucketRemote extends RemoteProvider { super(domain, path, protocol, name, custom); } + protected override get issueLinkPattern(): string { + return `${this.baseUrl}/issues/`; + } + private _autolinks: (AutolinkReference | DynamicAutolinkReference)[] | undefined; override get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] { if (this._autolinks === undefined) { this._autolinks = [ + ...super.autolinks, { prefix: 'issue #', - url: `${this.baseUrl}/issues/`, + url: this.issueLinkPattern, alphanumeric: false, ignoreCase: true, title: `Open Issue # on ${this.name}`, diff --git a/src/git/remotes/custom.ts b/src/git/remotes/custom.ts index 10beef3facece..9bdcbdb725bbe 100644 --- a/src/git/remotes/custom.ts +++ b/src/git/remotes/custom.ts @@ -1,4 +1,5 @@ import type { Range, Uri } from 'vscode'; +import type { AutolinkReference, DynamicAutolinkReference } from '../../autolinks'; import type { RemotesUrlsConfig } from '../../config'; import type { GkProviderId } from '../../gk/models/repositoryIdentities'; import { getTokensFromTemplate, interpolate } from '../../system/string'; @@ -26,6 +27,14 @@ export class CustomRemote extends RemoteProvider { return this.formatName('Custom'); } + protected override get issueLinkPattern(): string { + throw new Error('unsupported'); + } + + override get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] { + return []; + } + getLocalInfoFromRemoteUri( _repository: Repository, _uri: Uri, diff --git a/src/git/remotes/gerrit.ts b/src/git/remotes/gerrit.ts index 5d68e06bcf5c0..41e4245fad141 100644 --- a/src/git/remotes/gerrit.ts +++ b/src/git/remotes/gerrit.ts @@ -33,13 +33,18 @@ export class GerritRemote extends RemoteProvider { super(domain, path, protocol, name, custom); } + protected override get issueLinkPattern(): string { + return `${this.baseReviewUrl}/q/`; + } + private _autolinks: (AutolinkReference | DynamicAutolinkReference)[] | undefined; override get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] { if (this._autolinks === undefined) { this._autolinks = [ + ...super.autolinks, { prefix: 'Change-Id: ', - url: `${this.baseReviewUrl}/q/`, + url: this.issueLinkPattern, alphanumeric: true, ignoreCase: true, title: `Open Change # on ${this.name}`, diff --git a/src/git/remotes/gitea.ts b/src/git/remotes/gitea.ts index b2fae0ebc56c4..ea375d6446379 100644 --- a/src/git/remotes/gitea.ts +++ b/src/git/remotes/gitea.ts @@ -14,13 +14,18 @@ export class GiteaRemote extends RemoteProvider { super(domain, path, protocol, name, custom); } + protected override get issueLinkPattern(): string { + return `${this.baseUrl}/issues/`; + } + private _autolinks: (AutolinkReference | DynamicAutolinkReference)[] | undefined; override get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] { if (this._autolinks === undefined) { this._autolinks = [ + ...super.autolinks, { prefix: '#', - url: `${this.baseUrl}/issues/`, + url: this.issueLinkPattern, alphanumeric: false, ignoreCase: false, title: `Open Issue # on ${this.name}`, diff --git a/src/git/remotes/github.ts b/src/git/remotes/github.ts index 28afc7ddbb317..26c4104fc04d8 100644 --- a/src/git/remotes/github.ts +++ b/src/git/remotes/github.ts @@ -33,13 +33,18 @@ export class GitHubRemote extends RemoteProvider { return this.custom ? `${this.protocol}://${this.domain}/api/v3` : `https://api.${this.domain}`; } + protected override get issueLinkPattern(): string { + return `${this.baseUrl}/issues/`; + } + private _autolinks: (AutolinkReference | DynamicAutolinkReference)[] | undefined; override get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] { if (this._autolinks === undefined) { this._autolinks = [ + ...super.autolinks, { prefix: '#', - url: `${this.baseUrl}/issues/`, + url: this.issueLinkPattern, alphanumeric: false, ignoreCase: false, title: `Open Issue or Pull Request # on ${this.name}`, @@ -48,7 +53,7 @@ export class GitHubRemote extends RemoteProvider { }, { prefix: 'gh-', - url: `${this.baseUrl}/issues/`, + url: this.issueLinkPattern, alphanumeric: false, ignoreCase: true, title: `Open Issue or Pull Request # on ${this.name}`, diff --git a/src/git/remotes/gitlab.ts b/src/git/remotes/gitlab.ts index 39385a6a1dbf6..519bee06de052 100644 --- a/src/git/remotes/gitlab.ts +++ b/src/git/remotes/gitlab.ts @@ -33,13 +33,18 @@ export class GitLabRemote extends RemoteProvider { return this.custom ? `${this.protocol}://${this.domain}/api` : `https://${this.domain}/api`; } + protected override get issueLinkPattern(): string { + return `${this.baseUrl}/-/issues/`; + } + private _autolinks: (AutolinkReference | DynamicAutolinkReference)[] | undefined; override get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] { if (this._autolinks === undefined) { this._autolinks = [ + ...super.autolinks, { prefix: '#', - url: `${this.baseUrl}/-/issues/`, + url: this.issueLinkPattern, alphanumeric: false, ignoreCase: false, title: `Open Issue # on ${this.name}`, diff --git a/src/git/remotes/google-source.ts b/src/git/remotes/google-source.ts index 7d0995cb7a9e9..35ba78fdd3d03 100644 --- a/src/git/remotes/google-source.ts +++ b/src/git/remotes/google-source.ts @@ -1,3 +1,4 @@ +import type { AutolinkReference, DynamicAutolinkReference } from '../../autolinks'; import type { GkProviderId } from '../../gk/models/repositoryIdentities'; import { GerritRemote } from './gerrit'; import type { RemoteProviderId } from './remoteProvider'; @@ -19,6 +20,14 @@ export class GoogleSourceRemote extends GerritRemote { return this.formatName('Google Source'); } + protected override get issueLinkPattern(): string { + throw new Error('unsupported'); + } + + override get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] { + return []; + } + protected override get baseUrl(): string { return `${this.protocol}://${this.domain}/${this.path}`; } diff --git a/src/git/remotes/remoteProvider.ts b/src/git/remotes/remoteProvider.ts index c36e491947d2c..9d87da95938dd 100644 --- a/src/git/remotes/remoteProvider.ts +++ b/src/git/remotes/remoteProvider.ts @@ -36,8 +36,19 @@ export abstract class RemoteProvider on ${this.name}`, + referenceType: 'branchName', + alphanumeric: false, + ignoreCase: true, + }, + ]; } get avatarUri(): Uri | undefined {