diff options
author | Daniel Smith <daniel.smith@qt.io> | 2024-01-09 09:11:00 +0100 |
---|---|---|
committer | Daniel Smith <daniel.smith@qt.io> | 2024-03-20 13:37:17 +0100 |
commit | 11374fe9f9ab315df66fa68f4df7d0b186edaf28 (patch) | |
tree | fc729f2bb929017705bba910f5e5d8cb7ceca675 | |
parent | 9537dd5af6d94d34606baa26bcfac710f886e203 (diff) |
Add a plugin to welcome new contributors
This plugin will post a welcome message to new contributors when they
submit their first patchset to The Qt Project, also adding
a random selection of four users from a buddy group of reviewers
that have volunteered to help new contributors.
This patch also includes some minor fixups found in related code
discovered during development of this plugin.
Fixes: QTQAINFRA-5973
Change-Id: I1762a1047aa6c72e30b60c7ee5d277cee5750ade
Reviewed-by: Daniel Smith <daniel.smith@qt.io>
5 files changed, 335 insertions, 46 deletions
diff --git a/scripts/gerrit/cherry-pick_automation/gerritRESTTools.js b/scripts/gerrit/cherry-pick_automation/gerritRESTTools.js index db1cfd0c..a28c8d6a 100644 --- a/scripts/gerrit/cherry-pick_automation/gerritRESTTools.js +++ b/scripts/gerrit/cherry-pick_automation/gerritRESTTools.js @@ -258,13 +258,17 @@ function stageCherryPick(parentUuid, cherryPickJSON, customAuth, callback) { // Post a comment to the change on the latest revision. exports.postGerritComment = postGerritComment; function postGerritComment( - parentUuid, fullChangeID, revision, message, + parentUuid, fullChangeID, revision, message, reviewers, notifyScope, customAuth, callback ) { function _postComment() { let url = `${gerritBaseURL("changes")}/${fullChangeID}/revisions/${ revision || "current"}/review`; let data = { message: message, notify: notifyScope || "OWNER_REVIEWERS" }; + if (reviewers) { + // format reviewers as a list of ReviewInput entities + data.reviewers = reviewers.map((reviewer) => { return { reviewer: reviewer }; }); + } logger.log( `POST request to: ${url}\nRequest Body: ${safeJsonStringify(data)}`, @@ -648,6 +652,37 @@ function addToAttentionSet(parentUuid, changeJSON, user, reason, customAuth, cal ) } +exports.getGroupMembers = getGroupMembers; +function getGroupMembers(parentUuid, groupId, customAuth, callback) { + let url = `${gerritBaseURL("groups")}/${groupId}/members`; + logger.log(`GET request to: ${url}`, "debug", parentUuid); + + axios.get(url, { auth: customAuth || gerritAuth }) + .then(function (response) { + logger.log(`Raw response: ${response.data}`, "debug", parentUuid); + callback(true, JSON.parse(trimResponse(response.data))); + }).catch(function (error) { + if (error.response) { + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + logger.log( + `An error occurred in GET to "${url}". Error ${error.response.status}: ${ + error.response.data}`, + "error", parentUuid + ); + } else { + logger.log( + `Failed to get change group members for ${groupId}: ${safeJsonStringify(error)}`, + "error", parentUuid + ); + } + // Some kind of error occurred. Have the caller take some action to + // alert the owner that they need to add reviewers manually. + callback(false); + }); + +} + // Query gerrit for the existing reviewers on a change. exports.getChangeReviewers = getChangeReviewers; function getChangeReviewers(parentUuid, fullChangeID, customAuth, callback) { @@ -693,12 +728,80 @@ function getChangeReviewers(parentUuid, fullChangeID, customAuth, callback) { exports.setChangeReviewers = setChangeReviewers; function setChangeReviewers(parentUuid, fullChangeID, reviewers, customAuth, callback) { let failedItems = []; - if (reviewers.length == 0) // This function is a no-op if there are no reviewers. + if (reviewers.length == 0) { // This function is a no-op if there are no reviewers. callback(failedItems); - let project = /^((?:\w+-?)+(?:%2F|\/)(?:-?\w)+)/.exec(fullChangeID).pop(); - let branch = /~(.+)~/.exec(fullChangeID).pop(); + return + } + let project = ""; + let branch = ""; + + // Only try to parse strings. If it's not a string, then it's probably + // a change number. + if (typeof fullChangeID == String) { + try { + project = /^((?:\w+-?)+(?:%2F|\/)(?:-?\w)+)/.exec(fullChangeID).pop(); + branch = /~(.+)~/.exec(fullChangeID).pop(); + } catch (e) { + logger.log( + `Failed to parse project and branch from ${fullChangeID}: ${e}`, + "error", parentUuid + ); + return; + } + } + let doneCount = 0; + function postReviewer(reviewer) { + + function doPost(url, data) { + logger.log( + `POST request to ${url}\nRequest Body: ${safeJsonStringify(data)}`, + "debug", parentUuid + ); + axios({ method: "post", url: url, data: data, auth: customAuth || gerritAuth }) + .then(function (response) { + logger.log( + `Success adding ${reviewer} to ${fullChangeID}\n${response.data}`, + "info", parentUuid + ); + doneCount++; + if (doneCount == reviewers.length) + callback(failedItems); + }) + .catch(function (error) { + doneCount++; + if (doneCount == reviewers.length) + callback(failedItems); + if (error.response) { + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + logger.log( + `Error in POST to ${url} to add reviewer ${reviewer}: ${ + error.response.status}: ${error.response.data}`, + "error", parentUuid + ); + } else { + logger.log( + `Error adding a reviewer (${reviewer}) to ${fullChangeID}: ${safeJsonStringify(error)}`, + "warn", parentUuid + ); + } + failedItems.push(reviewer); + }); + } + + let url = `${gerritBaseURL("changes")}/${fullChangeID}/reviewers`; + let data = { reviewer: reviewer }; + + if (!project || !branch) { + // The fullChangeId passed is probably just a change number. + // Try to blindly set the reviewer. The operation will silently fail + // if the user does not have rights. + doPost(url, data); + return; + } + checkAccessRights( parentUuid, project, branch, reviewer, "read", customAuth, function (success, data) { @@ -709,42 +812,7 @@ function setChangeReviewers(parentUuid, fullChangeID, reviewers, customAuth, cal logger.log(`Reason: ${data}`, "debug", parentUuid); failedItems.push(reviewer); } else { - let url = `${gerritBaseURL("changes")}/${fullChangeID}/reviewers`; - let data = { reviewer: reviewer }; - logger.log( - `POST request to ${url}\nRequest Body: ${safeJsonStringify(data)}`, - "debug", parentUuid - ); - axios({ method: "post", url: url, data: data, auth: customAuth || gerritAuth }) - .then(function (response) { - logger.log( - `Success adding ${reviewer} to ${fullChangeID}\n${response.data}`, - "info", parentUuid - ); - doneCount++; - if (doneCount == reviewers.length) - callback(failedItems); - }) - .catch(function (error) { - doneCount++; - if (doneCount == reviewers.length) - callback(failedItems); - if (error.response) { - // The request was made and the server responded with a status code - // that falls out of the range of 2xx - logger.log( - `Error in POST to ${url} to add reviewer ${reviewer}: ${ - error.response.status}: ${error.response.data}`, - "error", parentUuid - ); - } else { - logger.log( - `Error adding a reviewer (${reviewer}) to ${fullChangeID}: ${safeJsonStringify(error)}`, - "warn", parentUuid - ); - } - failedItems.push(reviewer); - }); + doPost(url, data); } } ); @@ -1005,3 +1073,56 @@ function findIntegrationIDFromChange(uuid, fullChangeID, customAuth, callback) { callback(false); }) } + +exports.getContributorChangeCount = getContributorChangeCount; +function getContributorChangeCount(uuid, contributor, customAuth, callback) { + let url = `${gerritBaseURL("changes")}/?q=owner:${contributor}`; + logger.log(`GET request for ${url}`, "debug", uuid); + axios + .get(url, { auth: customAuth || gerritAuth }) + .then(function (response) { + logger.log(`Raw Response: ${response.data}`, "silly", uuid); + const changes = JSON.parse(trimResponse(response.data)); + callback(true, changes.length); + }) + .catch((error) => { + logger.log(`Failed to get change count for ${contributor}\n${error}`, "error", uuid); + callback(false); + }) +} + +exports.setHashtags = setHashtags; +function setHashtags(uuid, fullChangeID, hashtags, customAuth, callback) { + let url = `${gerritBaseURL("changes")}/${fullChangeID}/hashtags`; + logger.log(`POST add request for ${url}`, "debug", uuid); + if (typeof hashtags == "string") + hashtags = [hashtags]; + axios + .post(url, {"add": hashtags}, { auth: customAuth || gerritAuth }) + .then(function (response) { + logger.log(`Raw Response: ${response.data}`, "silly", uuid); + callback(true); + }) + .catch((error) => { + logger.log(`Failed to set hashtag for ${fullChangeID}\n${error}`, "error", uuid); + callback(false); + }) +} + +exports.removeHashtags = removeHashtags; +function removeHashtags(uuid, fullChangeID, hashtags, customAuth, callback) { + let url = `${gerritBaseURL("changes")}/${fullChangeID}/hashtags`; + logger.log(`POST remove request for ${url}`, "debug", uuid); + if (typeof hashtags == "string") + hashtags = [hashtags]; + axios + .post(url, {"remove": hashtags}, { auth: customAuth || gerritAuth }) + .then(function (response) { + logger.log(`Raw Response: ${response.data}`, "silly", uuid); + callback(true); + }) + .catch((error) => { + logger.log(`Failed to remove hashtag for ${fullChangeID}\n${error}`, "error", uuid); + callback(false); + }) +} diff --git a/scripts/gerrit/cherry-pick_automation/plugin_bots/new_contributor_welcome/config.json b/scripts/gerrit/cherry-pick_automation/plugin_bots/new_contributor_welcome/config.json new file mode 100644 index 00000000..08980744 --- /dev/null +++ b/scripts/gerrit/cherry-pick_automation/plugin_bots/new_contributor_welcome/config.json @@ -0,0 +1,5 @@ +{ + "NEW_CONTRIBUTOR_WELCOME_ENABLED": "", + "CONTRIBUTOR_GERRIT_USER": "", + "CONTRIBUTOR_GERRIT_PASS": "" +} diff --git a/scripts/gerrit/cherry-pick_automation/plugin_bots/new_contributor_welcome/new_contributor_welcome.js b/scripts/gerrit/cherry-pick_automation/plugin_bots/new_contributor_welcome/new_contributor_welcome.js new file mode 100644 index 00000000..7c2ad08f --- /dev/null +++ b/scripts/gerrit/cherry-pick_automation/plugin_bots/new_contributor_welcome/new_contributor_welcome.js @@ -0,0 +1,163 @@ +/* eslint-disable no-unused-vars */ +// Copyright (C) 2020 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +exports.id = "new_contributor_welcome"; + +const gerritTools = require("../../gerritRESTTools"); +const config = require("./config.json"); + +function envOrConfig(ID) { + return process.env[ID] || config[ID]; +} + +class new_contributor_welcome { + constructor(notifier) { + this.notifier = notifier; + this.logger = notifier.logger; + this.retryProcessor = notifier.retryProcessor; + this.requestProcessor = notifier.requestProcessor; + + this.gerritAuth = { + username: envOrConfig("CONTRIBUTOR_GERRIT_USER"), + password: envOrConfig("CONTRIBUTOR_GERRIT_PASS") + }; + + + notifier.registerCustomListener(notifier.requestProcessor, "check_new_contributor_reviewers", + this.check_new_contributor_reviewers.bind(this)); + notifier.server.registerCustomEvent( + "welcome_new_contributor", "patchset-created", + (req) => { + // Run a few checks. + + // Is the patchset a new change on patchset 1? + if (req.patchSet.number != 1) { + return; + } + + // Has the user already contributed? + gerritTools.getContributorChangeCount(req.uuid, req.change.owner.username, this.gerritAuth, + (success, count) => { + if (!success) { + this.logger.log("Failed to get contributor info for " + req.change.owner.username + + " on " + req.change.number, "error", req.uuid); + return; + } + if (count > 1) { + // Already contributed, no need to welcome. + return; + } + + // This is the first change the user has submitted. + // Wait two minutes to see if they add reviewers. + this.retryProcessor.addRetryJob("WELCOME", "check_new_contributor_reviewers", + [req], 120000); + }) + } + ); + this.logger.log("Initialized New contributor welcome bot", "info", "SYSTEM"); + } + + check_new_contributor_reviewers(req) { + // Get the current reviewers of the change. + gerritTools.getChangeReviewers(req.uuid, req.change.number, this.gerritAuth, + (success, reviewers) => { + if (!success) { + this.logger.log("Failed to get change info for " + req.change.number, + "error", req.uuid); + return; + } + // Check if the change has reviewers. Sanity bot will always be added as a reviewer. + reviewers = reviewers.filter(reviewer => reviewer != "qt_sanitybot@qt-project.org"); + this.logger.log("Existing reviewers: [" + reviewers + "]", "verbose", req.uuid); + if (reviewers.length > 0) { + // The change has reviewers, no need to welcome. + return; + } + + // The change has no reviewers, send a welcome message. + this.send_welcome_message(req, req.change.project); + }) + } + + send_welcome_message(req) { + const user = req.change.owner.name || req.change.owner.username; + const message = `Welcome to The Qt Project, ${user}! ` + + "Thank you for your contribution!\n\n" + + "In order to get your change merged, it needs to be reviewed and approved first.\n" + + "Since you are new to the project, we've added a few reviewers for you that can help" + + " you with your first contribution and find a reviewer who knows this code well." + + " If you have any questions about getting set up with Qt, or anything else in our review" + + " process, feel free to ask them.\n\n" + + "In case you haven't read it yet, please take a look at our " + + "[Contribution guide](https://wiki.qt.io/Qt_Contribution_Guidelines)," + + " [Coding style](https://wiki.qt.io/Qt_Coding_Style), and" + + " [Contributions homepage](https://wiki.qt.io/Contribute).\n\n" + + "Note that this change has been set to \"Ready for review\" automatically so that the added" + + " reviewers can see it. If you are not ready for review yet, please set the change back to" + + " \"Work in progress\" using the menu in the top-right of the page.\n\n" + + "And again, welcome to The Qt Project! Your contribution is greatly appreciated!"; + + // Add the reviewers. + gerritTools.postGerritComment(req.uuid, req.change.number, undefined, message, + undefined, undefined, this.gerritAuth, + (success, data) => { + if (!success) { + this.logger.log("Failed to post welcome message for " + req.change.number, + "error", req.uuid); + return; + } + // Get group members + // Gerrit group for review buddies: eb1d5ff38b9cfe20c4cfada58b5bf8ba246ad6ab + // AKA: "New Contributors Welcome Buddies" + gerritTools.getGroupMembers(req.uuid, "New Contributors Welcome Buddies", this.gerritAuth, + (success, members) => { + if (!success) { + this.logger.log("Failed to get group members for " + req.change.number, + "error", req.uuid); + return; + } + members = members.map(member => member.username); + // Randomly select 4 reviewers. + members = members.sort(() => Math.random() - Math.random()).slice(0, 4); + // Add the reviewers. + this.logger.log("Adding reviewers [" + members + "] for " + req.change.number, + "verbose", req.uuid); + gerritTools.setChangeReviewers(req.uuid, req.change.number, + members, this.gerritAuth, + (failedItems) => { + if (failedItems.length > 0) { + this.logger.log("Failed to add reviewers for " + req.change.number, + "error", req.uuid); + return; + } + this.logger.log("Added reviewers for " + req.change.number, + "verbose", req.uuid); + } + ); + } + ); + this.logger.log("Posted welcome message for " + req.change.number, + "verbose", req.uuid); + } + ); + + // Also set a hashtag marking the change as from a new contributor. + // This makes all changes from new contributors easy to find. + // https://codereview.qt-project.org/q/hashtag:new_contributor + gerritTools.setHashtags(req.uuid, req.change.number, ["new_contributor"], + this.gerritAuth, (success) => { + if (!success) { + this.logger.log("Failed to set hashtags for " + req.change.number, + "error", req.uuid); + return; + } + this.logger.log("Set hashtags for " + req.change.number, + "verbose", req.uuid); + } + ); + } +} + +module.exports = new_contributor_welcome; diff --git a/scripts/gerrit/cherry-pick_automation/requestProcessor.js b/scripts/gerrit/cherry-pick_automation/requestProcessor.js index b9e904cc..12ab828e 100644 --- a/scripts/gerrit/cherry-pick_automation/requestProcessor.js +++ b/scripts/gerrit/cherry-pick_automation/requestProcessor.js @@ -1356,7 +1356,7 @@ class requestProcessor extends EventEmitter { gerritCommentHandler(parentUuid, fullChangeID, revision, message, notifyScope, customGerritAuth) { let _this = this; gerritTools.postGerritComment( - parentUuid, fullChangeID, revision, message, notifyScope, customGerritAuth, + parentUuid, fullChangeID, revision, message, undefined, notifyScope, customGerritAuth, function (success, data) { if (!success && data == "retry") { _this.emit( diff --git a/scripts/gerrit/cherry-pick_automation/retryProcessor.js b/scripts/gerrit/cherry-pick_automation/retryProcessor.js index 4293af69..d98d0386 100644 --- a/scripts/gerrit/cherry-pick_automation/retryProcessor.js +++ b/scripts/gerrit/cherry-pick_automation/retryProcessor.js @@ -15,7 +15,7 @@ class retryProcessor extends EventEmitter { this.requestProcessor = requestProcessor; } - addRetryJob(originalUuid, retryAction, args) { + addRetryJob(originalUuid, retryAction, args, delay) { let _this = this; const retryUuid = uuidv1(); _this.logger.log(`Setting up ${retryAction}`, "warn", originalUuid); @@ -27,10 +27,10 @@ class retryProcessor extends EventEmitter { `Retry ${retryAction} registered for ${retryUuid}`, "verbose", originalUuid ); - // Call retry in 30 seconds. + // Call retry in 30 seconds or custom delay time. setTimeout(function () { _this.emit("processRetry", retryUuid); - }, 30000); + }, delay || 30000); } ); } @@ -39,7 +39,7 @@ class retryProcessor extends EventEmitter { // the process where it left off. processRetry(uuid, callback) { let _this = this; - _this.logger.log(`Processing retry event with uuid ${uuid}`); + _this.logger.log(`Processing retry event with uuid ${uuid}`, "debug", "RETRY"); function deleteRetryRecord() { postgreSQLClient.deleteDBEntry("retry_queue", "uuid", uuid, function (success, data) {}); } @@ -49,7 +49,7 @@ class retryProcessor extends EventEmitter { deleteRetryRecord(); let args = toolbox.decodeBase64toJSON(rows[0].args); _this.logger.log( - `Processing retryRequest "${rows[0].retryAction}" for ${uuid} with args: ${args}`, + `Processing retryRequest "${rows[0].retryaction}" for ${uuid} with args: ${args}`, "debug" ); _this.requestProcessor.emit(rows[0].retryaction, ...args); |