diff --git a/README.md b/README.md index 5554b3d..2fa46cb 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ The Fastlane GitHub Actions provide a set of GitHub Actions to make maintaining Adds a comment and a label to a pull request when it is merged. Read more [here](communicate-on-pull-request-merged). +- 🚀 [@github-actions/communicate-on-pull-request-released](communicate-on-pull-request-released) + + Adds a comment and a label to a pull request and referenced issue when it is released. Read more [here](communicate-on-pull-request-released). + ## Versioning All the actions are released in one batch. We do not support semantic versioning (yet). Reference a `latest` branch in your workflow: diff --git a/communicate-on-pull-request-released/.gitignore b/communicate-on-pull-request-released/.gitignore new file mode 100644 index 0000000..b16767d --- /dev/null +++ b/communicate-on-pull-request-released/.gitignore @@ -0,0 +1,3 @@ +package-lock.json +node_modules +__tests__/runner/* diff --git a/communicate-on-pull-request-released/.prettierrc.json b/communicate-on-pull-request-released/.prettierrc.json new file mode 100644 index 0000000..c134b7f --- /dev/null +++ b/communicate-on-pull-request-released/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "none", + "bracketSpacing": false, + "arrowParens": "avoid", + "parser": "typescript" +} \ No newline at end of file diff --git a/communicate-on-pull-request-released/Dockerfile b/communicate-on-pull-request-released/Dockerfile new file mode 100644 index 0000000..ff13242 --- /dev/null +++ b/communicate-on-pull-request-released/Dockerfile @@ -0,0 +1,7 @@ +FROM node:slim + +COPY . . + +RUN npm install --production + +ENTRYPOINT ["node", "/lib/main.js"] \ No newline at end of file diff --git a/communicate-on-pull-request-released/README.md b/communicate-on-pull-request-released/README.md new file mode 100644 index 0000000..604ef58 --- /dev/null +++ b/communicate-on-pull-request-released/README.md @@ -0,0 +1,18 @@ +# Communicate on pull request merged + +An action for adding comments and labels to a pull request and referenced issue when the pull request is released. + +# Usage + +See [action.yml](action.yml) + +```yaml +steps: +- uses: fastlane/github-action/communicate-on-pull-request-released@latest + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} +``` + +# License + +The scripts and documentation in this project are released under the [MIT License](LICENSE) \ No newline at end of file diff --git a/communicate-on-pull-request-released/__tests__/action-created.json b/communicate-on-pull-request-released/__tests__/action-created.json new file mode 100644 index 0000000..6c64e0c --- /dev/null +++ b/communicate-on-pull-request-released/__tests__/action-created.json @@ -0,0 +1,7 @@ +{ + "release": { + "tag_name": "2.134.1", + "body": "This is a release description." + }, + "action": "created" +} \ No newline at end of file diff --git a/communicate-on-pull-request-released/__tests__/main.test.ts b/communicate-on-pull-request-released/__tests__/main.test.ts new file mode 100644 index 0000000..f0f7722 --- /dev/null +++ b/communicate-on-pull-request-released/__tests__/main.test.ts @@ -0,0 +1,90 @@ +const path = require('path'); +const nock = require('nock'); + +const validScenarios = [ + { + response: 'release.json', + event_name: 'release' + } +]; + +const invalidScenarios = [ + { + response: 'pull-request-closed.json' + }, + { + response: 'action-created.json' + }, + { + response: 'release-missing-pr-numbers.json', + event_name: 'release' + } +]; + +describe('action test suite', () => { + for (const scenario of validScenarios) { + it(`It posts a comment on pull requests, referenced issues and update labels for (${scenario.response})`, async () => { + process.env['INPUT_REPO-TOKEN'] = 'token'; + process.env['INPUT_PR-LABEL-TO-ADD'] = 'label-to-add'; + process.env['INPUT_PR-LABEL-TO-REMOVE'] = 'label-to-remove'; + + process.env['GITHUB_REPOSITORY'] = 'foo/bar'; + process.env['GITHUB_EVENT_NAME'] = scenario.event_name; + process.env['GITHUB_EVENT_PATH'] = path.join( + __dirname, + scenario.response + ); + + const api = nock('https://api.github.com') + .persist() + .post( + '/repos/foo/bar/pulls/999/reviews', + '{"body":"Congratulations! :tada: This was released as part of [_fastlane_ 2.134.1](https://github.com/Codertocat/Hello-World/runs/128620228) :rocket:","event":"COMMENT"}' + ) + .reply(200) + .get('/repos/foo/bar/issues/999/labels') + .reply(200, JSON.parse('[]')) + .post('/repos/foo/bar/issues/999/labels', '{"labels":["label-to-add"]}') + .reply(200) + .get('/repos/foo/bar/pulls/999') + .reply(200, JSON.parse('{"body":"closes #10"}')) + .post( + '/repos/foo/bar/issues/10/comments', + '{"body":"The pull request #999 that closed this issue was merged and released as part of [_fastlane_ 2.134.1](https://github.com/Codertocat/Hello-World/runs/128620228) :rocket:\\nPlease let us know if the functionality works as expected as a reply here. If it does not, please open a new issue. Thanks!"}' + ) + .reply(200); + + const main = require('../src/main'); + await main.run(); + + expect(api.isDone()).toBeTruthy(); + }); + } +}); + +describe('action test suite', () => { + for (const scenario of invalidScenarios) { + it(`It does not post a comment on pull requests, referenced issues and does not update labels for (${scenario.response})`, async () => { + process.env['INPUT_REPO-TOKEN'] = 'token'; + process.env['INPUT_PR-LABEL-TO-ADD'] = 'label-to-add'; + process.env['INPUT_PR-LABEL-TO-REMOVE'] = 'label-to-remove'; + + process.env['GITHUB_REPOSITORY'] = 'foo/bar'; + process.env['GITHUB_EVENT_PATH'] = path.join( + __dirname, + scenario.response + ); + process.env['GITHUB_EVENT_NAME'] = scenario.event_name; + + const api = nock('https://api.github.com') + .persist() + .post('/repos/foo/bar/issues/10/labels', '{"labels":["label-to-add"]}') + .reply(200); + + const main = require('../src/main'); + await main.run(); + + expect(api.isDone()).not.toBeTruthy(); + }); + } +}); diff --git a/communicate-on-pull-request-released/__tests__/pull-request-closed.json b/communicate-on-pull-request-released/__tests__/pull-request-closed.json new file mode 100644 index 0000000..acc0f54 --- /dev/null +++ b/communicate-on-pull-request-released/__tests__/pull-request-closed.json @@ -0,0 +1,8 @@ +{ + "pull_request": { + "number": 10, + "body": "This is a pr description.", + "merged": true + }, + "action": "closed" +} \ No newline at end of file diff --git a/communicate-on-pull-request-released/__tests__/pull-request-parser.test.ts b/communicate-on-pull-request-released/__tests__/pull-request-parser.test.ts new file mode 100644 index 0000000..52f673c --- /dev/null +++ b/communicate-on-pull-request-released/__tests__/pull-request-parser.test.ts @@ -0,0 +1,133 @@ +const validScenarios = [ + { + prBody: 'The description. Close #444.', + issueNumber: 444, + owner: 'foo', + repo: 'bar' + }, + { + prBody: 'closed #444', + issueNumber: 444, + owner: 'foo', + repo: 'bar' + }, + { + prBody: 'Fix #444.', + issueNumber: 444, + owner: 'foo', + repo: 'bar' + }, + { + prBody: 'This pull request fixes #444', + issueNumber: 444, + owner: 'foo', + repo: 'bar' + }, + { + prBody: 'This PR fixed #1234', + issueNumber: 1234, + owner: 'foo', + repo: 'bar' + }, + { + prBody: 'This pull request resolve #444.', + issueNumber: 444, + owner: 'foo', + repo: 'bar' + }, + { + prBody: 'It resolves #1.', + issueNumber: 1, + owner: 'foo', + repo: 'bar' + }, + { + prBody: 'resolved #33.', + issueNumber: 33, + owner: 'foo', + repo: 'bar' + }, + { + prBody: 'This pull request closes #1234.', + issueNumber: 1234, + owner: 'foo', + repo: 'bar' + }, + { + prBody: 'This pull request closes https://github.com/foo/bar/issues/10', + issueNumber: 10, + owner: 'foo', + repo: 'bar' + } +]; + +const invalidScenarios = [ + { + prBody: 'This description does not contain referenced issue.', + owner: 'foo', + repo: 'bar' + }, + { + prBody: 'unsupported closing keyword #4444', + owner: 'foo', + repo: 'bar' + }, + { + prBody: '#4444', + owner: 'foo', + repo: 'bar' + }, + { + prBody: 'closes # 4444', + owner: 'foo', + repo: 'bar' + }, + { + prBody: 'fixes #abc', + owner: 'foo', + repo: 'bar' + }, + { + prBody: 'resolve 1234', + owner: 'foo', + repo: 'bar' + }, + { + prBody: 'This pull request closes http://github.com/foo/bar/issues/10', + owner: 'foo', + repo: 'bar' + }, + { + prBody: 'This pull request closes https://github.com/foo/bar/issues/10', + owner: 'foo', + repo: 'foo' + } +]; + +describe('pull request parser test suite', () => { + for (const scenario of validScenarios) { + it(`It detects referenced issue for (${scenario.prBody})`, async () => { + const parser = require('../src/pull-request-parser'); + expect( + parser.getReferencedIssue( + scenario.owner, + scenario.repo, + scenario.prBody + ) + ).toEqual(scenario.issueNumber); + }); + } + + for (const scenario of invalidScenarios) { + it(`It does not detect referenced issue for (${scenario.prBody})`, async () => { + const parser = require('../src/pull-request-parser'); + expect( + parser.getReferencedIssue( + scenario.owner, + scenario.repo, + scenario.prBody + ) + ).toBeUndefined(); + }); + } +}); diff --git a/communicate-on-pull-request-released/__tests__/release-missing-pr-numbers.json b/communicate-on-pull-request-released/__tests__/release-missing-pr-numbers.json new file mode 100644 index 0000000..978a391 --- /dev/null +++ b/communicate-on-pull-request-released/__tests__/release-missing-pr-numbers.json @@ -0,0 +1,7 @@ +{ + "release": { + "tag_name": "2.134.1", + "body": "Could not find pull request numbers included in the release." + }, + "action": "published" +} \ No newline at end of file diff --git a/communicate-on-pull-request-released/__tests__/release-parser.test.ts b/communicate-on-pull-request-released/__tests__/release-parser.test.ts new file mode 100644 index 0000000..1d2ec81 --- /dev/null +++ b/communicate-on-pull-request-released/__tests__/release-parser.test.ts @@ -0,0 +1,45 @@ +const validScenarios = [ + { + releaseBody: + '[action] cocoapods changes (#15490) via XYZ\n[fastlane] revert (#15399, #15407) via ZYX', + issues: [15490, 15399, 15407] + }, + { + releaseBody: '[action] title (#1) via XYZ\n[action] title (#2) via XYZ', + issues: [1, 2] + }, + { + releaseBody: '[action] title (#999) via XYZ', + issues: [999] + } +]; + +const invalidScenarios = [ + { + releaseBody: 'The description does not contain pull requests references' + }, + { + releaseBody: '' + }, + { + releaseBody: 'Incorrect syntax reference: # 999, 1100' + } +]; + +describe('release parser test suite', () => { + for (const scenario of validScenarios) { + it(`It detects the list of pull requests for (${scenario.releaseBody})`, async () => { + const parser = require('../src/release-parser'); + let pullRequests = parser.getReferencedPullRequests(scenario.releaseBody); + expect(pullRequests).toEqual(scenario.issues); + }); + } + + for (const scenario of invalidScenarios) { + it(`It does not detect the list of pull requests for (${scenario.releaseBody})`, async () => { + const parser = require('../src/release-parser'); + let pullRequests = parser.getReferencedPullRequests(scenario.releaseBody); + expect(pullRequests.length).toBe(0); + }); + } +}); diff --git a/communicate-on-pull-request-released/__tests__/release.json b/communicate-on-pull-request-released/__tests__/release.json new file mode 100644 index 0000000..31e1682 --- /dev/null +++ b/communicate-on-pull-request-released/__tests__/release.json @@ -0,0 +1,8 @@ +{ + "release": { + "tag_name": "2.134.1", + "body": "* [action] title (#999) via XYZ", + "html_url": "https://github.com/Codertocat/Hello-World/runs/128620228" + }, + "action": "published" +} \ No newline at end of file diff --git a/communicate-on-pull-request-released/action.yml b/communicate-on-pull-request-released/action.yml new file mode 100644 index 0000000..37edcc0 --- /dev/null +++ b/communicate-on-pull-request-released/action.yml @@ -0,0 +1,16 @@ +name: 'Communicate on pull request released' +description: 'An action for adding comments and labels to a pull request and referenced issue when the pull request is released' +author: 'fastlane' +inputs: + repo-token: + description: 'Token for the repo. Can be passed in using {{ secrets.GITHUB_TOKEN }}' + required: true + pr-label-to-add: + description: 'The label to apply when a pull request is released' + default: 'status: released' + pr-label-to-remove: + description: 'The label to remove when a pull request is released' + default: 'status: included-in-next-release' +runs: + using: 'docker' + image: 'Dockerfile' diff --git a/communicate-on-pull-request-released/jest.config.js b/communicate-on-pull-request-released/jest.config.js new file mode 100644 index 0000000..563d4cc --- /dev/null +++ b/communicate-on-pull-request-released/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + clearMocks: true, + moduleFileExtensions: ['js', 'ts'], + testEnvironment: 'node', + testMatch: ['**/*.test.ts'], + testRunner: 'jest-circus/runner', + transform: { + '^.+\\.ts$': 'ts-jest' + }, + verbose: true +} \ No newline at end of file diff --git a/communicate-on-pull-request-released/lib/main.js b/communicate-on-pull-request-released/lib/main.js new file mode 100644 index 0000000..3bd3ca3 --- /dev/null +++ b/communicate-on-pull-request-released/lib/main.js @@ -0,0 +1,152 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const core = __importStar(require("@actions/core")); +const github = __importStar(require("@actions/github")); +const pullRequestParser = __importStar(require("./pull-request-parser")); +const releaseParser = __importStar(require("./release-parser")); +function run() { + return __awaiter(this, void 0, void 0, function* () { + try { + if (github.context.eventName !== 'release') { + console.log('The event that triggered this action was not a release, exiting'); + return; + } + if (github.context.payload.action !== 'published') { + console.log('No release was published, exiting'); + return; + } + const release = extractReleaseFromPayload(); + if (typeof release === 'undefined') { + console.log('No release metadata found, exiting'); + return; + } + const repoToken = core.getInput('repo-token', { required: true }); + const client = new github.GitHub(repoToken); + const prNumbers = releaseParser.getReferencedPullRequests(release.body); + for (let prNumber of prNumbers) { + yield addCommentToPullRequest(client, prNumber, `Congratulations! :tada: This was released as part of [_fastlane_ ${release.tag}](${release.htmlURL}) :rocket:`); + const labelToRemove = core.getInput('pr-label-to-remove'); + const canRemoveLabel = yield canRemoveLabelFromIssue(client, prNumber, labelToRemove); + if (canRemoveLabel) { + yield removeLabel(client, prNumber, labelToRemove); + } + yield addLabels(client, prNumber, [core.getInput('pr-label-to-add')]); + yield addCommentToReferencedIssue(client, prNumber, release); + } + } + catch (error) { + core.setFailed(error.message); + } + }); +} +exports.run = run; +function addCommentToPullRequest(client, prNumber, comment) { + return __awaiter(this, void 0, void 0, function* () { + yield client.pulls.createReview({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + pull_number: prNumber, + body: comment, + event: 'COMMENT' + }); + }); +} +function addCommentToReferencedIssue(client, prNumber, release) { + return __awaiter(this, void 0, void 0, function* () { + const pullRequest = yield getPullRequest(client, prNumber); + const issueNumber = pullRequestParser.getReferencedIssue(github.context.repo.owner, github.context.repo.repo, pullRequest.body); + if (issueNumber) { + const message = [ + `The pull request #${prNumber} that closed this issue was merged and released as part of [_fastlane_ ${release.tag}](${release.htmlURL}) :rocket:`, + `Please let us know if the functionality works as expected as a reply here. If it does not, please open a new issue. Thanks!` + ]; + yield addIssueComment(client, issueNumber, message.join('\n')); + } + }); +} +function getPullRequest(client, prNumber) { + return __awaiter(this, void 0, void 0, function* () { + const response = yield client.pulls.get({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + pull_number: prNumber + }); + return response.data; + }); +} +function canRemoveLabelFromIssue(client, prNumber, label) { + return __awaiter(this, void 0, void 0, function* () { + const response = yield client.issues.listLabelsOnIssue({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: prNumber + }); + const issueLabels = response.data; + for (let issueLabel of issueLabels) { + if (issueLabel.name === label) { + return true; + } + } + return false; + }); +} +function addLabels(client, prNumber, labels) { + return __awaiter(this, void 0, void 0, function* () { + yield client.issues.addLabels({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: prNumber, + labels: labels + }); + }); +} +function removeLabel(client, prNumber, label) { + return __awaiter(this, void 0, void 0, function* () { + yield client.issues.removeLabel({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: prNumber, + name: label + }); + }); +} +function addIssueComment(client, issueNumber, message) { + return __awaiter(this, void 0, void 0, function* () { + yield client.issues.createComment({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: issueNumber, + body: message + }); + }); +} +function extractReleaseFromPayload() { + const release = github.context.payload['release']; + if (release === 'undefined') { + return undefined; + } + const tag = release['tag_name']; + const body = release['body']; + const htmlURL = release['html_url']; + if (tag == null || body == null || htmlURL == null) { + return undefined; + } + return { tag: tag, body: body, htmlURL: htmlURL }; +} +run(); diff --git a/communicate-on-pull-request-released/lib/pull-request-parser.js b/communicate-on-pull-request-released/lib/pull-request-parser.js new file mode 100644 index 0000000..efe5a7b --- /dev/null +++ b/communicate-on-pull-request-released/lib/pull-request-parser.js @@ -0,0 +1,39 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +// Issue closing keywords: https://help.github.com/en/articles/closing-issues-using-keywords +let ISSUE_CLOSING_KEYWORDS = [ + 'close', + 'closes', + 'closed', + 'fix', + 'fixes', + 'fixed', + 'resolve', + 'resolves', + 'resolved' +]; +function getReferencedIssue(owner, repo, prBody) { + // Searching for issue closing keywords and issue identifier in pull request's description, + // i.e. `fixes #1234`, `close #444`, `resolved #1` + var regex = new RegExp(`(${ISSUE_CLOSING_KEYWORDS.join('|')}) #\\d{1,}`, 'i'); + var matched = prBody.match(regex) || []; + if (matched.length > 0) { + const issue = matched[0].match(/#\d{1,}/i) || []; + if (issue.length > 0) { + const issueNumber = issue[0].replace('#', ''); + return Number(issueNumber); + } + } + // Searching for issue closing keywords and issue URL in pull request's description, + // i.e. `closes https://github.com/REPOSITORY_OWNER/REPOSITORY_NAME/issues/1234` + regex = new RegExp(`(${ISSUE_CLOSING_KEYWORDS.join('|')}) https:\\/\\/github.com\\/${owner}\\/${repo}\\/issues\\/\\d{1,}`, 'i'); + matched = prBody.match(regex) || []; + if (matched.length > 0) { + const issue = matched[0].split('/').pop(); + if (issue) { + return Number(issue); + } + } + return undefined; +} +exports.getReferencedIssue = getReferencedIssue; diff --git a/communicate-on-pull-request-released/lib/release-parser.js b/communicate-on-pull-request-released/lib/release-parser.js new file mode 100644 index 0000000..5fdcf1e --- /dev/null +++ b/communicate-on-pull-request-released/lib/release-parser.js @@ -0,0 +1,24 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +// Matches: +// (#8324) +// (#8324,#8325) +// (#8324, #8325) +// (#8324,#8325,#8326) +// (#8324, #8325, #8326) +// etc. +function getReferencedPullRequests(releaseBody) { + const lines = releaseBody.split('\n'); + let pullRequestNumbers = []; + lines.forEach(line => { + const regex = /\((#\d+(?:,\s*#\d+)*)\)/; + const matches = line.match(regex) || []; + if (matches.length >= 2) { + const rawPullRequestNumbers = matches[1].split(/,\s*/) || []; + var numbers = rawPullRequestNumbers.map(rawNumber => Number(rawNumber.replace('#', ''))); + pullRequestNumbers.push(...numbers); + } + }); + return [...new Set(pullRequestNumbers)]; +} +exports.getReferencedPullRequests = getReferencedPullRequests; diff --git a/communicate-on-pull-request-released/package.json b/communicate-on-pull-request-released/package.json new file mode 100644 index 0000000..d37cb36 --- /dev/null +++ b/communicate-on-pull-request-released/package.json @@ -0,0 +1,32 @@ +{ + "name": "communicate-on-pull-request-released", + "version": "0.0.0", + "private": true, + "description": "An action for adding comments and labels to a pull request and referenced issue when the pull request is released", + "main": "lib/main.js", + "scripts": { + "build": "tsc", + "format": "prettier --write **/*.ts", + "test": "jest" + }, + "keywords": [ + "actions", + "fastlane" + ], + "author": "fastlane", + "license": "MIT", + "dependencies": { + "@actions/core": "^1.0.0", + "@actions/github": "^1.0.0" + }, + "devDependencies": { + "@types/jest": "^24.0.13", + "@types/node": "^12.0.4", + "jest": "^24.8.0", + "jest-circus": "^24.7.1", + "nock": "^10.0.6", + "prettier": "^1.17.1", + "ts-jest": "^24.0.2", + "typescript": "^3.5.1" + } +} diff --git a/communicate-on-pull-request-released/src/main.ts b/communicate-on-pull-request-released/src/main.ts new file mode 100644 index 0000000..5f7c34f --- /dev/null +++ b/communicate-on-pull-request-released/src/main.ts @@ -0,0 +1,177 @@ +import * as core from '@actions/core'; +import * as github from '@actions/github'; +import * as pullRequestParser from './pull-request-parser'; +import * as releaseParser from './release-parser'; + +interface Release { + tag: string; + body: string; + htmlURL: string; +} + +export async function run() { + try { + if (github.context.eventName !== 'release') { + console.log( + 'The event that triggered this action was not a release, exiting' + ); + return; + } + + if (github.context.payload.action !== 'published') { + console.log('No release was published, exiting'); + return; + } + + const release = extractReleaseFromPayload(); + if (typeof release === 'undefined') { + console.log('No release metadata found, exiting'); + return; + } + + const repoToken = core.getInput('repo-token', {required: true}); + const client: github.GitHub = new github.GitHub(repoToken); + + const prNumbers = releaseParser.getReferencedPullRequests(release.body); + for (let prNumber of prNumbers) { + await addCommentToPullRequest( + client, + prNumber, + `Congratulations! :tada: This was released as part of [_fastlane_ ${release.tag}](${release.htmlURL}) :rocket:` + ); + const labelToRemove = core.getInput('pr-label-to-remove'); + const canRemoveLabel = await canRemoveLabelFromIssue( + client, + prNumber, + labelToRemove + ); + if (canRemoveLabel) { + await removeLabel(client, prNumber, labelToRemove); + } + await addLabels(client, prNumber, [core.getInput('pr-label-to-add')]); + await addCommentToReferencedIssue(client, prNumber, release); + } + } catch (error) { + core.setFailed(error.message); + } +} + +async function addCommentToPullRequest( + client: github.GitHub, + prNumber: number, + comment: string +) { + await client.pulls.createReview({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + pull_number: prNumber, + body: comment, + event: 'COMMENT' + }); +} + +async function addCommentToReferencedIssue( + client: github.GitHub, + prNumber: number, + release: Release +) { + const pullRequest = await getPullRequest(client, prNumber); + const issueNumber = pullRequestParser.getReferencedIssue( + github.context.repo.owner, + github.context.repo.repo, + pullRequest.body + ); + if (issueNumber) { + const message = [ + `The pull request #${prNumber} that closed this issue was merged and released as part of [_fastlane_ ${release.tag}](${release.htmlURL}) :rocket:`, + `Please let us know if the functionality works as expected as a reply here. If it does not, please open a new issue. Thanks!` + ]; + await addIssueComment(client, issueNumber, message.join('\n')); + } +} + +async function getPullRequest(client: github.GitHub, prNumber: number) { + const response = await client.pulls.get({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + pull_number: prNumber + }); + return response.data; +} + +async function canRemoveLabelFromIssue( + client: github.GitHub, + prNumber: number, + label: string +): Promise { + const response = await client.issues.listLabelsOnIssue({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: prNumber + }); + + const issueLabels = response.data; + for (let issueLabel of issueLabels) { + if (issueLabel.name === label) { + return true; + } + } + return false; +} + +async function addLabels( + client: github.GitHub, + prNumber: number, + labels: string[] +) { + await client.issues.addLabels({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: prNumber, + labels: labels + }); +} + +async function removeLabel( + client: github.GitHub, + prNumber: number, + label: string +) { + await client.issues.removeLabel({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: prNumber, + name: label + }); +} + +async function addIssueComment( + client: github.GitHub, + issueNumber: number, + message: string +) { + await client.issues.createComment({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: issueNumber, + body: message + }); +} + +function extractReleaseFromPayload(): Release | undefined { + const release = github.context.payload['release']; + if (release === 'undefined') { + return undefined; + } + + const tag = release['tag_name']; + const body = release['body']; + const htmlURL = release['html_url']; + if (tag == null || body == null || htmlURL == null) { + return undefined; + } + + return {tag: tag, body: body, htmlURL: htmlURL}; +} + +run(); diff --git a/communicate-on-pull-request-released/src/pull-request-parser.ts b/communicate-on-pull-request-released/src/pull-request-parser.ts new file mode 100644 index 0000000..1751ece --- /dev/null +++ b/communicate-on-pull-request-released/src/pull-request-parser.ts @@ -0,0 +1,48 @@ +// Issue closing keywords: https://help.github.com/en/articles/closing-issues-using-keywords +let ISSUE_CLOSING_KEYWORDS = [ + 'close', + 'closes', + 'closed', + 'fix', + 'fixes', + 'fixed', + 'resolve', + 'resolves', + 'resolved' +]; + +export function getReferencedIssue( + owner: string, + repo: string, + prBody: string +): number | undefined { + // Searching for issue closing keywords and issue identifier in pull request's description, + // i.e. `fixes #1234`, `close #444`, `resolved #1` + var regex = new RegExp(`(${ISSUE_CLOSING_KEYWORDS.join('|')}) #\\d{1,}`, 'i'); + var matched = prBody.match(regex) || []; + if (matched.length > 0) { + const issue = matched[0].match(/#\d{1,}/i) || []; + if (issue.length > 0) { + const issueNumber = issue[0].replace('#', ''); + return Number(issueNumber); + } + } + + // Searching for issue closing keywords and issue URL in pull request's description, + // i.e. `closes https://github.com/REPOSITORY_OWNER/REPOSITORY_NAME/issues/1234` + regex = new RegExp( + `(${ISSUE_CLOSING_KEYWORDS.join( + '|' + )}) https:\\/\\/github.com\\/${owner}\\/${repo}\\/issues\\/\\d{1,}`, + 'i' + ); + matched = prBody.match(regex) || []; + if (matched.length > 0) { + const issue = matched[0].split('/').pop(); + if (issue) { + return Number(issue); + } + } + + return undefined; +} diff --git a/communicate-on-pull-request-released/src/release-parser.ts b/communicate-on-pull-request-released/src/release-parser.ts new file mode 100644 index 0000000..ea5e88d --- /dev/null +++ b/communicate-on-pull-request-released/src/release-parser.ts @@ -0,0 +1,23 @@ +// Matches: +// (#8324) +// (#8324,#8325) +// (#8324, #8325) +// (#8324,#8325,#8326) +// (#8324, #8325, #8326) +// etc. +export function getReferencedPullRequests(releaseBody: string): number[] { + const lines = releaseBody.split('\n'); + let pullRequestNumbers: number[] = []; + lines.forEach(line => { + const regex = /\((#\d+(?:,\s*#\d+)*)\)/; + const matches = line.match(regex) || []; + if (matches.length >= 2) { + const rawPullRequestNumbers = matches[1].split(/,\s*/) || []; + var numbers = rawPullRequestNumbers.map(rawNumber => + Number(rawNumber.replace('#', '')) + ); + pullRequestNumbers.push(...numbers); + } + }); + return [...new Set(pullRequestNumbers)]; +} diff --git a/communicate-on-pull-request-released/tsconfig.json b/communicate-on-pull-request-released/tsconfig.json new file mode 100644 index 0000000..c66a82d --- /dev/null +++ b/communicate-on-pull-request-released/tsconfig.json @@ -0,0 +1,64 @@ +{ + "compilerOptions": { + /* Basic Options */ + // "incremental": true, /* Enable incremental compilation */ + "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ + "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ + // "allowJs": true, /* Allow javascript files to be compiled. */ + // "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ + // "declaration": true, /* Generates corresponding '.d.ts' file. */ + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + // "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "./", /* Concatenate and emit output to single file. */ + "outDir": "./lib", /* Redirect output structure to the directory. */ + "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "composite": true, /* Enable project compilation */ + // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ + // "removeComments": true, /* Do not emit comments to output. */ + // "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + + /* Strict Type-Checking Options */ + "strict": true, /* Enable all strict type-checking options. */ + "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + // "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + + /* Additional Checks */ + // "noUnusedLocals": true, /* Report errors on unused locals. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + + /* Module Resolution Options */ + // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + }, + "exclude": ["node_modules", "**/*.test.ts"] + } + \ No newline at end of file