From db512298bec3ec2829ac3f3b8c4ec00f92314696 Mon Sep 17 00:00:00 2001 From: Odonno Date: Sun, 18 Feb 2018 23:10:18 +0100 Subject: [PATCH 1/3] feat(autoLabelPRinProgress): automatically add or remove label on issues linked to PR in progress --- autoLabelPRinProgress/function.json | 15 +++++++ autoLabelPRinProgress/index.js | 57 +++++++++++++++++++++++ autoLabelPRinProgress/index.ts | 70 +++++++++++++++++++++++++++++ autoLabelPRinProgress/package.json | 5 +++ shared/functions.js | 7 +++ shared/functions.ts | 9 ++++ shared/github.js | 25 +++++++++++ shared/github.ts | 48 ++++++++++++++++++-- shared/models.ts | 5 +++ shared/utils.ts | 2 +- tests/autoLabelPRinProgress.js | 9 ++++ tests/autoLabelPRinProgress.ts | 8 ++++ unclosedIssuesInMergedPr/index.js | 11 +---- unclosedIssuesInMergedPr/index.ts | 11 +---- 14 files changed, 259 insertions(+), 23 deletions(-) create mode 100644 autoLabelPRinProgress/function.json create mode 100644 autoLabelPRinProgress/index.js create mode 100644 autoLabelPRinProgress/index.ts create mode 100644 autoLabelPRinProgress/package.json create mode 100644 tests/autoLabelPRinProgress.js create mode 100644 tests/autoLabelPRinProgress.ts diff --git a/autoLabelPRinProgress/function.json b/autoLabelPRinProgress/function.json new file mode 100644 index 0000000..22282ab --- /dev/null +++ b/autoLabelPRinProgress/function.json @@ -0,0 +1,15 @@ +{ + "bindings": [ + { + "webHookType": "github", + "name": "req", + "type": "httpTrigger", + "direction": "in" + }, + { + "type": "http", + "direction": "out", + "name": "$return" + } + ] +} \ No newline at end of file diff --git a/autoLabelPRinProgress/index.js b/autoLabelPRinProgress/index.js new file mode 100644 index 0000000..1012ba3 --- /dev/null +++ b/autoLabelPRinProgress/index.js @@ -0,0 +1,57 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var github_1 = require("../shared/github"); +var functions_1 = require("../shared/functions"); +var utils_1 = require("../shared/utils"); +var firstBlockTitle = '## PR Type'; +var labelPRinProgress = 'PR in progress'; +module.exports = function (context, req) { + var githubApiHeaders = { + 'User-Agent': 'github-bot-uwp-toolkit', + 'Authorization': 'token ' + process.env.GITHUB_BOT_UWP_TOOLKIT_ACCESS_TOKEN + }; + var repoOwner = process.env.GITHUB_BOT_UWP_TOOLKIT_REPO_OWNER; + var repoName = process.env.GITHUB_BOT_UWP_TOOLKIT_REPO_NAME; + var pullRequestNumber = req.number; + github_1.getPullRequest(githubApiHeaders, repoOwner, repoName, pullRequestNumber, function (pullRequest) { + var creationMessage = pullRequest.body; + var firstBlockOfCreationMessage = creationMessage.split(firstBlockTitle)[0]; + if (firstBlockOfCreationMessage) { + var linkedItemsNumbers = utils_1.distinct(functions_1.searchLinkedItemsNumbersInComment(firstBlockOfCreationMessage)); + github_1.getIssueOrPullRequestLinks(githubApiHeaders, repoOwner, repoName, linkedItemsNumbers, function (results) { + var issuesNumber = results + .filter(function (r) { return r.__typename === 'Issue'; }) + .map(function (r) { return r.__typename === 'Issue' ? r.number : null; }) + .filter(function (n) { return !!n; }); + if (issuesNumber.length <= 0) { + context.log('linked items are not issues'); + functions_1.completeFunction(context, req, { status: 201, body: { success: false, message: 'Linked items are not issues.' } }); + return; + } + if (process.env.GITHUB_BOT_UWP_TOOLKIT_ACTIVATE_MUTATION) { + github_1.getIssuesLabels(githubApiHeaders, repoOwner, repoName, issuesNumber, function (issuesWithLabels) { + if (req.action === 'closed') { + issuesWithLabels.map(function (issueWithLabels) { + var labels = utils_1.distinct(issueWithLabels.labels.filter(function (label) { return label !== labelPRinProgress; })); + github_1.setLabelsForIssue(githubApiHeaders, repoOwner, repoName, issueWithLabels.number, labels); + }); + } + if (req.action === 'opened' || req.action === 'reopened') { + issuesWithLabels.map(function (issueWithLabels) { + var labels = utils_1.distinct(issueWithLabels.labels.concat([labelPRinProgress])); + github_1.setLabelsForIssue(githubApiHeaders, repoOwner, repoName, issueWithLabels.number, labels); + }); + } + }); + } + context.log(issuesNumber); + functions_1.completeFunction(context, req, { status: 201, body: { success: true, message: issuesNumber } }); + }); + } + else { + context.log('no linked issues'); + functions_1.completeFunction(context, req, { status: 201, body: { success: false, message: 'No linked issues.' } }); + } + }); +}; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/autoLabelPRinProgress/index.ts b/autoLabelPRinProgress/index.ts new file mode 100644 index 0000000..db66552 --- /dev/null +++ b/autoLabelPRinProgress/index.ts @@ -0,0 +1,70 @@ +import { getPullRequest, getIssueOrPullRequestLinks, setLabelsForIssue, getIssuesLabels } from '../shared/github'; +import { searchLinkedItemsNumbersInComment, completeFunction } from '../shared/functions'; +import { distinct } from '../shared/utils'; + +const firstBlockTitle = '## PR Type'; +const labelPRinProgress = 'PR in progress'; + +module.exports = (context, req) => { + const githubApiHeaders = { + 'User-Agent': 'github-bot-uwp-toolkit', + 'Authorization': 'token ' + process.env.GITHUB_BOT_UWP_TOOLKIT_ACCESS_TOKEN + }; + + const repoOwner = process.env.GITHUB_BOT_UWP_TOOLKIT_REPO_OWNER; + const repoName = process.env.GITHUB_BOT_UWP_TOOLKIT_REPO_NAME; + const pullRequestNumber: number = req.number; + + getPullRequest( + githubApiHeaders, + repoOwner, + repoName, + pullRequestNumber, + (pullRequest) => { + // retrieve first block of creation block where user puts the linked issues + const creationMessage = pullRequest.body; + const firstBlockOfCreationMessage = creationMessage.split(firstBlockTitle)[0]; + + if (firstBlockOfCreationMessage) { + const linkedItemsNumbers = distinct(searchLinkedItemsNumbersInComment(firstBlockOfCreationMessage)); + + getIssueOrPullRequestLinks(githubApiHeaders, repoOwner, repoName, linkedItemsNumbers, (results) => { + const issuesNumber = results + .filter(r => r.__typename === 'Issue') + .map(r => r.__typename === 'Issue' ? r.number : null) + .filter(n => !!n); + + if (issuesNumber.length <= 0) { + context.log('linked items are not issues'); + completeFunction(context, req, { status: 201, body: { success: false, message: 'Linked items are not issues.' } }); + return; + } + + if (process.env.GITHUB_BOT_UWP_TOOLKIT_ACTIVATE_MUTATION) { + getIssuesLabels(githubApiHeaders, repoOwner, repoName, issuesNumber, (issuesWithLabels) => { + if (req.action === 'closed') { + // remove label 'PR in progress' + issuesWithLabels.map(issueWithLabels => { + const labels = distinct(issueWithLabels.labels.filter(label => label !== labelPRinProgress)); + setLabelsForIssue(githubApiHeaders, repoOwner, repoName, issueWithLabels.number, labels); + }); + } + if (req.action === 'opened' || req.action === 'reopened') { + // add label 'PR in progress' + issuesWithLabels.map(issueWithLabels => { + const labels = distinct(issueWithLabels.labels.concat([labelPRinProgress])); + setLabelsForIssue(githubApiHeaders, repoOwner, repoName, issueWithLabels.number, labels); + }); + } + }) + } + + context.log(issuesNumber); + completeFunction(context, req, { status: 201, body: { success: true, message: issuesNumber } }); + }); + } else { + context.log('no linked issues'); + completeFunction(context, req, { status: 201, body: { success: false, message: 'No linked issues.' } }); + } + }); +}; \ No newline at end of file diff --git a/autoLabelPRinProgress/package.json b/autoLabelPRinProgress/package.json new file mode 100644 index 0000000..57bc3e7 --- /dev/null +++ b/autoLabelPRinProgress/package.json @@ -0,0 +1,5 @@ +{ + "name": "auto-label-pr-in-progress", + "version": "0.0.1", + "description": "Automatically add/remove label PR in progress on issues linked to a PR" +} \ No newline at end of file diff --git a/shared/functions.js b/shared/functions.js index 20cfabf..78ffa94 100644 --- a/shared/functions.js +++ b/shared/functions.js @@ -16,4 +16,11 @@ exports.completeFunctionBySendingMail = function (context, personalizations, mai content: content }); }; +exports.searchLinkedItemsNumbersInComment = function (message) { + var matches = message.match(/[#][0-9]+/g); + if (matches) { + return matches.map(function (m) { return parseInt(m.trim().substr(1)); }); + } + return []; +}; //# sourceMappingURL=functions.js.map \ No newline at end of file diff --git a/shared/functions.ts b/shared/functions.ts index 933072b..1d9fdff 100644 --- a/shared/functions.ts +++ b/shared/functions.ts @@ -13,4 +13,13 @@ export const completeFunctionBySendingMail = (context: any, personalizations: an subject: subject, content: content }); +} + +export const searchLinkedItemsNumbersInComment = (message: string): number[] => { + const matches = message.match(/[#][0-9]+/g); + + if (matches) { + return matches.map(m => parseInt(m.trim().substr(1))); + } + return []; } \ No newline at end of file diff --git a/shared/github.js b/shared/github.js index 59ddee4..d299aea 100644 --- a/shared/github.js +++ b/shared/github.js @@ -88,6 +88,26 @@ exports.getAllMilestones = function (headers, repoOwner, repoName, callback) { var getAllMilestonesQuery = function (repoOwner, repoName) { return "\n query {\n repository(owner: \"" + repoOwner + "\", name: \"" + repoName + "\") {\n milestones(first: 100) {\n edges {\n node {\n id,\n state,\n dueOn,\n number\n }\n }\n }\n }\n }"; }; +exports.getIssuesLabels = function (headers, repoOwner, repoName, numbers, callback) { + performGitHubGraphqlRequest(headers, { + query: getIssuesLabelsQuery(repoOwner, repoName, numbers) + }, function (response) { + var results = numbers + .map(function (n, index) { return ({ + number: n, + labels: response.data.repository['result' + index].labels.edges.map(function (edge) { return edge.node.name; }) + }); }); + callback(results); + }); +}; +var getIssuesLabelsQuery = function (repoOwner, repoName, numbers) { + var resultList = numbers + .map(function (n, index) { + return "\n result" + index + ": issue(number: " + n + ") {\n labels(first: 100) {\n edges {\n node {\n name\n }\n }\n }\n }"; + }) + .join(','); + return "\n query {\n repository(owner: \"" + repoOwner + "\", name: \"" + repoName + "\") {\n " + resultList + "\n }\n }"; +}; exports.commentGitHubIssue = function (headers, issueId, comment) { performGitHubGraphqlRequest(headers, { query: commentGitHubIssueMutation(issueId, comment) @@ -112,4 +132,9 @@ exports.closeGitHubIssue = function (headers, owner, repo, issueNumber, issueId) var closeGitHubIssueMutation = function (issueId) { return "\n mutation {\n closeIssue(input: { subjectId: \"" + issueId + "\" }) {\n subject {\n id\n }\n }\n }"; }; +exports.setLabelsForIssue = function (headers, owner, repo, issueNumber, labels) { + performGitHubRestRequest(headers, "/repos/" + owner + "/" + repo + "/issues/" + issueNumber, 'PATCH', { + labels: labels + }); +}; //# sourceMappingURL=github.js.map \ No newline at end of file diff --git a/shared/github.ts b/shared/github.ts index 4cc6a2c..29cb35f 100644 --- a/shared/github.ts +++ b/shared/github.ts @@ -1,5 +1,5 @@ import { performHttpRequest } from './http'; -import { IssueNode, PullRequestNode, IssueOrPullRequestLinkNode, Milestone } from './models'; +import { IssueNode, PullRequestNode, IssueOrPullRequestLinkNode, Milestone, IssueWithLabels } from './models'; // private functions @@ -178,7 +178,7 @@ const getIssueOrPullRequestLinksQuery = (repoOwner: string, repoName: string, nu }`; } -export const getAllMilestones = (headers: any, repoOwner: string, repoName: string, callback: (milestones: Milestone[]) => any) => { +export const getAllMilestones = (headers: any, repoOwner: string, repoName: string, callback: (milestones: Milestone[]) => any) => { performGitHubGraphqlRequest(headers, { query: getAllMilestonesQuery(repoOwner, repoName) }, (response) => { @@ -203,6 +203,42 @@ const getAllMilestonesQuery = (repoOwner: string, repoName: string) => { }`; } +export const getIssuesLabels = (headers: any, repoOwner: string, repoName: string, numbers: number[], callback: (issuesWithLabels: IssueWithLabels[]) => any) => { + performGitHubGraphqlRequest(headers, { + query: getIssuesLabelsQuery(repoOwner, repoName, numbers) + }, (response) => { + const results = numbers + .map((n, index) => ({ + number: n, + labels: response.data.repository['result' + index].labels.edges.map(edge => edge.node.name) + })); + callback(results); + }); +} +const getIssuesLabelsQuery = (repoOwner: string, repoName: string, numbers: number[]) => { + const resultList = numbers + .map((n, index) => { + return ` + result${index}: issue(number: ${n}) { + labels(first: 100) { + edges { + node { + name + } + } + } + }`; + }) + .join(','); + + return ` + query { + repository(owner: "${repoOwner}", name: "${repoName}") { + ${resultList} + } + }`; +} + // mutations export const commentGitHubIssue = (headers: any, issueId: string, comment: string) => { @@ -221,7 +257,7 @@ const commentGitHubIssueMutation = (issueId: string, comment: string): string => }`; } -// this mutation is not currently available - using the REST API +// these mutations are not currently available - using the REST API instead export const closeGitHubIssue = (headers: any, owner: string, repo: string, issueNumber: number, issueId: string) => { const useGraphql = false; @@ -244,4 +280,10 @@ const closeGitHubIssueMutation = (issueId: string): string => { } } }`; +} + +export const setLabelsForIssue = (headers: any, owner: string, repo: string, issueNumber: number, labels: string[]) => { + performGitHubRestRequest(headers, `/repos/${owner}/${repo}/issues/${issueNumber}`, 'PATCH', { + labels + }); } \ No newline at end of file diff --git a/shared/models.ts b/shared/models.ts index 1579ce1..9dc8c47 100644 --- a/shared/models.ts +++ b/shared/models.ts @@ -76,4 +76,9 @@ export type Milestone = { state: 'CLOSED' | 'OPEN'; dueOn: string; number: number; +} + +export type IssueWithLabels = { + number: number; + labels: string[]; } \ No newline at end of file diff --git a/shared/utils.ts b/shared/utils.ts index ba46ecc..8b34144 100644 --- a/shared/utils.ts +++ b/shared/utils.ts @@ -1,6 +1,6 @@ // Array utils -export const distinct = (array: any[]): any[] => { +export const distinct = (array: T[]): T[] => { return array.filter((x, i, a) => { return a.indexOf(x) === i; }); diff --git a/tests/autoLabelPRinProgress.js b/tests/autoLabelPRinProgress.js new file mode 100644 index 0000000..bc2bdde --- /dev/null +++ b/tests/autoLabelPRinProgress.js @@ -0,0 +1,9 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var tests_1 = require("../shared/tests"); +var autoLabelPRinProgress = require('../autoLabelPRinProgress'); +autoLabelPRinProgress(tests_1.createFakeContext('autoLabelPRinProgress'), { + number: 1824, + action: 'opened' +}); +//# sourceMappingURL=autoLabelPRinProgress.js.map \ No newline at end of file diff --git a/tests/autoLabelPRinProgress.ts b/tests/autoLabelPRinProgress.ts new file mode 100644 index 0000000..9be22ee --- /dev/null +++ b/tests/autoLabelPRinProgress.ts @@ -0,0 +1,8 @@ +import { createFakeContext } from '../shared/tests'; + +const autoLabelPRinProgress = require('../autoLabelPRinProgress'); + +autoLabelPRinProgress(createFakeContext('autoLabelPRinProgress'), { + number: 1824, + action: 'opened' +}); \ No newline at end of file diff --git a/unclosedIssuesInMergedPr/index.js b/unclosedIssuesInMergedPr/index.js index 9ab361a..c3901fc 100644 --- a/unclosedIssuesInMergedPr/index.js +++ b/unclosedIssuesInMergedPr/index.js @@ -46,20 +46,13 @@ var getLinkedItemsNumbersInPullRequest = function (botUsername, pullRequest) { }).length > 0; if (!hasAlreadyGotTheMessage) { var linkedItemsNumbersInComments = pullRequest.comments.edges.map(function (edge) { return edge.node; }) - .map(function (c) { return searchLinkedItemsNumbersInComment(c.body); }) + .map(function (c) { return functions_1.searchLinkedItemsNumbersInComment(c.body); }) .reduce(function (a, b) { return a.concat(b); }, []); - var linkedItemsNubmersInBodyMessage = searchLinkedItemsNumbersInComment(pullRequest.body); + var linkedItemsNubmersInBodyMessage = functions_1.searchLinkedItemsNumbersInComment(pullRequest.body); var linkedItemsNumbers = linkedItemsNumbersInComments.concat(linkedItemsNubmersInBodyMessage); var distinctLinkedItemsNumbers = utils_1.distinct(linkedItemsNumbers); return distinctLinkedItemsNumbers; } return []; }; -var searchLinkedItemsNumbersInComment = function (message) { - var matches = message.match(/[#][0-9]+/g); - if (matches) { - return matches.map(function (m) { return parseInt(m.trim().substr(1)); }); - } - return []; -}; //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/unclosedIssuesInMergedPr/index.ts b/unclosedIssuesInMergedPr/index.ts index be1f87d..c654377 100644 --- a/unclosedIssuesInMergedPr/index.ts +++ b/unclosedIssuesInMergedPr/index.ts @@ -1,5 +1,5 @@ import { distinct } from '../shared/utils'; -import { completeFunction } from '../shared/functions'; +import { completeFunction, searchLinkedItemsNumbersInComment } from '../shared/functions'; import { PullRequestNode } from '../shared/models'; import { getPullRequest, getIssueOrPullRequestLinks, commentGitHubIssue } from '../shared/github'; @@ -81,14 +81,5 @@ const getLinkedItemsNumbersInPullRequest = (botUsername: string, pullRequest: Pu return distinctLinkedItemsNumbers; } - return []; -} - -const searchLinkedItemsNumbersInComment = (message: string): number[] => { - const matches = message.match(/[#][0-9]+/g); - - if (matches) { - return matches.map(m => parseInt(m.trim().substr(1))); - } return []; } \ No newline at end of file From 53f0b43077456e50df560352574c00cf9b95fc38 Mon Sep 17 00:00:00 2001 From: Odonno Date: Sun, 18 Feb 2018 23:12:38 +0100 Subject: [PATCH 2/3] docs(readme): add autoLabelPRinProgress section description --- readme.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/readme.md b/readme.md index 34faf4b..1af366a 100644 --- a/readme.md +++ b/readme.md @@ -26,6 +26,11 @@ Then, using the `pull_request` it will detect the linked issues that are not clo This function detects issues with `pending-uservoice-creation` label. +### autoLabelPRinProgress + +This function listens a GitHub webhook event when a PR is created, closed, reopened or merged. +Then, it will detect the linked issues to this PR and update the `labels` of each of these issues by adding/removing the `PR in progress` label. + ## How to use? 1. First, build the project using `tsc` command line. From b6d90f750936c656a4c9020151f1192cd13341f4 Mon Sep 17 00:00:00 2001 From: Odonno Date: Mon, 19 Feb 2018 19:50:36 +0100 Subject: [PATCH 3/3] refactor(autoLabelPRinProgress): filter issues if their labels are not updated by the function --- autoLabelPRinProgress/index.js | 6 ++++-- autoLabelPRinProgress/index.ts | 12 ++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/autoLabelPRinProgress/index.js b/autoLabelPRinProgress/index.js index 1012ba3..6505188 100644 --- a/autoLabelPRinProgress/index.js +++ b/autoLabelPRinProgress/index.js @@ -31,13 +31,15 @@ module.exports = function (context, req) { if (process.env.GITHUB_BOT_UWP_TOOLKIT_ACTIVATE_MUTATION) { github_1.getIssuesLabels(githubApiHeaders, repoOwner, repoName, issuesNumber, function (issuesWithLabels) { if (req.action === 'closed') { - issuesWithLabels.map(function (issueWithLabels) { + var issuesWithLabelsWithExpectedLabel = issuesWithLabels.filter(function (iwl) { return iwl.labels.some(function (label) { return label === labelPRinProgress; }); }); + issuesWithLabelsWithExpectedLabel.map(function (issueWithLabels) { var labels = utils_1.distinct(issueWithLabels.labels.filter(function (label) { return label !== labelPRinProgress; })); github_1.setLabelsForIssue(githubApiHeaders, repoOwner, repoName, issueWithLabels.number, labels); }); } if (req.action === 'opened' || req.action === 'reopened') { - issuesWithLabels.map(function (issueWithLabels) { + var issuesWithLabelsWithoutExpectedLabel = issuesWithLabels.filter(function (iwl) { return iwl.labels.every(function (label) { return label !== labelPRinProgress; }); }); + issuesWithLabelsWithoutExpectedLabel.map(function (issueWithLabels) { var labels = utils_1.distinct(issueWithLabels.labels.concat([labelPRinProgress])); github_1.setLabelsForIssue(githubApiHeaders, repoOwner, repoName, issueWithLabels.number, labels); }); diff --git a/autoLabelPRinProgress/index.ts b/autoLabelPRinProgress/index.ts index db66552..7e67cef 100644 --- a/autoLabelPRinProgress/index.ts +++ b/autoLabelPRinProgress/index.ts @@ -43,15 +43,23 @@ module.exports = (context, req) => { if (process.env.GITHUB_BOT_UWP_TOOLKIT_ACTIVATE_MUTATION) { getIssuesLabels(githubApiHeaders, repoOwner, repoName, issuesNumber, (issuesWithLabels) => { if (req.action === 'closed') { + // filter issues which DOES already contain the label + const issuesWithLabelsWithExpectedLabel = + issuesWithLabels.filter(iwl => iwl.labels.some(label => label === labelPRinProgress)); + // remove label 'PR in progress' - issuesWithLabels.map(issueWithLabels => { + issuesWithLabelsWithExpectedLabel.map(issueWithLabels => { const labels = distinct(issueWithLabels.labels.filter(label => label !== labelPRinProgress)); setLabelsForIssue(githubApiHeaders, repoOwner, repoName, issueWithLabels.number, labels); }); } if (req.action === 'opened' || req.action === 'reopened') { + // filter issues which does NOT already contain the label + const issuesWithLabelsWithoutExpectedLabel = + issuesWithLabels.filter(iwl => iwl.labels.every(label => label !== labelPRinProgress)); + // add label 'PR in progress' - issuesWithLabels.map(issueWithLabels => { + issuesWithLabelsWithoutExpectedLabel.map(issueWithLabels => { const labels = distinct(issueWithLabels.labels.concat([labelPRinProgress])); setLabelsForIssue(githubApiHeaders, repoOwner, repoName, issueWithLabels.number, labels); });