Skip to content

Commit

Permalink
feat: support parsing references with full issue URL
Browse files Browse the repository at this point in the history
  • Loading branch information
pvdlg committed Jul 8, 2018
1 parent 141c92e commit 44822e6
Show file tree
Hide file tree
Showing 4 changed files with 171 additions and 58 deletions.
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -287,6 +303,20 @@ Default: `['#', 'gh-']`

List of keywords used to identify issues and pull requests.

##### hosts

Type: `Array<String>` `String`<br>
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>` `String`<br>
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.
Expand Down
60 changes: 44 additions & 16 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = /(<code)+?((?!(<code|<\/code>)+?)[\S\s])*(<\/code>)+?/gim;
const LEADING_TRAILING_SLASH_REGEXP = /^\/?([^/]+(?:\/[^/]+)*)\/?$/;
const TRAILING_SLASH_REGEXP = /\/?$/;

function inverse(str) {
return str
Expand All @@ -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) {
Expand All @@ -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, ''));
Expand All @@ -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});
Expand All @@ -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');
}

Expand All @@ -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);

Expand Down
8 changes: 8 additions & 0 deletions lib/hosts-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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: [
Expand All @@ -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'],
},
};
131 changes: 89 additions & 42 deletions test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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'}],
Expand All @@ -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'}],
Expand Down

0 comments on commit 44822e6

Please sign in to comment.