import argparse
import datetime
+import difflib
+import hashlib
from getpass import getuser
import itertools
import json
import os
import re
import signal
+import subprocess
import sys
+import tempfile
import textwrap
+import threading
+import webbrowser
MISSING_DEPS = []
TEST_BRANCH = os.getenv("PTL_TOOL_TEST_BRANCH", "wip-{user}-testing-%Y%m%d.%H%M%S")
USER = os.getenv("PTL_TOOL_USER", getuser())
+SANDBOX_CFG = ['rerere.enabled=false', 'commit.gpgSign=false', 'core.hooksPath=/dev/null']
+
log = logging.getLogger(__name__)
log.addHandler(logging.StreamHandler())
log.setLevel(logging.INFO)
link = response.headers.get('link', None)
page += 1
+def resolve_ref(G, ref, remote_url, always_fetch=False):
+ """
+ Attempts to resolve a git reference locally. If it fails, or if
+ always_fetch is True, it fetches the ref from the remote.
+ Returns the git.Commit object.
+ """
+ if not always_fetch:
+ try:
+ c = G.commit(ref)
+ log.debug(f"Resolved {ref} locally to {c.hexsha[:8]}.")
+ return c
+ except (git.exc.BadName, ValueError):
+ pass
+
+ # Check typical remote prefix fallbacks for branches
+ if not re.match(r'^[0-9a-f]{40}$', ref) and not ref.startswith('refs/'):
+ for local_alias in [f"upstream/{ref}", f"origin/{ref}"]:
+ try:
+ c = G.commit(local_alias)
+ log.debug(f"Resolved {ref} locally via {local_alias} to {c.hexsha[:8]}.")
+ return c
+ except (git.exc.BadName, ValueError):
+ pass
+
+ log.info(f"Fetching {ref} from {remote_url}")
+ try:
+ G.git.fetch(remote_url, ref)
+ return G.commit("FETCH_HEAD")
+ except git.exc.GitCommandError as e:
+ log.error(f"Could not fetch {ref} from {remote_url}: {e}")
+ sys.exit(1)
+
def get_credits(session, pr, pr_req):
comments = [pr_req]
return "\n".join(credits), new_new_contributors
+def format_parity_row(left_sha, left_msg, right_sha, right_msg, is_missing=False, is_extra=False, right_prefix=""):
+ """Helper to format visualizer rows."""
+ if is_missing:
+ left_col = "\033[91m<<<< MISSING IN BACKPORT >>>>\033[0m"
+ visible_left_len = 29
+ else:
+ l_msg = (left_msg[:25] + '...') if len(left_msg) > 28 else left_msg
+ left_col = f"[ {left_sha[:8]} ] {l_msg}"
+ visible_left_len = len(left_col)
+
+ if is_extra:
+ right_col = "\033[93m<<<< EXTRA IN BACKPORT >>>>\033[0m"
+ else:
+ r_msg = (right_msg[:20] + '...') if len(right_msg) > 23 else right_msg
+ right_col = f"{right_prefix}[ {right_sha[:8]} ] {r_msg}"
+
+ padding = max(1, 47 - visible_left_len)
+ return f"{left_col}{' ' * padding}{right_col}"
+
+def make_pipe(content, fds_to_close, threads):
+ """
+ Helper to create a pipe, write content to it in a background thread,
+ and return the path to the file descriptor.
+ """
+ r, w = os.pipe()
+ fds_to_close.append(r)
+
+ def writer():
+ try:
+ with os.fdopen(w, 'wb') as pipe_w:
+ pipe_w.write(content.encode('utf-8'))
+ except Exception:
+ pass
+
+ t = threading.Thread(target=writer)
+ t.start()
+ threads.append(t)
+ return f"/dev/fd/{r}", r
+
+def post_draft_review(session, pr, initial_text, base=None):
+ """
+ Opens an editor with the draft text, previews it, and prompts the user to
+ post it as a REQUEST_CHANGES review.
+ """
+ if base:
+ rfa_msg = f"\n\nWhen you are ready for this to be reviewed and QA'd again, please add the `pdonnell-{base}-RFA` label to this PR."
+ if rfa_msg not in initial_text:
+ initial_text += rfa_msg
+
+ editor = os.environ.get('EDITOR', 'vim')
+ with tempfile.NamedTemporaryFile(mode='w+', suffix='.md', delete=False) as tf:
+ tf.write(initial_text)
+ tf_path = tf.name
+
+ try:
+ while True:
+ subprocess.run([editor, tf_path])
+ with open(tf_path, 'r', encoding='utf-8') as f_read:
+ final_text = f_read.read().strip()
+
+ print("\n" + "="*80)
+ print("DRAFT REVIEW PREVIEW (REQUEST_CHANGES):")
+ print("-" * 80)
+ print(final_text)
+ print("="*80 + "\n")
+
+ confirm = input(f"Post this review requesting changes to PR #{pr}? [y/e/N] (y=post, e=edit again, n=cancel): ").strip().lower()
+ if confirm == 'y':
+ endpoint = f"https://api.github.com/repos/{BASE_PROJECT}/{BASE_REPO}/pulls/{pr}/reviews"
+ r = session.post(endpoint, auth=gitauth(), json={'body': final_text, 'event': 'REQUEST_CHANGES'})
+ if r.status_code in (200, 201):
+ log.info(f"Successfully posted review to PR #{pr}")
+ return True
+ else:
+ log.error(f"Failed to post review: {r.status_code} {r.text}")
+ return False
+ elif confirm == 'e':
+ continue
+ else:
+ print("Review cancelled.")
+ return False
+ finally:
+ os.unlink(tf_path)
+
+def verify_commit_parity(G, session, pr, pr_commits, base):
+ """
+ Analyzes the local Git DAG to ensure all commits from the original main PRs
+ are present in the backport PR.
+ """
+ log.info("Verifying commit parity with original PR(s) locally...")
+ bp_cherry_picks = []
+ invalid_format_commits = []
+ cp_regex = re.compile(r"\(cherry picked from commit ([0-9a-f]{40})\)")
+ for commit in pr_commits:
+ m = cp_regex.search(commit.message)
+ is_cherry_pick = bool(m)
+ if not is_cherry_pick and not commit.summary.startswith(f"{base}:"):
+ invalid_format_commits.append(commit)
+
+ if is_cherry_pick:
+ bp_cherry_picks.append((commit, m.group(1)))
+
+ missing_commits = []
+ found_prs = set()
+ bp_orig_shas = set(orig_sha for _, orig_sha in bp_cherry_picks)
+ analyzed_merges = set()
+ pr_mapping = {}
+ bp_commits_mapped = set()
+
+ valid_ref = None
+ for ref in ['main', 'origin/main', 'upstream/main']:
+ try:
+ G.git.rev_parse('--verify', ref)
+ valid_ref = ref
+ break
+ except git.exc.GitCommandError:
+ pass
+
+ if valid_ref:
+ for commit, orig_sha in bp_cherry_picks:
+ try:
+ # Find merge commit using intersection of ancestry-path and first-parent
+ ancestry = G.git.rev_list(f"{orig_sha}..{valid_ref}", '--ancestry-path').splitlines()
+ first_parent = G.git.rev_list(f"{orig_sha}..{valid_ref}", '--first-parent').splitlines()
+
+ first_parent_set = set(first_parent)
+ merge_sha = None
+ for c in reversed(ancestry):
+ if c in first_parent_set:
+ merge_sha = c
+ break
+
+ if merge_sha and merge_sha not in analyzed_merges:
+ analyzed_merges.add(merge_sha)
+ # Extract the original PR commits using merge parents (merge^1..merge^2)
+ orig_pr_commits = G.git.rev_list(f"{merge_sha}^1..{merge_sha}^2").splitlines()
+ orig_pr_commits.reverse() # chronological
+
+ merge_msg = G.commit(merge_sha).summary
+ m_pr = re.search(r'(?:Merge PR|Merge pull request) #(\d+)', merge_msg, re.IGNORECASE)
+ pr_name = f"PR #{m_pr.group(1)}" if m_pr else f"Merge {merge_sha[:8]}"
+ found_prs.add(pr_name)
+ pr_mapping[pr_name] = []
+
+ for o_commit_sha in orig_pr_commits:
+ o_summary = G.commit(o_commit_sha).summary
+ bp_match = next((c for c, o_sha in bp_cherry_picks if o_sha == o_commit_sha), None)
+
+ pr_mapping[pr_name].append({
+ 'o_sha': o_commit_sha,
+ 'o_summary': o_summary,
+ 'bp_commit': bp_match,
+ 'm_sha': merge_sha
+ })
+
+ if bp_match:
+ bp_commits_mapped.add(bp_match.hexsha)
+ else:
+ missing_commits.append((pr_name, o_commit_sha, o_summary, merge_sha))
+ except git.exc.GitCommandError:
+ log.debug(f"Local DAG traversal skipped/failed for {orig_sha[:8]}")
+ else:
+ log.warning("Could not find local main/upstream ref to perform DAG parity check.")
+
+ if found_prs:
+ log.info(f"Original PRs identified in this backport: {', '.join(found_prs)}")
+ if len(found_prs) > 1:
+ print("\033[91m" + "="*80)
+ print("WARNING: Multiple original PRs detected in this backport!")
+ print("Normally we expect exactly one main PR per backport.")
+ print("Detected: " + ", ".join(found_prs))
+ print("="*80 + "\033[0m")
+
+ # Print Visualizer UI
+ visualizer_text = ""
+ visualizer_lines = []
+ if found_prs or pr_commits:
+ visualizer_lines.append("=" * 80)
+ visualizer_lines.append("COMMIT PARITY VISUALIZER")
+ visualizer_lines.append("=" * 80)
+
+ bp_to_source = {}
+ for pr_name, commit_list in pr_mapping.items():
+ for item in commit_list:
+ if item['bp_commit']:
+ bp_to_source[item['bp_commit'].hexsha] = (pr_name, item)
+
+ unprinted_missing = {}
+ for pr_name, o_sha, o_summary, m_sha in missing_commits:
+ if pr_name not in unprinted_missing:
+ unprinted_missing[pr_name] = []
+ unprinted_missing[pr_name].append((o_sha, o_summary))
+
+ visualizer_lines.append(f"BACKPORT PR #{pr}".ljust(47) + "SOURCE PR / STATUS")
+ visualizer_lines.append("-" * 80)
+
+ # Print all backport commits exactly in chronological order
+ current_pr = None
+ for bp_c in pr_commits:
+ if bp_c.hexsha in bp_to_source:
+ pr_name, item = bp_to_source[bp_c.hexsha]
+
+ # If PR block changes, flush the missing commits for the previous block
+ if current_pr is not None and pr_name != current_pr:
+ if current_pr in unprinted_missing:
+ for m_sha, m_summary in unprinted_missing[current_pr]:
+ prefix = " " * (len(current_pr) + 1)
+ visualizer_lines.append(format_parity_row(None, None, m_sha, m_summary, is_missing=True, right_prefix=prefix))
+ del unprinted_missing[current_pr]
+ visualizer_lines.append("") # Add visual grouping space between different PRs
+
+ prefix = f"{pr_name} " if pr_name != current_pr else " " * (len(pr_name) + 1)
+ current_pr = pr_name
+ visualizer_lines.append(format_parity_row(bp_c.hexsha, bp_c.summary, item['o_sha'], item['o_summary'], right_prefix=prefix))
+ else:
+ if current_pr is not None:
+ if current_pr in unprinted_missing:
+ for m_sha, m_summary in unprinted_missing[current_pr]:
+ prefix = " " * (len(current_pr) + 1)
+ visualizer_lines.append(format_parity_row(None, None, m_sha, m_summary, is_missing=True, right_prefix=prefix))
+ del unprinted_missing[current_pr]
+ visualizer_lines.append("")
+ current_pr = None # Reset so the next PR prints its name
+ visualizer_lines.append(format_parity_row(bp_c.hexsha, bp_c.summary, None, None, is_extra=True))
+
+ # Flush any remaining missing commits for the last processed PR block
+ if current_pr is not None and current_pr in unprinted_missing:
+ for m_sha, m_summary in unprinted_missing[current_pr]:
+ prefix = " " * (len(current_pr) + 1)
+ visualizer_lines.append(format_parity_row(None, None, m_sha, m_summary, is_missing=True, right_prefix=prefix))
+ del unprinted_missing[current_pr]
+
+ # Flush any missing commits for PRs that had NO commits successfully mapped
+ for pr_name, missing_list in unprinted_missing.items():
+ visualizer_lines.append("")
+ first = True
+ for m_sha, m_summary in missing_list:
+ prefix = f"{pr_name} " if first else " " * (len(pr_name) + 1)
+ visualizer_lines.append(format_parity_row(None, None, m_sha, m_summary, is_missing=True, right_prefix=prefix))
+ first = False
+
+ visualizer_lines.append("=" * 80)
+
+ visualizer_text = "\n".join(visualizer_lines)
+ print(visualizer_text)
+
+ for commit in invalid_format_commits:
+ log.error(f"Commit {commit.hexsha[:8]} invalid format. Must be cherry-pick or start with '{base}:'")
+ ans = input("Do you want to allow this commit anyway? [y/N] ")
+ if ans.lower() != 'y':
+ sys.exit(1)
+
+ visualizer_clean = re.sub(r'\033\[[0-9;]*m', '', visualizer_text) if visualizer_text else ""
+ if missing_commits:
+ # Generate markdown comment draft
+ md_text = f"**Automated Backport Parity Review**\n\n"
+ if len(found_prs) > 1:
+ md_text += f":warning: **Multiple Original PRs Detected:** {', '.join(found_prs)}\n\n"
+ md_text += "Discrepancies detected between the backport PR and the original source PR(s). Please review the mapping below:\n\n"
+ md_text += f"```text\n{visualizer_clean}\n```"
+
+ while True:
+ ans = input("Parity mismatch! [p]roceed, [o]pen browser to investigate, [r]eview PR (request changes), [q]uit: ").strip().lower()
+ if ans == 'p':
+ break
+ elif ans == 'q':
+ log.error("Rejecting PR due to incomplete backport.")
+ sys.exit(1)
+ elif ans == 'r':
+ if post_draft_review(session, pr, md_text, base=base):
+ log.error("Rejecting PR due to incomplete backport after posting review.")
+ sys.exit(1)
+ elif ans == 'o':
+ first_url = True
+ for pr_name, o_sha, o_summary, m_sha in missing_commits:
+ urls_to_open = []
+ m_pr = re.search(r'#(\d+)', pr_name)
+ if m_pr:
+ urls_to_open.append(f"https://github.com/{BASE_PROJECT}/{BASE_REPO}/pull/{m_pr.group(1)}")
+ urls_to_open.append(f"https://github.com/{BASE_PROJECT}/{BASE_REPO}/commit/{m_sha}")
+ urls_to_open.append(f"https://github.com/{BASE_PROJECT}/{BASE_REPO}/commit/{o_sha}")
+
+ for url in urls_to_open:
+ if first_url:
+ webbrowser.open_new(url)
+ first_url = False
+ else:
+ webbrowser.open_new_tab(url)
+ print("Opened URLs in a new browser window.")
+ else:
+ print("Invalid choice. Please enter p, o, r, or q.")
+ elif analyzed_merges:
+ print("\033[92mCommit parity check passed! All upstream commits from identified PRs are present.\033[0m")
+
+ if len(found_prs) > 1:
+ while True:
+ ans = input("Multiple original PRs detected! Do you want to post a review requesting documentation? [y/N/o] (y=yes, n=no, o=open PRs in browser): ").strip().lower()
+ if ans == 'o':
+ url = f"https://github.com/{BASE_PROJECT}/{BASE_REPO}/pull/{pr}"
+ webbrowser.open_new(url)
+ print(f"Opened {url} in browser.")
+ for pr_str in found_prs:
+ m_pr = re.search(r'#(\d+)', pr_str)
+ if m_pr:
+ orig_url = f"https://github.com/{BASE_PROJECT}/{BASE_REPO}/pull/{m_pr.group(1)}"
+ webbrowser.open_new_tab(orig_url)
+ print(f"Opened {orig_url} in browser.")
+ elif ans == 'y':
+ md_text = f"**Automated Backport Parity Review - Multiple PRs Detected**\n\n"
+ md_text += f"This backport appears to pull commits from multiple `main` PRs including: {', '.join(found_prs)}.\n\n"
+ md_text += "This must be explicitly documented in the backport PR description. Furthermore, each backport tracker ticket associated with these `main` PRs must be linked to this PR.\n\n"
+ md_text += f"**Commit Parity Visualizer:**\n```text\n{visualizer_clean}\n```\n"
+ if post_draft_review(session, pr, md_text, base=base):
+ log.error("Rejecting PR pending documentation of multiple original PRs.")
+ sys.exit(1)
+ break
+ else:
+ break
+
+ return visualizer_text
+
+def simulate_conflict_resolution(G, session, pr, pr_commits, base, always_fetch, visualizer_text):
+ """
+ Creates a temporary worktree to dry-run the cherry-pick sequence, verifying
+ conflict resolutions dynamically.
+ """
+ wt_dir = tempfile.mkdtemp(prefix="ptl-worktree-")
+ try:
+ G.git.worktree('add', '--detach', wt_dir, base)
+ wt_repo = git.Repo(wt_dir)
+
+ cp_regex = re.compile(r"\(cherry picked from commit ([0-9a-f]{40})\)")
+ auto_approve_conflicts = False
+ first_conflict = True
+
+ for commit in pr_commits:
+ if len(commit.parents) > 1:
+ log.error(f"Commit {commit.hexsha[:8]} is a merge commit. Not allowed.")
+ sys.exit(1)
+
+ m = cp_regex.search(commit.message)
+ is_cherry_pick = bool(m)
+
+ if is_cherry_pick:
+ orig_sha = m.group(1)
+ log.info(f"Simulating cherry-pick of {orig_sha[:8]} for {commit.hexsha[:8]} ...")
+ c = resolve_ref(wt_repo, orig_sha, BASE_REMOTE_URL, always_fetch)
+
+ has_conflict = False
+ clean_tree = None
+ try:
+ wt_repo.git(c=SANDBOX_CFG).cherry_pick(c.hexsha)
+ clean_tree = wt_repo.head.commit.tree.hexsha
+ wt_repo.git.reset('--hard', 'HEAD~1')
+ except git.exc.GitCommandError:
+ has_conflict = True
+ unmerged = wt_repo.git.diff('--name-only', '--diff-filter=U').splitlines()
+ wt_repo.git.cherry_pick('--abort')
+
+ # Always apply the backport commit so the worktree matches the PR sequence
+ try:
+ wt_repo.git(c=SANDBOX_CFG).cherry_pick("--allow-empty", commit.hexsha)
+ except git.exc.GitCommandError:
+ log.error(f"Failed to apply backport commit {commit.hexsha[:8]}! The PR likely has conflicts with the base branch and needs a rebase.")
+ wt_repo.git.cherry_pick('--abort')
+ sys.exit(1)
+
+ backport_tree = wt_repo.head.commit.tree.hexsha
+
+ is_sneaky = (not has_conflict) and (clean_tree != backport_tree)
+
+ if has_conflict or is_sneaky:
+ if auto_approve_conflicts:
+ log.info(f"Skipping approval and taking backport commit for {orig_sha[:8]}.")
+ continue
+
+ if first_conflict:
+ ans = input(f"Conflict or unapproved deviation detected in {c.hexsha[:8]}. Do you want to interactively review this PR? [Y/n/m]\n(y = yes, n = auto-approve remaining, m = skip to merge) ")
+ first_conflict = False
+ if ans.lower() == 'm':
+ log.info("Skipping ahead to merge.")
+ return
+ elif ans.lower() == 'n':
+ log.info("Auto-approving and skipping interactive checks for this PR.")
+ auto_approve_conflicts = True
+ continue
+
+ editor = os.environ.get('EDITOR', 'vim')
+ if has_conflict:
+ log.warning(f"Opening {editor} to inspect conflict in {c.hexsha[:8]} ...")
+ else:
+ log.warning(f"Opening {editor} to inspect unexplained deviation in clean cherry-pick {c.hexsha[:8]} ...")
+ unmerged = wt_repo.git.diff('--name-only', clean_tree, backport_tree).splitlines()
+ for f in unmerged:
+ log.debug("Unmerged: %s", f)
+ if unmerged:
+ # Generate a diff of the commit messages
+ orig_msg = c.message.splitlines(keepends=True)
+ bp_msg = commit.message.splitlines(keepends=True)
+ msg_diff = "".join(difflib.unified_diff(orig_msg, bp_msg,
+ fromfile=f'Original ({c.hexsha[:8]})',
+ tofile=f'Backport ({commit.hexsha[:8]})'))
+
+ # Generate git range-diff to highlight changes between the original patch and backport
+ try:
+ diff = wt_repo.git.range_diff(f"{c.hexsha}^!", f"{commit.hexsha}^!")
+ except git.exc.GitCommandError as e:
+ log.warning(f"Could not generate range-diff: {e}")
+ diff = f"Could not generate range-diff:\n{e}"
+
+ # Concatenate with a marker
+ diff = f"{msg_diff}\n" + "="*80 + "\nRANGE DIFF\n" + "="*80 + "\n\n" + diff
+
+ # Present file-specific 3-pane view (range-diff, orig patch, backport patch)
+ for f in unmerged:
+ try:
+ file_diff = f"Conflict in {f}:\n\n" + diff
+ orig_patch = wt_repo.git.show(c.hexsha, "--", f)
+ bp_patch = wt_repo.git.show(commit.hexsha, "--", f)
+
+ if orig_patch == "":
+ orig_patch = "(file not found or empty)"
+ if bp_patch == "":
+ bp_patch = "(file not found or empty)"
+
+ fds_to_close = []
+ threads = []
+
+ cmd = [editor]
+ if any(ed in editor for ed in ['vim', 'nvim', 'vi']):
+ cmd.append('-O')
+
+ pass_fds = []
+ if file_diff:
+ path, r_fd = make_pipe(file_diff, fds_to_close, threads)
+ cmd.append(path)
+ pass_fds.append(r_fd)
+
+ path1, r_fd1 = make_pipe(orig_patch, fds_to_close, threads)
+ cmd.append(path1)
+ pass_fds.append(r_fd1)
+
+ path2, r_fd2 = make_pipe(bp_patch, fds_to_close, threads)
+ cmd.append(path2)
+ pass_fds.append(r_fd2)
+
+ subprocess.run(cmd, pass_fds=pass_fds)
+
+ for t in threads:
+ t.join()
+ for fd in fds_to_close:
+ os.close(fd)
+ except git.exc.GitCommandError as e:
+ log.warning(f"Could not generate patch comparison for {f}: {e}")
+
+ prompt_text = "Does the PR properly explain and resolve this conflict/deviation? [y/N/s/m]\n(y = next check, n = abort, s = skip remaining checks, m = skip to merge) "
+ ans = input(prompt_text)
+ if ans.lower() == 'm':
+ log.info("Skipping ahead to merge.")
+ return
+ elif ans.lower() == 's':
+ log.info("Skipping remaining checks for this PR.")
+ auto_approve_conflicts = True
+ elif ans.lower() == 'y':
+ pass # Already applied backport commit
+ else:
+ log.error("Rejecting PR due to undocumented/unapproved change. Preparing draft review...")
+ visualizer_clean = re.sub(r'\033\[[0-9;]*m', '', visualizer_text) if visualizer_text else ""
+ diff_text = diff if 'diff' in locals() else "No range diff available."
+
+ md_text = "**Automated Backport Parity Review - Backport Deviation Alert**\n\n"
+ md_text += "A conflict or unapproved deviation was detected during the simulation of this backport, and the changes appear to be incorrect or undocumented. Please review the highlighted files.\n\n"
+ md_text += "**Affected File(s):**\n"
+ for f_name in unmerged:
+ f_hash = hashlib.sha256(f_name.encode('utf-8')).hexdigest()
+ md_text += f"* `{f_name}`\n"
+ md_text += f" * Original: https://github.com/{BASE_PROJECT}/{BASE_REPO}/commit/{c.hexsha}#diff-{f_hash}\n"
+ md_text += f" * Backport: https://github.com/{BASE_PROJECT}/{BASE_REPO}/commit/{commit.hexsha}#diff-{f_hash}\n"
+
+ if visualizer_clean:
+ md_text += f"\n**Commit Parity Visualizer:**\n```text\n{visualizer_clean}\n```\n\n"
+
+ md_text += f"**Range Diff:**\n<details><summary>Click to expand</summary>\n\n```diff\n{diff_text}\n```\n</details>\n\n"
+
+ post_draft_review(session, pr, md_text)
+ sys.exit(1)
+ else:
+ log.info(f"Applying branch-specific commit {commit.hexsha[:8]} ...")
+ wt_repo.git(c=SANDBOX_CFG).cherry_pick("--allow-empty", commit.hexsha)
+ log.info("Verification of backport cherry-pick for PR #%d is complete.", pr)
+ finally:
+ log.info("Removing temporary worktree `%s'", wt_dir)
+ G.git.worktree('remove', '--force', wt_dir)
+
def build_branch(args):
base = args.base
branch = datetime.datetime.utcnow().strftime(args.branch).format(user=USER)
G = git.Repo(args.git)
try:
- log.info("Fetching .githubmap from %s main branch", BASE_REMOTE_URL)
- G.git.fetch(BASE_REMOTE_URL, "main")
- githubmap_content = G.git.show("FETCH_HEAD:.githubmap")
+ c = resolve_ref(G, 'main', BASE_REMOTE_URL, args.always_fetch)
+ githubmap_content = G.git.show(f"{c.hexsha}:.githubmap")
comment = re.compile(r"\s*#")
patt = re.compile(r"([\w-]+)\s+(.*)")
for line in githubmap_content.splitlines():
prs.append(n)
log.info("Will merge PRs: {}".format(prs))
+ # PRE-FLIGHT: Auto-detect base from the first PR if necessary and cache responses
+ pr_responses = {}
+ for pr in prs:
+ log.info("Fetching information for PR #%d", pr)
+ endpoint = f"https://api.github.com/repos/{BASE_PROJECT}/{BASE_REPO}/pulls/{pr}"
+ pr_responses[pr] = next(get(session, endpoint, paging=False))
+
+ if prs and base is None:
+ for pr in prs:
+ detected_base = pr_responses[pr].get("base", {}).get("ref")
+ log.debug("Detected base for PR #%d as `%s'", pr, detected_base)
+ if detected_base:
+ log.info(f"Auto-detected target base from PR #{pr}: {detected_base}")
+ base = detected_base
+ if args.merge_branch_name is False:
+ merge_branch_name = detected_base
+ else:
+ if base != detected_base:
+ raise SystemExit("base of each PR is not equal, use hard-coded --base")
+
if base == 'HEAD':
log.info("Branch base is HEAD; not checking out!")
else:
log.info("Detaching HEAD onto base: {}".format(base))
try:
- G.git.fetch(BASE_REMOTE_URL, base)
- # So we know that we're not on an old test branch, detach HEAD onto ref:
- c = G.commit('FETCH_HEAD')
+ c = resolve_ref(G, base, BASE_REMOTE_URL, args.always_fetch)
+ G.git.checkout(c)
except git.exc.GitCommandError:
- log.debug("could not fetch %s from %s", base, BASE_REMOTE_URL)
log.info(f"Trying to checkout uninterpreted base {base}")
c = G.commit(base)
- G.git.checkout(c)
+ G.git.checkout(c)
+
+ # So we know that we're not on an old test branch, detach HEAD onto ref:
assert G.head.is_detached
qa_tracker_description = []
log.info("Merging PR #{pr}".format(pr=pr))
remote_ref = "refs/pull/{pr}/head".format(pr=pr)
- try:
- G.git.fetch(BASE_REMOTE_URL, remote_ref)
- except git.exc.GitCommandError:
- log.error("could not fetch %s from %s", remote_ref, BASE_REMOTE_URL)
- sys.exit(1)
- tip = G.commit("FETCH_HEAD")
+ remote_sha1 = pr_responses[pr].get('head', {}).get('sha')
+
+ ref_to_fetch = remote_sha1 if remote_sha1 else remote_ref
+ tip = resolve_ref(G, ref_to_fetch, BASE_REMOTE_URL, args.always_fetch)
+ log.info("Now have head for PR #%d: %s", pr, str(tip))
- endpoint = f"https://api.github.com/repos/{BASE_PROJECT}/{BASE_REPO}/pulls/{pr}"
- response = next(get(session, endpoint, paging=False))
+ response = pr_responses[pr]
+
+ log.info("Performing trivial merge check for PR #%d...", pr)
+ wt_dir = tempfile.mkdtemp(prefix="ptl-merge-check-")
+ has_base_conflicts = False
+ try:
+ G.git.worktree('add', '--detach', wt_dir, G.head.commit)
+ wt_repo = git.Repo(wt_dir)
+ try:
+ wt_repo.git(c=SANDBOX_CFG).merge(tip.hexsha, '--no-commit', '--no-ff')
+ except git.exc.GitCommandError:
+ has_base_conflicts = True
+ finally:
+ try:
+ wt_repo.git.merge('--abort')
+ except git.exc.GitCommandError:
+ pass
+ finally:
+ G.git.worktree('remove', '--force', wt_dir)
+
+ if has_base_conflicts:
+ log.error(f"PR #{pr} has conflicts with the target base branch.")
+ while True:
+ ans = input(f"PR #{pr} needs a rebase! Do you want to post a review requesting a rebase? [y/N/o] (y=yes, n=abort script, o=open PR in browser): ").strip().lower()
+ if ans == 'o':
+ url = f"https://github.com/{BASE_PROJECT}/{BASE_REPO}/pull/{pr}"
+ webbrowser.open_new(url)
+ print(f"Opened {url} in browser.")
+ elif ans == 'y':
+ md_text = "**Automated PR Review - Rebase Required**\n\n"
+ md_text += f"This PR currently has merge conflicts with the target base branch. Please rebase and resolve the conflicts."
+ if post_draft_review(session, pr, md_text):
+ log.error("Rejecting PR pending rebase.")
+ sys.exit(1)
+ elif ans == 'n' or ans == '':
+ log.error(f"Aborting script due to unmergeable PR #{pr}.")
+ sys.exit(1)
qa_tracker_description.append(f'* "PR #{pr}":{response["html_url"]} -- {response["title"].strip()}')
message = "Merge PR #%d into %s\n\n* %s:\n" % (pr, merge_branch_name, remote_ref)
- for commit in G.iter_commits(rev="HEAD.."+str(tip)):
- message = message + ("\t%s\n" % commit.message.split('\n', 1)[0])
+ pr_commits = list(G.iter_commits(rev="HEAD.."+str(tip)))
+ pr_commits.reverse() # chronological order for simulation
+
+ if base != 'main':
+ visualizer_text = verify_commit_parity(G, session, pr, pr_commits, base)
+ if not args.skip_conflict_check:
+ simulate_conflict_resolution(G, session, pr, pr_commits, base, args.always_fetch, visualizer_text)
+
+ if args.audit:
+ log.info(f"Audit of PR #{pr} complete. Skipping merge.")
+ continue
+
+ for commit in reversed(pr_commits): # back to reverse-chronological for the message
+ message = message + ("\t%s\n" % commit.summary)
# Get tracker issues / bzs cited so the PTL can do updates
short = commit.hexsha[:8]
for m in BZ_MATCH.finditer(commit.message):
log.info("[ {sha1} ] Ceph tracker cited: {cite}".format(sha1=short, cite=m.group(1)))
message = message + "\n"
+
if args.credits:
(addendum, new_contributors) = get_credits(session, pr, response)
message += addendum
sys.exit(1)
log.info("Labeled PR #{pr} {label}".format(pr=pr, label=label))
+ if args.audit:
+ log.info("Audit complete. Exiting without modifying branches or Redmine.")
+ return
+
message = """
ceph-dev-pipeline: configure
endpoint = f"https://api.github.com/repos/{BASE_PROJECT}/{BASE_REPO}/issues/{pr}/comments"
body = f"This PR is under test in [{issue.url}]({issue.url})."
r = session.post(endpoint, auth=gitauth(), data=json.dumps({'body':body}))
- log.debug(f"= {r}")
+ if r.status_code == 201:
+ log.info(f"Successfully posted comment to PR #{pr}")
+ else:
+ log.error(f"Failed to post comment: {r.status_code} {r.text}")
class SplitCommaAppendAction(argparse.Action):
"""
def main():
parser = argparse.ArgumentParser(description="Ceph PTL tool")
- default_base = 'main'
default_branch = TEST_BRANCH
default_label = ''
- if len(sys.argv) > 1 and sys.argv[1] in SPECIAL_BRANCHES:
- argv = sys.argv[2:]
- default_branch = 'HEAD' # Leave HEAD detached
- default_base = default_branch
- default_label = False
- else:
- argv = sys.argv[1:]
+ argv = sys.argv[1:]
group = parser.add_argument_group('General Options')
group.add_argument('--debug', dest='debug', action='store_true', help='turn debugging on')
group.add_argument('--pr-label', dest='pr_label', action='store', help='source PRs to merge via label')
group = parser.add_argument_group('Branch Control Options')
- group.add_argument('--base', dest='base', action='store', default=default_base, help='base for branch')
+ group.add_argument('--always-fetch', dest='always_fetch', action='store_true', help='always fetch commits from remote (bypass local cache)')
+ group.add_argument('--base', dest='base', action='store', help='base for branch')
group.add_argument('--branch', dest='branch', action='store', default=default_branch, help='branch to create ("HEAD" leaves HEAD detached; i.e. no branch is made)')
+ group.add_argument('--release-merge', dest='release_merge', action='store_true', help='enable release merge behavior (implies --branch HEAD)')
group.add_argument('--branch-name-append', dest='branch_append', action='store', help='append string to branch name')
group.add_argument('--branch-release', dest='branch_release', action='store', help='release name to embed in branch (for shaman)')
group.add_argument('--merge-branch-name', dest='merge_branch_name', action='store', default=False, help='name of the branch for merge messages')
group.add_argument('--no-push-ci', dest='no_push_ci', action='store_true', help='don\'t push branch to ceph-ci repo (when making QA tickets)')
group.add_argument('--push-ci', dest='push_ci', action='store_true', help='push branch and tag to CI repository (even when not making QA tickets)')
+ group = parser.add_argument_group('Backport Verification')
+ group.add_argument('--audit', dest='audit', action='store_true', help='run parity and conflict simulations without merging or modifying branches')
+ group.add_argument('--skip-conflict-check', dest='skip_conflict_check', action='store_true', help='skip conflict resolution simulation')
+
def parse_pr(value):
m = re.search(r'/pull/(\d+)', value)
if m:
if args.debug_build:
args.flavors.add('debug')
+ if args.release_merge:
+ args.branch = 'HEAD'
+
if not GITHUB_TOKEN:
log.error("Missing GitHub Personal Access Token.")
log.error("Please create a file at ~/.github_token containing your token,")