diff --git a/README.md b/README.md index f6868a1..e1abf98 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,22 @@ Duplicate of #1 {mentions: [{raw: '@user', prefix: '@', user: 'user'}]} ``` +### Parse references with full issue URL + +```text +https://github.com/owner/repo/pull/1 + +Fix https://github.com/owner/repo/issues/2 +``` +```js +{ + refs: [{raw: 'https://github.com/owner/repo/pull/1', slug: 'owner/repo', prefix: undefined, issue: '1'},] + actions: [ + {raw: 'Fix https://github.com/owner/repo/issues/2', action: 'Fix', slug: 'owner/repo', prefix: undefined, issue: '2'} + ] +} +``` + ### Ignore keywords case ```text @@ -287,6 +303,20 @@ Default: `['#', 'gh-']` List of keywords used to identify issues and pull requests. +##### hosts + +Type: `Array` `String`
+Default: `['https://github.com', 'https://gitlab.com']` + +List of base URL used to identify issues and pull requests with [full URL](#parse-references-with-full-issue-url). + +##### issueURLSegments + +Type: `Array` `String`
+Default: `['issues', 'pull', 'merge_requests']` + +List of URL segment used to identify issues and pull requests with [full URL](#parse-references-with-full-issue-url). + ### parse(text) => Result Parse an issue description and returns a [Result](#result) object. diff --git a/index.js b/index.js index 6291887..d10529b 100644 --- a/index.js +++ b/index.js @@ -9,6 +9,8 @@ const hostConfig = require('./lib/hosts-config'); const FENCE_BLOCK_REGEXP = /^(([ \t]*`{3,4})([^\n]*)([\s\S]+?)(^[ \t]*\2))/gm; const CODE_BLOCK_REGEXP = /(`(?![\\]))((?:.(?!\1(?![\\])))*.?)\1/g; const HTML_CODE_BLOCK_REGEXP = /()+?)[\S\s])*(<\/code>)+?/gim; +const LEADING_TRAILING_SLASH_REGEXP = /^\/?([^/]+(?:\/[^/]+)*)\/?$/; +const TRAILING_SLASH_REGEXP = /\/?$/; function inverse(str) { return str @@ -18,19 +20,34 @@ function inverse(str) { } function join(keywords) { - return keywords.map(escapeRegExp).join('|'); + return keywords + .filter(Boolean) + .map(escapeRegExp) + .join('|'); } -function buildMentionsRegexp(opts) { - return `((?:(?:[^\\w\\n\\v\\r]|^)+(?:${join(opts.mentionsPrefixes)})[\\w-\\.]+[^\\W])+)`; +function addLeadingAndTrailingSlash(value) { + return value.replace(LEADING_TRAILING_SLASH_REGEXP, '/$1/'); } -function buildRefRegexp(opts) { - return `(?:(?:[^\\w\\n\\v\\r]|^)+(${join([].concat(opts.referenceActions, opts.duplicateActions))}))?(?:${[ - '[^\\w\\n\\v\\r]|^', - ] - .concat(join(opts.issuePrefixes)) - .join('|')})+((?:(?:[\\w-\\.]+)\\/)+(?:[\\w-\\.]+))?(${join(opts.issuePrefixes)})(\\d+)(?!\\w)`; +function addTrailingSlash(value) { + return value.replace(TRAILING_SLASH_REGEXP, '/'); +} + +function includesIgnoreCase(arr, value) { + return arr.findIndex(val => val.toUpperCase() === value.toUpperCase()) > -1; +} + +function buildMentionsRegexp({mentionsPrefixes}) { + return `((?:(?:[^\\w\\n\\v\\r]|^)+(?:${join(mentionsPrefixes)})[\\w-\\.]+[^\\W])+)`; +} + +function buildRefRegexp({referenceActions, duplicateActions, issuePrefixes, issueURLSegments, hosts}) { + return `(?:(?:[^\\w\\n\\v\\r]|^)+(${join([].concat(referenceActions, duplicateActions))}))?(?:${['[^\\w\\n\\v\\r]|^'] + .concat(join(issuePrefixes.concat(issueURLSegments))) + .join('|')})+${hosts.length > 0 ? `(?:${join(hosts)})?` : ''}((?:(?:[\\w-\\.]+)\\/)+(?:[\\w-\\.]+))?(${join( + issuePrefixes.concat(issueURLSegments) + )})(\\d+)(?!\\w)`; } function buildRegexp(opts) { @@ -42,11 +59,11 @@ function buildRegexp(opts) { ); } -function buildMentionRegexp(opts) { - return new RegExp(`(${join(opts.mentionsPrefixes)})([\\w-.]+)`, 'gim'); +function buildMentionRegexp({mentionsPrefixes}) { + return new RegExp(`(${join(mentionsPrefixes)})([\\w-.]+)`, 'gim'); } -function parse(text, regexp, mentionRegexp, opts) { +function parse(text, regexp, mentionRegexp, {issuePrefixes, hosts, referenceActions, duplicateActions}) { let parsed; const results = {actions: [], refs: [], duplicates: [], mentions: []}; let noCodeBlock = inverse(inverse(text.replace(FENCE_BLOCK_REGEXP, '')).replace(CODE_BLOCK_REGEXP, '')); @@ -57,12 +74,20 @@ function parse(text, regexp, mentionRegexp, opts) { while ((parsed = regexp.exec(noCodeBlock)) !== null) { let [raw, action, slug, prefix, issue, mentions] = parsed; - raw = parsed[0].substring(parsed[0].indexOf(parsed[1] || parsed[2] || parsed[3])); + prefix = + prefix && issuePrefixes.some(issuePrefix => issuePrefix.toUpperCase() === prefix.toUpperCase()) + ? prefix + : undefined; + raw = parsed[0].substring( + parsed[0].indexOf( + parsed[1] || hosts.find(host => parsed[0].toUpperCase().includes(host.toUpperCase())) || parsed[2] || parsed[3] + ) + ); action = capitalize(parsed[1]); - if (opts.referenceActions.findIndex(fix => fix.toUpperCase() === action.toUpperCase()) > -1) { + if (includesIgnoreCase(referenceActions, action)) { results.actions.push({raw, action, slug, prefix, issue}); - } else if (opts.duplicateActions.findIndex(duplicate => duplicate.toUpperCase() === action.toUpperCase()) > -1) { + } else if (includesIgnoreCase(duplicateActions, action)) { results.duplicates.push({raw, action, slug, prefix, issue}); } else if (issue) { results.refs.push({raw, slug, prefix, issue}); @@ -79,7 +104,7 @@ function parse(text, regexp, mentionRegexp, opts) { } module.exports = options => { - if (!isUndefined(options) && !isString(options) && !isPlainObject(options)) { + if (options !== undefined && !isString(options) && !isPlainObject(options)) { throw new TypeError('Options must be a String or an Object'); } @@ -97,6 +122,9 @@ module.exports = options => { opts[opt] = opts[opt].filter(Boolean); } + opts.hosts = opts.hosts.map(addTrailingSlash); + opts.issueURLSegments = opts.issueURLSegments.map(addLeadingAndTrailingSlash); + const regexp = buildRegexp(opts); const mentionRegexp = buildMentionRegexp(opts); diff --git a/lib/hosts-config.js b/lib/hosts-config.js index 6faff9a..8a7daaf 100644 --- a/lib/hosts-config.js +++ b/lib/hosts-config.js @@ -7,6 +7,8 @@ module.exports = { // https://guides.github.com/features/issues/#notifications mentionsPrefixes: ['@'], issuePrefixes: ['#', 'gh-'], + hosts: ['https://github.com'], + issueURLSegments: ['issues', 'pull'], }, bitbucket: { // https://confluence.atlassian.com/bitbucket/resolve-issues-automatically-when-users-push-code-221451126.html @@ -29,6 +31,8 @@ module.exports = { mentionsPrefixes: ['@'], // https://confluence.atlassian.com/bitbucket/mark-up-comments-issues-and-commit-messages-321859781.html issuePrefixes: ['#'], + hosts: [], + issueURLSegments: [], }, gitlab: { // https://docs.gitlab.com/ee/user/project/issues/automatic_issue_closing.html @@ -56,6 +60,8 @@ module.exports = { mentionsPrefixes: ['@'], // https://about.gitlab.com/2016/03/08/gitlab-tutorial-its-all-connected issuePrefixes: ['#', '!'], + hosts: ['https://gitlab.com'], + issueURLSegments: ['issues', 'merge_requests'], }, default: { referenceActions: [ @@ -79,5 +85,7 @@ module.exports = { duplicateActions: ['Duplicate of', '/duplicate'], mentionsPrefixes: ['@'], issuePrefixes: ['#', 'gh-'], + hosts: ['https://github.com', 'https://gitlab.com'], + issueURLSegments: ['issues', 'pull', 'merge_requests'], }, }; diff --git a/test/index.test.js b/test/index.test.js index b526518..76a27e8 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -2,21 +2,30 @@ import test from 'ava'; import m from '..'; test('Parse GitHub issue', t => { - t.deepEqual(m('GitHub')('Fix #1 reSOLved gh-2 CLOSES Gh-3 fix o/r#4 #5 o/r#6 fixing #7 Duplicate OF #8 @user'), { - actions: [ - {raw: 'Fix #1', action: 'Fix', slug: undefined, prefix: '#', issue: '1'}, - {raw: 'reSOLved gh-2', action: 'Resolved', slug: undefined, prefix: 'gh-', issue: '2'}, - {raw: 'CLOSES Gh-3', action: 'Closes', slug: undefined, prefix: 'Gh-', issue: '3'}, - {raw: 'fix o/r#4', action: 'Fix', slug: 'o/r', prefix: '#', issue: '4'}, - ], - refs: [ - {raw: '#5', slug: undefined, prefix: '#', issue: '5'}, - {raw: 'o/r#6', slug: 'o/r', prefix: '#', issue: '6'}, - {raw: '#7', slug: undefined, prefix: '#', issue: '7'}, - ], - duplicates: [{raw: 'Duplicate OF #8', action: 'Duplicate of', slug: undefined, prefix: '#', issue: '8'}], - mentions: [{raw: '@user', prefix: '@', user: 'user'}], - }); + t.deepEqual( + m('GitHub')( + 'Fix #1 reSOLved gh-2 CLOSES Gh-3 fix o/r#4 #5 o/r#6 fix https://github.com/o/r/issues/7 https://github.com/o/r/issues/8 fix https://github.com/o/r/pull/9 https://github.com/o/r/pull/10 fixing #11 Duplicate OF #12 @user' + ), + { + actions: [ + {raw: 'Fix #1', action: 'Fix', slug: undefined, prefix: '#', issue: '1'}, + {raw: 'reSOLved gh-2', action: 'Resolved', slug: undefined, prefix: 'gh-', issue: '2'}, + {raw: 'CLOSES Gh-3', action: 'Closes', slug: undefined, prefix: 'Gh-', issue: '3'}, + {raw: 'fix o/r#4', action: 'Fix', slug: 'o/r', prefix: '#', issue: '4'}, + {raw: 'fix https://github.com/o/r/issues/7', action: 'Fix', slug: 'o/r', prefix: undefined, issue: '7'}, + {raw: 'fix https://github.com/o/r/pull/9', action: 'Fix', slug: 'o/r', prefix: undefined, issue: '9'}, + ], + refs: [ + {raw: '#5', slug: undefined, prefix: '#', issue: '5'}, + {raw: 'o/r#6', slug: 'o/r', prefix: '#', issue: '6'}, + {raw: 'https://github.com/o/r/issues/8', slug: 'o/r', prefix: undefined, issue: '8'}, + {raw: 'https://github.com/o/r/pull/10', slug: 'o/r', prefix: undefined, issue: '10'}, + {raw: '#11', slug: undefined, prefix: '#', issue: '11'}, + ], + duplicates: [{raw: 'Duplicate OF #12', action: 'Duplicate of', slug: undefined, prefix: '#', issue: '12'}], + mentions: [{raw: '@user', prefix: '@', user: 'user'}], + } + ); }); test('Parse Bitbucket issue', t => { @@ -39,39 +48,75 @@ test('Parse Bitbucket issue', t => { }); test('Parse GitLab issue', t => { - t.deepEqual(m('GitLab')('Fix #1 reSOLved #2 IMPLEMENT #3 fix g/sg/o/r#4 #5 o/r#6 fixing #7 /duplicate #8 @user'), { - actions: [ - {raw: 'Fix #1', action: 'Fix', slug: undefined, prefix: '#', issue: '1'}, - {raw: 'reSOLved #2', action: 'Resolved', slug: undefined, prefix: '#', issue: '2'}, - {raw: 'IMPLEMENT #3', action: 'Implement', slug: undefined, prefix: '#', issue: '3'}, - {raw: 'fix g/sg/o/r#4', action: 'Fix', slug: 'g/sg/o/r', prefix: '#', issue: '4'}, - {raw: 'fixing #7', action: 'Fixing', slug: undefined, prefix: '#', issue: '7'}, - ], - refs: [{raw: '#5', slug: undefined, prefix: '#', issue: '5'}, {raw: 'o/r#6', slug: 'o/r', prefix: '#', issue: '6'}], - duplicates: [{raw: '/duplicate #8', action: '/duplicate', slug: undefined, prefix: '#', issue: '8'}], - mentions: [{raw: '@user', prefix: '@', user: 'user'}], - }); + t.deepEqual( + m('GitLab')( + 'Fix #1 reSOLved #2 IMPLEMENT #3 fix g/sg/o/r#4 #5 o/r#6 fix https://gitlab.com/o/r/issues/7 https://gitlab.com/o/r/issues/8 fix https://gitlab.com/o/r/merge_requests/9 https://gitlab.com/o/r/merge_requests/10 fixing #11 fixing !12 /duplicate #13 @user' + ), + { + actions: [ + {raw: 'Fix #1', action: 'Fix', slug: undefined, prefix: '#', issue: '1'}, + {raw: 'reSOLved #2', action: 'Resolved', slug: undefined, prefix: '#', issue: '2'}, + {raw: 'IMPLEMENT #3', action: 'Implement', slug: undefined, prefix: '#', issue: '3'}, + {raw: 'fix g/sg/o/r#4', action: 'Fix', slug: 'g/sg/o/r', prefix: '#', issue: '4'}, + {raw: 'fix https://gitlab.com/o/r/issues/7', action: 'Fix', slug: 'o/r', prefix: undefined, issue: '7'}, + {raw: 'fix https://gitlab.com/o/r/merge_requests/9', action: 'Fix', slug: 'o/r', prefix: undefined, issue: '9'}, + {raw: 'fixing #11', action: 'Fixing', slug: undefined, prefix: '#', issue: '11'}, + {raw: 'fixing !12', action: 'Fixing', slug: undefined, prefix: '!', issue: '12'}, + ], + refs: [ + {raw: '#5', slug: undefined, prefix: '#', issue: '5'}, + {raw: 'o/r#6', slug: 'o/r', prefix: '#', issue: '6'}, + {raw: 'https://gitlab.com/o/r/issues/8', slug: 'o/r', prefix: undefined, issue: '8'}, + {raw: 'https://gitlab.com/o/r/merge_requests/10', slug: 'o/r', prefix: undefined, issue: '10'}, + ], + duplicates: [{raw: '/duplicate #13', action: '/duplicate', slug: undefined, prefix: '#', issue: '13'}], + mentions: [{raw: '@user', prefix: '@', user: 'user'}], + } + ); }); test('Parse with default options', t => { - t.deepEqual(m()('Fix #1 reSOLved gh-2 CLOSES Gh-3 fix o/r#4 #5 o/r#6 implementing #7 Duplicate OF #8 @user'), { - actions: [ - {raw: 'Fix #1', action: 'Fix', slug: undefined, prefix: '#', issue: '1'}, - {raw: 'reSOLved gh-2', action: 'Resolved', slug: undefined, prefix: 'gh-', issue: '2'}, - {raw: 'CLOSES Gh-3', action: 'Closes', slug: undefined, prefix: 'Gh-', issue: '3'}, - {raw: 'fix o/r#4', action: 'Fix', slug: 'o/r', prefix: '#', issue: '4'}, - {raw: 'implementing #7', action: 'Implementing', slug: undefined, prefix: '#', issue: '7'}, - ], - refs: [{raw: '#5', slug: undefined, prefix: '#', issue: '5'}, {raw: 'o/r#6', slug: 'o/r', prefix: '#', issue: '6'}], - duplicates: [{raw: 'Duplicate OF #8', action: 'Duplicate of', slug: undefined, prefix: '#', issue: '8'}], - mentions: [{raw: '@user', prefix: '@', user: 'user'}], - }); + t.deepEqual( + m()( + 'Fix #1 reSOLved gh-2 CLOSES Gh-3 fix o/r#4 #5 o/r#6 implementing #7 https://github.com/o/r/issues/8 implementing https://github.com/o/r/issues/9 Duplicate OF #10 @user' + ), + { + actions: [ + {raw: 'Fix #1', action: 'Fix', slug: undefined, prefix: '#', issue: '1'}, + {raw: 'reSOLved gh-2', action: 'Resolved', slug: undefined, prefix: 'gh-', issue: '2'}, + {raw: 'CLOSES Gh-3', action: 'Closes', slug: undefined, prefix: 'Gh-', issue: '3'}, + {raw: 'fix o/r#4', action: 'Fix', slug: 'o/r', prefix: '#', issue: '4'}, + {raw: 'implementing #7', action: 'Implementing', slug: undefined, prefix: '#', issue: '7'}, + { + raw: 'implementing https://github.com/o/r/issues/9', + action: 'Implementing', + slug: 'o/r', + prefix: undefined, + issue: '9', + }, + ], + refs: [ + {raw: '#5', slug: undefined, prefix: '#', issue: '5'}, + {raw: 'o/r#6', slug: 'o/r', prefix: '#', issue: '6'}, + {raw: 'https://github.com/o/r/issues/8', slug: 'o/r', prefix: undefined, issue: '8'}, + ], + duplicates: [{raw: 'Duplicate OF #10', action: 'Duplicate of', slug: undefined, prefix: '#', issue: '10'}], + mentions: [{raw: '@user', prefix: '@', user: 'user'}], + } + ); }); test('Parse with custom options', t => { t.deepEqual( - m({referenceActions: ['fix'], duplicateActions: [], mentionsPrefixes: '!', issuePrefixes: ['#']})( - 'Fix #1 reSOLved gh-2 CLOSES Gh-3 fixed o/r#4 #5 o/r#6 fixing #7 Duplicate OF #8 !user @other' + m({ + referenceActions: ['fix'], + duplicateActions: [], + mentionsPrefixes: '!', + issuePrefixes: ['#'], + hosts: ['http://host1.com/', 'http://host2.com'], + issueURLSegments: ['bugs'], + })( + 'Fix #1 reSOLved gh-2 CLOSES Gh-3 fixed o/r#4 #5 o/r#6 fixing #7 http://host1.com/o/r/bugs/8 http://host2.com/o/r/bugs/9 Duplicate OF #10 !user @other' ), { actions: [{raw: 'Fix #1', action: 'Fix', slug: undefined, prefix: '#', issue: '1'}], @@ -80,7 +125,9 @@ test('Parse with custom options', t => { {raw: '#5', slug: undefined, prefix: '#', issue: '5'}, {raw: 'o/r#6', slug: 'o/r', prefix: '#', issue: '6'}, {raw: '#7', slug: undefined, prefix: '#', issue: '7'}, - {raw: '#8', slug: undefined, prefix: '#', issue: '8'}, + {raw: 'http://host1.com/o/r/bugs/8', slug: 'o/r', prefix: undefined, issue: '8'}, + {raw: 'http://host2.com/o/r/bugs/9', slug: 'o/r', prefix: undefined, issue: '9'}, + {raw: '#10', slug: undefined, prefix: '#', issue: '10'}, ], duplicates: [], mentions: [{raw: '!user', prefix: '!', user: 'user'}],