From 41b04d56a71d2b3f78a1931931ca8b3af3d82663 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 15 Jul 2025 16:31:03 -0500 Subject: [PATCH 1/4] feat: enhance conflict prediction workflow with PR comments - Modified handle_potential_conflicts.py to output structured data via GitHub Actions outputs - Added PR comment functionality to predict-conflicts.yml workflow - Comments are updateable (sticky) using message-id to prevent duplicates - Shows list of conflicting PRs with links when conflicts are detected - Removes comment when no conflicts exist - Improved error handling and validation in Python script Co-Authored-By: Claude --- .../workflows/handle_potential_conflicts.py | 129 +++++++++++++++--- .github/workflows/predict-conflicts.yml | 28 ++++ 2 files changed, 137 insertions(+), 20 deletions(-) diff --git a/.github/workflows/handle_potential_conflicts.py b/.github/workflows/handle_potential_conflicts.py index ffdae440d85b..34e28ae3db1e 100755 --- a/.github/workflows/handle_potential_conflicts.py +++ b/.github/workflows/handle_potential_conflicts.py @@ -19,59 +19,148 @@ """ import sys +import os +import json +import uuid import requests # need to install via pip -import hjson +try: + import hjson +except ImportError: + print("Error: hjson module not found. Please install it with: pip install hjson", file=sys.stderr) + sys.exit(1) def get_pr_json(pr_num): - return requests.get(f'https://api.github.com/repos/dashpay/dash/pulls/{pr_num}').json() + try: + response = requests.get(f'https://api.github.com/repos/dashpay/dash/pulls/{pr_num}') + response.raise_for_status() + return response.json() + except requests.RequestException as e: + print(f"Error fetching PR {pr_num}: {e}", file=sys.stderr) + sys.exit(1) + except json.JSONDecodeError as e: + print(f"Error parsing JSON for PR {pr_num}: {e}", file=sys.stderr) + sys.exit(1) + +def set_github_output(name, value): + """Set GitHub Actions output""" + if 'GITHUB_OUTPUT' not in os.environ: + print(f"Warning: GITHUB_OUTPUT not set, skipping output: {name}={value}", file=sys.stderr) + return + + try: + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + # For multiline values, use the delimiter syntax + if '\n' in str(value): + delimiter = f"EOF_{uuid.uuid4()}" + f.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n") + else: + f.write(f"{name}={value}\n") + except IOError as e: + print(f"Error writing to GITHUB_OUTPUT: {e}", file=sys.stderr) def main(): if len(sys.argv) != 2: print(f'Usage: {sys.argv[0]} ', file=sys.stderr) sys.exit(1) - input = sys.argv[1] - print(input) - j_input = hjson.loads(input) - print(j_input) + conflict_input = sys.argv[1] + print(f"Debug: Input received: {conflict_input}", file=sys.stderr) + + try: + j_input = hjson.loads(conflict_input) + except Exception as e: + print(f"Error parsing input JSON: {e}", file=sys.stderr) + sys.exit(1) + + print(f"Debug: Parsed input: {j_input}", file=sys.stderr) + # Validate required fields + if 'pull_number' not in j_input: + print("Error: 'pull_number' field missing from input", file=sys.stderr) + sys.exit(1) + if 'conflictPrs' not in j_input: + print("Error: 'conflictPrs' field missing from input", file=sys.stderr) + sys.exit(1) our_pr_num = j_input['pull_number'] - our_pr_label = get_pr_json(our_pr_num)['head']['label'] - conflictPrs = j_input['conflictPrs'] + our_pr_json = get_pr_json(our_pr_num) + + if 'head' not in our_pr_json or 'label' not in our_pr_json['head']: + print(f"Error: Invalid PR data structure for PR {our_pr_num}", file=sys.stderr) + sys.exit(1) + + our_pr_label = our_pr_json['head']['label'] + conflict_prs = j_input['conflictPrs'] good = [] bad = [] + conflict_details = [] + + for conflict in conflict_prs: + if 'number' not in conflict: + print("Warning: Skipping conflict entry without 'number' field", file=sys.stderr) + continue - for conflict in conflictPrs: conflict_pr_num = conflict['number'] - print(conflict_pr_num) + print(f"Debug: Checking PR #{conflict_pr_num}", file=sys.stderr) conflict_pr_json = get_pr_json(conflict_pr_num) + + if 'head' not in conflict_pr_json or 'label' not in conflict_pr_json['head']: + print(f"Warning: Invalid PR data structure for PR {conflict_pr_num}, skipping", file=sys.stderr) + continue + conflict_pr_label = conflict_pr_json['head']['label'] - print(conflict_pr_label) + print(f"Debug: PR #{conflict_pr_num} label: {conflict_pr_label}", file=sys.stderr) + + if conflict_pr_json.get('mergeable_state') == "dirty": + print(f'PR #{conflict_pr_num} needs rebase. Skipping conflict check', file=sys.stderr) + continue - if conflict_pr_json['mergeable_state'] == "dirty": - print(f'{conflict_pr_num} needs rebase. Skipping conflict check') + if conflict_pr_json.get('draft', False): + print(f'PR #{conflict_pr_num} is a draft. Skipping conflict check', file=sys.stderr) continue - if conflict_pr_json['draft']: - print(f'{conflict_pr_num} is a draft. Skipping conflict check') + try: + pre_mergeable = requests.get(f'https://github.com/dashpay/dash/branches/pre_mergeable/{our_pr_label}...{conflict_pr_label}') + pre_mergeable.raise_for_status() + except requests.RequestException as e: + print(f"Error checking mergeability for PR {conflict_pr_num}: {e}", file=sys.stderr) continue - pre_mergeable = requests.get(f'https://github.com/dashpay/dash/branches/pre_mergeable/{our_pr_label}...{conflict_pr_label}') if "These branches can be automatically merged." in pre_mergeable.text: good.append(conflict_pr_num) - elif "Can’t automatically merge" in pre_mergeable.text: + elif "Can't automatically merge" in pre_mergeable.text: bad.append(conflict_pr_num) + conflict_details.append({ + 'number': conflict_pr_num, + 'title': conflict_pr_json.get('title', 'Unknown'), + 'url': conflict_pr_json.get('html_url', f'https://github.com/dashpay/dash/pull/{conflict_pr_num}') + }) + else: + print(f"Warning: Unexpected response for PR {conflict_pr_num} mergeability check. Response snippet: {pre_mergeable.text[:200]}", file=sys.stderr) + + print(f"Not conflicting PRs: {good}", file=sys.stderr) + print(f"Conflicting PRs: {bad}", file=sys.stderr) + + # Set GitHub Actions outputs + if 'GITHUB_OUTPUT' in os.environ: + set_github_output('has_conflicts', 'true' if len(bad) > 0 else 'false') + + # Format conflict details as markdown list + if conflict_details: + markdown_list = [] + for conflict in conflict_details: + markdown_list.append(f"- #{conflict['number']} - [{conflict['title']}]({conflict['url']})") + conflict_markdown = '\n'.join(markdown_list) + set_github_output('conflict_details', conflict_markdown) else: - raise Exception("not mergeable or unmergable!") + set_github_output('conflict_details', '') - print("Not conflicting PRs: ", good) + set_github_output('conflicting_prs', ','.join(map(str, bad))) - print("Conflicting PRs: ", bad) if len(bad) > 0: sys.exit(1) diff --git a/.github/workflows/predict-conflicts.yml b/.github/workflows/predict-conflicts.yml index 186abfd4d1df..b356d12ea019 100644 --- a/.github/workflows/predict-conflicts.yml +++ b/.github/workflows/predict-conflicts.yml @@ -23,10 +23,38 @@ jobs: runs-on: ubuntu-latest steps: - name: check for potential conflicts + id: check_conflicts uses: PastaPastaPasta/potential-conflicts-checker-action@v0.1.10 with: ghToken: "${{ secrets.GITHUB_TOKEN }}" - name: Checkout uses: actions/checkout@v3 - name: validate potential conflicts + id: validate_conflicts run: pip3 install hjson && .github/workflows/handle_potential_conflicts.py "$conflicts" + continue-on-error: true + - name: Post conflict comment + if: steps.validate_conflicts.outputs.has_conflicts == 'true' + uses: mshick/add-pr-comment@v2 + with: + message-id: conflict-prediction + message: | + ## ⚠️ Potential Merge Conflicts Detected + + This PR has potential conflicts with the following open PRs: + + ${{ steps.validate_conflicts.outputs.conflict_details }} + + Please coordinate with the authors of these PRs to avoid merge conflicts. + - name: Remove conflict comment if no conflicts + if: steps.validate_conflicts.outputs.has_conflicts == 'false' + uses: mshick/add-pr-comment@v2 + with: + message-id: conflict-prediction + message: | + ## ✅ No Merge Conflicts Detected + + This PR currently has no conflicts with other open PRs. + - name: Fail if conflicts exist + if: steps.validate_conflicts.outputs.has_conflicts == 'true' + run: exit 1 From 5eb60e45cfe879a95fbc5b7847db1c5ec0ddb848 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 15 Jul 2025 16:39:54 -0500 Subject: [PATCH 2/4] fix: handle missing PR data gracefully in conflict checker - Changed get_pr_json to return None on API errors instead of exiting - Added proper None checks before accessing PR data - Skip PRs that fail to fetch instead of crashing the entire workflow - This fixes the KeyError when PR API responses don't have expected fields --- .../workflows/handle_potential_conflicts.py | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/workflows/handle_potential_conflicts.py b/.github/workflows/handle_potential_conflicts.py index 34e28ae3db1e..6051b8909c1a 100755 --- a/.github/workflows/handle_potential_conflicts.py +++ b/.github/workflows/handle_potential_conflicts.py @@ -35,13 +35,20 @@ def get_pr_json(pr_num): try: response = requests.get(f'https://api.github.com/repos/dashpay/dash/pulls/{pr_num}') response.raise_for_status() - return response.json() + pr_data = response.json() + + # Check if we got an error response + if 'message' in pr_data and 'head' not in pr_data: + print(f"Warning: GitHub API error for PR {pr_num}: {pr_data.get('message', 'Unknown error')}", file=sys.stderr) + return None + + return pr_data except requests.RequestException as e: - print(f"Error fetching PR {pr_num}: {e}", file=sys.stderr) - sys.exit(1) + print(f"Warning: Error fetching PR {pr_num}: {e}", file=sys.stderr) + return None except json.JSONDecodeError as e: - print(f"Error parsing JSON for PR {pr_num}: {e}", file=sys.stderr) - sys.exit(1) + print(f"Warning: Error parsing JSON for PR {pr_num}: {e}", file=sys.stderr) + return None def set_github_output(name, value): """Set GitHub Actions output""" @@ -87,6 +94,10 @@ def main(): our_pr_num = j_input['pull_number'] our_pr_json = get_pr_json(our_pr_num) + if our_pr_json is None: + print(f"Error: Failed to fetch PR {our_pr_num}", file=sys.stderr) + sys.exit(1) + if 'head' not in our_pr_json or 'label' not in our_pr_json['head']: print(f"Error: Invalid PR data structure for PR {our_pr_num}", file=sys.stderr) sys.exit(1) @@ -108,6 +119,10 @@ def main(): conflict_pr_json = get_pr_json(conflict_pr_num) + if conflict_pr_json is None: + print(f"Warning: Failed to fetch PR {conflict_pr_num}, skipping", file=sys.stderr) + continue + if 'head' not in conflict_pr_json or 'label' not in conflict_pr_json['head']: print(f"Warning: Invalid PR data structure for PR {conflict_pr_num}, skipping", file=sys.stderr) continue From 42a9243703d3957698b7dc48dc6cdc54fa704664 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 15 Jul 2025 16:46:18 -0500 Subject: [PATCH 3/4] fix: make repository configurable using GITHUB_REPOSITORY env var - Use GITHUB_REPOSITORY environment variable for API calls - Defaults to dashpay/dash if not set - Fixes testing on forked repositories like PastaPastaPasta/dash --- .github/workflows/handle_potential_conflicts.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/handle_potential_conflicts.py b/.github/workflows/handle_potential_conflicts.py index 6051b8909c1a..bd0f5a128175 100755 --- a/.github/workflows/handle_potential_conflicts.py +++ b/.github/workflows/handle_potential_conflicts.py @@ -32,8 +32,11 @@ sys.exit(1) def get_pr_json(pr_num): + # Get repository from environment or default to dashpay/dash + repo = os.environ.get('GITHUB_REPOSITORY', 'dashpay/dash') + try: - response = requests.get(f'https://api.github.com/repos/dashpay/dash/pulls/{pr_num}') + response = requests.get(f'https://api.github.com/repos/{repo}/pulls/{pr_num}') response.raise_for_status() pr_data = response.json() @@ -138,8 +141,11 @@ def main(): print(f'PR #{conflict_pr_num} is a draft. Skipping conflict check', file=sys.stderr) continue + # Get repository from environment + repo = os.environ.get('GITHUB_REPOSITORY', 'dashpay/dash') + try: - pre_mergeable = requests.get(f'https://github.com/dashpay/dash/branches/pre_mergeable/{our_pr_label}...{conflict_pr_label}') + pre_mergeable = requests.get(f'https://github.com/{repo}/branches/pre_mergeable/{our_pr_label}...{conflict_pr_label}') pre_mergeable.raise_for_status() except requests.RequestException as e: print(f"Error checking mergeability for PR {conflict_pr_num}: {e}", file=sys.stderr) From 6642f668e12accf652894aba4c4c277c0c475e09 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 15 Jul 2025 16:53:44 -0500 Subject: [PATCH 4/4] test: modify workflow name differently for conflict test PR 2 --- .github/workflows/predict-conflicts.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/predict-conflicts.yml b/.github/workflows/predict-conflicts.yml index b356d12ea019..f101cfa45d99 100644 --- a/.github/workflows/predict-conflicts.yml +++ b/.github/workflows/predict-conflicts.yml @@ -1,4 +1,4 @@ -name: "Check Potential Conflicts" +name: "Check Potential Conflicts - Test PR 2 Different Change" on: pull_request_target: