summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDaniel Smith <daniel.smith@qt.io>2023-04-20 16:51:23 +0200
committerDaniel Smith <daniel.smith@qt.io>2024-01-15 10:20:46 +0200
commitb08c2007e9fdef4d2638fbebc6a83d41d933a0aa (patch)
tree53078e9ef2add3fa091b48585d69446154595020
parent02a42b3229a17f29a39cdbbb27810c2980f3d3f6 (diff)
Waterfall cherry-picks from newer to older branches
The current behavior of blasting all requested picks at once is causing some issues with apparent regressions in the event that a change is cherry-picked to both an older branch and a newer one, but the newer pick fails to merge. When a user upgrades versions, it then appears that there has been a regression. In order to reduce these types of incidents, the pick-to targets are checked for any gaps before determing the pick order. Any missing targets will be added to the Pick-to footer resulting from the first cherry-pick. The latest feature branch specified in or added to the footer will be picked first, and any remaining feature or release branches are tacked on to a new Pick-to: footer of the cherry-pick. Change-Id: I8176395fcd082e235280fc0f7da06cf95190bfcd Reviewed-by: Daniel Smith <Daniel.Smith@qt.io>
-rw-r--r--scripts/gerrit/cherry-pick_automation/gerritRESTTools.js142
-rw-r--r--scripts/gerrit/cherry-pick_automation/notifier.js6
-rw-r--r--scripts/gerrit/cherry-pick_automation/relationChainManager.js75
-rw-r--r--scripts/gerrit/cherry-pick_automation/requestProcessor.js300
-rw-r--r--scripts/gerrit/cherry-pick_automation/singleRequestManager.js74
-rw-r--r--scripts/gerrit/cherry-pick_automation/toolbox.js416
6 files changed, 916 insertions, 97 deletions
diff --git a/scripts/gerrit/cherry-pick_automation/gerritRESTTools.js b/scripts/gerrit/cherry-pick_automation/gerritRESTTools.js
index 0b84d4f8..7adf4ba2 100644
--- a/scripts/gerrit/cherry-pick_automation/gerritRESTTools.js
+++ b/scripts/gerrit/cherry-pick_automation/gerritRESTTools.js
@@ -52,6 +52,7 @@ let gerritResolvedURL = /^https?:\/\//g.test(gerritURL)
? gerritURL
: `${gerritPort == 80 ? "http" : "https"}://${gerritURL}`;
gerritResolvedURL += gerritPort != 80 && gerritPort != 443 ? ":" + gerritPort : "";
+exports.gerritResolvedURL = gerritResolvedURL;
// Return an assembled url to use as a base for requests to gerrit.
function gerritBaseURL(api) {
@@ -77,7 +78,7 @@ function generateCherryPick(changeJSON, parent, destinationBranch, customAuth, c
function doPick() {
logger.log(
- `New commit message for ${changeJSON.change.branch}:\n${newCommitMessage}`,
+ `New commit message for ${destinationBranch}:\n${newCommitMessage}`,
"verbose", changeJSON.uuid
);
logger.log(
@@ -126,8 +127,7 @@ function generateCherryPick(changeJSON, parent, destinationBranch, customAuth, c
);
}
- const newCommitMessage = changeJSON.change.commitMessage
- .replace(/^Pick-to:.+\s?/gm, "")
+ let newCommitMessage = changeJSON.change.commitMessage
.concat(`(cherry picked from commit ${changeJSON.patchSet.revision})`);
let url;
if (/^(tqtc(?:%2F|\/)lts-)/.test(changeJSON.change.branch)) {
@@ -154,10 +154,10 @@ function generateCherryPick(changeJSON, parent, destinationBranch, customAuth, c
} else {
// Something unexpected happened when trying to get the Topic.
logger.log(
- `UNKNOWN ERROR querying topic for change ${changeJSON.fullChangeID}: ${error}`,
+ `UNKNOWN ERROR querying topic for change ${changeJSON.fullChangeID}: ${topic}`,
"error", changeJSON.uuid
);
- callback(false, error);
+ callback(false, topic);
}
});
}
@@ -338,7 +338,61 @@ function validateBranch (parentUuid, project, branch, customAuth, callback) {
callback(false, error.message);
}
});
-};
+}
+
+exports.queryBranchesRe = queryBranchesRe;
+function queryBranchesRe(uuid, project, bypassTqtc, searchRegex, customAuth, callback) {
+ // Prefix the project with tqtc- if it's not already prefixed,
+ // but respect the bypassTqtc flag. This is so that we can get the
+ // latest branches prefixed with tqtc/lts- for comparison.
+ let tqtcProject = project;
+ if (!bypassTqtc) {
+ tqtcProject = project.includes("qt/tqtc-") ? project : project.replace("qt/", "qt/tqtc-");
+ }
+ let url = `${gerritBaseURL("projects")}/${encodeURIComponent(tqtcProject)}`
+ + `/branches?r=${searchRegex}`;
+ logger.log(`GET request to: ${url}`, "debug", uuid);
+ axios.get(url, { auth: customAuth || gerritAuth })
+ .then(function (response) {
+ // Execute callback and return the list of changes
+ logger.log(`Raw Response:\n${response.data}`, "debug", uuid);
+ let branches = [];
+ const parsed = JSON.parse(trimResponse(response.data));
+ for (let i=0; i < parsed.length; i++) {
+ branches.push(parsed[i].ref.slice(11,).replace("tqtc/lts-", "")); // trim "refs/heads/"
+ }
+ callback(true, branches);
+ })
+ .catch(function (error) {
+ if (error.response) {
+ if (error.response.status == 404 && !project.includes("qt/tqtc") && !bypassTqtc) {
+ // The tqtc- project doesn't exist, try again with the original prefix.
+ logger.log(`Project ${tqtcProject} doesn't exist, trying ${project}`, "debug", uuid);
+ queryBranchesRe(uuid, project, true, searchRegex, customAuth, callback);
+ return;
+ }
+ // An error here would be unexpected. A query with no results should
+ // still return an empty list.
+ callback(false, error.response);
+ logger.log(
+ `An error occurred in GET "${url}". Error ${error.response.status}: ${
+ error.response.data}`,
+ "error", uuid
+ );
+ } else if (error.request) {
+ // Gerrit failed to respond, try again later and resume the process.
+ callback(false, "retry");
+ } else {
+ // Something happened in setting up the request that triggered an Error
+ logger.log(
+ `Error in HTTP request while trying to query branches in ${project} with regex `
+ + `${searchRegex}. Error: ${safeJsonStringify(error)}`,
+ "error", uuid
+ );
+ callback(false, error.message);
+ }
+ });
+}
// Query gerrit commit for it's relation chain. Returns a list of changes.
exports.queryRelated = function (parentUuid, fullChangeID, latestPatchNum, customAuth, callback) {
@@ -420,7 +474,7 @@ function queryChange(parentUuid, fullChangeID, fields, customAuth, callback) {
callback(false, error.message);
}
});
-};
+}
// Query gerrit for a change's topic
exports.queryChangeTopic = queryChangeTopic
@@ -456,7 +510,7 @@ function queryChangeTopic(parentUuid, fullChangeID, customAuth, callback) {
callback(false, error.message);
}
});
-};
+}
// Query gerrit for a change and return it along with the current revision if it exists.
exports.queryProjectCommit = function (parentUuid, project, commit, customAuth, callback) {
@@ -499,12 +553,13 @@ exports.queryProjectCommit = function (parentUuid, project, commit, customAuth,
// Add a user to the attention set of a change
exports.addToAttentionSet = addToAttentionSet;
function addToAttentionSet(parentUuid, changeJSON, user, reason, customAuth, callback) {
+ let project = changeJSON.project.name ? changeJSON.project.name : changeJSON.project;
checkAccessRights(
- parentUuid, changeJSON.project, changeJSON.branch || changeJSON.change.branch,
+ parentUuid, project, changeJSON.branch || changeJSON.change.branch,
user, "push", customAuth || gerritAuth,
function (success, data) {
if (!success) {
- let msg = `User "${user}" cannot push to ${changeJSON.project}:${changeJSON.branch}.`
+ let msg = `User "${user}" cannot push to ${project}:${changeJSON.branch}.`
logger.log(msg, "warn", parentUuid);
callback(false, msg);
let botAssignee = envOrConfig("GERRIT_USER");
@@ -602,7 +657,9 @@ function getChangeReviewers(parentUuid, fullChangeID, customAuth, callback) {
exports.setChangeReviewers = setChangeReviewers;
function setChangeReviewers(parentUuid, fullChangeID, reviewers, customAuth, callback) {
let failedItems = [];
- let project = /^(\w+(?:%2F|\/)\w+-?\w+)~/.exec(fullChangeID).pop();
+ 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();
function postReviewer(reviewer) {
checkAccessRights(
@@ -678,9 +735,42 @@ function locateDefaultAttentionUser(uuid, cherryPickChange, uploader, callback)
// Query the cherry-pick's original branch change to identify the original
// author.
let ReviewRegex = /^Reviewed-by: .+<(.+)>$/m;
- let commitMessage = cherryPickChange.commitMessage;
+ let commitMessage = "";
let originalApprover = undefined;
+ try {
+ commitMessage = cherryPickChange.commitMessage || cherryPickChange.change.commitMessage;
+ doMain();
+ } catch {
+ queryChange(uuid, cherryPickChange.fullChangeID || cherryPickChange.id, undefined, undefined,
+ (success, data) => {
+ commitMessage = data.revisions[data.current_revision].commit.message;
+ doMain();
+ }
+ );
+ }
+ function doMain() {
+ try {
+ originalApprover = commitMessage.match(ReviewRegex)[1];
+ } catch {
+ logger.log(`Failed to locate a reviewer from commit message:\n${commitMessage}`,
+ "warn", uuid);
+ }
+ if (originalApprover && originalApprover != uploader) {
+ // The approver from the original change should be able to help.
+ checkAccessRights(uuid, cherryPickChange.project, cherryPickChange.branch, originalApprover,
+ "read", undefined,
+ (canRead) => {
+ if (canRead)
+ callback(originalApprover);
+ else
+ tryFallback();
+ }
+ );
+ } else {
+ callback(uploader);
+ }
+ }
function tryFallback() {
// This insane regex is the same as used in the commit message sanitizer,
@@ -691,10 +781,10 @@ function locateDefaultAttentionUser(uuid, cherryPickChange, uploader, callback)
try{
originSha = commitMessage.match(cherryPickRegex)[2];
} catch {
+ // Seems this isn't a cherry-pick. Perhaps this is being called for a standalone change.
logger.log(`Failed to match a cherry-pick footer for ${cherryPickChange.fullChangeID}`,
- "error", uuid);
- callback(false); // No point in continuing. Log the error and move on.
- return;
+ "warn", uuid);
+ originSha = cherryPickChange.change.current_revision || cherryPickChange.newRev;
}
queryChange(uuid, originSha, undefined, undefined,
function(success, changeData) {
@@ -731,28 +821,6 @@ function locateDefaultAttentionUser(uuid, cherryPickChange, uploader, callback)
}
);
}
-
-
- try {
- originalApprover = commitMessage.match(ReviewRegex)[1];
- } catch {
- // Should really never fail, since cherry-picks should always be created
- // with the original Review footers intact.
- logger.log(`Failed to locate a reviewer from commit message:\n${commitMessage}`,
- "error", uuid);
- }
- if (originalApprover && originalApprover != uploader) {
- // The approver from the original change should be able to help.
- checkAccessRights(uuid, cherryPickChange.project, cherryPickChange.branch, originalApprover,
- "read", undefined,
- (canRead) => {
- if (canRead)
- callback(originalApprover);
- else
- tryFallback();
- }
- );
- }
}
// Check permissions for a branch. Returns Bool.
diff --git a/scripts/gerrit/cherry-pick_automation/notifier.js b/scripts/gerrit/cherry-pick_automation/notifier.js
index 39a61146..c0583875 100644
--- a/scripts/gerrit/cherry-pick_automation/notifier.js
+++ b/scripts/gerrit/cherry-pick_automation/notifier.js
@@ -107,6 +107,12 @@ server.on("newRequest", (reqBody) => {
server.receiveEvent(reqBody);
});
+// Emitted by requestProcessor when there is a need to simulate a change-merged event.
+// Results in processing the request as if it were a fresh change-merged event.
+requestProcessor.on("mock-change-merged", (reqBody) => {
+ server.receiveEvent(reqBody);
+});
+
// Emitted by the server when the incoming request has been written to the database.
server.on("newRequestStored", (uuid) => {
requestProcessor.processMerge(uuid);
diff --git a/scripts/gerrit/cherry-pick_automation/relationChainManager.js b/scripts/gerrit/cherry-pick_automation/relationChainManager.js
index d05145f8..6cd4d82b 100644
--- a/scripts/gerrit/cherry-pick_automation/relationChainManager.js
+++ b/scripts/gerrit/cherry-pick_automation/relationChainManager.js
@@ -7,6 +7,7 @@ exports.id = "relationChainManager";
const safeJsonStringify = require("safe-json-stringify");
const toolbox = require("./toolbox");
+const gerritTools = require("./gerritRESTTools");
class relationChainManager {
constructor(logger, retryProcessor, requestProcessor) {
@@ -56,7 +57,7 @@ class relationChainManager {
this.requestProcessor.addListener("relationChain_stagingDone", this.handleStagingDone);
}
- start(currentJSON, branches) {
+ start(currentJSON, picks) {
let _this = this;
_this.logger.log(
@@ -64,6 +65,13 @@ class relationChainManager {
"verbose", currentJSON.uuid
);
+ function emit(parentJSON, branch) {
+ _this.requestProcessor.emit(
+ "validateBranch", parentJSON, branch,
+ "relationChain_checkLtsTarget"
+ );
+ }
+
// Determine if this change is the top-level change.
let positionInChain = currentJSON.relatedChanges.findIndex((i) =>
i.change_id === currentJSON.change.id);
@@ -74,18 +82,69 @@ class relationChainManager {
`Change ${currentJSON.fullChangeID} is the top level in it's relation chain`,
"debug", currentJSON.uuid
);
- _this.requestProcessor.emit("processAsSingleChange", currentJSON, branches);
+ _this.requestProcessor.emit("processAsSingleChange", currentJSON, picks);
} else {
// This change is dependent on a parent in the chain. Begin the process.
_this.logger.log(
- `Kicking off the process for each branch in ${safeJsonStringify(Array.from(branches))}`,
+ `Kicking off the process for each branch in ${safeJsonStringify(Object.keys(picks))}`,
"verbose", currentJSON.uuid
);
- branches.forEach(function (branch) {
- _this.requestProcessor.emit(
- "validateBranch", _this.requestProcessor.toolbox.deepCopy(currentJSON), branch,
- "relationChain_checkLtsTarget"
- );
+ Object.keys(picks).forEach(function (branch) {
+ let parentCopy = _this.requestProcessor.toolbox.deepCopy(currentJSON)
+ if (picks[branch].length > 0) {
+ const originalPicks = Array.from(toolbox.findPickToBranches(parentCopy.uuid,
+ parentCopy.change.commitMessage));
+ let missing = picks[branch].filter(x => !originalPicks.includes(x));
+ // Check the target branch itself since it may not be in originalPicks and could have been
+ // added by the bot.
+ if (!originalPicks.includes(branch))
+ missing.push(branch);
+ if (missing.length > 0) {
+ gerritTools.locateDefaultAttentionUser(parentCopy.uuid, parentCopy,
+ parentCopy.patchSet.uploader.email, function(user) {
+ function postComment() {
+ const plural = missing.length > 1;
+ _this.requestProcessor.gerritCommentHandler(parentCopy.uuid,
+ parentCopy.fullChangeID, undefined,
+ `Automatic cherry-picking detected missing Pick-to targets.`
+ +`\nTarget${plural ? 's' : ''} "${missing.join(", ")}"`
+ + ` ${plural ? "have" : "has"} been automatically added to the`
+ + ` cherry-pick for ${branch}.\nPlease review for correctness.`);
+ }
+
+ if (user && user == "copyReviewers") {
+ // Do nothing since we don't have a default attention user.
+ // This typically means the change was self-approved.
+ } else {
+ gerritTools.setChangeReviewers(parentCopy.uuid, parentCopy.fullChangeID,
+ [user], undefined, function() {
+ gerritTools.addToAttentionSet(
+ parentCopy.uuid, parentCopy, user, "Relevant user",
+ parentCopy.customGerritAuth,
+ function (success, data) {
+ if (!success) {
+ _this.logger.log(
+ `Failed to add "${safeJsonStringify(parentCopy.change.owner)}" to the`
+ + ` attention set of ${parentCopy.id}\n`
+ + `Reason: ${safeJsonStringify(data)}`,
+ "error", parentCopy.uuid
+ );
+ }
+ postComment();
+ }
+ );
+ });
+ }
+ });
+ }
+ parentCopy.change.commitMessage = parentCopy.change.commitMessage
+ .replace(/^Pick-to:.+$/gm, `Pick-to: ${picks[branch].join(" ")}`);
+ emit(parentCopy, branch);
+ } else {
+ parentCopy.change.commitMessage = parentCopy.change.commitMessage
+ .replace(/^Pick-to:.+$\n/gm, "");
+ emit(parentCopy, branch);
+ }
});
}
}
diff --git a/scripts/gerrit/cherry-pick_automation/requestProcessor.js b/scripts/gerrit/cherry-pick_automation/requestProcessor.js
index e565c9ee..32128b09 100644
--- a/scripts/gerrit/cherry-pick_automation/requestProcessor.js
+++ b/scripts/gerrit/cherry-pick_automation/requestProcessor.js
@@ -69,23 +69,74 @@ class requestProcessor extends EventEmitter {
});
// Parse the commit message and look for branches to pick to
- const branches = toolbox.findPickToBranches(incoming.uuid, incoming.change.commitMessage);
- if (branches.size == 0) {
- _this.logger.log(`Nothing to cherry-pick. Discarding`, "verbose", incoming.uuid);
- // The change did not have a "Pick To: " keyword or "Pick To:" did not include any branches.
- toolbox.setDBState(incoming.uuid, "discarded");
- } else {
- _this.logger.log(
- `Found ${branches.size} branches to pick to for ${incoming.uuid}`,
- "info", uuid
- );
- toolbox.setPickCountRemaining(incoming.uuid, branches.size, function (success, data) {
- // The change has a Pick-to label with at least one branch.
- // Next, determine if it's part of a relation chain and handle
- // it as a member of that chain.
- _this.emit("determineProcessingPath", incoming, branches);
- });
- }
+ let allBranches = toolbox.findPickToBranches(incoming.uuid, incoming.change.commitMessage);
+ toolbox.findMissingTargets(incoming.uuid, incoming.fullChangeID, incoming.project.name || incoming.project,
+ allBranches, (error, _change, missing) => {
+ if (missing.length > 0) {
+ missing.forEach(allBranches.add, allBranches);
+ }
+ const suggestedPicks = Array.from(allBranches);
+ const morePicks = suggestedPicks.length > 0;
+ if (error) {
+ allBranches.delete(_change.branch);
+ const message = ` ${_change.branch} was identified as a missing target based on this change's commit message.\n`
+ + `WARN: Cherry-pick bot cannot pick this change to ${_change.branch} because`
+ + ` a change already exists on ${_change.branch} which is in status: ${_change.status}.\n`
+ + `Cherry-pick bot will only proceed automatically for any release branch targets of this branch. (${incoming.change.branch})\n`
+ + (morePicks ? `It is recommended to update the existing change with further pick-to targets.\n\n` : "\n\n")
+ + ` Change ID: ${_change.change_id}\n`
+ + ` Subject: ${_change.subject}\n`
+ + (morePicks
+ ? ` Suggested Pick-to: ${suggestedPicks.join(" ")}\n\n`
+ : "\n\n")
+ + `Link: ${gerritTools.gerritResolvedURL}/c/${_change.project}/+/${_change._number}`;
+ gerritTools.locateDefaultAttentionUser(incoming.uuid, incoming,
+ incoming.change.owner.email, (user) => {
+ gerritTools.addToAttentionSet(incoming.uuid, incoming, user, undefined, undefined, () => {
+ const notifyScope = "ALL";
+ _this.gerritCommentHandler(
+ incoming.uuid, incoming.fullChangeID, undefined,
+ message, notifyScope
+ );
+ });
+ }
+ );
+ _this.logger.log(`Aborting non-release cherry picking due to unpickable primary target`
+ + ` on ${_change.branch}`, "error", uuid);
+ let thisStableBranch = incoming.change.branch.split(".")
+ if (thisStableBranch.length >= 2) {
+ thisStableBranch.pop();
+ thisStableBranch = thisStableBranch.join("\\.");
+ const restring = new RegExp(`^${thisStableBranch}\\.\\d+$`);
+ // Filter out all branches except releases of the current branch.
+ //Does not apply to dev.
+ allBranches = new Set(Array.from(allBranches).filter(
+ (branch) => branch.match(restring)));
+ } else {
+ // Non numeric branches cannot have release branches. Delete all targets.
+ allBranches.clear();
+ }
+ }
+ const picks = toolbox.waterfallCherryPicks(incoming.uuid, allBranches);
+ const pickCount = Object.keys(picks).length;
+ if (pickCount == 0) {
+ _this.logger.log(`Nothing to cherry-pick. Discarding`, "verbose", incoming.uuid);
+ // The change did not have a "Pick To: " keyword or "Pick To:"
+ // did not include any branches.
+ toolbox.setDBState(incoming.uuid, "discarded");
+ } else {
+ _this.logger.log(
+ `Found ${pickCount} branches to pick to for ${incoming.uuid}`,
+ "info", uuid
+ );
+ toolbox.setPickCountRemaining(incoming.uuid, pickCount, function (success, data) {
+ // The change has a Pick-to label with at least one branch.
+ // Next, determine if it's part of a relation chain and handle
+ // it as a member of that chain.
+ _this.emit("determineProcessingPath", incoming, picks);
+ });
+ }
+ });
});
}
@@ -152,7 +203,57 @@ class requestProcessor extends EventEmitter {
function done(responseSignal, incoming, branch, success, data, message) {
if (success) {
- _this.emit(responseSignal, incoming, branch, data);
+ // Check to see if a change already exists on the target branch.
+ // If it does, abort the cherry-pick and notify the owner.
+ gerritTools.queryChange(incoming.uuid,
+ encodeURIComponent(`${incoming.change.project}~${branch}~${incoming.change.id}`),
+ undefined, undefined, function (exists, changeData) {
+ if (exists && changeData.status != "MERGED") {
+ _this.logger.log(
+ `A change already exists on ${branch} for ${incoming.change.id}`
+ + ` and is ${changeData.status}`, "verbose", incoming.uuid);
+ let targets = toolbox.findPickToBranches(incoming.uuid, incoming.change.commitMessage);
+ targets.delete(branch);
+ const suggestedPicks = Array.from(targets);
+ const morePicks = suggestedPicks.length > 0;
+ let message = `WARN: Cherry-pick bot cannot pick this change to ${branch} because`
+ + ` a change already exists on ${branch} which is in status: ${changeData.status}.\n`
+ + `Cherry-pick bot will not proceed automatically.\n`
+ + (morePicks ? `It is recommended to update the existing change with further pick-to targets.\n\n` : "\n\n")
+ + ` Change ID: ${incoming.change.id}\n`
+ + ` Subject: ${incoming.change.subject}\n`
+ + (morePicks
+ ? ` Suggested Pick-to: ${suggestedPicks.join(" ")}\n\n`
+ : "\n\n")
+ + `Link: ${gerritTools.gerritResolvedURL}/c/${changeData.project}/+/${changeData._number}`;
+ gerritTools.locateDefaultAttentionUser(incoming.uuid, incoming,
+ incoming.change.owner.email, (user) => {
+ gerritTools.addToAttentionSet(incoming.uuid, incoming, user, undefined, undefined, () => {
+ _this.gerritCommentHandler(
+ incoming.uuid, incoming.fullChangeID, undefined,
+ message, "OWNER"
+ );
+ });
+ });
+ toolbox.addToCherryPickStateUpdateQueue(
+ incoming.uuid,
+ { branch: branch, statusDetail: `OpenOrAbandonedExistsOnTarget`, args: [] },
+ "done_targetExistsIsOpen",
+ function () {
+ toolbox.decrementPickCountRemaining(incoming.uuid);
+ }
+ );
+ } else if (data == "retry") {
+ _this.retryProcessor.addRetryJob(
+ incoming.uuid, "validateBranch",
+ [incoming, branch, responseSignal]
+ );
+ } else {
+ // Success (Change on target branch may not exist or is already merged)
+ _this.emit(responseSignal, incoming, branch, data);
+ }
+ });
+ // _this.emit(responseSignal, incoming, branch, data);
} else if (data == "retry") {
_this.retryProcessor.addRetryJob(
incoming.uuid, "validateBranch",
@@ -721,6 +822,30 @@ class requestProcessor extends EventEmitter {
// Generate a cherry pick and call the response signal.
doCherryPick(incoming, branch, newParentRev, responseSignal) {
let _this = this;
+
+ function _doPickAlreadyExists(data, message) {
+ toolbox.addToCherryPickStateUpdateQueue(
+ incoming.uuid,
+ {
+ branch: branch, statusCode: data.statusCode, statusDetail: data.statusDetail, args: []
+ },
+ "done_pickAlreadyExists",
+ function () {
+ toolbox.decrementPickCountRemaining(incoming.uuid);
+ gerritTools.locateDefaultAttentionUser(incoming.uuid, incoming,
+ incoming.change.owner.email, (user) => {
+ gerritTools.addToAttentionSet(incoming.uuid, incoming, user, undefined, undefined, () => {
+ const notifyScope = "ALL";
+ _this.gerritCommentHandler(
+ incoming.uuid, incoming.fullChangeID, undefined,
+ message, notifyScope
+ );
+ });
+ }
+ );
+ });
+ }
+
_this.logger.log(
`Performing cherry-pick to ${branch} from ${incoming.fullChangeID}`,
"info", incoming.uuid
@@ -755,22 +880,95 @@ class requestProcessor extends EventEmitter {
// Result looks okay, let's see what to do next.
_this.emit(responseSignal, incoming, data);
} else if (data.statusCode) {
- // Failed to cherry pick to target branch. Post a comment on the original change
- // and stop paying attention to this pick.
- toolbox.addToCherryPickStateUpdateQueue(
- incoming.uuid,
- {
- branch: branch, statusCode: data.statusCode, statusDetail: data.statusDetail, args: []
- },
- "done_pickFailed",
- function () {
- toolbox.decrementPickCountRemaining(incoming.uuid);
+ // Failed to cherry pick to target branch. Post a comment on the original change
+ // and stop paying attention to this pick.
+ if (data.statusCode == 400 && data.statusDetail.includes("could not update the existing change")) {
+ // The cherry-pick failed because the change already exists. This can happen if
+ // the pick-targets are user-specified and the user has already cherry-picked
+ // to the target branch.
+ // Pretend that the target branch has just merged and emit a change-merged signal.
+
+
+ let remainingPicks = toolbox.findPickToBranches(incoming.uuid, incoming.change.commitMessage);
+ remainingPicks.delete(branch); // Delete this branch from the list of remaining picks.
+ // Parse the status from the statusDetail. It exists as the last word in the string,
+ // wrapped in ().
+ let changeStatus = data.statusDetail.match(/\(([^)]+)\)(?:\\n)?/)[1];
+ // Parse the change number for the existing change. It is surrounded by "change" and "in destination"
+ // in the statusDetail.
+ let existingChangeNumber = data.statusDetail.match(/change (\d+) in destination/)[1];
+ let existingChangeURL = `${gerritTools.gerritResolvedURL}/c/${incoming.project.name || incoming.project}/+/${existingChangeNumber}`;
+ _this.logger.log(
+ `Cherry-pick to ${branch} already exists in state ${changeStatus}.`,
+ "info", incoming.uuid
+ );
+ if (changeStatus == "MERGED") {
+ if (remainingPicks.size == 0) {
+ // No more picks to do. Just post a comment.
+ _doPickAlreadyExists(data,
+ `A closed change already exists on ${branch} with the same change ID.\n`
+ + `No further picks are necessary. Please verify that the existing change`
+ + ` is correct.\n\n`
+ + `Link: ${existingChangeURL}`
+ );
+ } else {
+ // Mock up a change-merged signal and re-emit it as though the target
+ // branch just merged.
+ _this.logger.log(`Mocking Merge on ${branch}.`, "info", incoming.uuid);
+ toolbox.mockChangeMergedFromOther(incoming.uuid, incoming, branch, remainingPicks, (mockObject) => {
+ if (mockObject) {
+ _this.emit("mock-change-merged", mockObject);
+ }
+ _doPickAlreadyExists(data,
+ `A closed change already exists on ${branch} with this change ID.\n`
+ + `Picks to ${Array.from(remainingPicks).join(", ")} will be performed using`
+ + ` that change as a base.\n`
+ + `Please verify that the existing change and resulting picks are correct.\n\n`
+ + ` Change ID: ${mockObject.change.change_id}\n`
+ + ` Subject: ${mockObject.change.subject}\n\n`
+ + `Link: ${mockObject.change.url}`
+ );
+ });
+ }
+ } else if (changeStatus == "ABANDONED" || changeStatus == "DEFERRED") {
+ _doPickAlreadyExists(data,
+ `An abandoned change already exists on ${branch} with this change ID .\n`
+ + `WARN: Cherry-pick bot cannot continue.\n`
+ + `Picks to ${Array.from(remainingPicks).join(", ")} will not be performed automatically.\n\n`
+ + `Link: ${existingChangeURL}`
+ );
+ } else if (changeStatus == "INTEGRATING" || changeStatus == "STAGED") {
+ _doPickAlreadyExists(data,
+ `A change in in state ${changeStatus} already exists on ${branch} with this change ID .\n`
+ + `WARN: Cherry-pick bot cannot continue.\n`
+ + `Picks to ${Array.from(remainingPicks).join(", ")} will not be performed automatically from this change.\n`
+ + `Picks from the ${changeStatus} change on ${branch} will execute normally upon merge.`
+ + ` Please review that change's Pick-to: for correctness.`
+ + `Link: ${existingChangeURL}`
+ );
+ } else {
+ _doPickAlreadyExists(data,
+ `A change in in state ${changeStatus} already exists on ${branch} with this change ID .\n`
+ + `WARN: Cherry-pick bot cannot continue. Please report this issue to gerrit admins.\n`
+ + `Cherry-pick bot does not know how to handle changes in ${changeStatus} state.`
+ );
}
- );
- _this.gerritCommentHandler(
- incoming.uuid, incoming.fullChangeID, undefined,
- `Failed to cherry pick to ${branch}.\nReason: ${data.statusCode}: ${data.statusDetail}`
- );
+ } else {
+ toolbox.addToCherryPickStateUpdateQueue(
+ incoming.uuid,
+ {
+ branch: branch, statusCode: data.statusCode, statusDetail: data.statusDetail, args: []
+ },
+ "done_pickFailed",
+ function () {
+ toolbox.decrementPickCountRemaining(incoming.uuid);
+ }
+ );
+ _this.gerritCommentHandler(
+ incoming.uuid, incoming.fullChangeID, undefined,
+ `Failed to cherry pick to ${branch}.\nReason: ${data.statusCode}: ${data.statusDetail}`
+ );
+ }
} else if (data == "retry") {
// Do nothing. This callback function will be called again on retry.
toolbox.addToCherryPickStateUpdateQueue(
@@ -856,7 +1054,7 @@ class requestProcessor extends EventEmitter {
});
} else {
gerritTools.locateDefaultAttentionUser(parentJSON.uuid, cherryPickJSON,
- cherryPickJSON.currentPatchSet.uploader.email,
+ parentJSON.patchSet.uploader.email,
(user) => {
if (user == "copyReviewers")
return; // Copying users is done later regardless of attention set users.
@@ -1039,17 +1237,29 @@ class requestProcessor extends EventEmitter {
`Failed to stage ${cherryPickJSON.id}. Reason: ${safeJsonStringify(data)}`,
"error", parentJSON.uuid
);
- gerritTools.addToAttentionSet(
- parentJSON.uuid, cherryPickJSON,
- parentJSON.change.owner.email || parentJSON.change.owner.username, "Original Owner",
- parentJSON.customGerritAuth,
- function (success, data) {
- if (!success) {
- _this.logger.log(
- `Failed to add "${safeJsonStringify(parentJSON.change.owner)}" to the`
- + ` attention set of ${cherryPickJSON.id}\nReason: ${safeJsonStringify(data)}`,
- "error", parentJSON.uuid
- );
+ gerritTools.locateDefaultAttentionUser(parentJSON.uuid, cherryPickJSON,
+ parentJSON.patchSet.uploader.email, function(user) {
+ if (user && user == "copyReviewers") {
+ gerritTools.copyChangeReviewers(parentJSON.uuid, parentJSON.fullChangeID,
+ cherryPickJSON.id);
+ } else {
+ gerritTools.setChangeReviewers(parentJSON.uuid, cherryPickJSON.id,
+ [user], undefined, function() {
+ gerritTools.addToAttentionSet(
+ parentJSON.uuid, cherryPickJSON, user, "Relevant user",
+ parentJSON.customGerritAuth,
+ function (success, data) {
+ if (!success) {
+ _this.logger.log(
+ `Failed to add "${safeJsonStringify(parentJSON.change.owner)}" to the`
+ + ` attention set of ${cherryPickJSON.id}\n`
+ + `Reason: ${safeJsonStringify(data)}`,
+ "error", parentJSON.uuid
+ );
+ }
+ }
+ );
+ });
}
}
);
diff --git a/scripts/gerrit/cherry-pick_automation/singleRequestManager.js b/scripts/gerrit/cherry-pick_automation/singleRequestManager.js
index b5b89311..300e18d4 100644
--- a/scripts/gerrit/cherry-pick_automation/singleRequestManager.js
+++ b/scripts/gerrit/cherry-pick_automation/singleRequestManager.js
@@ -4,6 +4,11 @@
exports.id = "singleRequestManager";
+const safeJsonStringify = require("safe-json-stringify");
+
+const { findPickToBranches } = require("./toolbox");
+const gerritTools = require("./gerritRESTTools");
+
// The singleRequestManager processes incoming changes linerally.
// When start() is called, the request progresses through branch
// validation, tries to create a cherry pick, and tries to stage it.
@@ -35,17 +40,74 @@ class singleRequestManager {
this.requestProcessor.addListener("singleRequest_stagingDone", this.handleStagingDone);
}
- start(parentJSON, branches) {
+ start(parentJSON, picks) {
let _this = this;
+
+ function emit(parentCopy, branch) {
+ _this.requestProcessor.emit(
+ "validateBranch", parentCopy, branch,
+ "singleRequest_validBranch"
+ );
+ }
_this.logger.log(
`Starting SingleRequest Manager process for ${parentJSON.fullChangeID}`,
"verbose", parentJSON.uuid
);
- branches.forEach(function (branch) {
- _this.requestProcessor.emit(
- "validateBranch", _this.requestProcessor.toolbox.deepCopy(parentJSON), branch,
- "singleRequest_validBranch"
- );
+ Object.keys(picks).forEach(function (branch) {
+ let parentCopy = _this.requestProcessor.toolbox.deepCopy(parentJSON)
+ if (picks[branch].length > 0) {
+ const originalPicks = Array.from(findPickToBranches(parentCopy.uuid, parentCopy.change.commitMessage));
+ let missing = picks[branch].filter(x => !originalPicks.includes(x));
+ // Check the target branch itself since it may not be in originalPicks and could have been
+ // added by the bot.
+ if (!originalPicks.includes(branch))
+ missing.push(branch);
+ if (missing.length > 0) {
+ gerritTools.locateDefaultAttentionUser(parentJSON.uuid, parentCopy,
+ parentJSON.patchSet.uploader.email, function(user) {
+ function postComment() {
+ const plural = missing.length > 1;
+ _this.requestProcessor.gerritCommentHandler(parentCopy.uuid,
+ parentCopy.fullChangeID, undefined,
+ `Automatic cherry-picking detected missing Pick-to targets.`
+ +`\nTarget${plural ? 's' : ''} "${missing.join(", ")}"`
+ + ` ${plural ? "have" : "has"} been automatically added to the`
+ + ` cherry-pick for ${branch}.\nPlease review for correctness.`);
+ }
+
+ if (user && user == "copyReviewers") {
+ // Do nothing since we don't have a default attention user.
+ // This typically means the change was self-approved.
+ } else {
+ gerritTools.setChangeReviewers(parentJSON.uuid, parentCopy.fullChangeID,
+ [user], undefined, function() {
+ gerritTools.addToAttentionSet(
+ parentJSON.uuid, parentCopy, user, "Relevant user",
+ parentJSON.customGerritAuth,
+ function (success, data) {
+ if (!success) {
+ _this.logger.log(
+ `Failed to add "${safeJsonStringify(parentJSON.change.owner)}" to the`
+ + ` attention set of ${parentCopy.id}\n`
+ + `Reason: ${safeJsonStringify(data)}`,
+ "error", parentJSON.uuid
+ );
+ }
+ postComment();
+ }
+ );
+ });
+ }
+ });
+ }
+ parentCopy.change.commitMessage = parentCopy.change.commitMessage
+ .replace(/^Pick-to:.+$/gm, `Pick-to: ${picks[branch].join(" ")}`);
+ emit(parentCopy, branch);
+ } else {
+ parentCopy.change.commitMessage = parentCopy.change.commitMessage
+ .replace(/^Pick-to:.+$\n/gm, "");
+ emit(parentCopy, branch);
+ }
});
}
diff --git a/scripts/gerrit/cherry-pick_automation/toolbox.js b/scripts/gerrit/cherry-pick_automation/toolbox.js
index 121367dc..55989075 100644
--- a/scripts/gerrit/cherry-pick_automation/toolbox.js
+++ b/scripts/gerrit/cherry-pick_automation/toolbox.js
@@ -7,6 +7,7 @@ const safeJsonStringify = require("safe-json-stringify");
const v8 = require('v8');
const postgreSQLClient = require("./postgreSQLClient");
+const { queryBranchesRe, checkBranchAndAccess, queryChange } = require("./gerritRESTTools");
const Logger = require("./logger");
const logger = new Logger();
@@ -17,7 +18,8 @@ let dbListenerCacheUpdateLockout = false;
// Deep copy an object. Useful for forking processing paths with different data
// than originally entered the system.
-exports.deepCopy = function (obj) {
+exports.deepCopy = deepCopy;
+function deepCopy(obj) {
return v8.deserialize(v8.serialize(obj));
}
@@ -43,6 +45,418 @@ exports.findPickToBranches = function (requestUuid, message) {
return branchSet;
};
+// Build a waterfall of cherry picks based on the list of branches.
+// Picks to older feature and release branches must be attached to newer
+// feature branch picks.
+// Example: for input of ["6.5.0", "6.4", "5.15.12", "5.15.11", "5.15"],
+// the output will be:
+// {
+// '6.5.0': [],
+// '6.4': ['5.15.12', '5.15.11', '5.15']
+// }
+exports.waterfallCherryPicks = function (requestUuid, branchList) {
+ branchList = Array.from(branchList); // Might be a set.
+
+ if (branchList.length === 0) {
+ logger.log(`No branches to waterfall.`, 'debug', requestUuid);
+ return {};
+ }
+
+ // If any of the entries of branchList are not numerical,
+ // then return a waterfall object with each branch as a key.
+ if (branchList.some((branch) => !/^(dev|master|\d+\.\d+(\.\d+)?)$/.test(branch))) {
+ let waterfall = {};
+ for (let branch of branchList) {
+ waterfall[branch] = [];
+ }
+ return waterfall;
+ }
+
+ branchList = sortBranches(branchList);
+
+ let waterfall = {};
+ let youngestFeatureBranch = branchList.find(isFeatureBranchOrDev);
+ let remainingBranches = branchList.filter((branch) => branch !== youngestFeatureBranch);
+ if (!youngestFeatureBranch || remainingBranches.length === 0) {
+ waterfall[youngestFeatureBranch || remainingBranches[0]] = [];
+ return waterfall;
+ }
+
+ let children = remainingBranches.filter((branch) => {
+ let result = (isAncestor(branch, youngestFeatureBranch)
+ || isChild(branch, youngestFeatureBranch));
+ return result;
+ });
+
+ waterfall[youngestFeatureBranch] = children;
+
+ remainingBranches = remainingBranches.filter((branch) => !children.includes(branch));
+
+ for (let branch of remainingBranches) {
+ waterfall[branch] = [];
+ }
+
+ return waterfall;
+};
+
+// Determine if a given branch is a child of another branch.
+function isChild(maybeChild, maybeParent) {
+ // Account for dev and master branches.
+ if (maybeChild === "dev" || maybeChild === "master") {
+ return false;
+ }
+ if (maybeParent === "dev" || maybeParent === "master") {
+ return true;
+ }
+ let childParts = maybeChild.split('.').map(Number);
+ let parentParts = maybeParent.split('.').map(Number);
+
+ // Major version less than the parent is always a child.
+ if (childParts[0] < parentParts[0]) {
+ return true;
+ }
+
+ // Feature versions are children if lesser than the parent. This
+ // also catches release versions of the same or newer feature versions.
+ if (childParts[1] <= parentParts[1]) {
+ return true;
+ }
+
+ // Then the release version is newer than the parent.
+ return false;
+}
+
+// Determine if a given branch is an ancestor of another branch.
+function isAncestor(maybeAncestor, reference) {
+ // Account for dev and master branches.
+ if (maybeAncestor === "dev" || maybeAncestor === "master") {
+ return true;
+ }
+ if (reference === "dev" || reference === "master") {
+ return false;
+ }
+ let branchParts = maybeAncestor.split('.').map(Number);
+ let ancestorParts = reference.split('.').map(Number);
+
+ // Release branches like 5.15.0 cannot be ancestors of feature branches like 5.15.
+ if (branchParts.length > ancestorParts.length) {
+ return false;
+ }
+
+ // Major versions that are less than the passed ancestor are ancestors.
+ if (branchParts[0] < reference[0]) {
+ return true;
+ }
+
+ // Feature versions are ancestors if it is greater than the reference.
+ if (branchParts[1] > reference[1]) {
+ return true;
+ }
+
+ return true;
+}
+
+function isFeatureBranchOrDev(branch) {
+ return branch === "dev" || branch === "master" || branch.split('.').length === 2;
+}
+
+// given a list of pick-to branches, determine if any gaps exist in the list.
+// Return a completed list of pick-to targets with a diff.
+exports.findMissingTargets = function (uuid, changeId, project, targets, callback) {
+
+ const isTqtc = /tqtc-/.test(project); // Is the cherry-pick request coming from a tqtc repo?
+ const isLTS = /^(tqtc\/)?lts-/.test(decodeURIComponent(changeId).split('~')[1]); // Is the change on an LTS branch?
+ const prefix = isTqtc && isLTS
+ ? "tqtc/lts-"
+ : isTqtc
+ ? "tqtc/"
+ : isLTS
+ ? "lts-" : "";
+ const bareBranch = decodeURIComponent(changeId).split('~')[1].replace(/^(tqtc\/)?(lts-)?/, '');
+ let highestTarget = "";
+
+ if (Array.from(targets).length === 0) {
+ logger.log(`No targets to check.`, 'debug', uuid);
+ callback(false, undefined, []);
+ return;
+ }
+
+ // targets will always be bare versions, like "5.15".
+ let featureBranches = new Set();
+ for (let target of targets) {
+ // account for dev and master branches.
+ if (target === "dev" || target === "master") {
+ continue;
+ }
+ let parts = target.split('.');
+ featureBranches.add(parts[0] + '.' + parts[1]);
+ }
+ highestTarget = Array.from(featureBranches).sort(sortBranches)[0];
+ if (isChild(highestTarget, bareBranch)) {
+ // If the highest target is a child of the bare branch, then the highest target
+ // should be considered self.
+ highestTarget = bareBranch;
+ }
+
+
+
+ let releaseBranches = {};
+ for (let key of featureBranches) {
+ releaseBranches[key] = [];
+ }
+ for (let target of targets) {
+ // account for dev and master branches.
+ if (target === "dev" || target === "master") {
+ continue;
+ }
+ let parts = target.split('.');
+ if (parts.length === 3) {
+ releaseBranches[parts[0] + '.' + parts[1]].push(target);
+ }
+ }
+
+ // Sort by release version
+ for (let key of featureBranches) {
+ releaseBranches[key].sort((a, b) => {
+ let aParts = a.split('.').map(Number);
+ let bParts = b.split('.').map(Number);
+ return aParts[2] - bParts[2];
+ });
+ }
+
+ // Filter result branches if the originating change had a tqtc and/or an LTS prefix.
+ const searchRe = `(${prefix}(?:${Array.from(featureBranches).join('|')}|[6-9].[0-9]{1,}|${prefix}dev|${prefix}master)).*`
+ queryBranchesRe(uuid, project, false, searchRe, undefined, (success, remoteBranches) => {
+ if (!success) {
+ return;
+ }
+
+ function makeNextFeature(branch) {
+ return branch.split('.').slice(0, 1).join('.')
+ + '.' + (Number(branch.split('.')[1]) + 1); // f.e. 5.15 -> 5.16
+ }
+
+ // Use sanitized branches to determine if any gaps exist.
+ const bareRemoteBranches = remoteBranches.map((branch) => branch.replace(/^(tqtc\/)?(lts-)?/, ''));
+
+ function _finishUp(error, change) {
+ // Always include all gaps in release branches.
+ for (let branch of featureBranches) {
+ for (let release of releaseBranches[branch]) {
+ const nextRelease = release.split('.').slice(0, 2).join('.')
+ + '.' + (Number(release.split('.')[2]) + 1); // f.e. 5.15.12 -> 5.15.13
+ if (releaseBranches[branch].includes(nextRelease)) {
+ continue;
+ }
+ if (bareRemoteBranches.includes(nextRelease)) {
+ missing.push(nextRelease);
+ }
+ }
+ }
+
+ if (missing.length) {
+ logger.log(`Missing branches: ${missing.join(', ')}`, 'info', uuid);
+ }
+ callback(Boolean(error), change, missing);
+ }
+
+ let missing = [];
+ function _findMissingFeatures(branch, start = false) {
+ if (start) {
+ if (bareRemoteBranches.includes(branch)) {
+ if (branch === bareBranch) {
+ // Do not test the branch we're picking from. It must exist.
+ _findMissingFeatures(branch); // recurse normally
+ } else if (!targets.has(branch)) {
+ // Check to see if the next feature branch is still open for new changes.
+ checkBranchAndAccess(uuid, project, prefix + branch, "cherrypickbot", "push", undefined,
+ (success, hasAccess) => {
+ if (success && hasAccess) {
+ const missingChangeId = `${encodeURIComponent(project)}~`
+ + `${encodeURIComponent(prefix + branch)}~${changeId.split('~').pop()}`
+ queryChange(uuid, missingChangeId, undefined, undefined, (success) => {
+ if (!success)
+ missing.push(branch);
+ else
+ logger.log(`Skipping ${branch}. Change already exists.`, "debug", uuid);
+ _findMissingFeatures(branch); // recurse normally
+ });
+ } else {
+ logger.log(`Skipping ${branch} because it is closed.`, "debug", uuid);
+ _findMissingFeatures(branch); // recurse normally
+ }
+ });
+ } else {
+ logger.log(`Skipping ${branch} because it is already in the list.`, "debug", uuid);
+ _findMissingFeatures(branch); // recurse normally
+ }
+ } else {
+ logger.log(`Skipping ${branch} because it does not exist.`, "debug", uuid);
+ _findMissingFeatures(branch); // recurse normally
+ }
+ return;
+ }
+
+ const nextFeature = makeNextFeature(branch);
+ if (bareRemoteBranches.includes(nextFeature)) {
+ if (nextFeature === bareBranch || featureBranches.has(nextFeature)) {
+ _findMissingFeatures(nextFeature); // Recurse to the next feature branch.
+ } else if (isChild(nextFeature, highestTarget)) {
+ // Only test branches which are older than/children to the current branch.
+ // This forces the waterfall to only work downwards from the highest
+ // specified branch.
+ // Check to see if the next feature branch is still open for new changes.
+ checkBranchAndAccess(uuid, project, prefix + nextFeature, "cherrypickbot", "push", undefined,
+ (success, hasAccess) => {
+ if (success && hasAccess) {
+ const missingChangeId = `${encodeURIComponent(project)}~`
+ + `${encodeURIComponent(prefix + nextFeature)}~${changeId.split('~').pop()}`
+ queryChange(uuid, missingChangeId, undefined, undefined, (success, data) => {
+ if (!success)
+ missing.push(prefix + nextFeature);
+ else {
+ if (data.status == "MERGED") {
+ logger.log(`Skipping ${prefix + nextFeature}. Merged change already exists.`, "debug", uuid);
+ } else {
+ const _next = makeNextFeature(nextFeature);
+ if (!bareRemoteBranches.includes(_next)) {
+ // This was the last and highest feature branch, which means it would
+ // be our first pick target (excepting dev). Since the change is open,
+ // we should not touch it.
+ // Call _finishUp() and pick to only any immediate release targets.
+ logger.log(`Missing immediate target ${prefix + nextFeature} has a change in ${data.status}.`, "error", uuid);
+ _finishUp(true, data);
+ return;
+ }
+ }
+ }
+ _findMissingFeatures(nextFeature);
+ });
+ } else {
+ logger.log(`Skipping ${prefix + nextFeature} because it is closed.`, "debug", uuid);
+ _findMissingFeatures(nextFeature);
+ }
+ });
+ } else {
+ // NextFeature exists remotely, but is out of scope based on pick targets and source branch.
+ _finishUp();
+ }
+ } else {
+ // We've reached the end of the feature branches which are older than the highest branch.
+ _finishUp();
+ }
+ }
+
+ // Use the oldest feature version to find any gaps since then.
+ if (Array.from(featureBranches).length === 0) {
+ _finishUp();
+ return;
+ }
+ try {
+ _findMissingFeatures(Array.from(featureBranches).sort(sortBranches)[0], true);
+ } catch (e) {
+ logger.log(`Error finding missing targets: ${e}`, 'error', uuid);
+ }
+ });
+}
+
+exports.sortBranches = sortBranches;
+function sortBranches(branches) {
+ return Array.from(branches).sort((a, b) => {
+ // Account for dev and master branches.
+ if (a === "dev" || a === "master") {
+ return -1;
+ }
+ if (b === "dev" || b === "master") {
+ return 1;
+ }
+ let aParts = a.split('.').map(Number);
+ let bParts = b.split('.').map(Number);
+
+ for (let i = 0; i < Math.min(aParts.length, bParts.length); i++) {
+ if (aParts[i] !== bParts[i]) {
+ return bParts[i] - aParts[i];
+ }
+ }
+
+ return aParts.length - bParts.length;
+ });
+}
+
+// Take a gerrit Change object and mock a change-merged event.
+// Use the original merge event as the template for the mocked event.
+exports.mockChangeMergedFromOther = mockChangeMergedFromOther;
+function mockChangeMergedFromOther(uuid, originalMerge, targetBranch, remainingPicks, callback) {
+ if (remainingPicks.size == 0) {
+ logger.log(`No remaining picks for existing target on ${targetBranch}. Nothing to do.`,
+ "debug", uuid);
+ callback(null);
+ return;
+ }
+ let mockMerge = deepCopy(originalMerge);
+ // Assemble the fullChangeID from the project and branch.
+ let targetChangeId = encodeURIComponent(mockMerge.change.project) + "~"
+ + encodeURIComponent(targetBranch) + "~" + mockMerge.change.id;
+ // Query the target change from gerrit.
+ queryChange(uuid, targetChangeId, undefined, undefined, function (success, targetChange) {
+ if (!success) {
+ logger.log(`Error mocking change-merged event: ${targetChange}, ${targetChange}`, "error", uuid);
+ callback(null);
+ return;
+ }
+ // Replace the following properties of mockMerge with the targetChange:
+ // newRev
+ // patchSet
+ // change
+ // refName
+ // changeKey
+ // fullChangeID
+ // eventCreatedOn
+ delete mockMerge.uuid;
+ mockMerge.newRev = targetChange.current_revision;
+ mockMerge.patchSet = {
+ number: targetChange.revisions[targetChange.current_revision]._number,
+ revision: targetChange.current_revision,
+ parents: targetChange.revisions[targetChange.current_revision].commit.parents,
+ ref: `refs/changes/${targetChange._number}/${targetChange.current_revision}`,
+ uploader: targetChange.owner,
+ author: targetChange.revisions[targetChange.current_revision].commit.author,
+ createdOn: targetChange.created
+ };
+ targetChange.id = targetChange.change_id;
+ targetChange.number = targetChange._number;
+ // build the url from the project and change number.
+ targetChange.url = `https://codereview.qt-project.org/c/${targetChange.project}/+/`
+ + `${targetChange._number}`;
+ // Replace Pick-to: footer with the remaining picks.
+ // If targetChange did not have a Pick-to footer, add one at the beginning
+ // of the footers. Footers are separated from the commit message body by the last\n\n.
+ const origCommitMessage = targetChange.revisions[targetChange.current_revision].commit.message;
+ let footerIndex = origCommitMessage.lastIndexOf("\n\n");
+ if (footerIndex === -1) {
+ footerIndex = origCommitMessage.length;
+ } else {
+ footerIndex += 2; // Start after the last \n\n.
+ }
+ let footer = origCommitMessage.slice(footerIndex);
+ let pickToFooter = "Pick-to: " + Array.from(remainingPicks).join(" ");
+ if (footer.match(/Pick-to:.*$/m)) {
+ footer = footer.replace(/Pick-to:.*$/m, pickToFooter);
+ } else {
+ footer = pickToFooter + "\n\n" + footer;
+ }
+ targetChange.commitMessage = origCommitMessage.slice(0, footerIndex) + footer;
+ mockMerge.change = targetChange;
+ mockMerge.refName = `refs/heads/${targetChange.branch}`;
+ mockMerge.changeKey = { key: targetChange.change_id };
+ mockMerge.fullChangeID = encodeURIComponent(targetChange.project) + "~"
+ + encodeURIComponent(targetChange.branch) + "~" + targetChange.change_id;
+ mockMerge.eventCreatedOn = Date.now();
+
+ callback(mockMerge);
+ });
+}
+
// Get all database items in the processing_queue with state "processing"
exports.getAllProcessingRequests = getAllProcessingRequests;
function getAllProcessingRequests(callback) {