diff options
author | Daniel Smith <daniel.smith@qt.io> | 2023-04-20 16:51:23 +0200 |
---|---|---|
committer | Daniel Smith <daniel.smith@qt.io> | 2024-01-15 10:20:46 +0200 |
commit | b08c2007e9fdef4d2638fbebc6a83d41d933a0aa (patch) | |
tree | 53078e9ef2add3fa091b48585d69446154595020 | |
parent | 02a42b3229a17f29a39cdbbb27810c2980f3d3f6 (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>
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) { |