Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Automerge #1247

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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/*"

Comment on lines +3 to +7
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add workflow permissions and trigger restrictions

The current trigger configuration could pose security risks:

  1. Missing permissions configuration to limit the workflow's access scope
  2. No restrictions on who can trigger the workflow

Apply these changes to improve security:

name: Auto Rebase Branches

on:
  push:
    branches:
      - "release/*"
+    paths-ignore:
+      - "**.md"
+
+permissions:
+  contents: write
+  issues: write
+
+# Ensure workflow is not triggered by forks
+if: github.repository == 'ephemeraHQ/converse-app'

Committable suggestion skipped: line range outside the PR's diff.

jobs:
auto-rebase:
runs-on: ubuntu-22.04
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: "20"

- name: Install Dependencies
run: |
npm install simple-git@^3.21.0

- name: Run Auto Rebase Script
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/git/auto_rebase.js
147 changes: 147 additions & 0 deletions scripts/git/auto_rebase.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Handle the case when nextBranch is null to prevent errors

Currently, if nextBranch is null, calling rebaseBranch(updatedBranch, nextBranch) will result in an error. Add a check to ensure nextBranch is not null before attempting to rebase.

Apply this diff to fix the issue:

 if (nextBranch) {
   // 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}`);
   }
+ } else {
+   console.log('No subsequent release branches to rebase.');
 }

Committable suggestion skipped: line range outside the PR's diff.


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}`);
}
alexrisch marked this conversation as resolved.
Show resolved Hide resolved
} 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) {
const status = await git.status();
if (status.conflicts.length > 0) {
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();
178 changes: 178 additions & 0 deletions scripts/git/cherry-pick-branch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
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"]);

alexrisch marked this conversation as resolved.
Show resolved Hide resolved
// 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,
});

if (!selectedCommits || selectedCommits.length === 0) {
console.log("No commits selected. Exiting.");
return;
}

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, options = { captureOutput: false }) {
return new Promise((resolve, reject) => {
let output = "";
const spawnOptions = options.captureOutput
? { shell: true }
: { stdio: "inherit", shell: true };
const cmd = spawn(command, args, spawnOptions);

if (options.captureOutput) {
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(
`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 executeCommand(
"git",
["log", `${targetBranch}..${sourceBranch}`, "--pretty=format:%H %s"],
{ captureOutput: true }
);

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

return commits;
}

cherryPickCommits();
Loading