renovate
Version:
Automated dependency updates. Flexible so you don't need to be.
616 lines (615 loc) • 23.7 kB
JavaScript
import { __exportAll } from "../../../_virtual/_rolldown/runtime.js";
import { REPOSITORY_ACCESS_FORBIDDEN, REPOSITORY_ARCHIVED, REPOSITORY_BLOCKED, REPOSITORY_CHANGED, REPOSITORY_EMPTY, REPOSITORY_MIRRORED } from "../../../constants/error-messages.js";
import { getEnv } from "../../../util/env.js";
import { GlobalConfig } from "../../../config/global.js";
import { sanitize } from "../../../util/sanitize.js";
import { logger } from "../../../logger/index.js";
import { ensureTrailingSlash } from "../../../util/url.js";
import { parseJson } from "../../../util/common.js";
import { deduplicateArray } from "../../../util/array.js";
import { map } from "../../../util/promises.js";
import { getBranchCommit, initRepo as initRepo$1 } from "../../../util/git/index.js";
import { setBaseUrl } from "../../../util/http/forgejo.js";
import { getPrBodyStruct, hashBody } from "../pr-body.js";
import { repoFingerprint } from "../util.js";
import { smartTruncate } from "../utils/pr-body.js";
import { DRAFT_PREFIX, getMergeMethod, getRepoUrl, isAllowed, smartLinks, toRenovatePR, trimTrailingApiPath, usableRepo } from "./utils.js";
import { closeIssue, createComment, createCommitStatus, createIssue, createPR, deleteComment, forgejoHttp, forgejoToRenovateStatusMapping, getCombinedCommitStatus, getComments, getCurrentUser, getIssue as getIssue$1, getOrgLabels, getPR, getPRByBranch, getRepo, getRepoContents, getRepoLabels, getVersion, isOrg, mergePR, orgListRepos, renovateToForgejoStatusMapping, requestPrReviewers, searchIssues, searchRepos, unassignLabel, updateComment, updateIssue, updateIssueLabels, updatePR } from "./forgejo-helper.js";
import { ForgejoPrCache } from "./pr-cache.js";
import { isNumber, isString } from "@sindresorhus/is";
import semver from "semver";
//#region lib/modules/platform/forgejo/index.ts
var forgejo_exports = /* @__PURE__ */ __exportAll({
addAssignees: () => addAssignees,
addReviewers: () => addReviewers,
createPr: () => createPr,
deleteLabel: () => deleteLabel,
ensureComment: () => ensureComment,
ensureCommentRemoval: () => ensureCommentRemoval,
ensureIssue: () => ensureIssue,
ensureIssueClosing: () => ensureIssueClosing,
findIssue: () => findIssue,
findPr: () => findPr,
getBranchPr: () => getBranchPr,
getBranchStatus: () => getBranchStatus,
getBranchStatusCheck: () => getBranchStatusCheck,
getIssue: () => getIssue,
getIssueList: () => getIssueList,
getJsonFile: () => getJsonFile,
getPr: () => getPr,
getPrList: () => getPrList,
getRawFile: () => getRawFile,
getRepos: () => getRepos,
id: () => id,
initPlatform: () => initPlatform,
initRepo: () => initRepo,
massageMarkdown: () => massageMarkdown,
maxBodyLength: () => maxBodyLength,
mergePr: () => mergePr,
resetPlatform: () => resetPlatform,
setBranchStatus: () => setBranchStatus,
updatePr: () => updatePr
});
const id = "forgejo";
const defaults = {
hostType: "forgejo",
endpoint: "https://code.forgejo.org/",
version: "0.0.0"
};
let config = {};
let botUserID;
let botUserName;
function resetPlatform() {
config = {};
botUserID = void 0;
botUserName = void 0;
defaults.hostType = "forgejo";
defaults.endpoint = "https://code.forgejo.org/";
defaults.version = "0.0.0";
setBaseUrl(defaults.endpoint);
}
function toRenovateIssue(data) {
return {
number: data.number,
state: data.state,
title: data.title,
body: data.body
};
}
function matchesState(actual, expected) {
if (expected === "all") return true;
if (expected.startsWith("!")) return actual !== expected.substring(1);
return actual === expected;
}
function findCommentByTopic(comments, topic) {
return comments.find((c) => c.body.startsWith(`### ${topic}\n\n`)) ?? null;
}
function findCommentByContent(comments, content) {
return comments.find((c) => c.body.trim() === content) ?? null;
}
function getLabelList() {
if (config.labelList === null) {
const repoLabels = getRepoLabels(config.repository, { memCache: false }).then((labels) => {
logger.debug(`Retrieved ${labels.length} repo labels`);
return labels;
});
const orgLabels = config.isOrgRepo ? getOrgLabels(config.orgName, { memCache: false }).then((labels) => {
logger.debug(`Retrieved ${labels.length} org labels`);
return labels;
}).catch((err) => {
logger.debug({ err }, `Unable to fetch organization labels`);
return [];
}) : Promise.resolve([]);
config.labelList = Promise.all([repoLabels, orgLabels]).then((labels) => [].concat(...labels));
}
return config.labelList;
}
async function lookupLabelByName(name) {
logger.debug(`lookupLabelByName(${name})`);
return (await getLabelList()).find((l) => l.name === name)?.id ?? null;
}
async function fetchRepositories({ topic, sort, order }) {
return (await searchRepos({
uid: botUserID,
archived: false,
...topic && {
topic: true,
q: topic
},
...sort && { sort },
...order && { order }
})).filter(usableRepo).map((r) => r.full_name);
}
const platform = {
async initPlatform({ endpoint, token }) {
if (!token) throw new Error("Init: You must configure a Forgejo personal access token");
if (endpoint) {
let baseEndpoint = trimTrailingApiPath(endpoint);
baseEndpoint = ensureTrailingSlash(baseEndpoint);
defaults.endpoint = baseEndpoint;
} else logger.debug(`Using default Forgejo endpoint: ${defaults.endpoint}`);
setBaseUrl(defaults.endpoint);
let gitAuthor;
try {
const user = await getCurrentUser({ token });
gitAuthor = `${user.full_name || user.login} <${user.email}>`;
botUserID = user.id;
botUserName = user.login;
const env = getEnv();
/* v8 ignore if: experimental feature */
if (semver.valid(env.RENOVATE_X_PLATFORM_VERSION)) defaults.version = env.RENOVATE_X_PLATFORM_VERSION;
else defaults.version = await getVersion({ token });
logger.debug(`Forgejo version: ${defaults.version}`);
} catch (err) {
logger.debug({ err }, "Error authenticating with Forgejo. Check your token");
throw new Error("Init: Authentication failure");
}
return {
endpoint: defaults.endpoint,
gitAuthor
};
},
async getRawFile(fileName, repoName, branchOrTag) {
return (await getRepoContents(repoName ?? config.repository, fileName, branchOrTag)).contentString ?? null;
},
async getJsonFile(fileName, repoName, branchOrTag) {
return parseJson(await platform.getRawFile(fileName, repoName, branchOrTag), fileName);
},
async initRepo({ repository, cloneSubmodules, cloneSubmodulesFilter, gitUrl }) {
let repo;
config = {};
config.repository = repository;
config.cloneSubmodules = !!cloneSubmodules;
config.cloneSubmodulesFilter = cloneSubmodulesFilter;
config.ignorePrAuthor = GlobalConfig.get("ignorePrAuthor");
try {
repo = await getRepo(repository);
} catch (err) {
logger.debug({ err }, "Unknown Forgejo initRepo error");
throw err;
}
if (repo.archived) {
logger.debug("Repository is archived - aborting renovation");
throw new Error(REPOSITORY_ARCHIVED);
}
if (repo.mirror) {
logger.debug("Repository is a mirror - aborting renovation");
throw new Error(REPOSITORY_MIRRORED);
}
if (repo.permissions.pull === false || repo.permissions.push === false) {
logger.debug("Repository does not permit pull or push - aborting renovation");
throw new Error(REPOSITORY_ACCESS_FORBIDDEN);
}
if (repo.empty) {
logger.debug("Repository is empty - aborting renovation");
throw new Error(REPOSITORY_EMPTY);
}
if (repo.has_pull_requests === false) {
logger.debug("Repo has disabled pull requests - aborting renovation");
throw new Error(REPOSITORY_BLOCKED);
}
const mergeStyle = [
repo.default_merge_style,
"fast-forward-only",
"squash",
"merge",
"rebase",
"rebase-merge"
].find((style) => isAllowed(style, repo));
if (mergeStyle) config.mergeMethod = mergeStyle;
else {
logger.debug("Repository has no allowed merge methods - aborting renovation");
throw new Error(REPOSITORY_BLOCKED);
}
try {
config.isOrgRepo = await isOrg(repo.owner.login);
} catch (err) {
logger.debug({ err }, "Forgejo initRepo() error");
throw err;
}
config.defaultBranch = repo.default_branch;
logger.debug(`${repository} default branch = ${config.defaultBranch}`);
const url = getRepoUrl(repo, gitUrl, defaults.endpoint);
await initRepo$1({
...config,
url
});
config.issueList = null;
config.labelList = null;
config.hasIssuesEnabled = !repo.external_tracker && repo.has_issues;
config.orgName = repo.owner.login;
return {
defaultBranch: config.defaultBranch,
isFork: !!repo.fork,
repoFingerprint: repoFingerprint(repo.id, defaults.endpoint)
};
},
async getRepos(config) {
logger.debug("Auto-discovering Forgejo repositories");
try {
if (config?.topics) {
logger.debug({ topics: config.topics }, "Auto-discovering by topics");
return deduplicateArray((await map(config.topics.map((topic) => {
return {
topic,
sort: config.sort,
order: config.order
};
}), fetchRepositories)).flat());
} else if (config?.namespaces) {
logger.debug({ namespaces: config.namespaces }, "Auto-discovering by organization");
return deduplicateArray((await map(config.namespaces, async (organization) => {
return (await orgListRepos(organization)).filter((r) => !r.mirror && !r.archived).map((r) => r.full_name);
})).flat());
} else return await fetchRepositories({
sort: config?.sort,
order: config?.order
});
} catch (err) {
logger.error({ err }, "Forgejo getRepos() error");
throw err;
}
},
async setBranchStatus({ branchName, context, description, state, url: target_url }) {
try {
const branchCommit = getBranchCommit(branchName);
await createCommitStatus(config.repository, branchCommit, {
state: renovateToForgejoStatusMapping[state] || "pending",
context,
description,
...target_url && { target_url }
});
await getCombinedCommitStatus(config.repository, branchName, { memCache: false });
} catch (err) {
logger.warn({ err }, "Failed to set branch status");
}
},
async getBranchStatus(branchName, internalChecksAsSuccess) {
let ccs;
try {
ccs = await getCombinedCommitStatus(config.repository, branchName);
} catch (err) {
if (err.statusCode === 404) {
logger.debug("Received 404 when checking branch status, assuming branch deletion");
throw new Error(REPOSITORY_CHANGED);
}
logger.debug("Unknown error when checking branch status");
throw err;
}
logger.debug({ ccs }, "Branch status check result");
if (!internalChecksAsSuccess && ccs.worstStatus === "success" && ccs.statuses.every((status) => status.context?.startsWith("renovate/"))) {
logger.debug("Successful checks are all internal renovate/ checks, so returning \"pending\" branch status");
return "yellow";
}
/* v8 ignore next */
return forgejoToRenovateStatusMapping[ccs.worstStatus] ?? "yellow";
},
async getBranchStatusCheck(branchName, context) {
const cs = (await getCombinedCommitStatus(config.repository, branchName)).statuses.find((s) => s.context === context);
if (!cs) return null;
const status = forgejoToRenovateStatusMapping[cs.status];
if (status) return status;
logger.warn({ check: cs }, "Could not map Forgejo status value to Renovate status");
return "yellow";
},
getPrList() {
return ForgejoPrCache.getPrs(forgejoHttp, config.repository, config.ignorePrAuthor, botUserName);
},
async getPr(number) {
let pr = (await platform.getPrList()).find((p) => p.number === number) ?? null;
if (pr) logger.debug("Returning from cached PRs");
else {
logger.debug("PR not found in cached PRs - trying to fetch directly");
pr = toRenovatePR(await getPR(config.repository, number), botUserName);
if (pr) await ForgejoPrCache.setPr(forgejoHttp, config.repository, config.ignorePrAuthor, botUserName, pr);
}
if (!pr) return null;
return pr;
},
async findPr({ branchName, prTitle: title, state = "all", includeOtherAuthors, targetBranch }) {
logger.debug(`findPr(${branchName}, ${title}, ${state})`);
if (includeOtherAuthors && isString(targetBranch)) {
const pr = await getPRByBranch(config.repository, targetBranch, branchName);
if (!pr) return null;
return toRenovatePR(pr, null);
}
const pr = (await platform.getPrList()).find((p) => p.sourceRepo === config.repository && p.sourceBranch === branchName && matchesState(p.state, state) && (!title || p.title === title));
if (pr) logger.debug(`Found PR #${pr.number}`);
return pr ?? null;
},
async createPr({ sourceBranch, targetBranch, prTitle, prBody: rawBody, labels: labelNames, platformPrOptions, draftPR }) {
let title = prTitle;
const base = targetBranch;
const head = sourceBranch;
const body = sanitize(rawBody);
if (draftPR) title = DRAFT_PREFIX + title;
logger.debug(`Creating pull request: ${title} (${head} => ${base})`);
try {
const labels = Array.isArray(labelNames) ? await map(labelNames, lookupLabelByName) : [];
const gpr = await createPR(config.repository, {
base,
head,
title,
body,
labels: labels.filter(isNumber)
});
if (platformPrOptions?.usePlatformAutomerge) if (semver.gte(defaults.version, "10.0.0-0")) try {
await mergePR(config.repository, gpr.number, {
Do: getMergeMethod(platformPrOptions?.automergeStrategy) ?? config.mergeMethod,
merge_when_checks_succeed: true,
delete_branch_after_merge: true
});
logger.debug({ prNumber: gpr.number }, "Forgejo-native automerge: success");
} catch (err) {
logger.warn({
err,
prNumber: gpr.number
}, "Forgejo-native automerge: fail");
}
else logger.debug({ prNumber: gpr.number }, `Forgejo-native automerge: not supported on this version of Forgejo. Use 10.0.0 or newer.`);
const pr = toRenovatePR(gpr, botUserName);
if (!pr) throw new Error("Can not parse newly created Pull Request");
await ForgejoPrCache.setPr(forgejoHttp, config.repository, config.ignorePrAuthor, botUserName, pr);
return pr;
} catch (err) {
if (err.statusCode === 409) {
logger.warn({
prTitle: title,
sourceBranch
}, "Attempting to gracefully recover from 409 Conflict response in createPr()");
ForgejoPrCache.forceSync();
const pr = await platform.findPr({
branchName: sourceBranch,
state: "open"
});
// v8 ignore else -- TODO: add test #40625
if (pr?.bodyStruct) {
if (pr.title !== title || pr.bodyStruct.hash !== hashBody(body)) {
logger.debug(`Recovered from 409 Conflict, but PR for ${sourceBranch} is outdated. Updating...`);
await platform.updatePr({
number: pr.number,
prTitle: title,
prBody: body
});
pr.title = title;
pr.bodyStruct = getPrBodyStruct(body);
} else logger.debug(`Recovered from 409 Conflict and PR for ${sourceBranch} is up-to-date`);
return pr;
}
}
throw err;
}
},
async updatePr({ number, prTitle, prBody: body, labels, state, targetBranch }) {
let title = prTitle;
if ((await getPrList()).find((pr) => pr.number === number)?.isDraft) title = DRAFT_PREFIX + title;
const prUpdateParams = {
title,
...body && { body },
...state && { state }
};
if (targetBranch) prUpdateParams.base = targetBranch;
/**
* Update PR labels.
* In the Forgejo API, labels are replaced on each update if the field is present.
* If the field is not present (i.e., undefined), labels aren't updated.
* However, the labels array must contain label IDs instead of names,
* so a lookup is performed to fetch the details (including the ID) of each label.
*/
if (Array.isArray(labels)) {
prUpdateParams.labels = (await map(labels, lookupLabelByName)).filter(isNumber);
if (labels.length !== prUpdateParams.labels.length) logger.warn("Some labels could not be looked up. Renovate may halt label updates assuming changes by others.");
}
const pr = toRenovatePR(await updatePR(config.repository, number, prUpdateParams), botUserName);
if (pr) await ForgejoPrCache.setPr(forgejoHttp, config.repository, config.ignorePrAuthor, botUserName, pr);
},
async mergePr({ id, strategy }) {
try {
await mergePR(config.repository, id, { Do: getMergeMethod(strategy) ?? config.mergeMethod });
return true;
} catch (err) {
logger.warn({
err,
id
}, "Merging of PR failed");
return false;
}
},
getIssueList() {
if (config.hasIssuesEnabled === false) return Promise.resolve([]);
config.issueList ??= searchIssues(config.repository, { state: "all" }, { memCache: false }).then((issues) => {
const issueList = issues.map(toRenovateIssue);
logger.debug(`Retrieved ${issueList.length} Issues`);
return issueList;
});
return config.issueList;
},
async getIssue(number, memCache = true) {
if (config.hasIssuesEnabled === false) return null;
try {
return {
number,
body: (await getIssue$1(config.repository, number, { memCache })).body
};
} catch (err) {
logger.debug({
err,
number
}, "Error getting issue");
return null;
}
},
async findIssue(title) {
const issue = (await platform.getIssueList()).find((i) => i.state === "open" && i.title === title);
if (!issue) return null;
logger.debug(`Found Issue #${issue.number}`);
return getIssue(issue.number);
},
async ensureIssue({ title, reuseTitle, body: content, labels: labelNames, shouldReOpen, once }) {
logger.debug(`ensureIssue(${title})`);
if (config.hasIssuesEnabled === false) {
logger.info("Cannot ensure issue because issues are disabled in this repository");
return null;
}
try {
const body = smartLinks(content);
const issueList = await platform.getIssueList();
let issues = issueList.filter((i) => i.title === title);
if (!issues.length) issues = issueList.filter((i) => i.title === reuseTitle);
const labels = Array.isArray(labelNames) ? (await Promise.all(labelNames.map(lookupLabelByName))).filter(isNumber) : void 0;
if (issues.length) {
let activeIssue = issues.find((i) => i.state === "open");
if (!activeIssue) {
if (once) {
logger.debug("Issue already closed - skipping update");
return null;
}
if (shouldReOpen) logger.debug("Reopening previously closed Issue");
activeIssue = issues[issues.length - 1];
}
for (const issue of issues) if (issue.state === "open" && issue.number !== activeIssue.number) {
logger.warn({ issueNo: issue.number }, "Closing duplicate issue");
await closeIssue(config.repository, issue.number);
}
if (activeIssue.title === title && activeIssue.body === body && activeIssue.state === "open") {
logger.debug(`Issue #${activeIssue.number} is open and up to date - nothing to do`);
return null;
}
if (shouldReOpen || activeIssue.state === "open") {
logger.debug(`Updating Issue #${activeIssue.number}`);
const existingLabelIds = ((await updateIssue(config.repository, activeIssue.number, {
body,
title,
state: "open"
})).labels ?? []).map((label) => label.id);
if (labels && (labels.length !== existingLabelIds.length || labels.filter((labelId) => !existingLabelIds.includes(labelId)).length !== 0)) await updateIssueLabels(config.repository, activeIssue.number, { labels });
return "updated";
}
}
const issue = await createIssue(config.repository, {
body,
title,
labels
});
logger.debug(`Created new Issue #${issue.number}`);
config.issueList = null;
return "created";
} catch (err) {
logger.warn({ err }, "Could not ensure issue");
}
return null;
},
async ensureIssueClosing(title) {
logger.debug(`ensureIssueClosing(${title})`);
if (config.hasIssuesEnabled === false) return;
const issueList = await platform.getIssueList();
for (const issue of issueList) if (issue.state === "open" && issue.title === title) {
logger.debug(`Closing issue...issueNo: ${issue.number}`);
await closeIssue(config.repository, issue.number);
}
},
async deleteLabel(issue, labelName) {
logger.debug(`Deleting label ${labelName} from Issue #${issue}`);
const label = await lookupLabelByName(labelName);
if (label) await unassignLabel(config.repository, issue, label);
else logger.warn({
issue,
labelName
}, "Failed to lookup label for deletion");
},
async ensureComment({ number: issue, topic, content }) {
try {
let body = sanitize(content);
const commentList = await getComments(config.repository, issue);
let comment = null;
if (topic) {
comment = findCommentByTopic(commentList, topic);
body = `### ${topic}\n\n${body}`;
} else comment = findCommentByContent(commentList, body);
if (!comment) {
comment = await createComment(config.repository, issue, body);
logger.info({
repository: config.repository,
issue,
comment: comment.id
}, "Comment added");
} else if (comment.body === body) logger.debug(`Comment #${comment.id} is already up-to-date`);
else {
await updateComment(config.repository, comment.id, body);
logger.debug({
repository: config.repository,
issue,
comment: comment.id
}, "Comment updated");
}
return true;
} catch (err) {
logger.warn({
err,
issue,
subject: topic
}, "Error ensuring comment");
return false;
}
},
async ensureCommentRemoval(deleteConfig) {
const { number: issue } = deleteConfig;
const key = deleteConfig.type === "by-topic" ? deleteConfig.topic : deleteConfig.content;
logger.debug(`Ensuring comment "${key}" in #${issue} is removed`);
const commentList = await getComments(config.repository, issue);
let comment = null;
// v8 ignore else -- TODO: add test #40625
if (deleteConfig.type === "by-topic") comment = findCommentByTopic(commentList, deleteConfig.topic);
else if (deleteConfig.type === "by-content") comment = findCommentByContent(commentList, sanitize(deleteConfig.content));
if (!comment) return;
try {
await deleteComment(config.repository, comment.id);
} catch (err) {
logger.warn({
err,
issue,
config: deleteConfig
}, "Error deleting comment");
}
},
async getBranchPr(branchName) {
logger.debug(`getBranchPr(${branchName})`);
const pr = await platform.findPr({
branchName,
state: "open"
});
return pr ? platform.getPr(pr.number) : null;
},
async addAssignees(number, assignees) {
logger.debug(`Updating assignees '${assignees?.join(", ")}' on Issue #${number}`);
await updateIssue(config.repository, number, { assignees });
},
async addReviewers(number, reviewers) {
logger.debug(`Adding reviewers '${reviewers?.join(", ")}' to #${number}`);
try {
const teamReviewers = new Set(reviewers.filter((r) => r.startsWith("team:")).map((r) => r.substring(5)));
const userReviewers = new Set(reviewers.filter((r) => !r.startsWith("team:")));
await requestPrReviewers(config.repository, number, {
reviewers: [...userReviewers],
...teamReviewers.size && { team_reviewers: [...teamReviewers] }
});
} catch (err) {
logger.warn({
err,
number,
reviewers
}, "Failed to assign reviewer");
}
},
massageMarkdown(prBody) {
return smartTruncate(smartLinks(prBody), maxBodyLength());
},
maxBodyLength
};
function maxBodyLength() {
return 1e6;
}
const { addAssignees, addReviewers, createPr, deleteLabel, ensureComment, ensureCommentRemoval, ensureIssue, ensureIssueClosing, findIssue, findPr, getBranchPr, getBranchStatus, getBranchStatusCheck, getIssue, getRawFile, getJsonFile, getIssueList, getPr, massageMarkdown, getPrList, getRepos, initPlatform, initRepo, mergePr, setBranchStatus, updatePr } = platform;
//#endregion
export { forgejo_exports, id };
//# sourceMappingURL=index.js.map