// /* eslint-disable no-unused-vars */ // Copyright (C) 2024 The Qt Company Ltd. // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only const axios = require("axios"); const axiosRetry = require('axios-retry').default; const safeJsonStringify = require("safe-json-stringify"); const { logger } = require("./logger"); const config = require("./config.json"); axiosRetry(axios, { retries: 3, // Random delay in ms between 1 and 6 sec. Helps reduce load on gerrit. retryDelay: function () { Math.floor(Math.random() * 5 * 1000) + 1 }, shouldResetTimeout: true, retryCondition: (error) => { let status = error.response.status; let text = error.response.data; if ( axiosRetry.isNetworkOrIdempotentRequestError(error) // The default retry behavior || (status == 409 && text.includes("com.google.gerrit.git.LockFailureException")) || status == 408 // "Server Deadline Exceeded" Hit the anti-DDoS timeout threshold. ) return true; }, }); // Set default values with the config file, but prefer environment variable. function envOrConfig(ID) { return process.env[ID] || config[ID]; } let gerritURL = envOrConfig("GERRIT_URL"); let gerritPort = envOrConfig("GERRIT_PORT"); let gerritAuth = { username: envOrConfig("GERRIT_USER"), password: envOrConfig("GERRIT_PASS") }; // Assemble the gerrit URL, and tack on http/https if it's not already // in the URL. Add the port if it's non-standard, and assume https // if the port is anything other than port 80. let gerritResolvedURL = /^https?:\/\//g.test(gerritURL) ? gerritURL : `${gerritPort == 80 ? "http" : "https"}://${gerritURL}`; gerritResolvedURL += gerritPort != 80 && gerritPort != 443 ? ":" + gerritPort : ""; // Return an assembled url to use as a base for requests to gerrit. function gerritBaseURL(api) { return `${gerritResolvedURL}/a/${api}`; } // Trim )]}' off of a gerrit response. This magic prefix in the response // from gerrit helpts to prevent against XSSI attacks and will // always be included in a genuine response from gerrit. // See https://gerrit-review.googlesource.com/Documentation/rest-api.html function trimResponse(response) { if (response.startsWith(")]}'")) return response.slice(4); else return response; } // Post a comment to the change on the latest revision. exports.postGerritComment = postGerritComment; function postGerritComment( fullChangeID, revision, message, reviewers, notifyScope, customAuth, callback ) { function _postComment() { let url = `${gerritBaseURL("changes")}/${fullChangeID}/revisions/${revision || "current"}/review`; let data = { message: message, notify: notifyScope || "OWNER_REVIEWERS" }; if (reviewers) { // format reviewers as a list of ReviewInput entities data.reviewers = reviewers.map((reviewer) => { return { reviewer: reviewer }; }); } logger.info( `POST request to: ${url}\nRequest Body: ${safeJsonStringify(data)}`, ); axios({ method: "post", url: url, data: data, auth: customAuth || gerritAuth }) .then(function (response) { logger.info(`Posted comment "${message}" to change "${fullChangeID}"`); callback(true, undefined); }) .catch(function (error) { if (error.response) { // The request was made and the server responded with a status code // that falls out of the range of 2xx logger.info( `An error occurred in POST (gerrit comment) to "${url}". Error ${error.response.status}: ${error.response.data}`, ); callback(false, error.response); } else if (error.request) { // The request was made but no response was received callback(false, "retry"); } else { // Something happened in setting up the request that triggered an Error logger.info( `Error in HTTP request while posting comment. Error: ${safeJsonStringify(error)}`, ); callback(false, error.message); } }); } // Query the change first to see if we've posted the same comment on the current revision before const message_url = `${gerritBaseURL("changes")}/${fullChangeID}/messages`; logger.info(`GET request to: ${message_url}`); axios({ method: "get", url: message_url, auth: customAuth || gerritAuth }) .then(function (response) { let parsedResponse = JSON.parse(trimResponse(response.data)); let messages = parsedResponse .filter((message) => message.author.name == "Qt Cherry-pick Bot") .map((message) => message.message); if (messages.length == 0) { // If there are no messages, then the bot hasn't posted a comment yet. _postComment(); return; } let patchset = parsedResponse[parsedResponse.length - 1]._revision_number; // Reverse messages so that we can find the most recent comment first. // Then iterate and check for message in the current patchset. for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].includes(message) && messages[i].includes(`Patch Set ${patchset}:`)) { logger.info( `Comment "${message}" already posted on patchset ${patchset} of ${fullChangeID}` ); callback(true, undefined); return; } } // If we get here, then the comment hasn't been posted yet. _postComment(); }); } // Query gerrit for a change and return it along with the current revision if it exists. exports.queryChange = queryChange; function queryChange(fullChangeID, fields, customAuth, callback) { let url = `${gerritBaseURL("changes")}/${fullChangeID}/?o=CURRENT_COMMIT&o=CURRENT_REVISION`; // Tack on any additional fields requested if (fields) fields.forEach((field) => url = `${url}&o=${field}`); logger.info(`Querying gerrit for ${url}`); axios.get(url, { auth: customAuth || gerritAuth }) .then(function (response) { // Execute callback and return the list of changes logger.info(`Raw response: ${response.data}`); callback(true, JSON.parse(trimResponse(response.data))); }) .catch(function (error) { if (error.response) { if (error.response.status == 404) { // Change does not exist. Depending on usage, this may not // be considered an error, so only write an error trace if // a status other than 404 is returned. callback(false, { statusCode: 404 }); } else { // Some other error was returned logger.info( `An error occurred in GET "${url}". Error ${error.response.status}: ${error.response.data}`, ); callback(false, { statusCode: error.response.status, statusDetail: error.response.data }); } } 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.info( `Error in HTTP request while trying to query ${fullChangeID}. ${error}`, ); callback(false, error.message); } }); } // Add a user to the attention set of a change exports.addToAttentionSet = addToAttentionSet; function addToAttentionSet(changeJSON, user, reason, customAuth, callback) { let project = changeJSON.project.name ? changeJSON.project.name : changeJSON.project; checkAccessRights( project, changeJSON.branch || changeJSON.change.branch, user, "push", customAuth || gerritAuth, function (success, data) { if (!success) { let msg = `User "${user}" cannot push to ${project}:${changeJSON.branch}.` logger.info(msg); callback(false, msg); let botAssignee = envOrConfig("GERRIT_USER"); if (botAssignee && user != botAssignee) { logger.info(`Falling back to GERRIT_USER (${botAssignee}) as assignee...`); addToAttentionSet( changeJSON, botAssignee, "fallback to bot", customAuth, function () { } ); } } else { let url = `${gerritBaseURL("changes")}/${changeJSON.fullChangeID || changeJSON.id}/attention`; let data = { user: user, "reason": reason || "Update Attention Set" }; logger.info( `POST request to: ${url}\nRequest Body: ${safeJsonStringify(data)}` ); axios({ method: "POST", url: url, data: data, auth: customAuth || gerritAuth }) .then(function (response) { logger.info( `Added Attention Set user: "${user}" on "${changeJSON.fullChangeID || changeJSON.id}"` ); callback(true, undefined); }) .catch(function (error) { if (error.response) { // The request was made and the server responded with a status code // that falls out of the range of 2xx logger.info( `An error occurred in POST to "${url}". Error: ${error.response.status}: ${error.response.data}` ); callback(false, { status: error.response.status, data: error.response.data }); } else if (error.request) { // The request was made but no response was received. Retry it later. callback(false, "retry"); } else { // Something happened in setting up the request that triggered an Error logger.info( `Error in HTTP request while trying to add to attention set. Error: ${error}` ); callback(false, error.message); } }); } } ) } // Check permissions for a branch. Returns Bool. function checkAccessRights(repo, branch, user, permission, customAuth, callback) { // Decode and re-encode to be sure we don't double-encode something that was already // passed to us in URI encoded format. repo = encodeURIComponent(decodeURIComponent(repo)); branch = encodeURIComponent(decodeURIComponent(branch)); let url = `${gerritBaseURL("projects")}/${repo}/check.access?account=${user}&ref=${encodeURIComponent('refs/for/refs/heads/')}${branch}&perm=${permission}`; logger.info(`GET request for ${url}`); axios .get(url, { auth: customAuth || gerritAuth }) .then(function (response) { // A successful response's JSON object has a status field (independent // of the HTTP response's status), that tells us whether this user // does (200) or doesn't (403) have the requested permissions. logger.info(`Raw Response: ${response.data}`); callback(JSON.parse(trimResponse(response.data)).status == 200, undefined) }) .catch(function (error) { let data = "" if (error.response) { if (error.response.status != 403) { // The request was made and the server responded with a status code // that falls out of the range of 2xx and response code is unexpected. // However, a 403 response code means that the bot does not have permissions // to check permissions of other users, a much bigger problem. data = "retry"; } logger.info( `An error occurred in GET to "${url}". Error ${error.response.status}: ${error.response.data}` ); } else { data = `${error.status}:${error.message}`; logger.info( `Failed to get ${permission} access rights on ${repo}:${branch}\n${safeJsonStringify(error)}` ); } callback(false, data); }); }