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..6505188 --- /dev/null +++ b/autoLabelPRinProgress/index.js @@ -0,0 +1,59 @@ +"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') { + 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') { + 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); + }); + } + }); + } + 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..7e67cef --- /dev/null +++ b/autoLabelPRinProgress/index.ts @@ -0,0 +1,78 @@ +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') { + // filter issues which DOES already contain the label + const issuesWithLabelsWithExpectedLabel = + issuesWithLabels.filter(iwl => iwl.labels.some(label => label === labelPRinProgress)); + + // remove label 'PR in progress' + 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' + issuesWithLabelsWithoutExpectedLabel.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/readme.md b/readme.md index beda6a7..01c68fb 100644 --- a/readme.md +++ b/readme.md @@ -32,6 +32,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. diff --git a/shared/functions.js b/shared/functions.js index 6d132c2..8ab7afb 100644 --- a/shared/functions.js +++ b/shared/functions.js @@ -23,4 +23,11 @@ exports.containsExclusiveLabels = function (rootNode, exclusiveLabels) { return exclusiveLabels.some(function (l) { return l === label.name; }); }); }; +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 76064be..b0525f8 100644 --- a/shared/functions.ts +++ b/shared/functions.ts @@ -23,4 +23,13 @@ export const containsExclusiveLabels = (rootNode: IssueNode | PullRequest, exclu .some(label => { return exclusiveLabels.some(l => l === label.name); }); +} + +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 3a59669..3973cd0 100644 --- a/shared/github.js +++ b/shared/github.js @@ -98,6 +98,26 @@ exports.getAllOpenPullRequests = function (headers, repoOwner, repoName, callbac var getAllOpenPullRequestsQuery = function (repoOwner, repoName) { return "\n query { \n repository(owner: \"" + repoOwner + "\", name: \"" + repoName + "\") { \n pullRequests(states: [OPEN], first: 100) {\n edges {\n node {\n id,\n number,\n author {\n login\n },\n createdAt,\n comments {\n totalCount\n },\n lastComment: comments(last: 1) {\n edges {\n node {\n updatedAt\n }\n }\n },\n lastTwoComments: comments(last: 2) {\n edges {\n node {\n author {\n login\n },\n body\n }\n }\n },\n labels(first: 10) {\n edges {\n node {\n name\n }\n }\n },\n milestone {\n number\n }\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: addGitHubCommentMutation(issueId, comment) @@ -127,4 +147,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 593a7c4..c2ebe4d 100644 --- a/shared/github.ts +++ b/shared/github.ts @@ -1,5 +1,5 @@ import { performHttpRequest } from './http'; -import { IssueNode, PullRequestNode, IssueOrPullRequestLinkNode, Milestone, PullRequest } from './models'; +import { IssueNode, PullRequestNode, IssueOrPullRequestLinkNode, Milestone, PullRequest, IssueWithLabels } from './models'; // private functions @@ -260,6 +260,42 @@ const getAllOpenPullRequestsQuery = (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) => { @@ -283,7 +319,7 @@ const addGitHubCommentMutation = (subjectId: 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; @@ -306,4 +342,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 1327061..46bf783 100644 --- a/shared/models.ts +++ b/shared/models.ts @@ -115,4 +115,9 @@ export type PullRequest = { }[]; }; milestone: MilestoneWithNumberAndState | undefined; +} + +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