Skip to content

Commit

Permalink
#499 If a branch's name contains an issue number, the issue can be vi…
Browse files Browse the repository at this point in the history
…ewed via the branch's context menu.
  • Loading branch information
mhutchie committed Apr 18, 2021
1 parent 77cdae7 commit a28d573
Show file tree
Hide file tree
Showing 7 changed files with 105 additions and 27 deletions.
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@
"type": "boolean",
"title": "Push Branch..."
},
"viewIssue": {
"type": "boolean",
"title": "View Issue"
},
"createPullRequest": {
"type": "boolean",
"title": "Create Pull Request..."
Expand Down Expand Up @@ -263,6 +267,10 @@
"type": "boolean",
"title": "Pull into current branch..."
},
"viewIssue": {
"type": "boolean",
"title": "View Issue"
},
"createPullRequest": {
"type": "boolean",
"title": "Create Pull Request"
Expand Down
4 changes: 2 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,9 @@ class Config {
get contextMenuActionsVisibility(): ContextMenuActionsVisibility {
const userConfig = this.config.get('contextMenuActionsVisibility', {});
const config: ContextMenuActionsVisibility = {
branch: { checkout: true, rename: true, delete: true, merge: true, rebase: true, push: true, createPullRequest: true, createArchive: true, selectInBranchesDropdown: true, unselectInBranchesDropdown: true, copyName: true },
branch: { checkout: true, rename: true, delete: true, merge: true, rebase: true, push: true, viewIssue: true, createPullRequest: true, createArchive: true, selectInBranchesDropdown: true, unselectInBranchesDropdown: true, copyName: true },
commit: { addTag: true, createBranch: true, checkout: true, cherrypick: true, revert: true, drop: true, merge: true, rebase: true, reset: true, copyHash: true, copySubject: true },
remoteBranch: { checkout: true, delete: true, fetch: true, merge: true, pull: true, createPullRequest: true, createArchive: true, selectInBranchesDropdown: true, unselectInBranchesDropdown: true, copyName: true },
remoteBranch: { checkout: true, delete: true, fetch: true, merge: true, pull: true, viewIssue: true, createPullRequest: true, createArchive: true, selectInBranchesDropdown: true, unselectInBranchesDropdown: true, copyName: true },
stash: { apply: true, createBranch: true, pop: true, drop: true, copyName: true, copyHash: true },
tag: { viewDetails: true, delete: true, push: true, createArchive: true, copyName: true },
uncommittedChanges: { stash: true, reset: true, clean: true, openSourceControlView: true }
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ export interface ContextMenuActionsVisibility {
readonly merge: boolean;
readonly rebase: boolean;
readonly push: boolean;
readonly viewIssue: boolean;
readonly createPullRequest: boolean;
readonly createArchive: boolean;
readonly selectInBranchesDropdown: boolean;
Expand All @@ -373,6 +374,7 @@ export interface ContextMenuActionsVisibility {
readonly fetch: boolean;
readonly merge: boolean;
readonly pull: boolean;
readonly viewIssue: boolean;
readonly createPullRequest: boolean;
readonly createArchive: boolean;
readonly selectInBranchesDropdown: boolean;
Expand Down
6 changes: 6 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ describe('Config', () => {
merge: true,
rebase: true,
push: true,
viewIssue: true,
createPullRequest: true,
createArchive: true,
selectInBranchesDropdown: true,
Expand All @@ -295,6 +296,7 @@ describe('Config', () => {
fetch: true,
merge: true,
pull: true,
viewIssue: true,
createPullRequest: true,
createArchive: true,
selectInBranchesDropdown: true,
Expand Down Expand Up @@ -339,6 +341,7 @@ describe('Config', () => {
merge: true,
rebase: true,
push: true,
viewIssue: true,
createPullRequest: true,
createArchive: true,
selectInBranchesDropdown: true,
Expand All @@ -364,6 +367,7 @@ describe('Config', () => {
fetch: true,
merge: true,
pull: true,
viewIssue: true,
createPullRequest: true,
createArchive: true,
selectInBranchesDropdown: true,
Expand Down Expand Up @@ -423,6 +427,7 @@ describe('Config', () => {
merge: true,
rebase: true,
push: true,
viewIssue: true,
createPullRequest: true,
createArchive: true,
selectInBranchesDropdown: true,
Expand All @@ -448,6 +453,7 @@ describe('Config', () => {
fetch: false,
merge: true,
pull: true,
viewIssue: true,
createPullRequest: true,
createArchive: true,
selectInBranchesDropdown: true,
Expand Down
32 changes: 32 additions & 0 deletions web/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1055,6 +1055,7 @@ class GitGraphView {
}
}
], [
this.getViewIssueAction(refName, visibility.viewIssue, target),
{
title: 'Create Pull Request' + ELLIPSIS,
visible: visibility.createPullRequest && this.gitRepos[this.currentRepo].pullRequestConfig !== null,
Expand Down Expand Up @@ -1283,6 +1284,7 @@ class GitGraphView {
}
}
], [
this.getViewIssueAction(refName, visibility.viewIssue, target),
{
title: 'Create Pull Request',
visible: visibility.createPullRequest && this.gitRepos[this.currentRepo].pullRequestConfig !== null && branchName !== 'HEAD' &&
Expand Down Expand Up @@ -1507,6 +1509,36 @@ class GitGraphView {
]];
}

private getViewIssueAction(refName: string, visible: boolean, target: DialogTarget & RefTarget): ContextMenuAction {
const issueLinks: { url: string, displayText: string }[] = [];

let issueLinking: IssueLinking | null, match: RegExpExecArray | null;
if (visible && (issueLinking = parseIssueLinkingConfig(this.gitRepos[this.currentRepo].issueLinkingConfig)) !== null) {
issueLinking.regexp.lastIndex = 0;
while (match = issueLinking.regexp.exec(refName)) {
if (match[0].length === 0) break;
issueLinks.push({
url: generateIssueLinkFromMatch(match, issueLinking),
displayText: match[0]
});
}
}

return {
title: 'View Issue' + (issueLinks.length > 1 ? ELLIPSIS : ''),
visible: issueLinks.length > 0,
onClick: () => {
if (issueLinks.length > 1) {
dialog.showSelect('Select which issue you want to view for this branch:', '0', issueLinks.map((issueLink, i) => ({ name: issueLink.displayText, value: i.toString() })), 'View Issue', (value) => {
sendMessage({ command: 'openExternalUrl', url: issueLinks[parseInt(value)].url });
}, target);
} else if (issueLinks.length === 1) {
sendMessage({ command: 'openExternalUrl', url: issueLinks[0].url });
}
}
};
}


/* Actions */

Expand Down
12 changes: 6 additions & 6 deletions web/settingsWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,11 +204,11 @@ class SettingsWidget {
const issueLinkingConfig = this.repo.issueLinkingConfig || globalState.issueLinkingConfig;
if (issueLinkingConfig !== null) {
const escapedIssue = escapeHtml(issueLinkingConfig.issue), escapedUrl = escapeHtml(issueLinkingConfig.url);
html += '<table><tr><td class="left">Issue Regex:</td><td class="leftWithEllipsis" title="' + escapedIssue + '">' + escapedIssue + '</td></tr><tr><td class="left">Issue URL:</td><td class="leftWithEllipsis" title="' + escapedUrl + '">' + escapedUrl + '</td></tr></table>';
html += '<div class="settingsSectionButtons"><div id="editIssueLinking" class="editBtn">' + SVG_ICONS.pencil + 'Edit</div><div id="removeIssueLinking" class="removeBtn">' + SVG_ICONS.close + 'Remove</div></div>';
html += '<table><tr><td class="left">Issue Regex:</td><td class="leftWithEllipsis" title="' + escapedIssue + '">' + escapedIssue + '</td></tr><tr><td class="left">Issue URL:</td><td class="leftWithEllipsis" title="' + escapedUrl + '">' + escapedUrl + '</td></tr></table>' +
'<div class="settingsSectionButtons"><div id="editIssueLinking" class="editBtn">' + SVG_ICONS.pencil + 'Edit</div><div id="removeIssueLinking" class="removeBtn">' + SVG_ICONS.close + 'Remove</div></div>';
} else {
html += '<span>Issue Linking converts issue numbers in commit messages into hyperlinks, that open the issue in your issue tracking system.</span>';
html += '<div class="settingsSectionButtons"><div id="editIssueLinking" class="addBtn">' + SVG_ICONS.plus + 'Add Issue Linking</div></div>';
html += '<span>Issue Linking converts issue numbers in commit &amp; tag messages into hyperlinks, that open the issue in your issue tracking system. If a branch\'s name contains an issue number, the issue can be viewed via the branch\'s context menu.</span>' +
'<div class="settingsSectionButtons"><div id="editIssueLinking" class="addBtn">' + SVG_ICONS.plus + 'Add Issue Linking</div></div>';
}
html += '</div>';

Expand All @@ -233,7 +233,7 @@ class SettingsWidget {
'<tr><td class="left">Destination Branch:</td><td class="leftWithEllipsis" title="' + destinationBranch + '">' + destinationBranch + '</td></tr></table>' +
'<div class="settingsSectionButtons"><div id="editPullRequestIntegration" class="editBtn">' + SVG_ICONS.pencil + 'Edit</div><div id="removePullRequestIntegration" class="removeBtn">' + SVG_ICONS.close + 'Remove</div></div>';
} else {
html += '<span>Pull Request Creation automates the opening and pre-filling of a Pull Request form, directly from a branches context menu.</span>' +
html += '<span>Pull Request Creation automates the opening and pre-filling of a Pull Request form, directly from a branch\'s context menu.</span>' +
'<div class="settingsSectionButtons"><div id="editPullRequestIntegration" class="addBtn">' + SVG_ICONS.plus + 'Configure "Pull Request Creation" Integration</div></div>';
}
html += '</div>';
Expand Down Expand Up @@ -591,7 +591,7 @@ class SettingsWidget {

dialog.showForm(html, [
{ type: DialogInputType.Text, name: 'Issue Regex', default: defaultIssueRegex !== null ? defaultIssueRegex : '', placeholder: null, info: 'A regular expression that matches your issue numbers, with one or more capturing groups ( ) that will be substituted into the "Issue URL".' },
{ type: DialogInputType.Text, name: 'Issue URL', default: defaultIssueUrl !== null ? defaultIssueUrl : '', placeholder: null, info: 'The issue\'s URL in your project’s issue tracking system, with placeholders ($1, $2, etc.) for the groups captured ( ) in the "Issue Regex".' },
{ type: DialogInputType.Text, name: 'Issue URL', default: defaultIssueUrl !== null ? defaultIssueUrl : '', placeholder: null, info: 'The issue\'s URL in your issue tracking system, with placeholders ($1, $2, etc.) for the groups captured ( ) in the "Issue Regex".' },
{ type: DialogInputType.Checkbox, name: 'Use Globally', value: defaultUseGlobally, info: 'Use the "Issue Regex" and "Issue URL" for all repositories by default (it can be overridden per repository). Note: "Use Globally" is only suitable if identical Issue Linking applies to the majority of your repositories (e.g. when using JIRA or Pivotal Tracker).' }
], 'Save', (values) => {
let issueRegex = (<string>values[0]).trim(), issueUrl = (<string>values[1]).trim(), useGlobally = <boolean>values[2];
Expand Down
68 changes: 49 additions & 19 deletions web/textFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,7 @@ class TextFormatter {
urls: boolean
}>;
private readonly commits: ReadonlyArray<GG.GitCommit>;
private readonly issueLinking: Readonly<{
regexp: RegExp,
url: string
}> | null = null;
private readonly issueLinking: IssueLinking | null = null;

private static readonly BACKTICK_REGEXP: RegExp = /(\\*)(`+)/gu;
private static readonly BACKSLASH_ESCAPE_REGEXP: RegExp = /\\[\u0021-\u002F\u003A-\u0040\u005B-\u0060\u007B-\u007E]/gu;
Expand Down Expand Up @@ -141,15 +138,8 @@ class TextFormatter {
? repoIssueLinkingConfig
: globalState.issueLinkingConfig;

if (this.config.issueLinking && issueLinkingConfig !== null) {
try {
this.issueLinking = {
regexp: new RegExp(issueLinkingConfig.issue, 'gu'),
url: issueLinkingConfig.url
};
} catch (e) {
this.issueLinking = null;
}
if (this.config.issueLinking) {
this.issueLinking = parseIssueLinkingConfig(issueLinkingConfig);
}
}

Expand Down Expand Up @@ -266,12 +256,7 @@ class TextFormatter {
type: TF.NodeType.Url,
start: match.index,
end: this.issueLinking.regexp.lastIndex - 1,
url: match.length > 1
? this.issueLinking.url.replace(/\$([1-9][0-9]*)/g, (placeholder, index) => {
const i = parseInt(index);
return i < match!.length ? match![i] : placeholder;
})
: this.issueLinking.url,
url: generateIssueLinkFromMatch(match, this.issueLinking),
displayText: match[0],
contains: []
});
Expand Down Expand Up @@ -567,6 +552,9 @@ class TextFormatter {
}
}


/* URL Element Methods */

/**
* Is an element an external or internal URL.
* @param elem The element to check.
Expand All @@ -593,3 +581,45 @@ function isExternalUrlElem(elem: Element) {
function isInternalUrlElem(elem: Element) {
return elem.classList.contains(CLASS_INTERNAL_URL);
}


/* Issue Linking Methods */

interface IssueLinking {
readonly regexp: RegExp;
readonly url: string;
}

const ISSUE_LINKING_ARGUMENT_REGEXP = /\$([1-9][0-9]*)/g;

/**
* Parses the Issue Linking Configuration of a repository, so it's ready to be used for detecting issues and generating links.
* @param issueLinkingConfig The Issue Linking Configuration.
* @returns The parsed Issue Linking, or `NULL` if it's not available.
*/
function parseIssueLinkingConfig(issueLinkingConfig: GG.IssueLinkingConfig | null): IssueLinking | null {
if (issueLinkingConfig !== null) {
try {
return {
regexp: new RegExp(issueLinkingConfig.issue, 'gu'),
url: issueLinkingConfig.url
};
} catch (_) { }
}
return null;
}

/**
* Generate the URL for an issue link, performing all variable substitutions from a match.
* @param match The match produced by `IssueLinking.regexp`.
* @param issueLinking The Issue Linking.
* @returns The URL for the issue link.
*/
function generateIssueLinkFromMatch(match: RegExpExecArray, issueLinking: IssueLinking) {
return match.length > 1
? issueLinking.url.replace(ISSUE_LINKING_ARGUMENT_REGEXP, (placeholder, index) => {
const i = parseInt(index);
return i < match.length ? match[i] : placeholder;
})
: issueLinking.url;
}

0 comments on commit a28d573

Please sign in to comment.