Skip to content

Commit

Permalink
feat: Automerge
Browse files Browse the repository at this point in the history
Adds helper scripts
Adds hopefully good action to manage automerges
  • Loading branch information
alexrisch committed Nov 22, 2024
1 parent 139d263 commit 744df06
Show file tree
Hide file tree
Showing 4 changed files with 455 additions and 0 deletions.
29 changes: 29 additions & 0 deletions .github/workflows/auto_rebase.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Auto Rebase Branches

on:
push:
branches:
- "release/*"

jobs:
auto-rebase:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v3
with:
fetch-depth: 0 # Fetch all history so we can work with all branches

- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: "16"

- name: Install Dependencies
run: |
npm install [email protected]
- name: Run Auto Rebase Script
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/git/auto_rebase.js
146 changes: 146 additions & 0 deletions scripts/git/auto_rebase.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
const simpleGit = require("simple-git");
const { Octokit } = require("@octokit/rest");
const git = simpleGit();

async function main() {
try {
const { GITHUB_REF, GITHUB_REPOSITORY, GITHUB_TOKEN } = process.env;
const octokit = new Octokit({ auth: GITHUB_TOKEN });
const [owner, repo] = GITHUB_REPOSITORY.split("/");

// Extract the branch that was just pushed
const updatedBranch = GITHUB_REF.replace("refs/heads/", "");
console.log(`Updated branch: ${updatedBranch}`);

// Determine the list of release branches
const releaseBranches = await getBranchesMatchingPattern("release/");

// Sort release branches (assuming semantic versioning)
releaseBranches.sort(compareReleaseBranches);

// Find the next release branch
const currentIndex = releaseBranches.indexOf(updatedBranch);
let nextBranch = null;

if (currentIndex !== -1 && currentIndex < releaseBranches.length - 1) {
nextBranch = releaseBranches[currentIndex + 1];
console.log(`Next release branch: ${nextBranch}`);
} else {
// No more release branches, proceed to feature branches
const featureBranches = await getBranchesMatchingPattern("feature/");

for (const featureBranch of featureBranches) {
const rebaseResult = await rebaseBranch(updatedBranch, featureBranch);

if (!rebaseResult) {
await createIssue(octokit, owner, repo, updatedBranch, featureBranch);
} else {
console.log(
`Successfully rebased ${featureBranch} onto ${updatedBranch}`
);
}
}
}

// Attempt to rebase nextBranch onto updatedBranch
const rebaseResult = await rebaseBranch(updatedBranch, nextBranch);

if (!rebaseResult) {
// Rebase failed due to conflicts, create an issue
await createIssue(octokit, owner, repo, updatedBranch, nextBranch);
} else {
console.log(`Successfully rebased ${nextBranch} onto ${updatedBranch}`);
}
} catch (error) {
console.error("An error occurred:", error.message);
process.exit(1);
}
}

async function getBranchesMatchingPattern(pattern) {
const branches = await git.branch(["-r"]);
return branches.all
.map((branch) => branch.replace("origin/", ""))
.filter((branch) => branch.startsWith(pattern));
}

function compareReleaseBranches(a, b) {
// Extract version numbers and compare
const versionA = a.replace("release/", "").split(".").map(Number);
const versionB = b.replace("release/", "").split(".").map(Number);

for (let i = 0; i < Math.max(versionA.length, versionB.length); i++) {
const numA = versionA[i] || 0;
const numB = versionB[i] || 0;
if (numA !== numB) {
return numA - numB;
}
}
return 0;
}

async function rebaseBranch(baseBranch, targetBranch) {
try {
await git.fetch();

// Checkout target branch
await git.checkout(targetBranch);

// Pull latest changes
await git.pull("origin", targetBranch, { "--ff-only": null });

// Attempt rebase
await git.rebase([baseBranch]);

// Push rebased branch
await git.push("origin", targetBranch, { "--force-with-lease": null });

return true;
} catch (error) {
if (error.message.includes("CONFLICT")) {
console.error(
`Conflicts occurred while rebasing ${targetBranch} onto ${baseBranch}`
);
await git.rebase(["--abort"]);
return false;
} else {
throw error;
}
}
}

async function createIssue(octokit, owner, repo, baseBranch, targetBranch) {
const issueTitle = `Conflict: Rebase ${targetBranch} onto ${baseBranch}`;
const issueBody = `An automatic rebase of **${targetBranch}** onto **${baseBranch}** failed due to conflicts. Please resolve the conflicts manually.
**Base Branch**: \`${baseBranch}\`
**Target Branch**: \`${targetBranch}\`
<details>
<summary>Rebase attempt output</summary>
\`\`\`
[Rebase output and conflict details can be included here]
\`\`\`
</details>
---
*This issue was generated by an automated workflow.*
`;

await octokit.rest.issues.create({
owner,
repo,
title: issueTitle,
body: issueBody,
labels: ["rebase-conflict"],
});

console.log(
`Created issue for conflict between ${baseBranch} and ${targetBranch}`
);
}

main();
190 changes: 190 additions & 0 deletions scripts/git/cherry-pick-branch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
const { spawn } = require("child_process");
const prompts = require("prompts");

async function cherryPickCommits() {
const response = await prompts([
{
type: "text",
name: "sourceBranch",
message: "Enter the source branch (containing commits to cherry-pick)",
},
{
type: "text",
name: "targetBranch",
message: "Enter the target branch (branch to apply commits to)",
},
{
type: "confirm",
name: "selectCommits",
message: "Do you want to select specific commits to cherry-pick?",
initial: false,
},
]);

const { sourceBranch, targetBranch, selectCommits } = response;

try {
// Fetch and checkout target branch
await executeCommand("git", ["fetch", "origin"]);
await executeCommand("git", ["checkout", targetBranch]);
await executeCommand("git", ["pull", "origin", targetBranch, "--ff-only"]);

// Update source branch
await executeCommand("git", ["fetch", "origin"]);
await executeCommand("git", ["checkout", sourceBranch]);
await executeCommand("git", ["pull", "origin", sourceBranch, "--ff-only"]);

// Get list of commits from source branch not in target branch
const commitList = await getCommitsNotInTarget(sourceBranch, targetBranch);

if (commitList.length === 0) {
console.log("No new commits to cherry-pick.");
process.exit(0);
}

let commitsToCherryPick = commitList;

if (selectCommits) {
// Prompt user to select commits
const commitChoices = commitList.map((commit) => ({
title: `${commit.hash} ${commit.message}`,
value: commit.hash,
}));

const { selectedCommits } = await prompts({
type: "multiselect",
name: "selectedCommits",
message: "Select commits to cherry-pick",
choices: commitChoices,
min: 1,
});

commitsToCherryPick = commitList.filter((commit) =>
selectedCommits.includes(commit.hash)
);
}

// Switch back to target branch
await executeCommand("git", ["checkout", targetBranch]);

// Cherry-pick the commits
for (const commit of commitsToCherryPick) {
console.log(`Cherry-picking commit ${commit.hash}: ${commit.message}`);
try {
await executeCommand("git", ["cherry-pick", commit.hash]);
} catch (error) {
console.error(
`Conflict occurred while cherry-picking commit ${commit.hash}.`
);
// Prompt user to resolve conflicts
const { resolveNow } = await prompts({
type: "confirm",
name: "resolveNow",
message:
"Do you want to resolve the conflicts now? (If not, the cherry-pick will be aborted.)",
initial: true,
});

if (resolveNow) {
console.log(
"Please resolve the conflicts manually, then continue the cherry-pick."
);
console.log("After resolving, run: git cherry-pick --continue");
console.log(
"If you want to abort the cherry-pick, run: git cherry-pick --abort"
);
process.exit(1);
} else {
await executeCommand("git", ["cherry-pick", "--abort"]);
throw new Error(
`Cherry-pick aborted due to conflicts in commit ${commit.hash}.`
);
}
}
}

// Push the updated target branch to origin
await executeCommand("git", ["push", "origin", targetBranch]);

console.log(
`Successfully cherry-picked ${commitsToCherryPick.length} commits onto '${targetBranch}' and pushed to origin.`
);
} catch (error) {
console.error("An error occurred:", error.message);
process.exit(1);
}
}

function executeCommand(command, args) {
return new Promise((resolve, reject) => {
const cmd = spawn(command, args, { stdio: "inherit", shell: true });

cmd.on("close", (code) => {
if (code === 0) {
resolve();
} else {
reject(
new Error(
`Command "${command} ${args.join(" ")}" exited with code ${code}`
)
);
}
});

cmd.on("error", (error) => {
reject(error);
});
});
}

async function getCommitsNotInTarget(sourceBranch, targetBranch) {
// Get commits in sourceBranch not in targetBranch
const commitsOutput = await executeGitCommand([
"log",
`${targetBranch}..${sourceBranch}`,
"--pretty=format:%H %s",
]);

const commits = commitsOutput
.split("\n")
.filter((line) => line.trim() !== "")
.map((line) => {
const [hash, ...messageParts] = line.split(" ");
return { hash, message: messageParts.join(" ") };
});

return commits;
}

function executeGitCommand(args) {
return new Promise((resolve, reject) => {
let output = "";
const cmd = spawn("git", args, { shell: true });

cmd.stdout.on("data", (data) => {
output += data.toString();
});

cmd.stderr.on("data", (data) => {
output += data.toString();
});

cmd.on("close", (code) => {
if (code === 0) {
resolve(output.trim());
} else {
reject(
new Error(
`Git command "git ${args.join(" ")}" exited with code ${code}`
)
);
}
});

cmd.on("error", (error) => {
reject(error);
});
});
}

cherryPickCommits();
Loading

0 comments on commit 744df06

Please sign in to comment.