From 6fc59f7367a6bbcfbe409671c37f15d2d5a5c4e0 Mon Sep 17 00:00:00 2001 From: Thomas Woodward Date: Fri, 7 Feb 2020 11:20:18 -0500 Subject: [PATCH] feature: add link issues check (#74) --- README.md | 2 + app.yml | 2 +- src/index.js | 1 + src/link-issues.js | 74 ++++++ src/utils/addConnectedPRToIssue.js | 34 +++ src/utils/connectedPRRegexes.js | 23 ++ src/utils/getConnectedIssueForPR.js | 26 ++ src/utils/getConnectedPRsForIssue.js | 18 ++ src/utils/regexes.js | 34 +++ src/utils/removeConnectedPRFromIssue.js | 40 +++ test/link-issues.test.js | 242 ++++++++++++++++++ test/utils/addConnectedPRToIssue.test.js | 113 ++++++++ test/utils/getConnectedIssueForPR.test.js | 45 ++++ test/utils/getConnectedPRsForIssue.test.js | 55 ++++ test/utils/removeConnectedPRFromIssue.test.js | 125 +++++++++ 15 files changed, 833 insertions(+), 1 deletion(-) create mode 100644 src/link-issues.js create mode 100644 src/utils/addConnectedPRToIssue.js create mode 100644 src/utils/connectedPRRegexes.js create mode 100644 src/utils/getConnectedIssueForPR.js create mode 100644 src/utils/getConnectedPRsForIssue.js create mode 100644 src/utils/regexes.js create mode 100644 src/utils/removeConnectedPRFromIssue.js create mode 100644 test/link-issues.test.js create mode 100644 test/utils/addConnectedPRToIssue.test.js create mode 100644 test/utils/getConnectedIssueForPR.test.js create mode 100644 test/utils/getConnectedPRsForIssue.test.js create mode 100644 test/utils/removeConnectedPRFromIssue.test.js diff --git a/README.md b/README.md index 09be824..7529212 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ When someone mentions a channel in a message, that channel gets a link back to t This is useful for notifying multiple groups, or casually asking for help. ### Other features +- [#74](https://github.com/openstax/staxly/pull/74) verify that PR descriptions contain a link to their issue +- [#73](https://github.com/openstax/staxly/pull/73) automatically update PRs when their base branch has new commits (that will naturally trigger the checks to run again) - [#8](https://github.com/openstax/staxly/pull/8) Reminder to add a Pull Request Reviewer ![image](https://user-images.githubusercontent.com/253202/35791407-c04a6d56-0a15-11e8-8790-c2d0b4a73d0b.png) diff --git a/app.yml b/app.yml index bfa5608..4e3b127 100644 --- a/app.yml +++ b/app.yml @@ -55,7 +55,7 @@ default_permissions: # Checks on code. # https://developer.github.com/v3/apps/permissions/#permission-on-checks - # checks: read + checks: write # Repository contents, commits, branches, downloads, releases, and merges. # https://developer.github.com/v3/apps/permissions/#permission-on-contents diff --git a/src/index.js b/src/index.js index a315746..faf4190 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,7 @@ module.exports = (robot) => { require('./changelog')(robot) require('./merge-bases')(robot) + require('./link-issues')(robot) // Addons that are noisy during tests if (!IGNORE_FOR_TESTING) { diff --git a/src/link-issues.js b/src/link-issues.js new file mode 100644 index 0000000..b420fd6 --- /dev/null +++ b/src/link-issues.js @@ -0,0 +1,74 @@ +// Merge base into PR branch whenever updated +const getConnectedIssueForPR = require('./utils/getConnectedIssueForPR') +const addConnectedPRToIssue = require('./utils/addConnectedPRToIssue') +const removeConnectedPRFromIssue = require('./utils/removeConnectedPRFromIssue') + +const repoWhitelist = [ + 'testrepo', + 'rex-web', + 'testing-stuff' +] + +const name = 'has issue link' + +module.exports = (robot) => { + const logger = robot.log.child({name: 'link-issues-check'}) + robot.on([ + 'pull_request.opened', + 'pull_request.edited', + 'pull_request.synchronize' + ], checkPR) + + async function checkPR (context) { + const pullRequest = context.payload.pull_request + if (!repoWhitelist.includes(context.payload.repository.name)) { + return + } + logger.info(`checking pr ${pullRequest.number}`) + + const check = await context.github.checks.create(context.repo({ + name, + head_sha: context.payload.pull_request.head.sha, + status: 'in_progress', + output: {title: name, summary: 'processing'} + })) + + const linkedIssueParams = getConnectedIssueForPR(pullRequest) + const linkedIssue = linkedIssueParams && await context.github.issues.get(linkedIssueParams) + .then(response => response.data) + .catch(() => null) + + logger.info(`pr ${pullRequest.number} ${linkedIssue ? 'passed' : 'failed'}`) + + await context.github.checks.update(context.repo({ + check_run_id: check.data.id, + status: 'completed', + conclusion: linkedIssue ? 'success' : 'failure', + output: linkedIssue + ? { + title: 'all is as it should be', + summary: 'good job linking to that issue! :+1:' + } + : { + title: 'please add an issue reference', + summary: 'please add a link to the issue this PR is for to the PR description', + text: 'for example `for: openstax/cool-repo#5`. `for: ` also work' + } + })) + + if (context.payload.action === 'edited' && context.payload.changes.body) { + const previousIssueParams = getConnectedIssueForPR({...pullRequest, body: context.payload.changes.body.from}) + const previousIssue = previousIssueParams && await context.github.issues.get(previousIssueParams) + .then(response => response.data) + .catch(() => null) + + if (previousIssue && (!linkedIssue || previousIssue.number !== linkedIssue.number)) { + await removeConnectedPRFromIssue(context.github, previousIssueParams, previousIssue, pullRequest) + } + } + + if (linkedIssue) { + await addConnectedPRToIssue(context.github, linkedIssueParams, linkedIssue, pullRequest) + } + }; +} diff --git a/src/utils/addConnectedPRToIssue.js b/src/utils/addConnectedPRToIssue.js new file mode 100644 index 0000000..bd3e48f --- /dev/null +++ b/src/utils/addConnectedPRToIssue.js @@ -0,0 +1,34 @@ +const {prBlockRegex} = require('./connectedPRRegexes') +const getConnectedPRsForIssue = require('./getConnectedPRsForIssue') + +/* + * @argument context.github + * @argument IssueData + * @argument PullRequestData + * + * @returns Promise + */ +module.exports = (github, issueParams, issue, pullRequest) => { + const prs = getConnectedPRsForIssue(issue) + + const pullNumber = pullRequest.number + const repo = pullRequest.base.repo.name + const owner = pullRequest.base.repo.owner.login + + const existing = prs.find(pr => Number(pr.pull_number) === pullNumber && pr.repo === repo && pr.owner === owner) + + if (existing) { + return + } + + const newLink = `\n- [ ] ${owner}/${repo}#${pullNumber}` + const blockMatch = issue.body.match(new RegExp(prBlockRegex, 'i')) + const newBody = blockMatch + ? issue.body.replace(blockMatch[0], blockMatch[0] + newLink) + : issue.body + '\n\npull requests:' + newLink + + return github.issues.update({ + ...issueParams, + body: newBody + }) +} diff --git a/src/utils/connectedPRRegexes.js b/src/utils/connectedPRRegexes.js new file mode 100644 index 0000000..37b42ea --- /dev/null +++ b/src/utils/connectedPRRegexes.js @@ -0,0 +1,23 @@ +const { + githubRefGroups, githubPullRequestLinkGroups, zenhubLinkGroups, + githubRef, githubPullRequestLink, zenhubLink, + whitespace, beginningOfStringOrNewline +} = require('./regexes') + +const anyLink = `((${githubRef})|(${githubPullRequestLink})|(${zenhubLink}))` + +const listPrefix = '\\n\\- \\[( |x)\\] ' +const prBlockRegex = `${beginningOfStringOrNewline}#* ?\\*{0,2}pull requests:?\\*{0,2}:?(${whitespace}*${listPrefix}${anyLink})*` + +const anyLinkGroups = [ + githubRefGroups, + githubPullRequestLinkGroups, + zenhubLinkGroups +] + +module.exports = { + listPrefix, + anyLinkGroups, + anyLink, + prBlockRegex +} diff --git a/src/utils/getConnectedIssueForPR.js b/src/utils/getConnectedIssueForPR.js new file mode 100644 index 0000000..094d8ce --- /dev/null +++ b/src/utils/getConnectedIssueForPR.js @@ -0,0 +1,26 @@ +const { + beginningOfStringOrWhitespace, endOfStringOrWhitespace, + githubRefGroups, githubIssueLinkGroups, zenhubLinkGroups +} = require('./regexes') + +const targetRegexes = [ + `${beginningOfStringOrWhitespace}for: ${githubRefGroups}${endOfStringOrWhitespace}`, + `${beginningOfStringOrWhitespace}for: ${githubIssueLinkGroups}${endOfStringOrWhitespace}`, + `${beginningOfStringOrWhitespace}for: ${zenhubLinkGroups}${endOfStringOrWhitespace}` +] + +/* + * @argument PullRequestData + * + * @returns IssueParams | null + */ +module.exports = (pullRequest) => { + const target = targetRegexes.reduce((result, regex) => result || pullRequest.body.match(new RegExp(regex, 'i')), null) + + if (target && target.groups) { + const {number, ...params} = target.groups + return {...params, issue_number: number} + } + + return null +} diff --git a/src/utils/getConnectedPRsForIssue.js b/src/utils/getConnectedPRsForIssue.js new file mode 100644 index 0000000..def474d --- /dev/null +++ b/src/utils/getConnectedPRsForIssue.js @@ -0,0 +1,18 @@ +const {anyLink, anyLinkGroups, prBlockRegex} = require('./connectedPRRegexes') + +/* + * @argument IssueData + * + * @returns PullRequestParams | null + */ +module.exports = (issue) => { + const blockMatch = issue.body.match(new RegExp(prBlockRegex, 'i')) + const links = blockMatch && blockMatch[0].match(new RegExp(anyLink, 'gi')) + + return (links || []).map(link => { + const result = anyLinkGroups.reduce((result, regex) => result || link.match(new RegExp(regex, 'i')), null) + return result ? result.groups : null + }) + .filter(params => !!params) + .map(({number, ...params}) => ({...params, pull_number: number})) +} diff --git a/src/utils/regexes.js b/src/utils/regexes.js new file mode 100644 index 0000000..baacfcd --- /dev/null +++ b/src/utils/regexes.js @@ -0,0 +1,34 @@ +const whitespace = '(\\s|\\r)' +const beginningOfStringOrNewline = '^(.*[\\n\\r]+)*' +const beginningOfStringOrWhitespace = '^(.*[\\s\\r]+)*' +const endOfStringOrWhitespace = '([\\s\\r]+.*)*$' + +/* eslint-disable-next-line */ +const githubRefGroups = '(?[a-z\-]+)\/(?[a-z\-]+)#(?[0-9]+)' +/* eslint-disable-next-line */ +const githubIssueLinkGroups = 'https:\/\/github.com\/(?[a-z\-]+)\/(?[a-z\-]+)\/issues\/(?[0-9]+)' +/* eslint-disable-next-line */ +const githubPullRequestLinkGroups = 'https:\/\/github.com\/(?[a-z\-]+)\/(?[a-z\-]+)\/pulls\/(?[0-9]+)' +/* eslint-disable-next-line */ +const zenhubLinkGroups = 'https:\/\/app.zenhub.com\/workspaces\/[0-9a-z\-]+\/issues\/(?[a-z\-]+)\/(?[a-z\-]+)\/(?[0-9]+)' + +/* eslint-disable-next-line */ +const githubRef = '[a-z\-]+\/[a-z\-]+#[0-9]+' +/* eslint-disable-next-line */ +const githubPullRequestLink = 'https:\/\/github.com\/[a-z\-]+\/[a-z\-]+\/pulls\/[0-9]+' +/* eslint-disable-next-line */ +const zenhubLink = 'https:\/\/app.zenhub.com\/workspaces\/[0-9a-z\-]+\/issues\/[a-z\-]+\/[a-z\-]+\/[0-9]+' + +module.exports = { + whitespace, + beginningOfStringOrNewline, + beginningOfStringOrWhitespace, + endOfStringOrWhitespace, + githubRefGroups, + githubIssueLinkGroups, + githubPullRequestLinkGroups, + zenhubLinkGroups, + githubRef, + githubPullRequestLink, + zenhubLink +} diff --git a/src/utils/removeConnectedPRFromIssue.js b/src/utils/removeConnectedPRFromIssue.js new file mode 100644 index 0000000..53f55c1 --- /dev/null +++ b/src/utils/removeConnectedPRFromIssue.js @@ -0,0 +1,40 @@ +const {anyLink, listPrefix, anyLinkGroups, prBlockRegex} = require('./connectedPRRegexes') + +/* + * @argument context.github + * @argument IssueData + * @argument PullRequestData + * + * @returns Promise + */ +module.exports = (github, issueParams, issue, pullRequest) => { + const pullNumber = pullRequest.number + const repo = pullRequest.base.repo.name + const owner = pullRequest.base.repo.owner.login + + const blockMatch = issue.body.match(new RegExp(prBlockRegex, 'i')) + + if (!blockMatch) { + return + } + + const lines = blockMatch[0].match(new RegExp(listPrefix + anyLink, 'gi')) + + const linesToRemove = lines.filter(line => { + const match = anyLinkGroups.reduce((result, regex) => result || line.match(new RegExp(regex, 'i')), null) + const params = match && match.groups + return params && Number(params.number) === pullNumber && params.repo === repo && params.owner === owner + }) + + if (!linesToRemove.length) { + return + } + + const newPRBlock = linesToRemove.reduce((result, line) => result.replace(line, ''), blockMatch[0]) + const newBody = issue.body.replace(blockMatch[0], newPRBlock) + + return github.issues.update({ + ...issueParams, + body: newBody + }) +} diff --git a/test/link-issues.test.js b/test/link-issues.test.js new file mode 100644 index 0000000..9fd76cd --- /dev/null +++ b/test/link-issues.test.js @@ -0,0 +1,242 @@ +const nock = require('nock') +const linkIssues = require('../src/link-issues') +const { createProbot } = require('probot') + +describe('link issues', () => { + let app + + beforeEach(() => { + nock.disableNetConnect() + app = createProbot({ id: 1, cert: 'test', githubToken: 'test' }) + app.load(linkIssues) + }) + + test('fails if there is no link', async () => { + nock('https://api.github.com') + .post('/repos/testowner/testrepo/check-runs') + .reply(200, {id: 5}) + + nock('https://api.github.com') + .patch('/repos/testowner/testrepo/check-runs/5', body => body.conclusion === 'failure') + .reply(200, {id: 5}) + + await app.receive({ + name: 'pull_request.opened', + payload: { + pull_request: { + number: 2, + body: 'no link', + head: { + sha: 'shashashashasha' + } + }, + repository: { + name: 'testrepo', + owner: { + login: 'testowner' + } + } + } + }) + + expect(nock.isDone()).toBe(true) + }) + + test('fails with an invalid link', async () => { + nock('https://api.github.com') + .post('/repos/testowner/testrepo/check-runs') + .reply(200, {id: 5}) + + nock('https://api.github.com') + .get('/repos/openstax/rex-web/issues/4') + .reply(404, {}) + + nock('https://api.github.com') + .patch('/repos/testowner/testrepo/check-runs/5', body => body.conclusion === 'failure') + .reply(200, {id: 5}) + + await app.receive({ + name: 'pull_request.opened', + payload: { + pull_request: { + number: 2, + body: 'for: openstax/rex-web#4', + head: { + sha: 'shashashashasha' + } + }, + repository: { + name: 'testrepo', + owner: { + login: 'testowner' + } + } + } + }) + + expect(nock.isDone()).toBe(true) + }) + + test('passes if there is a link', async () => { + const repo = { + name: 'testrepo', + owner: { + login: 'testowner' + } + } + nock('https://api.github.com') + .post('/repos/testowner/testrepo/check-runs') + .reply(200, {id: 5}) + + nock('https://api.github.com') + .get('/repos/openstax/rex-web/issues/123') + .reply(200, {body: 'pull requests:\n- [ ] testowner/testrepo#2'}) + + nock('https://api.github.com') + .patch('/repos/testowner/testrepo/check-runs/5', body => body.conclusion === 'success') + .reply(200, {id: 5}) + + await app.receive({ + name: 'pull_request.opened', + action: 'created', + payload: { + pull_request: { + number: 2, + body: 'asdf\nfor: openstax/rex-web#123', + head: { + sha: 'shashashashasha' + }, + base: { repo } + }, + repository: repo + } + }) + + expect(nock.isDone()).toBe(true) + }) + + test('links to the issue if it isn\'t already', async () => { + const repo = { + name: 'testrepo', + owner: { + login: 'testowner' + } + } + nock('https://api.github.com') + .post('/repos/testowner/testrepo/check-runs') + .reply(200, {id: 5}) + + nock('https://api.github.com') + .get('/repos/openstax/rex-web/issues/123') + .reply(200, {body: '', repo: {name: 'rex-web', owner: {login: 'openstax'}}, number: 123}) + + nock('https://api.github.com') + .patch('/repos/testowner/testrepo/check-runs/5', body => body.conclusion === 'success') + .reply(200, {id: 5}) + + nock('https://api.github.com') + .patch('/repos/openstax/rex-web/issues/123', body => body.body === '\n\npull requests:\n- [ ] testowner/testrepo#2') + .reply(200, {}) + + await app.receive({ + name: 'pull_request.opened', + action: 'created', + payload: { + pull_request: { + number: 2, + body: 'asdf\nfor: openstax/rex-web#123', + head: { + sha: 'shashashashasha' + }, + base: { repo } + }, + repository: repo + } + }) + + expect(nock.isDone()).toBe(true) + }) + + test('removes link from previous issue if link is changed', async () => { + const repo = { + name: 'testrepo', + owner: { + login: 'testowner' + } + } + nock('https://api.github.com') + .post('/repos/testowner/testrepo/check-runs') + .reply(200, {id: 5}) + + nock('https://api.github.com') + .get('/repos/openstax/rex-web/issues/123') + .reply(200, {body: '', repo: {name: 'rex-web', owner: {login: 'openstax'}}, number: 123}) + + nock('https://api.github.com') + .patch('/repos/testowner/testrepo/check-runs/5', body => body.conclusion === 'success') + .reply(200, {id: 5}) + + nock('https://api.github.com') + .get('/repos/openstax/rex-web/issues/234') + .reply(200, {body: 'pull requests:\n- [ ] testowner/testrepo#2', repo: {name: 'rex-web', owner: {login: 'openstax'}}, number: 234}) + + nock('https://api.github.com') + .patch('/repos/openstax/rex-web/issues/234', body => body.body === 'pull requests:') + .reply(200, {}) + + nock('https://api.github.com') + .patch('/repos/openstax/rex-web/issues/123', body => body.body === '\n\npull requests:\n- [ ] testowner/testrepo#2') + .reply(200, {}) + + await app.receive({ + name: 'pull_request.opened', + payload: { + action: 'edited', + changes: { + body: { + from: 'for: openstax/rex-web#234' + } + }, + pull_request: { + number: 2, + body: 'for: openstax/rex-web#123', + head: { + sha: 'shashashashasha' + }, + base: { repo } + }, + repository: repo + } + }) + + expect(nock.isDone()).toBe(true) + }) + + test('noops outside whitelist', async () => { + // Simulates delivery of an issues.opened webhook + await app.receive({ + name: 'push', + payload: { + pull_request: { + number: 2, + head: { + sha: 'shashashashasha' + } + }, + repository: { + name: 'randomrepo', + owner: { + login: 'testowner' + } + } + } + }) + + expect(nock.isDone()).toBe(true) + }) + + afterEach(() => { + nock.cleanAll() + nock.enableNetConnect() + }) +}) diff --git a/test/utils/addConnectedPRToIssue.test.js b/test/utils/addConnectedPRToIssue.test.js new file mode 100644 index 0000000..0a7470e --- /dev/null +++ b/test/utils/addConnectedPRToIssue.test.js @@ -0,0 +1,113 @@ +const addConnectedPRToIssue = require('../../src/utils/addConnectedPRToIssue') + +describe('addConnectedPRToIssue', () => { + let github + const issueParams = { + owner: 'openstax', + repo: 'unified', + issue_number: 123 + } + const issue = { + body: '', + number: 123 + } + const pullRequest = { + number: 234, + base: { + repo: { + name: 'rex-web', + owner: { + login: 'openstax' + } + } + } + } + + beforeEach(() => { + github = { + issues: { + update: jest.fn() + } + } + }) + + test('noops if link is already there', () => { + addConnectedPRToIssue( + github, + issueParams, + {...issue, body: 'pull requests:\n- [ ] openstax/rex-web#234'}, + pullRequest + ) + expect(github.issues.update).not.toHaveBeenCalled() + }) + + test('appends to existing list', () => { + addConnectedPRToIssue( + github, + issueParams, + {...issue, body: 'pull requests:\n- [ ] openstax/rex-web#111'}, + pullRequest + ) + expect(github.issues.update).toHaveBeenCalledWith(expect.objectContaining({ + body: 'pull requests:\n- [ ] openstax/rex-web#111\n- [ ] openstax/rex-web#234' + })) + }) + + test('appends to existing list, with caps', () => { + addConnectedPRToIssue( + github, + issueParams, + {...issue, body: 'pull requests:\n- [ ] OpenStax/rex-web#111'}, + { + ...pullRequest, + base: { + repo: { + name: 'rex-web', + owner: { + login: 'OpenStax' + } + } + } + } + ) + expect(github.issues.update).toHaveBeenCalledWith(expect.objectContaining({ + body: 'pull requests:\n- [ ] OpenStax/rex-web#111\n- [ ] OpenStax/rex-web#234' + })) + }) + + test('appends to empty list', () => { + addConnectedPRToIssue( + github, + issueParams, + {...issue, body: 'pull requests:'}, + pullRequest + ) + expect(github.issues.update).toHaveBeenCalledWith(expect.objectContaining({ + body: 'pull requests:\n- [ ] openstax/rex-web#234' + })) + }) + + test('appends to empty list with trailing content', () => { + addConnectedPRToIssue( + github, + issueParams, + {...issue, body: 'pull requests:\nasdf'}, + pullRequest + ) + expect(github.issues.update).toHaveBeenCalledWith(expect.objectContaining({ + body: 'pull requests:\n- [ ] openstax/rex-web#234\nasdf' + })) + }) + + test('adds the list if missing', () => { + addConnectedPRToIssue( + github, + issueParams, + {...issue, body: 'asdf'}, + pullRequest + ) + expect(github.issues.update).toHaveBeenCalledWith(expect.objectContaining({ + body: 'asdf\n\npull requests:\n- [ ] openstax/rex-web#234' + })) + }) +}) diff --git a/test/utils/getConnectedIssueForPR.test.js b/test/utils/getConnectedIssueForPR.test.js new file mode 100644 index 0000000..77c4175 --- /dev/null +++ b/test/utils/getConnectedIssueForPR.test.js @@ -0,0 +1,45 @@ +const getConnectedIssueForPR = require('../../src/utils/getConnectedIssueForPR') + +describe('getConnectedIssueForPR', () => { + test('resolves github ref', () => { + const result = getConnectedIssueForPR({body: 'for: openstax/rex-web#123'}) + expect(result).toEqual({repo: 'rex-web', owner: 'openstax', issue_number: '123'}) + }) + + test('resolves github ref, case insensitive', () => { + const result = getConnectedIssueForPR({body: 'for: OpenStax/rex-web#123'}) + expect(result).toEqual({repo: 'rex-web', owner: 'OpenStax', issue_number: '123'}) + }) + + test('resolves github link', () => { + const result = getConnectedIssueForPR({body: 'for: https://github.com/openstax/unified/issues/123'}) + expect(result).toEqual({repo: 'unified', owner: 'openstax', issue_number: '123'}) + }) + + test('resolves zenhub link', () => { + const result = getConnectedIssueForPR({body: 'for: https://app.zenhub.com/workspaces/openstax-unified-5b71aabe3815ff014b102258/issues/openstax/unified/123'}) + expect(result).toEqual({repo: 'unified', owner: 'openstax', issue_number: '123'}) + }) + + test('resolves with stuff around', () => { + const result = getConnectedIssueForPR({body: 'asdf\nasdf\nasdf\nasdf for: openstax/rex-web#123 asdf'}) + expect(result).toEqual({repo: 'rex-web', owner: 'openstax', issue_number: '123'}) + }) + + test('doesn\'t resolve with adjacent stuff', () => { + const result1 = getConnectedIssueForPR({body: 'asdffor: openstax/rex-web#123'}) + const result2 = getConnectedIssueForPR({body: 'for: openstax/rex-web#123asdf'}) + expect(result1).toBe(null) + expect(result2).toBe(null) + }) + + test('resolves with newlines', () => { + const result = getConnectedIssueForPR({body: 'asdf\nfor: openstax/rex-web#123\nasdf'}) + expect(result).toEqual({repo: 'rex-web', owner: 'openstax', issue_number: '123'}) + }) + + test('resolves with carriage returns', () => { + const result = getConnectedIssueForPR({body: 'asdf\r\nfor: openstax/rex-web#123\r\nasdf'}) + expect(result).toEqual({repo: 'rex-web', owner: 'openstax', issue_number: '123'}) + }) +}) diff --git a/test/utils/getConnectedPRsForIssue.test.js b/test/utils/getConnectedPRsForIssue.test.js new file mode 100644 index 0000000..9a6fa3c --- /dev/null +++ b/test/utils/getConnectedPRsForIssue.test.js @@ -0,0 +1,55 @@ +const getConnectedPRsForIssue = require('../../src/utils/getConnectedPRsForIssue') + +describe('getConnectedPRsForIssue', () => { + test('resolves github ref', () => { + const result = getConnectedPRsForIssue({body: 'pull requests: \n- [ ] openstax/rex-web#123'}) + expect(result).toEqual([{repo: 'rex-web', owner: 'openstax', pull_number: '123'}]) + }) + test('resolves github ref case insensitive', () => { + const result = getConnectedPRsForIssue({body: 'pull requests: \n- [ ] OpenStax/rex-web#123'}) + expect(result).toEqual([{repo: 'rex-web', owner: 'OpenStax', pull_number: '123'}]) + }) + test('resolves github link', () => { + const result = getConnectedPRsForIssue({body: 'pull requests: \n- [ ] https://github.com/openstax/rex-web/pulls/123'}) + expect(result).toEqual([{repo: 'rex-web', owner: 'openstax', pull_number: '123'}]) + }) + test('resolves zenhub link', () => { + const result = getConnectedPRsForIssue({body: 'pull requests: \n- [ ] https://app.zenhub.com/workspaces/openstax-unified-5b71aabe3815ff014b102258/issues/openstax/unified/123'}) + expect(result).toEqual([{repo: 'unified', owner: 'openstax', pull_number: '123'}]) + }) + test('resolves empty list', () => { + const result = getConnectedPRsForIssue({body: 'pull requests:\n\r\n\rasdf'}) + expect(result).toEqual([]) + }) + test('resolves multiple PRs', () => { + const result = getConnectedPRsForIssue({body: 'pull requests: \n- [ ] openstax/rex-web#123\n- [ ] openstax/rex-web#234'}) + expect(result).toEqual([ + {repo: 'rex-web', owner: 'openstax', pull_number: '123'}, + {repo: 'rex-web', owner: 'openstax', pull_number: '234'} + ]) + }) + test('resolves bold', () => { + const result = getConnectedPRsForIssue({body: '**pull requests**: \n- [ ] openstax/rex-web#123'}) + expect(result).toEqual([{repo: 'rex-web', owner: 'openstax', pull_number: '123'}]) + }) + test('resolves italic', () => { + const result = getConnectedPRsForIssue({body: '*pull requests:* \n- [ ] openstax/rex-web#123'}) + expect(result).toEqual([{repo: 'rex-web', owner: 'openstax', pull_number: '123'}]) + }) + test('resolves heading', () => { + const result = getConnectedPRsForIssue({body: '# pull requests \n- [ ] openstax/rex-web#123'}) + expect(result).toEqual([{repo: 'rex-web', owner: 'openstax', pull_number: '123'}]) + }) + test('resolves sub heading', () => { + const result = getConnectedPRsForIssue({body: '### pull requests \n- [ ] openstax/rex-web#123'}) + expect(result).toEqual([{repo: 'rex-web', owner: 'openstax', pull_number: '123'}]) + }) + test('resolves deep in body', () => { + const result = getConnectedPRsForIssue({body: 'asdf\r\nasdf\r\nasdf\r\n### pull requests \r\n- [ ] openstax/rex-web#123'}) + expect(result).toEqual([{repo: 'rex-web', owner: 'openstax', pull_number: '123'}]) + }) + test('but not without the whitespace', () => { + const result = getConnectedPRsForIssue({body: 'asdf\r\nasdf\r\nasdf### pull requests \r\n- [ ] openstax/rex-web#123'}) + expect(result).toEqual([]) + }) +}) diff --git a/test/utils/removeConnectedPRFromIssue.test.js b/test/utils/removeConnectedPRFromIssue.test.js new file mode 100644 index 0000000..56466d5 --- /dev/null +++ b/test/utils/removeConnectedPRFromIssue.test.js @@ -0,0 +1,125 @@ +const removeConnectedPRFromIssue = require('../../src/utils/removeConnectedPRFromIssue') + +describe('removeConnectedPRFromIssue', () => { + let github + const issueParams = { + owner: 'openstax', + repo: 'unified', + issue_number: 123 + } + const issue = { + body: '', + number: 123 + } + const pullRequest = { + number: 234, + base: { + repo: { + name: 'rex-web', + owner: { + login: 'openstax' + } + } + } + } + + beforeEach(() => { + github = { + issues: { + update: jest.fn() + } + } + }) + + test('noops if link is not already there', () => { + removeConnectedPRFromIssue( + github, + issueParams, + {...issue, body: 'pull requests:\n- [ ] openstax/rex-web#123'}, + pullRequest + ) + expect(github.issues.update).not.toHaveBeenCalled() + }) + + test('removes github ref', () => { + removeConnectedPRFromIssue( + github, + issueParams, + {...issue, body: 'pull requests:\n- [ ] openstax/rex-web#234'}, + pullRequest + ) + expect(github.issues.update).toHaveBeenCalledWith(expect.objectContaining({ + body: 'pull requests:' + })) + }) + + test('removes github ref, with caps', () => { + removeConnectedPRFromIssue( + github, + issueParams, + {...issue, body: 'pull requests:\n- [ ] OpenStax/rex-web#234'}, + { + ...pullRequest, + base: { + repo: { + name: 'rex-web', + owner: { + login: 'OpenStax' + } + } + } + } + ) + expect(github.issues.update).toHaveBeenCalledWith(expect.objectContaining({ + body: 'pull requests:' + })) + }) + + test('removes github link', () => { + removeConnectedPRFromIssue( + github, + issueParams, + {...issue, body: 'pull requests:\n- [ ] https://github.com/openstax/rex-web/pulls/234'}, + pullRequest + ) + expect(github.issues.update).toHaveBeenCalledWith(expect.objectContaining({ + body: 'pull requests:' + })) + }) + + test('removes zenhub link', () => { + removeConnectedPRFromIssue( + github, + issueParams, + {...issue, body: 'pull requests:\n- [ ] https://app.zenhub.com/workspaces/openstax-unified-5b71aabe3815ff014b102258/issues/openstax/rex-web/234'}, + pullRequest + ) + expect(github.issues.update).toHaveBeenCalledWith(expect.objectContaining({ + body: 'pull requests:' + })) + }) + + test('preserves rest of list', () => { + removeConnectedPRFromIssue( + github, + issueParams, + {...issue, body: 'pull requests:\n- [ ] openstax/rex-web#111\n- [ ] openstax/rex-web#234\n- [ ] openstax/rex-web#555'}, + pullRequest + ) + expect(github.issues.update).toHaveBeenCalledWith(expect.objectContaining({ + body: 'pull requests:\n- [ ] openstax/rex-web#111\n- [ ] openstax/rex-web#555' + })) + }) + + test('preserves surrounding content', () => { + removeConnectedPRFromIssue( + github, + issueParams, + {...issue, body: 'asdf\nasdf\nasdf\npull requests:\n- [ ] openstax/rex-web#234\nasdf\nasdf\n'}, + pullRequest + ) + expect(github.issues.update).toHaveBeenCalledWith(expect.objectContaining({ + body: 'asdf\nasdf\nasdf\npull requests:\nasdf\nasdf\n' + })) + }) +})