Skip to content

Commit a28d573

Browse files
committed
#499 If a branch's name contains an issue number, the issue can be viewed via the branch's context menu.
1 parent 77cdae7 commit a28d573

File tree

7 files changed

+105
-27
lines changed

7 files changed

+105
-27
lines changed

package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,10 @@
169169
"type": "boolean",
170170
"title": "Push Branch..."
171171
},
172+
"viewIssue": {
173+
"type": "boolean",
174+
"title": "View Issue"
175+
},
172176
"createPullRequest": {
173177
"type": "boolean",
174178
"title": "Create Pull Request..."
@@ -263,6 +267,10 @@
263267
"type": "boolean",
264268
"title": "Pull into current branch..."
265269
},
270+
"viewIssue": {
271+
"type": "boolean",
272+
"title": "View Issue"
273+
},
266274
"createPullRequest": {
267275
"type": "boolean",
268276
"title": "Create Pull Request"

src/config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@ class Config {
8181
get contextMenuActionsVisibility(): ContextMenuActionsVisibility {
8282
const userConfig = this.config.get('contextMenuActionsVisibility', {});
8383
const config: ContextMenuActionsVisibility = {
84-
branch: { checkout: true, rename: true, delete: true, merge: true, rebase: true, push: true, createPullRequest: true, createArchive: true, selectInBranchesDropdown: true, unselectInBranchesDropdown: true, copyName: true },
84+
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 },
8585
commit: { addTag: true, createBranch: true, checkout: true, cherrypick: true, revert: true, drop: true, merge: true, rebase: true, reset: true, copyHash: true, copySubject: true },
86-
remoteBranch: { checkout: true, delete: true, fetch: true, merge: true, pull: true, createPullRequest: true, createArchive: true, selectInBranchesDropdown: true, unselectInBranchesDropdown: true, copyName: true },
86+
remoteBranch: { checkout: true, delete: true, fetch: true, merge: true, pull: true, viewIssue: true, createPullRequest: true, createArchive: true, selectInBranchesDropdown: true, unselectInBranchesDropdown: true, copyName: true },
8787
stash: { apply: true, createBranch: true, pop: true, drop: true, copyName: true, copyHash: true },
8888
tag: { viewDetails: true, delete: true, push: true, createArchive: true, copyName: true },
8989
uncommittedChanges: { stash: true, reset: true, clean: true, openSourceControlView: true }

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ export interface ContextMenuActionsVisibility {
348348
readonly merge: boolean;
349349
readonly rebase: boolean;
350350
readonly push: boolean;
351+
readonly viewIssue: boolean;
351352
readonly createPullRequest: boolean;
352353
readonly createArchive: boolean;
353354
readonly selectInBranchesDropdown: boolean;
@@ -373,6 +374,7 @@ export interface ContextMenuActionsVisibility {
373374
readonly fetch: boolean;
374375
readonly merge: boolean;
375376
readonly pull: boolean;
377+
readonly viewIssue: boolean;
376378
readonly createPullRequest: boolean;
377379
readonly createArchive: boolean;
378380
readonly selectInBranchesDropdown: boolean;

tests/config.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ describe('Config', () => {
270270
merge: true,
271271
rebase: true,
272272
push: true,
273+
viewIssue: true,
273274
createPullRequest: true,
274275
createArchive: true,
275276
selectInBranchesDropdown: true,
@@ -295,6 +296,7 @@ describe('Config', () => {
295296
fetch: true,
296297
merge: true,
297298
pull: true,
299+
viewIssue: true,
298300
createPullRequest: true,
299301
createArchive: true,
300302
selectInBranchesDropdown: true,
@@ -339,6 +341,7 @@ describe('Config', () => {
339341
merge: true,
340342
rebase: true,
341343
push: true,
344+
viewIssue: true,
342345
createPullRequest: true,
343346
createArchive: true,
344347
selectInBranchesDropdown: true,
@@ -364,6 +367,7 @@ describe('Config', () => {
364367
fetch: true,
365368
merge: true,
366369
pull: true,
370+
viewIssue: true,
367371
createPullRequest: true,
368372
createArchive: true,
369373
selectInBranchesDropdown: true,
@@ -423,6 +427,7 @@ describe('Config', () => {
423427
merge: true,
424428
rebase: true,
425429
push: true,
430+
viewIssue: true,
426431
createPullRequest: true,
427432
createArchive: true,
428433
selectInBranchesDropdown: true,
@@ -448,6 +453,7 @@ describe('Config', () => {
448453
fetch: false,
449454
merge: true,
450455
pull: true,
456+
viewIssue: true,
451457
createPullRequest: true,
452458
createArchive: true,
453459
selectInBranchesDropdown: true,

web/main.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,6 +1055,7 @@ class GitGraphView {
10551055
}
10561056
}
10571057
], [
1058+
this.getViewIssueAction(refName, visibility.viewIssue, target),
10581059
{
10591060
title: 'Create Pull Request' + ELLIPSIS,
10601061
visible: visibility.createPullRequest && this.gitRepos[this.currentRepo].pullRequestConfig !== null,
@@ -1283,6 +1284,7 @@ class GitGraphView {
12831284
}
12841285
}
12851286
], [
1287+
this.getViewIssueAction(refName, visibility.viewIssue, target),
12861288
{
12871289
title: 'Create Pull Request',
12881290
visible: visibility.createPullRequest && this.gitRepos[this.currentRepo].pullRequestConfig !== null && branchName !== 'HEAD' &&
@@ -1507,6 +1509,36 @@ class GitGraphView {
15071509
]];
15081510
}
15091511

1512+
private getViewIssueAction(refName: string, visible: boolean, target: DialogTarget & RefTarget): ContextMenuAction {
1513+
const issueLinks: { url: string, displayText: string }[] = [];
1514+
1515+
let issueLinking: IssueLinking | null, match: RegExpExecArray | null;
1516+
if (visible && (issueLinking = parseIssueLinkingConfig(this.gitRepos[this.currentRepo].issueLinkingConfig)) !== null) {
1517+
issueLinking.regexp.lastIndex = 0;
1518+
while (match = issueLinking.regexp.exec(refName)) {
1519+
if (match[0].length === 0) break;
1520+
issueLinks.push({
1521+
url: generateIssueLinkFromMatch(match, issueLinking),
1522+
displayText: match[0]
1523+
});
1524+
}
1525+
}
1526+
1527+
return {
1528+
title: 'View Issue' + (issueLinks.length > 1 ? ELLIPSIS : ''),
1529+
visible: issueLinks.length > 0,
1530+
onClick: () => {
1531+
if (issueLinks.length > 1) {
1532+
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) => {
1533+
sendMessage({ command: 'openExternalUrl', url: issueLinks[parseInt(value)].url });
1534+
}, target);
1535+
} else if (issueLinks.length === 1) {
1536+
sendMessage({ command: 'openExternalUrl', url: issueLinks[0].url });
1537+
}
1538+
}
1539+
};
1540+
}
1541+
15101542

15111543
/* Actions */
15121544

web/settingsWidget.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -204,11 +204,11 @@ class SettingsWidget {
204204
const issueLinkingConfig = this.repo.issueLinkingConfig || globalState.issueLinkingConfig;
205205
if (issueLinkingConfig !== null) {
206206
const escapedIssue = escapeHtml(issueLinkingConfig.issue), escapedUrl = escapeHtml(issueLinkingConfig.url);
207-
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>';
208-
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>';
207+
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>' +
208+
'<div class="settingsSectionButtons"><div id="editIssueLinking" class="editBtn">' + SVG_ICONS.pencil + 'Edit</div><div id="removeIssueLinking" class="removeBtn">' + SVG_ICONS.close + 'Remove</div></div>';
209209
} else {
210-
html += '<span>Issue Linking converts issue numbers in commit messages into hyperlinks, that open the issue in your issue tracking system.</span>';
211-
html += '<div class="settingsSectionButtons"><div id="editIssueLinking" class="addBtn">' + SVG_ICONS.plus + 'Add Issue Linking</div></div>';
210+
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>' +
211+
'<div class="settingsSectionButtons"><div id="editIssueLinking" class="addBtn">' + SVG_ICONS.plus + 'Add Issue Linking</div></div>';
212212
}
213213
html += '</div>';
214214

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

592592
dialog.showForm(html, [
593593
{ 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".' },
594-
{ 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".' },
594+
{ 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".' },
595595
{ 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).' }
596596
], 'Save', (values) => {
597597
let issueRegex = (<string>values[0]).trim(), issueUrl = (<string>values[1]).trim(), useGlobally = <boolean>values[2];

web/textFormatter.ts

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,7 @@ class TextFormatter {
110110
urls: boolean
111111
}>;
112112
private readonly commits: ReadonlyArray<GG.GitCommit>;
113-
private readonly issueLinking: Readonly<{
114-
regexp: RegExp,
115-
url: string
116-
}> | null = null;
113+
private readonly issueLinking: IssueLinking | null = null;
117114

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

144-
if (this.config.issueLinking && issueLinkingConfig !== null) {
145-
try {
146-
this.issueLinking = {
147-
regexp: new RegExp(issueLinkingConfig.issue, 'gu'),
148-
url: issueLinkingConfig.url
149-
};
150-
} catch (e) {
151-
this.issueLinking = null;
152-
}
141+
if (this.config.issueLinking) {
142+
this.issueLinking = parseIssueLinkingConfig(issueLinkingConfig);
153143
}
154144
}
155145

@@ -266,12 +256,7 @@ class TextFormatter {
266256
type: TF.NodeType.Url,
267257
start: match.index,
268258
end: this.issueLinking.regexp.lastIndex - 1,
269-
url: match.length > 1
270-
? this.issueLinking.url.replace(/\$([1-9][0-9]*)/g, (placeholder, index) => {
271-
const i = parseInt(index);
272-
return i < match!.length ? match![i] : placeholder;
273-
})
274-
: this.issueLinking.url,
259+
url: generateIssueLinkFromMatch(match, this.issueLinking),
275260
displayText: match[0],
276261
contains: []
277262
});
@@ -567,6 +552,9 @@ class TextFormatter {
567552
}
568553
}
569554

555+
556+
/* URL Element Methods */
557+
570558
/**
571559
* Is an element an external or internal URL.
572560
* @param elem The element to check.
@@ -593,3 +581,45 @@ function isExternalUrlElem(elem: Element) {
593581
function isInternalUrlElem(elem: Element) {
594582
return elem.classList.contains(CLASS_INTERNAL_URL);
595583
}
584+
585+
586+
/* Issue Linking Methods */
587+
588+
interface IssueLinking {
589+
readonly regexp: RegExp;
590+
readonly url: string;
591+
}
592+
593+
const ISSUE_LINKING_ARGUMENT_REGEXP = /\$([1-9][0-9]*)/g;
594+
595+
/**
596+
* Parses the Issue Linking Configuration of a repository, so it's ready to be used for detecting issues and generating links.
597+
* @param issueLinkingConfig The Issue Linking Configuration.
598+
* @returns The parsed Issue Linking, or `NULL` if it's not available.
599+
*/
600+
function parseIssueLinkingConfig(issueLinkingConfig: GG.IssueLinkingConfig | null): IssueLinking | null {
601+
if (issueLinkingConfig !== null) {
602+
try {
603+
return {
604+
regexp: new RegExp(issueLinkingConfig.issue, 'gu'),
605+
url: issueLinkingConfig.url
606+
};
607+
} catch (_) { }
608+
}
609+
return null;
610+
}
611+
612+
/**
613+
* Generate the URL for an issue link, performing all variable substitutions from a match.
614+
* @param match The match produced by `IssueLinking.regexp`.
615+
* @param issueLinking The Issue Linking.
616+
* @returns The URL for the issue link.
617+
*/
618+
function generateIssueLinkFromMatch(match: RegExpExecArray, issueLinking: IssueLinking) {
619+
return match.length > 1
620+
? issueLinking.url.replace(ISSUE_LINKING_ARGUMENT_REGEXP, (placeholder, index) => {
621+
const i = parseInt(index);
622+
return i < match.length ? match[i] : placeholder;
623+
})
624+
: issueLinking.url;
625+
}

0 commit comments

Comments
 (0)