]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
script/ptl-tool: detect scrambled commits and clean up visualizer
authorPatrick Donnelly <pdonnell@ibm.com>
Sat, 11 Jul 2026 18:15:02 +0000 (14:15 -0400)
committerPatrick Donnelly <pdonnell@ibm.com>
Mon, 13 Jul 2026 00:49:30 +0000 (20:49 -0400)
Refactor the Commit Parity Visualizer generation to use a deterministic
ordered grouping loop instead of a state machine. Missing commits are now
reliably printed within their respective original PR blocks, and any extra
backport commits are isolated at the bottom of the table.

In addition, add automated checks to detect "scrambled" commits during
backports:
* Verify that commits from multiple upstream PRs are not interleaved or
  scrambled together.
* Verify that commits within an individual upstream PR are applied in
  their exact original chronological sequence.

If scrambled or out-of-order commits are detected, log a failure in CI mode
or prompt the user for interactive resolution to ensure clean backport
history and reduce regression risks.

Signed-off-by: Patrick Donnelly <pdonnell@ibm.com>
src/script/ptl-tool.py

index d5a2544b6af1b9a39353ce677c22f77746a6514e..178d42c8bfee4bffb826d979061d60e2b1f7de49 100755 (executable)
@@ -785,67 +785,59 @@ class CommitParityCheck(BaseAuditCheck):
             visualizer_md_lines.append(f"| BACKPORT PR #{pr} | SOURCE PR <picture><img width=\"120\" height=\"1\"></picture> | SOURCE STATUS |")
             visualizer_md_lines.append("|---|---|---|")
 
-            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)
             
-            current_pr = None
+            bp_commits_mapped = set()
+            bp_to_source = {}
+            for pr_name, commit_list in pr_mapping.items():
+                for item in commit_list:
+                    if item.get('bp_commit'):
+                        bp_to_source[item['bp_commit'].hexsha] = (str(pr_name), item)
+                        bp_commits_mapped.add(item['bp_commit'].hexsha)
+
+            ordered_prs = []
             for bp_c in pr_commits:
                 if bp_c.hexsha in bp_to_source:
-                    pr_name, item = bp_to_source[bp_c.hexsha]
-                    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))
-                                visualizer_md_lines.append(format_parity_row_md(None, None, m_sha, m_summary, is_missing=True, right_prefix=prefix))
-                            del unprinted_missing[current_pr]
-                        visualizer_lines.append("")
-                        
-                    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))
-                    visualizer_md_lines.append(format_parity_row_md(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))
-                                visualizer_md_lines.append(format_parity_row_md(None, None, m_sha, m_summary, is_missing=True, right_prefix=prefix))
-                            del unprinted_missing[current_pr]
-                        visualizer_lines.append("")
-                    current_pr = None
-                    visualizer_lines.append(format_parity_row(bp_c.hexsha, bp_c.summary, None, None, is_extra=True))
-                    visualizer_md_lines.append(format_parity_row_md(bp_c.hexsha, bp_c.summary, None, None, is_extra=True))
+                    p_name = bp_to_source[bp_c.hexsha]
+                    if p_name not in ordered_prs:
+                        ordered_prs.append(p_name)
+            for pr_name in sorted(list(pr_mapping.keys()), key=lambda x: str(x)):
+                p_name = str(pr_name)
+                if p_name not in ordered_prs:
+                    ordered_prs.append(p_name)
             
-            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))
-                    visualizer_md_lines.append(format_parity_row_md(None, None, m_sha, m_summary, is_missing=True, right_prefix=prefix))
-                del unprinted_missing[current_pr]
+            first_pr_block = True
+            for pr_name_str in ordered_prs:
+                dict_key = next((k for k in pr_mapping.keys() if str(k) == pr_name_str), None)
+                if dict_key is None:
+                    continue
                 
-            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))
-                    visualizer_md_lines.append(format_parity_row_md(None, None, m_sha, m_summary, is_missing=True, right_prefix=prefix))
-                    first = False
-    
+                if not first_pr_block:
+                    visualizer_lines.append("")
+                first_pr_block = False
+
+                first_commit_in_pr = True
+                for item in pr_mapping[dict_key]:
+                    prefix = f"{pr_name_str} " if first_commit_in_pr else " " * (len(pr_name_str) + 1)
+                    first_commit_in_pr = False
+
+                    bp_c = item.get('bp_commit')
+                    if bp_c:
+                        visualizer_lines.append(format_parity_row(bp_c.hexsha, bp_c.summary, item['o_sha'], item['o_summary'], right_prefix=prefix))
+                        visualizer_md_lines.append(format_parity_row_md(bp_c.hexsha, bp_c.summary, item['o_sha'], item['o_summary'], right_prefix=prefix))
+                    else:
+                        visualizer_lines.append(format_parity_row(None, None, item['o_sha'], item['o_summary'], is_missing=True, right_prefix=prefix))
+                        visualizer_md_lines.append(format_parity_row_md(None, None, item['o_sha'], item['o_summary'], is_missing=True, right_prefix=prefix))
+
+            extra_commits = [c for c in pr_commits if c.hexsha not in bp_commits_mapped]
+            if extra_commits:
+                if not first_pr_block:
+                    visualizer_lines.append("")
+                for bp_c in extra_commits:
+                    visualizer_lines.append(format_parity_row(bp_c.hexsha, bp_c.summary, None, None, is_extra=True))
+                    visualizer_md_lines.append(format_parity_row_md(bp_c.hexsha, bp_c.summary, None, None, is_extra=True))
+
             visualizer_lines.append("=" * 80)
             
             visualizer_text = "\n".join(visualizer_lines)
@@ -1010,6 +1002,69 @@ class CommitParityCheck(BaseAuditCheck):
         if vis_md:
             ctx.report.set_visualizer(vis_md)
 
+        # Check for scrambled commits relative to the original PR chronological sequences
+        bp_to_source = {}
+        for pr_name, commit_list in mapping['pr_mapping'].items():
+            for item in commit_list:
+                if item['bp_commit']:
+                    bp_to_source[item['bp_commit'].hexsha] = (str(pr_name), item)
+
+        mapped_sequence = []
+        for bp_c in ctx.pr_commits:
+            if bp_c.hexsha in bp_to_source:
+                pr_name_str, item = bp_to_source[bp_c.hexsha]
+                dict_key = next((k for k in mapping['pr_mapping'].keys() if str(k) == pr_name_str), None)
+                if dict_key is not None:
+                    orig_commits = [x['o_sha'] for x in mapping['pr_mapping'][dict_key]]
+                    if item['o_sha'] in orig_commits:
+                        idx = orig_commits.index(item['o_sha'])
+                        mapped_sequence.append((pr_name_str, idx))
+
+        is_scrambled = False
+        scramble_reasons = []
+        if mapped_sequence:
+            pr_blocks = [k for k, _ in itertools.groupby([x[0] for x in mapped_sequence])]
+            if len(pr_blocks) != len(set(pr_blocks)):
+                is_scrambled = True
+                scramble_reasons.append("Commits from different original PRs are interleaved/scrambled together.")
+
+            for pr_name in mapping['pr_mapping']:
+                pr_name_str = str(pr_name)
+                pr_bp_indices = [idx for name, idx in mapped_sequence if name == pr_name_str]
+                if pr_bp_indices != sorted(pr_bp_indices):
+                    is_scrambled = True
+                    scramble_reasons.append(f"Commits from {pr_name_str} are applied out of their original chronological order.")
+
+        if is_scrambled:
+            scramble_msg = "### Automated Backport Parity Review - Scrambled Commits Detected\n\n"
+            scramble_msg += "The backport contains commits that are scrambled or out of order relative to the original PR(s).\n\n"
+            for reason in scramble_reasons:
+                scramble_msg += f"* {reason}\n"
+            scramble_msg += "\nBackports should apply cherry-picks in the exact same chronological order as they were merged into the main branch to ensure clean history and avoid subtle regression risks.\n"
+
+            if ctx.args.ci_mode:
+                ctx.report.add("Scrambled Commits", scramble_msg)
+            else:
+                print("\033[91m" + "="*80)
+                print("WARNING: Scrambled commits detected in the backport!")
+                for reason in scramble_reasons:
+                    print(f"  - {reason}")
+                print("="*80 + "\033[0m")
+                while True:
+                    ans = input("Do you want to request changes for scrambled commits? [p/r/m/q] (p=proceed anyway, r=add to review, m=skip to merge, q=quit): ").strip().lower()
+                    if ans == 'm':
+                        raise SkipToMerge()
+                    elif ans == 'r':
+                        ctx.report.add("Scrambled Commits", scramble_msg)
+                        ctx.report.record_failure()
+                        break
+                    elif ans == 'q':
+                        sys.exit(1)
+                    elif ans == 'p':
+                        break
+                    else:
+                        print("Invalid choice. Please enter p, r, m, or q.")
+
         if invalid_format_commits:
             self._handle_invalid_formats(ctx, invalid_format_commits)