diff --git a/src/backport.ts b/src/backport.ts index be2d884..bf2ece2 100644 --- a/src/backport.ts +++ b/src/backport.ts @@ -1,175 +1,170 @@ import { existsSync, rmSync } from 'node:fs' import { Octokit } from '@octokit/rest' -import { cherryPickCommits, cloneAndCacheRepo, hasDiff, hasEmptyCommits, hasSkipCiCommits, pushBranch } from './gitUtils' -import { CherryPickResult, Task } from './constants' -import { debug, error, info, warn } from './logUtils' -import { Reaction, addReaction, getAuthToken, getAvailableLabels, getLabelsFromPR, getAvailableMilestones, requestReviewers, getReviewers, createBackportPullRequest, setPRLabels, setPRMilestone, getChangesFromPR, updatePRBody, commentOnPR, assignToPR } from './githubUtils' -import { getBackportBody, getFailureCommentBody, getLabelsForPR, getMilestoneFromBase } from './nextcloudUtils' - -export const backport = (task: Task) => new Promise((resolve, reject) => { - getAuthToken(task.installationId).then(async token => { - const octokit = new Octokit({ auth: token }) - - let tmpDir: string = '' - let prNumber: number = 0 - let conflicts: CherryPickResult|null = null - const backportBranch = `backport/${task.prNumber}/${task.branch}` - - info(task, `Starting backport request`) - - // Add a reaction to the comment to indicate that we're processing it +import { cherryPickCommits, cloneAndCacheRepo, hasDiff, hasEmptyCommits, hasSkipCiCommits, pushBranch } from './gitUtils.js' +import { CherryPickResult, Task } from './constants.js' +import { debug, error, info, warn } from './logUtils.js' +import { Reaction, addReaction, getAuthToken, getAvailableLabels, getLabelsFromPR, getAvailableMilestones, requestReviewers, getReviewers, createBackportPullRequest, setPRLabels, setPRMilestone, getChangesFromPR, updatePRBody, commentOnPR, assignToPR } from './githubUtils.js' +import { getBackportBody, getFailureCommentBody, getLabelsForPR, getMilestoneFromBase } from './nextcloudUtils.js' + +export async function backport(task: Task): Promise { + const token = await getAuthToken(task.installationId) + const octokit = new Octokit({ auth: token }) + + let tmpDir: string = '' + let prNumber: number = 0 + let conflicts: CherryPickResult|null = null + const backportBranch = `backport/${task.prNumber}/${task.branch}` + + info(task, `Starting backport request`) + + // Add a reaction to the comment to indicate that we're processing it + try { + await addReaction(octokit, task, Reaction.THUMBS_UP) + } catch (e) { + error(task, `Failed to add reaction to PR: ${e.message}`) + // continue, this is not a fatal error + } + + try { + // Clone and cache the repo try { - await addReaction(octokit, task, Reaction.THUMBS_UP) + tmpDir = await cloneAndCacheRepo(task, backportBranch) + info(task, `Cloned to ${tmpDir}`) } catch (e) { - error(task, `Failed to add reaction to PR: ${e.message}`) - // continue, this is not a fatal error + throw new Error(`Failed to clone repository: ${e.message}`) } + // Cherry pick the commits try { - // Clone and cache the repo - try { - tmpDir = await cloneAndCacheRepo(task, backportBranch) - info(task, `Cloned to ${tmpDir}`) - } catch (e) { - throw new Error(`Failed to clone repository: ${e.message}`) + conflicts = await cherryPickCommits(task, tmpDir) + if (conflicts === CherryPickResult.CONFLICTS) { + warn(task, `Cherry picking commits resulted in conflicts`) + } else { + info(task, `Cherry picking commits successful`) } + } catch (e) { + throw new Error(`Failed to cherry pick commits: ${e.message}`) + } - // Cherry pick the commits - try { - conflicts = await cherryPickCommits(task, tmpDir) - if (conflicts === CherryPickResult.CONFLICTS) { - warn(task, `Cherry picking commits resulted in conflicts`) - } else { - info(task, `Cherry picking commits successful`) - } - } catch (e) { - throw new Error(`Failed to cherry pick commits: ${e.message}`) - } + // Check if there are any changes to backport + const hasChanges = await hasDiff(tmpDir, task.branch, backportBranch, task) + if (!hasChanges) { + throw new Error(`No changes found in backport branch`) + } - // Check if there are any changes to backport - const hasChanges = await hasDiff(tmpDir, task.branch, backportBranch, task) - if (!hasChanges) { - throw new Error(`No changes found in backport branch`) - } + // Push the branch + try { + await pushBranch(task, tmpDir, token, backportBranch) + info(task, `Pushed branch ${backportBranch}`) + } catch (e) { + throw new Error(`Failed to push branch ${backportBranch}: ${e.message}`) + } - // Push the branch - try { - await pushBranch(task, tmpDir, token, backportBranch) - info(task, `Pushed branch ${backportBranch}`) - } catch (e) { - throw new Error(`Failed to push branch ${backportBranch}: ${e.message}`) - } + // Create the pull request + try { + const reviewers = await getReviewers(octokit, task) + const prCreationResult = await createBackportPullRequest(octokit, task, backportBranch, conflicts === CherryPickResult.CONFLICTS) + prNumber = prCreationResult.data.number + info(task, `Opened Pull Request #${prNumber} on ${prCreationResult.data.html_url}`) - // Create the pull request try { - const reviewers = await getReviewers(octokit, task) - const prCreationResult = await createBackportPullRequest(octokit, task, backportBranch, conflicts === CherryPickResult.CONFLICTS) - prNumber = prCreationResult.data.number - info(task, `Opened Pull Request #${prNumber} on ${prCreationResult.data.html_url}`) - - try { - // Ask for reviews from all reviewers of the original PR - if (reviewers.length !== 0) { - await requestReviewers(octokit, task, prNumber, reviewers) - } - - // Also ask the author of the original PR for a review - await requestReviewers(octokit, task, prNumber, [task.author]) - info(task, `Requested reviews from ${[...reviewers, task.author].join(', ')}`) - } catch (e) { - error(task, `Failed to request reviews: ${e.message}`) + // Ask for reviews from all reviewers of the original PR + if (reviewers.length !== 0) { + await requestReviewers(octokit, task, prNumber, reviewers) } - } catch (e) { - throw new Error(`Failed to create pull request: ${e.message}`) - } - // Get labels from original PR and set them on the new PR - try { - const availableLabels = await getAvailableLabels(octokit, task) - const prLabels = await getLabelsFromPR(octokit, task) - const labels = getLabelsForPR(prLabels, availableLabels) - await setPRLabels(octokit, task, prNumber, labels) - info(task, `Set labels: ${labels.join(', ')}`) + // Also ask the author of the original PR for a review + await requestReviewers(octokit, task, prNumber, [task.author]) + info(task, `Requested reviews from ${[...reviewers, task.author].join(', ')}`) } catch (e) { - error(task, `Failed to get and set labels: ${e.message}`) - // continue, this is not a fatal error + error(task, `Failed to request reviews: ${e.message}`) } + } catch (e) { + throw new Error(`Failed to create pull request: ${e.message}`) + } - // Find new appropriate Milestone and set it on the new PR - try { - const availableMilestone = await getAvailableMilestones(octokit, task) - const milestone = await getMilestoneFromBase(task.branch, availableMilestone) - await setPRMilestone(octokit, task, prNumber, milestone) - info(task, `Set milestone: ${milestone.title}`) - } catch (e) { - error(task, `Failed to find appropriate milestone: ${e.message}`) - // continue, this is not a fatal error - } + // Get labels from original PR and set them on the new PR + try { + const availableLabels = await getAvailableLabels(octokit, task) + const prLabels = await getLabelsFromPR(octokit, task) + const labels = getLabelsForPR(prLabels, availableLabels) + await setPRLabels(octokit, task, prNumber, labels) + info(task, `Set labels: ${labels.join(', ')}`) + } catch (e) { + error(task, `Failed to get and set labels: ${e.message}`) + // continue, this is not a fatal error + } - // Assign the PR to the author of the original PR - try { - await assignToPR(octokit, task, prNumber, [task.author]) - info(task, `Assigned original author: ${task.author}`) - } catch (e) { - error(task, `Failed to assign PR: ${e.message}`) - // continue, this is not a fatal error - } + // Find new appropriate Milestone and set it on the new PR + try { + const availableMilestone = await getAvailableMilestones(octokit, task) + const milestone = await getMilestoneFromBase(task.branch, availableMilestone) + await setPRMilestone(octokit, task, prNumber, milestone) + info(task, `Set milestone: ${milestone.title}`) + } catch (e) { + error(task, `Failed to find appropriate milestone: ${e.message}`) + // continue, this is not a fatal error + } + + // Assign the PR to the author of the original PR + try { + await assignToPR(octokit, task, prNumber, [task.author]) + info(task, `Assigned original author: ${task.author}`) + } catch (e) { + error(task, `Failed to assign PR: ${e.message}`) + // continue, this is not a fatal error + } - // Compare the original PR with the new PR + // Compare the original PR with the new PR + try { + const oldChanges = await getChangesFromPR(octokit, task, task.prNumber) + const newChanges = await getChangesFromPR(octokit, task, prNumber) + const diffChanges = oldChanges.additions !== newChanges.additions + || oldChanges.deletions !== newChanges.deletions + || oldChanges.changedFiles !== newChanges.changedFiles + const skipCi = await hasSkipCiCommits(tmpDir, task.commits.length) + const emptyCommits = await hasEmptyCommits(tmpDir, task.commits.length, task) + const hasConflicts = conflicts === CherryPickResult.CONFLICTS + + debug(task, `hasConflicts: ${hasConflicts}, diffChanges: ${diffChanges}, emptyCommits: ${emptyCommits}, skipCi: ${skipCi}`) try { - const oldChanges = await getChangesFromPR(octokit, task, task.prNumber) - const newChanges = await getChangesFromPR(octokit, task, prNumber) - const diffChanges = oldChanges.additions !== newChanges.additions - || oldChanges.deletions !== newChanges.deletions - || oldChanges.changedFiles !== newChanges.changedFiles - const skipCi = await hasSkipCiCommits(tmpDir, task.commits.length) - const emptyCommits = await hasEmptyCommits(tmpDir, task.commits.length, task) - const hasConflicts = conflicts === CherryPickResult.CONFLICTS - - debug(task, `hasConflicts: ${hasConflicts}, diffChanges: ${diffChanges}, emptyCommits: ${emptyCommits}, skipCi: ${skipCi}`) - try { - if (hasConflicts || diffChanges || emptyCommits || skipCi) { - const newBody = await getBackportBody(task.prNumber, hasConflicts, diffChanges, emptyCommits, skipCi) - await updatePRBody(octokit, task, prNumber, newBody) - } - } catch (e) { - error(task, `Failed to update PR body: ${e.message}`) - // continue, this is not a fatal error + if (hasConflicts || diffChanges || emptyCommits || skipCi) { + const newBody = await getBackportBody(task.prNumber, hasConflicts, diffChanges, emptyCommits, skipCi) + await updatePRBody(octokit, task, prNumber, newBody) } } catch (e) { - error(task, `Failed to compare changes: ${e.message}`) + error(task, `Failed to update PR body: ${e.message}`) // continue, this is not a fatal error } - - // Success! We're done here - addReaction(octokit, task, Reaction.HOORAY) } catch (e) { - // Add a thumbs down reaction to the comment to indicate that we failed - try { - addReaction(octokit, task, Reaction.THUMBS_DOWN) - const failureComment = getFailureCommentBody(task, backportBranch, e?.message) - await commentOnPR(octokit, task, failureComment) - } catch (e) { - error(task, `Failed to comment failure on PR: ${e.message}`) - // continue, this is not a fatal error - } + error(task, `Failed to compare changes: ${e.message}`) + // continue, this is not a fatal error + } - reject(`Failed to backport: ${e.message}`) + // Success! We're done here + addReaction(octokit, task, Reaction.HOORAY) + } catch (e) { + // Add a thumbs down reaction to the comment to indicate that we failed + try { + addReaction(octokit, task, Reaction.THUMBS_DOWN) + const failureComment = getFailureCommentBody(task, backportBranch, e?.message) + await commentOnPR(octokit, task, failureComment) + } catch (e) { + error(task, `Failed to comment failure on PR: ${e.message}`) + // continue, this is not a fatal error } - // Remove the temp dir if it exists - if (tmpDir !== '' && existsSync(tmpDir)) { - try { - rmSync(tmpDir, { recursive: true }) - info(task, `Removed ${tmpDir}`) - resolve() - } catch (e) { - reject(`Failed to remove ${tmpDir}: ${e.message}`) - } + throw new Error(`Failed to backport: ${e.message}`) + } + + // Remove the temp dir if it exists + if (tmpDir !== '' && existsSync(tmpDir)) { + try { + rmSync(tmpDir, { recursive: true }) + info(task, `Removed ${tmpDir}`) + } catch (e) { + throw new Error(`Failed to remove ${tmpDir}: ${e.message}`) } - }) -}).catch(e => { - error(task, e) - throw e -}) + } +} \ No newline at end of file diff --git a/src/gitUtils.ts b/src/gitUtils.ts index b80302a..d697ba4 100644 --- a/src/gitUtils.ts +++ b/src/gitUtils.ts @@ -1,9 +1,10 @@ -import { cpSync, existsSync, mkdirSync} from 'node:fs' +import { existsSync, mkdirSync} from 'node:fs' import { join } from 'node:path' import { simpleGit } from 'simple-git' -import { CACHE_DIRNAME, CherryPickResult, ROOT_DIR, Task, WORK_DIRNAME } from './constants' -import { debug, error } from './logUtils' +import { CACHE_DIRNAME, CherryPickResult, ROOT_DIR, Task, WORK_DIRNAME } from './constants.js' +import { debug, error } from './logUtils.js' +import { randomBytes } from 'node:crypto' export const setGlobalGitConfig = async (user: string): Promise => { const git = simpleGit() @@ -27,11 +28,18 @@ export const cloneAndCacheRepo = async (task: Task, backportBranch: string): Pro // Clone the repo into the cache dir or make sure it already exists const cachedRepoRoot = join(ROOT_DIR, CACHE_DIRNAME, owner, repo) try { - if (!existsSync(cachedRepoRoot + '/.git')) { + // Create repo path if needed + if (!existsSync(cachedRepoRoot)) { mkdirSync(cachedRepoRoot, { recursive: true }) - const git = simpleGit(cachedRepoRoot) + } + + const git = simpleGit(cachedRepoRoot) + if (!existsSync(cachedRepoRoot + '/.git')) { + // Is not a repository, so clone await git.clone(`https://github.com/${owner}/${repo}`, '.') } else { + // Is already a repository so make sure it is clean and follows the default branch + await git.clean(['-X', '-d', '-f']) debug(task, `Repo already cached at ${cachedRepoRoot}`) } } catch (e) { @@ -52,36 +60,19 @@ export const cloneAndCacheRepo = async (task: Task, backportBranch: string): Pro // } // Init a new temp repo in the work dir - const tmpDirName = Math.random().toString(36).substring(7) + const tmpDirName = randomBytes(7).toString('hex') const tmpRepoRoot = join(ROOT_DIR, WORK_DIRNAME, tmpDirName) try { // Copy the cached repo to the temp repo mkdirSync(join(ROOT_DIR, WORK_DIRNAME), { recursive: true }) - cpSync(cachedRepoRoot, tmpRepoRoot, { recursive: true }) - } catch (e) { - throw new Error(`Failed to copy cached repo: ${e.message}`) - } - - try { - // Checkout all the branches - const git = simpleGit(tmpRepoRoot) - // TODO: We could do that to the cached repo, but - // this seem to create some concurrency issues. - await git.raw(['fetch', '--all']) - await git.raw(['pull', '--prune']) - - // reset and clean the repo - await git.raw(['reset', '--hard', `origin/${branch}`]) - await git.raw(['clean', '--force', '-dfx']) - - // Checkout the branch we want to backport from - await git.checkout(branch) - await git.checkoutBranch( - backportBranch, - branch - ) + // create worktree + const git = simpleGit(cachedRepoRoot) + // fetch upstream version of the branch - well we need to fetch all because we do not know where the commits are located we need to cherry-pick + await git.fetch(['-p', '--all']) + // create work tree with up-to-date content of that branch + await git.raw(['worktree', 'add', '-b', backportBranch, tmpRepoRoot, `origin/${branch}`]) } catch (e) { - throw new Error(`Failed to checkout branches: ${e.message}`) + throw new Error(`Failed to create working tree: ${e.message}`) } return tmpRepoRoot diff --git a/src/index.ts b/src/index.ts index acef5bf..aa13100 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,16 +3,16 @@ import { createServer } from 'http' import { info, error, warn } from 'node:console' import { Octokit } from '@octokit/rest' -import { addToQueue } from './queue' -import { ALLOWED_ORGS, CACHE_DIRNAME, COMMAND_PREFIX, LABEL_BACKPORT, PRIVATE_KEY_PATH, ROOT_DIR, SERVE_HOST, SERVE_PORT, TO_SEPARATOR, Task, WEBHOOK_SECRET, WORK_DIRNAME } from './constants' -import { extractBranchFromPayload, extractCommitsFromPayload } from './payloadUtils' -import { getApp } from './appUtils' -import { Reaction, addPRLabel, addReaction, getAuthToken, getBackportRequestsFromPR, getCommitsForPR, removePRLabel } from './githubUtils' -import { setGlobalGitConfig } from './gitUtils' +import { addToQueue } from './queue.js' +import { ALLOWED_ORGS, CACHE_DIRNAME, COMMAND_PREFIX, LABEL_BACKPORT, PRIVATE_KEY_PATH, ROOT_DIR, SERVE_HOST, SERVE_PORT, TO_SEPARATOR, Task, WEBHOOK_SECRET, WORK_DIRNAME } from './constants.js' +import { extractBranchFromPayload, extractCommitsFromPayload } from './payloadUtils.js' +import { getApp } from './appUtils.js' +import { Reaction, addPRLabel, addReaction, getAuthToken, getBackportRequestsFromPR, getCommitsForPR, removePRLabel } from './githubUtils.js' +import { setGlobalGitConfig } from './gitUtils.js' const app = getApp() -app.webhooks.onError(err => { +app.webhooks.onError((err) => { error(`Error occurred in ${err.event.name}: ${err.message}`) }) diff --git a/src/queue.ts b/src/queue.ts index 6591b39..89f2671 100644 --- a/src/queue.ts +++ b/src/queue.ts @@ -1,17 +1,21 @@ import PQueue from 'p-queue' -import { Task } from './constants' -import { backport } from './backport' +import { Task } from './constants.js' +import { backport } from './backport.js' +import { error } from './logUtils.js' let queue: PQueue -export const addToQueue = (task: Task): Promise => { +export async function addToQueue(task: Task): Promise { if (!queue) { queue = new PQueue({ concurrency: 1 }) } - return new Promise((resolve, reject) => { - queue.add(async () => { - await backport(task).then(resolve).catch(reject) + try { + await queue.add(async () => { + await backport(task) }) - }) + } catch(e) { + error(task, e) + throw e + } } diff --git a/tsconfig.json b/tsconfig.json index 21f34d6..5eb141d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,9 +3,10 @@ "include": ["src/**/*.ts"], "exclude": ["node_modules"], "compilerOptions": { + "lib": ["ES2023"], "allowJs": true, - "moduleResolution": "node16", - "module": "node16", - "target": "es2022" + "moduleResolution": "Node16", + "module": "Node16", + "target": "ES2022" }, }