diff --git a/CHANGELOG.md b/CHANGELOG.md index 90f931a..8dd8e43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added +- Adds user-defined autolinks to external resources in commit messages — closes [#897](https://github.com/eamodio/vscode-gitlens/issues/897) + - Adds a `gitlens.autolinks` setting to configure the autolinks + - For example to autolink Jira issues (e.g. `JIRA-123 ⟶ https://jira.company.com/issue?query=123`): + - Use `"gitlens.autolinks": [{ "prefix": "JIRA-", "url": "https://jira.company.com/issue?query=" }]` - Adds a _Highlight Changes_ command (`gitlens.views.highlightChanges`) to commits in GitLens views to highlight the changes lines in the current file - Adds a _Highlight Revision Changes_ command (`gitlens.views.highlightRevisionChanges`) to commits in GitLens views to highlight the changes lines in the revision - Adds branch and tag sorting options to the interactive settings editor diff --git a/README.md b/README.md index 7aae86e..87a3697 100644 --- a/README.md +++ b/README.md @@ -884,7 +884,13 @@ See also [View Settings](#view-settings- 'Jump to the View settings') | `gitlens.mode.statusBar.alignment` | Specifies the active GitLens mode alignment in the status bar

`left` - aligns to the left
`right` - aligns to the right | | `gitlens.modes` | Specifies the user-defined GitLens modes

Example — adds heatmap annotations to the built-in _Reviewing_ mode
`"gitlens.modes": { "review": { "annotations": "heatmap" } }`

Example — adds a new _Annotating_ mode with blame annotations
`"gitlens.modes": {`
    `"annotate": {`
        `"name": "Annotating",`
        `"statusBarItemName": "Annotating",`
        `"description": "for root cause analysis",`
        `"annotations": "blame",`
        `"codeLens": false,`
        `"currentLine": false,`
        `"hovers": true`
    `}`
`}` | -#### Custom Remotes Settings +### Autolink Settings + +| Name | Description | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `gitlens.autolinks` | Specifies autolinks to external resources in commit messages. Use `` as the variable for the reference number

Example to autolink Jira issues: (e.g. `JIRA-123 ⟶ https://jira.company.com/issue?query=123`)
`"gitlens.autolinks": [{ "prefix": "JIRA-", "url": "https://jira.company.com/issue?query=" }]` | + +### Custom Remotes Settings | Name | Description | | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/package.json b/package.json index 1436a38..2d1fce1 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,40 @@ "type": "object", "title": "GitLens — Use 'GitLens: Open Settings' for a richer, interactive experience", "properties": { + "gitlens.autolinks": { + "type": "array", + "items": { + "type": "object", + "required": [ + "prefix", + "url" + ], + "properties": { + "prefix": { + "type": "string", + "description": "Specifies the short prefix to use to generate autolinks for the external resource" + }, + "ignoreCase": { + "type": "boolean", + "description": "Specifies whether case should be ignored when matching the prefix", + "default": false + }, + "title": { + "type": "string", + "description": "Specifies an optional title for the generated autolink. Use `` as the variable for the reference number", + "default": null + }, + "url": { + "type": "string", + "description": "Specifies the url of the external resource you want to link to. Use `` as the variable for the reference number" + } + }, + "default": null + }, + "uniqueItems": true, + "markdownDescription": "Specifies autolinks to external resources in commit messages. Use as the variable for the reference number", + "scope": "window" + }, "gitlens.blame.avatars": { "type": "boolean", "default": true, diff --git a/src/annotations/annotations.ts b/src/annotations/annotations.ts index 5f4b7f7..fa3abda 100644 --- a/src/annotations/annotations.ts +++ b/src/annotations/annotations.ts @@ -1,3 +1,4 @@ +'use strict'; import { DecorationInstanceRenderOptions, DecorationOptions, diff --git a/src/annotations/autolinks.ts b/src/annotations/autolinks.ts new file mode 100644 index 0000000..5a4c568 --- /dev/null +++ b/src/annotations/autolinks.ts @@ -0,0 +1,84 @@ +'use strict'; +import { ConfigurationChangeEvent, Disposable } from 'vscode'; +import { AutolinkReference, configuration } from '../configuration'; +import { Container } from '../container'; +import { Strings } from '../system'; +import { Logger } from '../logger'; +import { GitRemote } from '../git/git'; + +const numRegex = //g; + +export interface DynamicAutolinkReference { + linkify: (text: string) => string; +} + +function requiresGenerator(ref: AutolinkReference | DynamicAutolinkReference): ref is AutolinkReference { + return ref.linkify === undefined; +} + +export class Autolinks implements Disposable { + protected _disposable: Disposable | undefined; + private _references: AutolinkReference[] = []; + + constructor() { + this._disposable = Disposable.from(configuration.onDidChange(this.onConfigurationChanged, this)); + + this.onConfigurationChanged(configuration.initializingChangeEvent); + } + + dispose() { + this._disposable && this._disposable.dispose(); + } + + private onConfigurationChanged(e: ConfigurationChangeEvent) { + if (configuration.changed(e, 'autolinks')) { + this._references = Container.config.autolinks ?? []; + } + } + + linkify(text: string, remotes?: GitRemote[]) { + for (const ref of this._references) { + if (requiresGenerator(ref)) { + ref.linkify = this._getAutolinkGenerator(ref); + } + + if (ref.linkify != null) { + text = ref.linkify(text); + } + } + + if (remotes !== undefined) { + for (const r of remotes) { + if (r.provider === undefined) continue; + + for (const ref of this._references) { + if (requiresGenerator(ref)) { + ref.linkify = this._getAutolinkGenerator(ref); + } + + if (ref.linkify != null) { + text = ref.linkify(text); + } + } + } + } + + return text; + } + + private _getAutolinkGenerator({ prefix, url, title }: AutolinkReference) { + try { + const regex = new RegExp( + `(?<=^|\\s)(${Strings.escapeMarkdown(prefix).replace(/\\/g, '\\\\')}([0-9]+))\\b`, + 'g' + ); + const markdown = `[$1](${url.replace(numRegex, '$2')}${ + title ? ` "${title.replace(numRegex, '$2')}"` : '' + })`; + return (text: string) => text.replace(regex, markdown); + } catch (ex) { + Logger.error(ex, `Failed to create autolink generator: prefix=${prefix}, url=${url}, title=${title}`); + return null; + } + } +} diff --git a/src/config.ts b/src/config.ts index 424fc9c..55535f5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,7 @@ import { TraceLevel } from './logger'; export interface Config { + autolinks: AutolinkReference[] | null; blame: { avatars: boolean; compact: boolean; @@ -117,6 +118,14 @@ export enum AnnotationsToggleMode { Window = 'window' } +export interface AutolinkReference { + prefix: string; + url: string; + title?: string; + ignoreCase?: boolean; + linkify?: ((text: string) => string) | null; +} + export enum BranchSorting { NameDesc = 'name:desc', NameAsc = 'name:asc', diff --git a/src/container.ts b/src/container.ts index 4456ad3..5110638 100644 --- a/src/container.ts +++ b/src/container.ts @@ -1,5 +1,6 @@ 'use strict'; import { commands, ConfigurationChangeEvent, Disposable, ExtensionContext, Uri } from 'vscode'; +import { Autolinks } from './annotations/autolinks'; import { FileAnnotationController } from './annotations/fileAnnotationController'; import { LineAnnotationController } from './annotations/lineAnnotationController'; import { GitCodeLensController } from './codelens/codeLensController'; @@ -141,6 +142,15 @@ export class Container { } } + private static _autolinks: Autolinks; + static get autolinks() { + if (this._autolinks === undefined) { + this._context.subscriptions.push((this._autolinks = new Autolinks())); + } + + return this._autolinks; + } + private static _codeLensController: GitCodeLensController; static get codeLens() { return this._codeLensController; diff --git a/src/git/formatters/commitFormatter.ts b/src/git/formatters/commitFormatter.ts index 57dbea5..546b67e 100644 --- a/src/git/formatters/commitFormatter.ts +++ b/src/git/formatters/commitFormatter.ts @@ -307,16 +307,7 @@ export class CommitFormatter extends Formatter { return message; } - message = Strings.escapeMarkdown(message, { quoted: true }); - - if (this._options.remotes !== undefined) { - for (const r of this._options.remotes) { - if (r.provider === undefined) continue; - - message = r.provider.enrichMessage(message); - break; - } - } + message = Container.autolinks.linkify(Strings.escapeMarkdown(message, { quoted: true }), this._options.remotes); return `\n> ${message}`; } @@ -357,3 +348,13 @@ export class CommitFormatter extends Formatter { return regex.test(format); } } + +// const autolinks = new Autolinks(); +// const text = autolinks.linkify(`\\#756 +// foo +// bar +// baz \\#756 +// boo\\#789 +// \\#666 +// gh\\-89 gh\\-89gh\\-89 GH\\-89`); +// console.log(text); diff --git a/src/git/remotes/azure-devops.ts b/src/git/remotes/azure-devops.ts index e68e058..b2b5c2b 100644 --- a/src/git/remotes/azure-devops.ts +++ b/src/git/remotes/azure-devops.ts @@ -1,8 +1,8 @@ 'use strict'; import { Range } from 'vscode'; import { RemoteProvider } from './provider'; - -const issueEnricherRegex = /(^|\s)\\?(#([0-9]+))\b/gi; +import { AutolinkReference } from '../../config'; +import { DynamicAutolinkReference } from '../../annotations/autolinks'; const gitRegex = /\/_git\/?/i; const legacyDefaultCollectionRegex = /^DefaultCollection\//i; @@ -34,6 +34,22 @@ export class AzureDevOpsRemote extends RemoteProvider { super(domain, path, protocol, name); } + private _autolinks: (AutolinkReference | DynamicAutolinkReference)[] | undefined; + get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] { + if (this._autolinks === undefined) { + // Strip off any `_git` part from the repo url + const baseUrl = this.baseUrl.replace(gitRegex, '/'); + this._autolinks = [ + { + prefix: '#', + url: `${baseUrl}/_workitems/edit/`, + title: 'Open Work Item #' + } + ]; + } + return this._autolinks; + } + get icon() { return 'vsts'; } @@ -50,16 +66,6 @@ export class AzureDevOpsRemote extends RemoteProvider { return this._displayPath; } - enrichMessage(message: string): string { - // Strip off any `_git` part from the repo url - const baseUrl = this.baseUrl.replace(gitRegex, '/'); - return ( - message - // Matches #123 - .replace(issueEnricherRegex, `$1[$2](${baseUrl}/_workitems/edit/$3 "Open Work Item $2")`) - ); - } - protected getUrlForBranches(): string { return `${this.baseUrl}/branches`; } diff --git a/src/git/remotes/bitbucket-server.ts b/src/git/remotes/bitbucket-server.ts index f934935..1aeb9c5 100644 --- a/src/git/remotes/bitbucket-server.ts +++ b/src/git/remotes/bitbucket-server.ts @@ -1,15 +1,33 @@ 'use strict'; import { Range } from 'vscode'; import { RemoteProvider } from './provider'; - -const issueEnricherRegex = /(^|\s)(issue \\?#([0-9]+))\b/gi; -const prEnricherRegex = /(^|\s)(pull request \\?#([0-9]+))\b/gi; +import { AutolinkReference } from '../../config'; +import { DynamicAutolinkReference } from '../../annotations/autolinks'; export class BitbucketServerRemote extends RemoteProvider { constructor(domain: string, path: string, protocol?: string, name?: string, custom: boolean = false) { super(domain, path, protocol, name, custom); } + private _autolinks: (AutolinkReference | DynamicAutolinkReference)[] | undefined; + get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] { + if (this._autolinks === undefined) { + this._autolinks = [ + { + prefix: 'issue #', + url: `${this.baseUrl}/issues/`, + title: 'Open Issue #' + }, + { + prefix: 'pull request #', + url: `${this.baseUrl}/pull-requests/`, + title: 'Open PR #' + } + ]; + } + return this._autolinks; + } + protected get baseUrl() { const [project, repo] = this.path.startsWith('scm/') ? this.path.replace('scm/', '').split('/') @@ -25,16 +43,6 @@ export class BitbucketServerRemote extends RemoteProvider { return this.formatName('Bitbucket Server'); } - enrichMessage(message: string): string { - return ( - message - // Matches issue #123 - .replace(issueEnricherRegex, `$1[$2](${this.baseUrl}/issues/$3 "Open Issue $2")`) - // Matches pull request #123 - .replace(prEnricherRegex, `$1[$2](${this.baseUrl}/pull-requests/$3 "Open PR $2")`) - ); - } - protected getUrlForBranches(): string { return `${this.baseUrl}/branches`; } diff --git a/src/git/remotes/bitbucket.ts b/src/git/remotes/bitbucket.ts index 5892153..f469eea 100644 --- a/src/git/remotes/bitbucket.ts +++ b/src/git/remotes/bitbucket.ts @@ -1,15 +1,33 @@ 'use strict'; import { Range } from 'vscode'; import { RemoteProvider } from './provider'; - -const issueEnricherRegex = /(^|\s)(issue \\?#([0-9]+))\b/gi; -const prEnricherRegex = /(^|\s)(pull request \\?#([0-9]+))\b/gi; +import { AutolinkReference } from '../../config'; +import { DynamicAutolinkReference } from '../../annotations/autolinks'; export class BitbucketRemote extends RemoteProvider { constructor(domain: string, path: string, protocol?: string, name?: string, custom: boolean = false) { super(domain, path, protocol, name, custom); } + private _autolinks: (AutolinkReference | DynamicAutolinkReference)[] | undefined; + get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] { + if (this._autolinks === undefined) { + this._autolinks = [ + { + prefix: 'issue #', + url: `${this.baseUrl}/issues/`, + title: 'Open Issue #' + }, + { + prefix: 'pull request #', + url: `${this.baseUrl}/pull-requests/`, + title: 'Open PR #' + } + ]; + } + return this._autolinks; + } + get icon() { return 'bitbucket'; } @@ -18,16 +36,6 @@ export class BitbucketRemote extends RemoteProvider { return this.formatName('Bitbucket'); } - enrichMessage(message: string): string { - return ( - message - // Matches issue #123 - .replace(issueEnricherRegex, `$1[$2](${this.baseUrl}/issues/$3 "Open Issue $2")`) - // Matches pull request #123 - .replace(prEnricherRegex, `$1[$2](${this.baseUrl}/pull-requests/$3 "Open PR $2")`) - ); - } - protected getUrlForBranches(): string { return `${this.baseUrl}/branches`; } diff --git a/src/git/remotes/github.ts b/src/git/remotes/github.ts index 9dc0079..506d361 100644 --- a/src/git/remotes/github.ts +++ b/src/git/remotes/github.ts @@ -1,8 +1,9 @@ 'use strict'; import { Range } from 'vscode'; import { RemoteProvider } from './provider'; +import { AutolinkReference } from '../../config'; +import { DynamicAutolinkReference } from '../../annotations/autolinks'; -const issueEnricherRegex = /(^|\s)((?:\\?#|gh\\?-)([0-9]+))\b/gi; const issueEnricher3rdParyRegex = /\b(\w+\\?-?\w+(?!\\?-)\/\w+\\?-?\w+(?!\\?-))\\?#([0-9]+)\b/g; export class GitHubRemote extends RemoteProvider { @@ -10,6 +11,33 @@ export class GitHubRemote extends RemoteProvider { super(domain, path, protocol, name, custom); } + private _autolinks: (AutolinkReference | DynamicAutolinkReference)[] | undefined; + get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] { + if (this._autolinks === undefined) { + this._autolinks = [ + { + prefix: '#', + url: `${this.baseUrl}/issues/`, + title: 'Open Issue #' + }, + { + prefix: 'gh-', + url: `${this.baseUrl}/issues/`, + title: 'Open Issue #', + ignoreCase: true + }, + { + linkify: (text: string) => + text.replace( + issueEnricher3rdParyRegex, + `[$&](${this.protocol}://${this.domain}/$1/issues/$2 "Open Issue #$2 from $1")` + ) + } + ]; + } + return this._autolinks; + } + get icon() { return 'github'; } @@ -18,18 +46,18 @@ export class GitHubRemote extends RemoteProvider { return this.formatName('GitHub'); } - enrichMessage(message: string): string { - return ( - message - // Matches #123 or gh-123 or GH-123 - .replace(issueEnricherRegex, `$1[$2](${this.baseUrl}/issues/$3 "Open Issue $2")`) - // Matches eamodio/vscode-gitlens#123 - .replace( - issueEnricher3rdParyRegex, - `[$&](${this.protocol}://${this.domain}/$1/issues/$2 "Open Issue #$2 from $1")` - ) - ); - } + // enrichMessage(message: string): string { + // return ( + // message + // // Matches #123 or gh-123 or GH-123 + // .replace(issueEnricherRegex, `$1[$2](${this.baseUrl}/issues/$3 "Open Issue $2")`) + // // Matches eamodio/vscode-gitlens#123 + // .replace( + // issueEnricher3rdParyRegex, + // `[$&](${this.protocol}://${this.domain}/$1/issues/$2 "Open Issue #$2 from $1")` + // ) + // ); + // } protected getUrlForBranches(): string { return `${this.baseUrl}/branches`; diff --git a/src/git/remotes/gitlab.ts b/src/git/remotes/gitlab.ts index cf13815..1392a9f 100644 --- a/src/git/remotes/gitlab.ts +++ b/src/git/remotes/gitlab.ts @@ -1,14 +1,28 @@ 'use strict'; import { Range } from 'vscode'; import { RemoteProvider } from './provider'; - -const issueEnricherRegex = /(^|\s)(\\?#([0-9]+))\b/gi; +import { AutolinkReference } from '../../config'; +import { DynamicAutolinkReference } from '../../annotations/autolinks'; export class GitLabRemote extends RemoteProvider { constructor(domain: string, path: string, protocol?: string, name?: string, custom: boolean = false) { super(domain, path, protocol, name, custom); } + private _autolinks: (AutolinkReference | DynamicAutolinkReference)[] | undefined; + get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] { + if (this._autolinks === undefined) { + this._autolinks = [ + { + prefix: '#', + url: `${this.baseUrl}/issues/`, + title: 'Open Issue #' + } + ]; + } + return this._autolinks; + } + get icon() { return 'gitlab'; } @@ -17,14 +31,6 @@ export class GitLabRemote extends RemoteProvider { return this.formatName('GitLab'); } - enrichMessage(message: string): string { - return ( - message - // Matches #123 - .replace(issueEnricherRegex, `$1[$2](${this.baseUrl}/issues/$3 "Open Issue $2")`) - ); - } - protected getUrlForBranches(): string { return `${this.baseUrl}/branches`; } diff --git a/src/git/remotes/provider.ts b/src/git/remotes/provider.ts index f4d984f..e294d8e 100644 --- a/src/git/remotes/provider.ts +++ b/src/git/remotes/provider.ts @@ -1,8 +1,10 @@ 'use strict'; import { env, Range, Uri, window } from 'vscode'; +import { AutolinkReference } from '../../config'; import { Logger } from '../../logger'; import { Messages } from '../../messages'; import { GitLogCommit } from '../models/logCommit'; +import { DynamicAutolinkReference } from '../../annotations/autolinks'; export enum RemoteResourceType { Branch = 'branch', @@ -75,6 +77,10 @@ export abstract class RemoteProvider { this._name = name; } + get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] { + return []; + } + get icon(): string { return 'remote'; } @@ -89,10 +95,6 @@ export abstract class RemoteProvider { return `${this.protocol}://${this.domain}/${this.path}`; } - enrichMessage(message: string): string { - return message; - } - protected formatName(name: string) { if (this._name !== undefined) return this._name; return `${name}${this.custom ? ` (${this.domain})` : ''}`;