diff --git a/.codespellignore b/.codespellignore index ad053601e..6af9bb0f3 100644 --- a/.codespellignore +++ b/.codespellignore @@ -1,2 +1,3 @@ Wen REGIST +PullRequest diff --git a/.github/workflows/sunnypilot-master-dev-c3-prep.yaml b/.github/workflows/sunnypilot-master-dev-c3-prep.yaml new file mode 100644 index 000000000..2f3cbd68c --- /dev/null +++ b/.github/workflows/sunnypilot-master-dev-c3-prep.yaml @@ -0,0 +1,162 @@ +name: Nightly Branch Reset and PR Squash + +env: + DEFAULT_SOURCE_BRANCH: "master-new" + DEFAULT_TARGET_BRANCH: "nightly" + PR_LABEL: "dev-c3" + +on: + workflow_dispatch: + inputs: + source_branch: + description: 'Source branch to reset from' + required: true + default: 'master-new' + type: string + target_branch: + description: 'Target branch to reset and squash into' + required: true + default: 'master-dev-c3-new' + type: string +# schedule: +# - cron: '0 0 * * *' # Run at midnight UTC for nightly + +jobs: + reset-and-squash: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for all branches + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Git + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install PyGithub + + - name: Check branches exist + run: | + # Check if source branch exists + if ! git ls-remote --heads origin ${{ inputs.source_branch || env.DEFAULT_SOURCE_BRANCH }} | grep -q "${{ inputs.source_branch || env.DEFAULT_SOURCE_BRANCH }}"; then + echo "Source branch ${{ inputs.source_branch || env.DEFAULT_SOURCE_BRANCH }} does not exist!" + exit 1 + fi + + # Make sure we have the latest source branch + git fetch origin ${{ inputs.source_branch || env.DEFAULT_SOURCE_BRANCH }} + + # Check if target branch exists + if ! git ls-remote --heads origin ${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }} | grep -q "${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}"; then + echo "Target branch ${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }} does not exist, creating it from ${{ inputs.source_branch || env.DEFAULT_SOURCE_BRANCH }}" + git checkout -b ${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }} origin/${{ inputs.source_branch || env.DEFAULT_SOURCE_BRANCH }} + git push origin ${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }} + else + # Fetch target branch if it exists + git fetch origin ${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }} + fi + + - name: Reset target branch + run: | + echo "Resetting ${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }} to match ${{ inputs.source_branch || env.DEFAULT_SOURCE_BRANCH }}" + # Delete if exists and recreate pointing to source + git branch -D ${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }} || true + git branch ${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }} origin/${{ inputs.source_branch || env.DEFAULT_SOURCE_BRANCH }} + + - name: Get PRs to squash + id: get-prs + run: | + # Use GitHub API to get PRs with specific label, ordered by creation date + PR_LIST=$(gh api graphql -f query=' + query($label:String!) { + search(query: $label, type:ISSUE, first:100) { + nodes { + ... on PullRequest { + number + headRefName + title + createdAt + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + state + } + } + } + } + } + } + } + }' -F label="is:pr is:open label:${PR_LABEL} sort:created-asc") + + echo "PR_LIST=${PR_LIST}" >> $GITHUB_OUTPUT + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Process PRs + run: | + python3 ${{ github.workspace }}/release/ci/squash_and_merge_prs.py \ + --pr-data '${{ steps.get-prs.outputs.PR_LIST }}' \ + --target-branch ${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }} \ + --squash-script-path '${{ github.workspace }}/release/ci/squash_and_merge.py' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Push changes if there are diffs + id: push-changes # Add an id so we can reference this step + run: | + TARGET_BRANCH="${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}" + + # Fetch the latest from remote + git fetch origin $TARGET_BRANCH + + # Check for diffs between local and remote + if git diff $TARGET_BRANCH origin/$TARGET_BRANCH --quiet; then + echo "No changes to push - local and remote branches are identical" + echo "has_changes=false" >> $GITHUB_OUTPUT + exit 0 + fi + + # If we get here, there are diffs, so push + if ! git push origin $TARGET_BRANCH --force; then + echo "Failed to push changes to $TARGET_BRANCH" + exit 1 + fi + + echo "Branch $TARGET_BRANCH has been reset and updated with squashed PRs" + echo "has_changes=true" >> $GITHUB_OUTPUT + + - name: Trigger and wait for selfdrive tests + if: steps.push-changes.outputs.has_changes == 'true' + run: | + echo "Triggering selfdrive tests..." + gh workflow run selfdrive_tests.yaml --ref "${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}" + + echo "Sleeping for 120s to give plenty of time for the action to start and then we wait" + sleep 120 + + echo "Getting latest run ID..." + RUN_ID=$(gh run list --workflow=selfdrive_tests.yaml --branch="${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}" --limit=1 --json databaseId --jq '.[0].databaseId') + + echo "Watching run ID: $RUN_ID" + gh run watch "$RUN_ID" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Trigger prebuilt workflow + if: success() && steps.push-changes.outputs.has_changes == 'true' + run: | + gh workflow run sunnypilot-build-prebuilt.yaml --ref "${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/release/ci/squash_and_merge.py b/release/ci/squash_and_merge.py index 804b2119c..7ab088801 100755 --- a/release/ci/squash_and_merge.py +++ b/release/ci/squash_and_merge.py @@ -117,7 +117,7 @@ def workspace_manager(original_branch: str): if temp_branch: run_command(f"git branch -D {temp_branch}") print("\nOperation interrupted, but changes were already restored.") - sys.exit(1) + sys.exit(3) # First, switch back to original branch current = get_current_branch() @@ -139,12 +139,12 @@ def workspace_manager(original_branch: str): if signum: print("\nOperation interrupted. Cleaned up and restored original state.") - sys.exit(1) + sys.exit(4) except Exception as e: print(f"Error during cleanup: {e}") if signum: - sys.exit(1) + sys.exit(5) try: # Set up signal handlers @@ -275,7 +275,7 @@ def squash_and_merge(source_branch: str, target_branch: str, manual_title: str | return False print(f"Attempting to merge changes from {temp_branch}...") - code, _, error = run_command(f"git merge {temp_branch}") + code, _, error = run_command(f"git rebase {temp_branch}") if code != 0: print(f"\nMerge failed with error: {error}") @@ -344,7 +344,7 @@ def main(): parser.add_argument('--push', action='store_true', help='Push changes to remote after squashing') - args = parser.parse_args() + args, unknown = parser.parse_known_args() # Determine source branch early source_branch = args.source @@ -354,7 +354,7 @@ def main(): sys.exit(1) if not squash_and_merge(source_branch, args.target, args.title, args.backup, args.push): - sys.exit(1) + sys.exit(2) if __name__ == "__main__": diff --git a/release/ci/squash_and_merge_prs.py b/release/ci/squash_and_merge_prs.py new file mode 100755 index 000000000..11bb67b5a --- /dev/null +++ b/release/ci/squash_and_merge_prs.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 + +import subprocess +import sys +import os +import argparse +import json +from datetime import datetime + + +def setup_argument_parser(): + parser = argparse.ArgumentParser(description='Process and squash GitHub PRs') + parser.add_argument('--pr-data', type=str, help='PR data in JSON format') + parser.add_argument('--source-branch', type=str, default='master-new', + help='Source branch for merging') + parser.add_argument('--target-branch', type=str, default='master-dev-c3-new-test', + help='Target branch for merging') + parser.add_argument('--squash-script-path', type=str, required=True, + help='Path to the squash_and_merge.py script') + return parser + + +def validate_squash_script(script_path): + if not os.path.isfile(script_path): + raise FileNotFoundError(f"Squash script not found at: {script_path}") + if not os.access(script_path, os.X_OK): + raise PermissionError(f"Squash script is not executable: {script_path}") + + +def sort_prs_by_creation(pr_data): + """Sort PRs by creation date""" + nodes = (pr_data.get('data', {}).get('search', {}).get('nodes', [])) + + return sorted( + nodes, + key=lambda x: datetime.fromisoformat(x.get('createdAt', '').replace('Z', '+00:00')) + ) + + +def add_pr_comment(pr_number, comment): + """Add a comment to a PR using gh cli""" + try: + subprocess.run( + ['gh', 'pr', 'comment', str(pr_number), '--body', comment], + check=True, + capture_output=True, + text=True + ) + except subprocess.CalledProcessError as e: + print(f"Failed to add comment to PR #{pr_number}: {e.stderr}") + + +def validate_pr(pr): + """Validate a PR and return (is_valid, skip_reason)""" + pr_number = pr.get('number', 'UNKNOWN') + branch = pr.get('headRefName', '') + + if not branch: + return False, f"Missing branch name for PR #{pr_number}" + + # Check if checks have passed + commits = pr.get('commits', {}).get('nodes', []) + if not commits: + return False, "No commit data found" + + status = commits[0].get('commit', {}).get('statusCheckRollup', {}) + if not status or status.get('state') != 'SUCCESS': + return False, "Not all checks have passed" + + # Check for merge conflicts + merge_status = subprocess.run(['gh', 'pr', 'view', str(pr_number), '--json', 'mergeable,mergeStateStatus'], capture_output=True, text=True) + merge_data = json.loads(merge_status.stdout) + if not merge_data.get('mergeable'): + return False, "Merge conflicts detected" + + if (mergeStateStatus := merge_data.get('mergeStateStatus')) != "CLEAN": + return False, f"Branch is `{mergeStateStatus}`" + + return True, None + + +def process_pr(pr_data, source_branch, target_branch, squash_script_path): + try: + nodes = sort_prs_by_creation(pr_data) + if not nodes: + print("No PRs to squash") + return 0 + + print(f"Deleting target branch {target_branch}") + subprocess.run(['git', 'branch', '-D', target_branch], check=False) + subprocess.run(['git', 'branch', target_branch, f'origin/{source_branch}'], check=True) + success_count = 0 + for pr in nodes: + pr_number = pr.get('number', 'UNKNOWN') + branch = pr.get('headRefName', '') + title = pr.get('title', '') + is_valid, skip_reason = validate_pr(pr) + + if not is_valid: + print(f"Warning: {skip_reason} for PR #{pr_number}, skipping") + add_pr_comment(pr_number, f"⚠️ This PR was skipped in the automated `{target_branch}` squash because {skip_reason}.") + continue + + try: + # Fetch PR branch + subprocess.run(['git', 'fetch', 'origin', branch], check=True) + # Delete branch if it exists (ignore errors if it doesn't) + subprocess.run(['git', 'branch', '-D', branch], check=False) + # Create new branch pointing to origin's branch + subprocess.run(['git', 'branch', branch, f'origin/{branch}'], check=True) + + # Run squash script + subprocess.run([ + squash_script_path, + '--target', target_branch, + '--source', branch, + '--title', f"{title} (#{pr_number})", + ], check=True) + + print(f"Successfully processed PR #{pr_number}") + success_count += 1 + + except subprocess.CalledProcessError as e: + print(f"Error processing PR #{pr_number}:") + print(f"Command failed with exit code {e.returncode}") + error_output = getattr(e, 'stderr', 'No error output available') + print(f"Error output: {error_output}") + add_pr_comment(pr_number, f"⚠️ Error during automated {target_branch} squash:\n```\n{error_output}\n```") + continue + except Exception as e: + print(f"Unexpected error processing PR #{pr_number}: {str(e)}") + continue + + return success_count + + except Exception as e: + import traceback + print(f"Error in process_pr: {str(e)}") + print("Full traceback:") + print(traceback.format_exc()) + return 0 + + +def main(): + parser = setup_argument_parser() + try: + args = parser.parse_args() + validate_squash_script(args.squash_script_path) + pr_data_json = json.loads(args.pr_data) + + # Process the PRs + success_count = process_pr(pr_data_json, args.source_branch, args.target_branch, args.squash_script_path) + print(f"Successfully processed {success_count} PRs") + + except Exception as e: + print(f"Fatal error: {str(e)}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())