summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDaniel Smith <daniel.smith@qt.io>2024-01-09 09:11:00 +0100
committerDaniel Smith <daniel.smith@qt.io>2024-03-20 13:37:17 +0100
commit11374fe9f9ab315df66fa68f4df7d0b186edaf28 (patch)
treefc729f2bb929017705bba910f5e5d8cb7ceca675
parent9537dd5af6d94d34606baa26bcfac710f886e203 (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>
-rw-r--r--scripts/gerrit/cherry-pick_automation/gerritRESTTools.js201
-rw-r--r--scripts/gerrit/cherry-pick_automation/plugin_bots/new_contributor_welcome/config.json5
-rw-r--r--scripts/gerrit/cherry-pick_automation/plugin_bots/new_contributor_welcome/new_contributor_welcome.js163
-rw-r--r--scripts/gerrit/cherry-pick_automation/requestProcessor.js2
-rw-r--r--scripts/gerrit/cherry-pick_automation/retryProcessor.js10
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);