Skip to content

Commit 8b29e5e

Browse files
Add automerge script (#13)
This introduces a script to automatically perform the merge of incoming changes from upstream LLVM which live in the `main` branch into the downstream `arm-software` branch. It also includes a GitHub Workflow to execute it after every sync from upstream. --------- Co-authored-by: Davide <[email protected]>
1 parent 4ce33ec commit 8b29e5e

File tree

3 files changed

+243
-0
lines changed

3 files changed

+243
-0
lines changed

.github/workflows/automerge.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# This workflow executes the automerge python script to automatically merge
2+
# changes from the `main` branch of upstream LLVM into the `arm-software`
3+
# branch of the arm/arm-toolchain repository.
4+
name: Automerge
5+
on:
6+
workflow_run:
7+
workflows: [Sync from Upstream LLVM]
8+
types:
9+
- completed
10+
jobs:
11+
Run-Automerge:
12+
runs-on: ubuntu-latest
13+
env:
14+
FROM_BRANCH: main
15+
TO_BRANCH: arm-software
16+
steps:
17+
- name: Checkout
18+
uses: actions/checkout@v4
19+
- name: Configure Git Identity
20+
run: |
21+
git config --local user.name "github-actions[bot]"
22+
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
23+
- name: Run automerge
24+
run: python3 arm-software/ci/automerge.py --project-name ${{ env.GITHUB_REPOSITORY }} --from-branch ${{ env.FROM_BRANCH }} --to-branch ${{ env.TO_BRANCH }}
25+
env:
26+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

arm-software/ci/.automerge_ignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.github
2+
arm-software
3+
CONTRIBUTING.md
4+
README.md
5+
LICENSE.TXT

arm-software/ci/automerge.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
#!/usr/bin/env python3
2+
3+
"""
4+
A script to automatically perform the merge of incoming changes from a branch
5+
in upstream LLVM into a downstream branch.
6+
"""
7+
8+
import argparse
9+
import json
10+
import logging
11+
import subprocess
12+
import sys
13+
from pathlib import Path
14+
15+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
16+
logger = logging.getLogger(__name__)
17+
18+
19+
MERGE_CONFLICT_LABEL = "automerge_conflict"
20+
AUTOMERGE_BRANCH = "automerge"
21+
REMOTE_NAME = "origin"
22+
MERGE_IGNORE_PATHSPEC_FILE = Path(__file__).parent / ".automerge_ignore"
23+
24+
25+
class MergeConflictError(Exception):
26+
"""
27+
An exception representing a failed merge from upstream due to a conflict.
28+
"""
29+
30+
def __init__(self, commit_hash: str) -> None:
31+
super().__init__()
32+
self.commit_hash = commit_hash
33+
34+
35+
class Git:
36+
"""
37+
A helper class for running Git commands on a repository that lives in a
38+
specific path.
39+
"""
40+
41+
def __init__(self, repo_path: Path) -> None:
42+
self.repo_path = repo_path
43+
44+
def run_cmd(self, args: list[str], check: bool = True) -> str:
45+
git_cmd = ["git", "-C", str(self.repo_path)] + args
46+
git_process = subprocess.run(git_cmd, check=check, capture_output=True, text=True)
47+
return git_process.stdout
48+
49+
50+
def has_unresolved_conflicts(git_repo: Git) -> bool:
51+
diff_output = git_repo.run_cmd(["diff", "--name-only", "--diff-filter=U"])
52+
diff_output = diff_output.strip()
53+
return bool(diff_output)
54+
55+
56+
def prefix_current_commit_message(git_repo: Git) -> None:
57+
log_output = git_repo.run_cmd(["log", "HEAD", "--max-count=1", "--pretty=format:%B"])
58+
commit_msg = f"Automerge: {log_output}"
59+
git_repo.run_cmd(["commit", "--amend", "--message=" + commit_msg])
60+
61+
62+
def merge_commit(git_repo: Git, to_branch: str, commit_hash: str, dry_run: bool) -> None:
63+
logger.info("Merging commit %s into %s", commit_hash, to_branch)
64+
git_repo.run_cmd(["switch", to_branch])
65+
git_repo.run_cmd(["merge", commit_hash, "--no-commit", "--no-ff"], check=False)
66+
# Ensure all paths that should be ignored stay unchanged
67+
git_repo.run_cmd(
68+
["restore", "--ours", "--staged", "--worktree", f"--pathspec-from-file={MERGE_IGNORE_PATHSPEC_FILE}"]
69+
)
70+
if has_unresolved_conflicts(git_repo):
71+
logger.info("Merge failed")
72+
git_repo.run_cmd(["merge", "--abort"])
73+
raise MergeConflictError(commit_hash)
74+
git_repo.run_cmd(["commit", "--reuse-message", commit_hash])
75+
prefix_current_commit_message(git_repo)
76+
if dry_run:
77+
logger.info("Dry run. Skipping push into remote repository.")
78+
else:
79+
git_repo.run_cmd(["push", REMOTE_NAME, to_branch])
80+
logger.info("Merge successful")
81+
82+
83+
def create_pull_request(git_repo: Git, to_branch: str) -> None:
84+
logger.info("Creating Pull Request")
85+
log_output = git_repo.run_cmd(["log", "HEAD", "--max-count=1", "--pretty=format:%s"])
86+
pr_title = f"Automerge conflict: {log_output}"
87+
subprocess.run(
88+
["gh", "pr", "create", "--head", AUTOMERGE_BRANCH, "--base", to_branch, "--fill", "--title", pr_title],
89+
check=True,
90+
)
91+
92+
93+
def process_conflict(git_repo: Git, commit_hash: str, to_branch: str, dry_run: bool) -> None:
94+
logger.info("Processing conflict for %s", commit_hash)
95+
git_repo.run_cmd(["switch", "--force-create", AUTOMERGE_BRANCH, commit_hash])
96+
if dry_run:
97+
logger.info("Dry run, skipping push and creation of PR.")
98+
return
99+
git_repo.run_cmd(["push", REMOTE_NAME, AUTOMERGE_BRANCH])
100+
logger.info("Publishing Pull Request for conflict")
101+
create_pull_request(git_repo, to_branch)
102+
103+
104+
def get_merge_commit_list(git_repo: Git, from_branch: str, to_branch: str) -> list[str]:
105+
logger.info("Calculating list of commits to be merged from %s to %s", from_branch, to_branch)
106+
merge_base_output = git_repo.run_cmd(["merge-base", from_branch, to_branch])
107+
merge_base_commit = merge_base_output.strip()
108+
log_output = git_repo.run_cmd(["log", f"{merge_base_commit}..{from_branch}", "--pretty=format:%H"])
109+
commit_list = log_output.strip()
110+
if not commit_list:
111+
logger.info("No commits to be merged")
112+
return []
113+
commit_list = commit_list.split("\n")
114+
commit_list.reverse()
115+
logger.info("Found %d commits to be merged", len(commit_list))
116+
return commit_list
117+
118+
119+
def ensure_branch_exists(git_repo: Git, branch_name: str) -> None:
120+
try:
121+
git_repo.run_cmd(["rev-parse", "--verify", branch_name])
122+
except subprocess.CalledProcessError:
123+
git_repo.run_cmd(["remote", "set-branches", "--add", REMOTE_NAME, branch_name])
124+
125+
126+
def fetch_branch(git_repo: Git, branch_name: str) -> None:
127+
logger.info("Fetching '%s' branch from remote.", branch_name)
128+
ensure_branch_exists(git_repo, branch_name)
129+
git_repo.run_cmd(["fetch", REMOTE_NAME, f"{branch_name}:{branch_name}"])
130+
131+
132+
def pr_exist_for_label(project_name: str, label: str) -> bool:
133+
logger.info("Fetching list of open PRs for label '%s'.", label)
134+
gh_process = subprocess.run(
135+
["gh", "pr", "list", "--label", label, "--repo", project_name, "--json", "id"],
136+
check=True,
137+
capture_output=True,
138+
text=True,
139+
)
140+
return len(json.loads(gh_process.stdout)) > 0
141+
142+
143+
def main():
144+
arg_parser = argparse.ArgumentParser(
145+
prog="automerge",
146+
description="A script that automatically merges individual commits from one branch into another.",
147+
)
148+
arg_parser.add_argument(
149+
"--project-name",
150+
required=True,
151+
metavar="OWNER/REPO",
152+
help="The name of the project in GitHub.",
153+
)
154+
arg_parser.add_argument(
155+
"--from-branch",
156+
required=True,
157+
metavar="BRANCH_NAME",
158+
help="The branch where the incoming commits are found.",
159+
)
160+
arg_parser.add_argument(
161+
"--to-branch",
162+
required=True,
163+
metavar="BRANCH_NAME",
164+
help="The target branch for merging incoming commits",
165+
)
166+
arg_parser.add_argument(
167+
"--repo-path",
168+
metavar="PATH",
169+
default=Path.cwd(),
170+
help="The path to the existing local checkout of the repository (default: working directory)",
171+
)
172+
arg_parser.add_argument(
173+
"--dry-run",
174+
action="store_true",
175+
help="Process changes locally, but don't merge them into the remote repository and don't create PRs",
176+
)
177+
178+
args = arg_parser.parse_args()
179+
180+
try:
181+
if pr_exist_for_label(args.project_name, MERGE_CONFLICT_LABEL):
182+
logger.error("There are pending automerge PRs. Cannot continue.")
183+
sys.exit(1)
184+
logger.info("No pending merge conflicts. Proceeding with automerge.")
185+
186+
git_repo = Git(args.repo_path)
187+
188+
fetch_branch(git_repo, args.from_branch)
189+
fetch_branch(git_repo, args.to_branch)
190+
191+
merge_commits = get_merge_commit_list(git_repo, args.from_branch, args.to_branch)
192+
for commit_hash in merge_commits:
193+
merge_commit(git_repo, args.to_branch, commit_hash, args.dry_run)
194+
except MergeConflictError as conflict:
195+
process_conflict(
196+
git_repo,
197+
conflict.commit_hash,
198+
args.to_branch,
199+
args.dry_run,
200+
)
201+
except subprocess.CalledProcessError as error:
202+
logger.error(
203+
'Failed to run command: "%s"\nstdout:\n%s\nstderr:\n%s',
204+
" ".join(str(error.cmd)),
205+
error.stdout,
206+
error.stderr,
207+
)
208+
sys.exit(1)
209+
210+
211+
if __name__ == "__main__":
212+
main()

0 commit comments

Comments
 (0)