diff --git a/.github/workflows/address_undefined_behavior_leak_sanitizer.yml b/.github/workflows/address_undefined_behavior_leak_sanitizer.yml index 6a2dfbb85..79f401d69 100644 --- a/.github/workflows/address_undefined_behavior_leak_sanitizer.yml +++ b/.github/workflows/address_undefined_behavior_leak_sanitizer.yml @@ -33,6 +33,10 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4.2.2 + with: + fetch-depth: 0 # Required for rebasing + - name: Rebase on dependency PR + uses: ./actions/rebase_on_dependency - name: Free Disk Space (Ubuntu) uses: ./actions/free_disk_space - uses: bazel-contrib/setup-bazel@0.18.0 diff --git a/.github/workflows/build_and_test_host.yml b/.github/workflows/build_and_test_host.yml index febf1c59f..6bb03319a 100644 --- a/.github/workflows/build_and_test_host.yml +++ b/.github/workflows/build_and_test_host.yml @@ -40,6 +40,10 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4.2.2 + with: + fetch-depth: 0 # Required for rebasing + - name: Rebase on dependency PR + uses: ./actions/rebase_on_dependency - name: Free Disk Space (Ubuntu) uses: ./actions/free_disk_space - uses: bazel-contrib/setup-bazel@0.18.0 diff --git a/.github/workflows/build_and_test_qnx.yml b/.github/workflows/build_and_test_qnx.yml index 515e3f1f9..022ec8df9 100644 --- a/.github/workflows/build_and_test_qnx.yml +++ b/.github/workflows/build_and_test_qnx.yml @@ -103,6 +103,9 @@ jobs: with: ref: ${{ github.head_ref || github.event.pull_request.head.ref || github.ref }} repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + fetch-depth: 0 # Required for rebasing + - name: Rebase on dependency PR + uses: ./actions/rebase_on_dependency - name: Free Disk Space (Ubuntu) uses: ./actions/free_disk_space - name: Setup Bazel diff --git a/.github/workflows/coverage_report.yml b/.github/workflows/coverage_report.yml index 8b512f3b4..34066dd5c 100644 --- a/.github/workflows/coverage_report.yml +++ b/.github/workflows/coverage_report.yml @@ -39,7 +39,10 @@ jobs: steps: - name: Checkout Repository uses: actions/checkout@v4.2.2 - + with: + fetch-depth: 0 # Required for rebasing + - name: Rebase on dependency PR + uses: ./actions/rebase_on_dependency - name: Free Disk Space (Ubuntu) uses: ./actions/free_disk_space diff --git a/.github/workflows/thread_sanitizer.yml b/.github/workflows/thread_sanitizer.yml index 1225bce90..47e9899cd 100644 --- a/.github/workflows/thread_sanitizer.yml +++ b/.github/workflows/thread_sanitizer.yml @@ -32,6 +32,10 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4.2.2 + with: + fetch-depth: 0 # Required for rebasing + - name: Rebase on dependency PR + uses: ./actions/rebase_on_dependency - name: Free Disk Space (Ubuntu) uses: ./actions/free_disk_space - uses: bazel-contrib/setup-bazel@0.18.0 diff --git a/actions/rebase_on_dependency/BUILD b/actions/rebase_on_dependency/BUILD new file mode 100644 index 000000000..89376125f --- /dev/null +++ b/actions/rebase_on_dependency/BUILD @@ -0,0 +1,40 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("@rules_python//python:defs.bzl", "py_library", "py_test") + +py_library( + name = "find_dependencies_lib", + srcs = ["scripts/find_dependencies.py"], + imports = ["scripts"], +) + +py_library( + name = "rebase_on_prs_lib", + srcs = ["scripts/rebase_on_prs.py"], + imports = ["scripts"], +) + +py_test( + name = "find_dependencies_py_test", + srcs = ["test/find_dependencies_test.py"], + main = "test/find_dependencies_test.py", + deps = [":find_dependencies_lib"], +) + +py_test( + name = "rebase_on_prs_py_test", + srcs = ["test/rebase_on_prs_test.py"], + main = "test/rebase_on_prs_test.py", + deps = [":rebase_on_prs_lib"], +) diff --git a/actions/rebase_on_dependency/action.yml b/actions/rebase_on_dependency/action.yml new file mode 100644 index 000000000..2945174a0 --- /dev/null +++ b/actions/rebase_on_dependency/action.yml @@ -0,0 +1,97 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +name: "Rebase on Dependency PR" +description: "Rebases the current PR onto another PR specified via 'Depends-On: #' in the PR description" + +inputs: + token: + description: "GitHub token with permissions to read PR information" + required: false + default: ${{ github.token }} + +outputs: + dependency_prs: + description: "Comma-separated list of PR numbers that this PR depends on (if found)" + value: ${{ steps.find-dependency.outputs.dependency_prs }} + dependency_count: + description: "Number of dependency PRs found" + value: ${{ steps.find-dependency.outputs.dependency_count }} + rebased: + description: "Whether a rebase was performed" + value: ${{ steps.rebase.outputs.rebased }} + rebased_count: + description: "Number of PRs successfully rebased onto" + value: ${{ steps.rebase.outputs.rebased_count }} + +runs: + using: composite + steps: + - name: Find dependency PR from description + id: find-dependency + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + ACTION_PATH: ${{ github.action_path }} + run: python3 "${ACTION_PATH}/scripts/find_dependencies.py" + + - name: Rebase on dependency PR + id: rebase + if: steps.find-dependency.outputs.dependency_prs != '' + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + DEPENDENCY_PRS: ${{ steps.find-dependency.outputs.dependency_prs }} + ACTION_PATH: ${{ github.action_path }} + run: python3 "${ACTION_PATH}/scripts/rebase_on_prs.py" "${DEPENDENCY_PRS}" + + - name: Summary + if: always() + shell: bash + env: + DEPENDENCY_PRS: ${{ steps.find-dependency.outputs.dependency_prs || 0 }} + DEPENDENCY_COUNT: ${{ steps.find-dependency.outputs.dependency_count || 0}} + REBASED: ${{ steps.rebase.outputs.rebased || 0 }} + REBASED_COUNT: ${{ steps.rebase.outputs.rebased_count || 0 }} + SKIPPED_PRS: ${{ steps.rebase.outputs.skipped_prs || 0}} + FAILED_PRS: ${{ steps.rebase.outputs.failed_prs || 0}} + run: | + echo "### Dependency Check" >> $GITHUB_STEP_SUMMARY + + if [[ -z "$DEPENDENCY_PRS" ]]; then + echo "No dependency found in PR description." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "To declare dependencies, add \`Depends-On: #\` to your PR description." >> $GITHUB_STEP_SUMMARY + echo "You can add multiple \`Depends-On:\` lines for multiple dependencies." >> $GITHUB_STEP_SUMMARY + else + echo "Found **${DEPENDENCY_COUNT}** dependency PR(s): ${DEPENDENCY_PRS}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [[ "${REBASED_COUNT:-0}" -gt 0 ]]; then + echo "✅ Successfully rebased onto **${REBASED_COUNT}** PR(s)" >> $GITHUB_STEP_SUMMARY + fi + + if [[ -n "$SKIPPED_PRS" ]]; then + echo "ℹ️ Skipped PRs (already merged or closed): ${SKIPPED_PRS}" >> $GITHUB_STEP_SUMMARY + fi + + if [[ -n "$FAILED_PRS" ]]; then + echo "❌ Failed to rebase onto PR(s): ${FAILED_PRS}" >> $GITHUB_STEP_SUMMARY + fi + + if [[ "$REBASED" != "true" && -z "$FAILED_PRS" ]]; then + echo "ℹ️ No rebase was needed (all dependency PRs are merged or closed)" >> $GITHUB_STEP_SUMMARY + fi + fi diff --git a/actions/rebase_on_dependency/scripts/find_dependencies.py b/actions/rebase_on_dependency/scripts/find_dependencies.py new file mode 100644 index 000000000..8e2c0ac68 --- /dev/null +++ b/actions/rebase_on_dependency/scripts/find_dependencies.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 + +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +""" +find_dependencies.py +Parses a PR description and extracts Depends-On: references. + +Reads EVENT_NAME, PR_NUMBER, GH_TOKEN from environment variables. +Calls 'gh pr view' to fetch the PR body. +Writes dependency_prs= and dependency_count= directly to $GITHUB_OUTPUT. +""" + +import os +import re +import subprocess +import sys + + +def parse_depends_on(pr_body: str) -> list[str]: + """Parse Depends-On patterns from PR body text. + + Supports formats: "Depends-On: #123", "Depends-On: 123", "Depends-On:#123" + Case insensitive. Returns a list of PR number strings. + """ + if not pr_body: + return [] + return re.findall(r"(?i)depends-on:\s*#?(\d+)", pr_body) + + +def get_pr_body(pr_number: str) -> str: + """Fetch the PR body text using the GitHub CLI. + + Raises: + RuntimeError: if the gh CLI exits with a non-zero return code. + """ + result = subprocess.run( + ["gh", "pr", "view", pr_number, "--json", "body", "--jq", ".body"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError( + f"gh CLI failed for PR #{pr_number} (exit {result.returncode}): {result.stderr.strip()}" + ) + return result.stdout.strip() + + +def write_github_output(output_path: str, prs: list[str]) -> None: + """Write dependency_prs and dependency_count to the GITHUB_OUTPUT file.""" + with open(output_path, "a", encoding="utf-8") as f: + f.write(f"dependency_prs={','.join(prs)}\n") + f.write(f"dependency_count={len(prs)}\n") + + +def main() -> int: + event_name = os.environ.get("EVENT_NAME", "") + pr_number = os.environ.get("PR_NUMBER", "") + output_path = os.environ.get("GITHUB_OUTPUT", os.devnull) + + if event_name not in ("pull_request", "pull_request_target", "workflow_run"): + print("Not a pull request event, skipping dependency check") + write_github_output(output_path, []) + return 0 + + if not pr_number: + print("Could not determine PR number") + write_github_output(output_path, []) + return 0 + + print(f"Checking PR #{pr_number} for dependencies...") + + try: + pr_body = get_pr_body(pr_number) + except RuntimeError as exc: + print(f"::error::{exc}") + write_github_output(output_path, []) + return 1 + + if not pr_body: + print("Could not fetch PR description or description is empty") + write_github_output(output_path, []) + return 0 + + dependency_prs = parse_depends_on(pr_body) + + if not dependency_prs: + print("No 'Depends-On:' keyword found in PR description") + write_github_output(output_path, []) + return 0 + + print(f"Found {len(dependency_prs)} dependency PR(s): {','.join(dependency_prs)}") + write_github_output(output_path, dependency_prs) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + diff --git a/actions/rebase_on_dependency/scripts/rebase_on_prs.py b/actions/rebase_on_dependency/scripts/rebase_on_prs.py new file mode 100644 index 000000000..1cd5382ed --- /dev/null +++ b/actions/rebase_on_dependency/scripts/rebase_on_prs.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 + +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +""" +rebase_on_prs.py +Rebases the current branch onto dependency PR branches. + +Accepts a comma-separated list of PR numbers as a CLI argument. +Requires GH_TOKEN environment variable for GitHub CLI authentication. +Writes rebased=, rebased_count=, skipped_prs=, failed_prs= directly to $GITHUB_OUTPUT. +""" + +import os +import subprocess +import sys + +# Return codes for rebase_on_pr +REBASE_SUCCESS = 0 +REBASE_SKIPPED = 1 +REBASE_FAILED = 2 + + +def get_pr_field(pr_number: str, field: str) -> str: + """Get a single field from a PR using the GitHub CLI.""" + result = subprocess.run( + ["gh", "pr", "view", pr_number, "--json", field, "--jq", f".{field}"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return "" + return result.stdout.strip() + + +def rebase_on_pr(pr_number: str) -> int: + """Perform rebase onto a single dependency PR. + + Returns: + REBASE_SUCCESS (0) on successful rebase + REBASE_SKIPPED (1) when the PR is merged or closed + REBASE_FAILED (2) on any error + """ + print() + print("==========================================") + print(f"Processing dependency PR #{pr_number}...") + print("==========================================") + + dependency_branch = get_pr_field(pr_number, "headRefName") + if not dependency_branch: + print( + f"::error::Could not fetch branch name for PR #{pr_number}. " + "Make sure the PR exists and is open." + ) + return REBASE_FAILED + + print(f"Dependency PR branch: {dependency_branch}") + + dependency_state = get_pr_field(pr_number, "state") + + if dependency_state == "MERGED": + print(f"Dependency PR #{pr_number} has been merged. Skipping.") + return REBASE_SKIPPED + + if dependency_state == "CLOSED": + print( + f"::warning::Dependency PR #{pr_number} is closed but not merged. " + "Consider removing the Depends-On reference." + ) + return REBASE_SKIPPED + + # Use refs/pull//head so fork PRs are supported: GitHub exposes the + # head commit of every PR at this ref on the base repository, regardless + # of whether the head lives on a fork or the same repo. + pr_ref = f"refs/pull/{pr_number}/head" + local_ref = f"refs/remotes/origin/pr/{pr_number}" + + print(f"Fetching PR #{pr_number} via {pr_ref} (fork-compatible)...") + fetch_result = subprocess.run(["git", "fetch", "origin", f"{pr_ref}:{local_ref}"]) + if fetch_result.returncode != 0: + print(f"::error::Failed to fetch PR #{pr_number}.") + return REBASE_FAILED + + print(f"Rebasing current branch onto {local_ref} (branch: {dependency_branch})...") + rebase_result = subprocess.run(["git", "rebase", local_ref]) + if rebase_result.returncode == 0: + print(f"Rebase onto PR #{pr_number} successful!") + return REBASE_SUCCESS + + print(f"::error::Rebase failed for PR #{pr_number}. There may be conflicts.") + subprocess.run(["git", "rebase", "--abort"]) + return REBASE_FAILED + + +def write_github_output( + output_path: str, + rebased: bool, + rebased_count: int, + skipped_prs: list[str], + failed_prs: list[str], +) -> None: + """Write rebase results to the GITHUB_OUTPUT file.""" + with open(output_path, "a", encoding="utf-8") as f: + f.write(f"rebased={'true' if rebased else 'false'}\n") + f.write(f"rebased_count={rebased_count}\n") + f.write(f"skipped_prs={','.join(skipped_prs)}\n") + f.write(f"failed_prs={','.join(failed_prs)}\n") + + +def main(dependency_prs_str: str) -> int: + output_path = os.environ.get("GITHUB_OUTPUT", os.devnull) + + rebased_count = 0 + skipped_prs: list[str] = [] + failed_prs: list[str] = [] + + # Configure git user for the rebase + subprocess.run(["git", "config", "user.name", "github-actions[bot]"]) + subprocess.run( + ["git", "config", "user.email", "github-actions[bot]@users.noreply.github.com"] + ) + + pr_numbers = [p.strip() for p in dependency_prs_str.split(",") if p.strip()] + + for pr_number in pr_numbers: + result = rebase_on_pr(pr_number) + if result == REBASE_SUCCESS: + rebased_count += 1 + elif result == REBASE_SKIPPED: + skipped_prs.append(pr_number) + elif result == REBASE_FAILED: + failed_prs.append(pr_number) + + write_github_output(output_path, rebased_count > 0, rebased_count, skipped_prs, failed_prs) + + print() + print("==========================================") + print(f"Summary: Rebased onto {rebased_count} PR(s)") + if skipped_prs: + print(f"Skipped: {','.join(skipped_prs)}") + if failed_prs: + print(f"Failed: {','.join(failed_prs)}") + print("==========================================") + + return 1 if failed_prs else 0 + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: rebase_on_prs.py ", file=sys.stderr) + sys.exit(1) + sys.exit(main(sys.argv[1])) + diff --git a/actions/rebase_on_dependency/test/find_dependencies_test.py b/actions/rebase_on_dependency/test/find_dependencies_test.py new file mode 100644 index 000000000..a58624c55 --- /dev/null +++ b/actions/rebase_on_dependency/test/find_dependencies_test.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 + +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Unit tests for find_dependencies.py""" + +import os +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +from find_dependencies import get_pr_body, main, parse_depends_on, write_github_output + + +# --------------------------------------------------------------------------- +# Tests for parse_depends_on +# --------------------------------------------------------------------------- + + +class TestParseDependsOn(unittest.TestCase): + def test_single_dependency_with_hash(self): + body = "This PR depends on another one.\n\nDepends-On: #123\n\nMore description." + self.assertEqual(parse_depends_on(body), ["123"]) + + def test_single_dependency_without_hash(self): + self.assertEqual(parse_depends_on("Depends-On: 456"), ["456"]) + + def test_single_dependency_no_space(self): + self.assertEqual(parse_depends_on("Depends-On:#789"), ["789"]) + + def test_multiple_dependencies(self): + body = "Depends-On: #100\nDepends-On: #200\nDepends-On: #300" + self.assertEqual(parse_depends_on(body), ["100", "200", "300"]) + + def test_mixed_formats(self): + body = "Depends-On: #111\nDepends-On: 222\nDepends-On:#333" + self.assertEqual(parse_depends_on(body), ["111", "222", "333"]) + + def test_case_insensitive_lower(self): + self.assertEqual(parse_depends_on("depends-on: #555"), ["555"]) + + def test_case_insensitive_upper(self): + self.assertEqual(parse_depends_on("DEPENDS-ON: #666"), ["666"]) + + def test_case_insensitive_mixed(self): + self.assertEqual(parse_depends_on("DePeNdS-On: #777"), ["777"]) + + def test_no_dependencies(self): + self.assertEqual(parse_depends_on("No dependencies here."), []) + + def test_empty_body(self): + self.assertEqual(parse_depends_on(""), []) + + def test_extra_whitespace(self): + self.assertEqual(parse_depends_on("Depends-On: #888"), ["888"]) + + def test_markdown_list(self): + body = "## Dependencies\n- Depends-On: #999\n- Depends-On: #1000" + self.assertEqual(parse_depends_on(body), ["999", "1000"]) + + def test_surrounding_text(self): + self.assertEqual( + parse_depends_on("Please merge Depends-On: #1111 first before this one"), ["1111"] + ) + + def test_large_pr_numbers(self): + self.assertEqual(parse_depends_on("Depends-On: #99999"), ["99999"]) + + def test_partial_no_number(self): + self.assertEqual(parse_depends_on("Depends-On:"), []) + + def test_number_before_keyword(self): + self.assertEqual(parse_depends_on("123 Depends-On"), []) + + +# --------------------------------------------------------------------------- +# Tests for write_github_output +# --------------------------------------------------------------------------- + + +class TestWriteGithubOutput(unittest.TestCase): + def _read_output(self, prs: list[str]) -> str: + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + path = f.name + write_github_output(path, prs) + with open(path, encoding="utf-8") as f: + return f.read() + + def test_writes_prs_and_count(self): + content = self._read_output(["100", "200", "300"]) + self.assertIn("dependency_prs=100,200,300\n", content) + self.assertIn("dependency_count=3\n", content) + + def test_writes_single_pr(self): + content = self._read_output(["42"]) + self.assertIn("dependency_prs=42\n", content) + self.assertIn("dependency_count=1\n", content) + + def test_writes_empty(self): + content = self._read_output([]) + self.assertIn("dependency_prs=\n", content) + self.assertIn("dependency_count=0\n", content) + + def test_appends_to_existing_content(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write("existing=value\n") + path = f.name + write_github_output(path, ["7"]) + with open(path, encoding="utf-8") as f: + content = f.read() + self.assertIn("existing=value\n", content) + self.assertIn("dependency_prs=7\n", content) + + +# --------------------------------------------------------------------------- +# Tests for get_pr_body +# --------------------------------------------------------------------------- + + +class TestGetPrBody(unittest.TestCase): + def _mock_run(self, returncode: int, stdout: str) -> MagicMock: + m = MagicMock() + m.returncode = returncode + m.stdout = stdout + return m + + def test_returns_pr_body(self): + with patch("find_dependencies.subprocess.run", return_value=self._mock_run(0, "Depends-On: #123\n")): + result = get_pr_body("42") + self.assertEqual(result, "Depends-On: #123") + + def test_strips_trailing_newline(self): + with patch("find_dependencies.subprocess.run", return_value=self._mock_run(0, "body text\n\n")): + result = get_pr_body("1") + self.assertEqual(result, "body text") + + def test_raises_on_gh_failure(self): + with patch("find_dependencies.subprocess.run", return_value=self._mock_run(1, "")): + with self.assertRaises(RuntimeError): + get_pr_body("999") + + def test_exception_message_contains_pr_number_and_exit_code(self): + mock = self._mock_run(2, "") + mock.stderr = "authentication required" + with patch("find_dependencies.subprocess.run", return_value=mock): + with self.assertRaises(RuntimeError) as ctx: + get_pr_body("42") + self.assertIn("42", str(ctx.exception)) + self.assertIn("2", str(ctx.exception)) + self.assertIn("authentication required", str(ctx.exception)) + + +# --------------------------------------------------------------------------- +# Tests for main +# --------------------------------------------------------------------------- + + +class TestMain(unittest.TestCase): + def _run_main(self, env: dict, pr_body: str = "") -> tuple[int, str]: + """Run main() with given env overrides; return (exit_code, github_output_content).""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + output_path = f.name + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = pr_body + + with patch.dict(os.environ, {**env, "GITHUB_OUTPUT": output_path}, clear=False): + with patch("find_dependencies.subprocess.run", return_value=mock_result): + exit_code = main() + + with open(output_path, encoding="utf-8") as f: + return exit_code, f.read() + + def test_non_pr_event_skips(self): + code, output = self._run_main({"EVENT_NAME": "push", "PR_NUMBER": ""}) + self.assertEqual(code, 0) + self.assertIn("dependency_prs=\n", output) + self.assertIn("dependency_count=0\n", output) + + def test_pull_request_event_runs(self): + code, output = self._run_main( + {"EVENT_NAME": "pull_request", "PR_NUMBER": "42"}, + pr_body="Depends-On: #100", + ) + self.assertEqual(code, 0) + self.assertIn("dependency_prs=100\n", output) + self.assertIn("dependency_count=1\n", output) + + def test_merge_group_event_runs(self): + code, output = self._run_main( + {"EVENT_NAME": "merge_group", "PR_NUMBER": "123"}, + pr_body="Depends-On: #456", + ) + self.assertEqual(code, 0) + self.assertIn("dependency_prs=456\n", output) + self.assertIn("dependency_count=1\n", output) + + def test_missing_pr_number_skips(self): + code, output = self._run_main({"EVENT_NAME": "pull_request", "PR_NUMBER": ""}) + self.assertEqual(code, 0) + self.assertIn("dependency_prs=\n", output) + self.assertIn("dependency_count=0\n", output) + + def test_gh_failure_returns_exit_1(self): + failing_mock = MagicMock() + failing_mock.returncode = 1 + failing_mock.stdout = "" + failing_mock.stderr = "authentication required" + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + output_path = f.name + with patch.dict(os.environ, {"EVENT_NAME": "pull_request", "PR_NUMBER": "42", "GITHUB_OUTPUT": output_path}, clear=False): + with patch("find_dependencies.subprocess.run", return_value=failing_mock): + code = main() + with open(output_path, encoding="utf-8") as f: + output = f.read() + self.assertEqual(code, 1) + self.assertIn("dependency_prs=\n", output) + self.assertIn("dependency_count=0\n", output) + + def test_empty_pr_body_skips(self): + code, output = self._run_main( + {"EVENT_NAME": "pull_request", "PR_NUMBER": "42"}, + pr_body="", + ) + self.assertEqual(code, 0) + self.assertIn("dependency_prs=\n", output) + self.assertIn("dependency_count=0\n", output) + + def test_pr_with_no_depends_on(self): + code, output = self._run_main( + {"EVENT_NAME": "pull_request", "PR_NUMBER": "42"}, + pr_body="Just a regular PR description.", + ) + self.assertEqual(code, 0) + self.assertIn("dependency_prs=\n", output) + self.assertIn("dependency_count=0\n", output) + + def test_multiple_dependencies(self): + code, output = self._run_main( + {"EVENT_NAME": "pull_request", "PR_NUMBER": "42"}, + pr_body="Depends-On: #100\nDepends-On: #200", + ) + self.assertEqual(code, 0) + self.assertIn("dependency_prs=100,200\n", output) + self.assertIn("dependency_count=2\n", output) + + +if __name__ == "__main__": + unittest.main() + diff --git a/actions/rebase_on_dependency/test/rebase_on_prs_test.py b/actions/rebase_on_dependency/test/rebase_on_prs_test.py new file mode 100644 index 000000000..8717968ae --- /dev/null +++ b/actions/rebase_on_dependency/test/rebase_on_prs_test.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 + +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Unit tests for rebase_on_prs.py""" + +import os +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +from rebase_on_prs import ( + REBASE_FAILED, + REBASE_SKIPPED, + REBASE_SUCCESS, + get_pr_field, + main, + rebase_on_pr, + write_github_output, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _completed(returncode: int = 0, stdout: str = "") -> MagicMock: + m = MagicMock() + m.returncode = returncode + m.stdout = stdout + return m + + +def _gh_side_effect(branch: str = "feature-branch", state: str = "OPEN"): + """Return a subprocess.run side-effect that mocks gh pr view calls.""" + + def side_effect(cmd, **kwargs): + if cmd[0] == "gh": + field = cmd[5] # ["gh", "pr", "view", pr, "--json", field, "--jq", ...] + if field == "headRefName": + return _completed(0, f"{branch}\n") + if field == "state": + return _completed(0, f"{state}\n") + # git calls (fetch, rebase, config) succeed by default + return _completed(0, "") + + return side_effect + + +# --------------------------------------------------------------------------- +# Tests for get_pr_field +# --------------------------------------------------------------------------- + + +class TestGetPrField(unittest.TestCase): + def test_returns_branch_name(self): + with patch("rebase_on_prs.subprocess.run", return_value=_completed(0, "feature-branch\n")): + self.assertEqual(get_pr_field("123", "headRefName"), "feature-branch") + + def test_strips_trailing_newline(self): + with patch("rebase_on_prs.subprocess.run", return_value=_completed(0, "OPEN\n")): + self.assertEqual(get_pr_field("123", "state"), "OPEN") + + def test_returns_empty_on_gh_failure(self): + with patch("rebase_on_prs.subprocess.run", return_value=_completed(1, "")): + self.assertEqual(get_pr_field("999", "headRefName"), "") + + +# --------------------------------------------------------------------------- +# Tests for rebase_on_pr +# --------------------------------------------------------------------------- + + +class TestRebaseOnPr(unittest.TestCase): + def test_open_pr_successful_rebase(self): + with patch("rebase_on_prs.subprocess.run", side_effect=_gh_side_effect("my-branch", "OPEN")): + self.assertEqual(rebase_on_pr("123"), REBASE_SUCCESS) + + def test_merged_pr_returns_skipped(self): + with patch("rebase_on_prs.subprocess.run", side_effect=_gh_side_effect("my-branch", "MERGED")): + self.assertEqual(rebase_on_pr("123"), REBASE_SKIPPED) + + def test_closed_pr_returns_skipped(self): + with patch("rebase_on_prs.subprocess.run", side_effect=_gh_side_effect("my-branch", "CLOSED")): + self.assertEqual(rebase_on_pr("123"), REBASE_SKIPPED) + + def test_pr_not_found_returns_failed(self): + # gh returns an empty string for the branch name + with patch("rebase_on_prs.subprocess.run", return_value=_completed(0, "\n")): + self.assertEqual(rebase_on_pr("999"), REBASE_FAILED) + + def test_git_fetch_failure_returns_failed(self): + def side_effect(cmd, **kwargs): + if cmd[0] == "gh": + field = cmd[5] + if field == "headRefName": + return _completed(0, "feature-branch\n") + if field == "state": + return _completed(0, "OPEN\n") + if cmd[0] == "git" and cmd[1] == "fetch": + return _completed(1) # fetch fails + return _completed(0) + + with patch("rebase_on_prs.subprocess.run", side_effect=side_effect): + self.assertEqual(rebase_on_pr("123"), REBASE_FAILED) + + def test_rebase_conflict_returns_failed(self): + def side_effect(cmd, **kwargs): + if cmd[0] == "gh": + field = cmd[5] + if field == "headRefName": + return _completed(0, "feature-branch\n") + if field == "state": + return _completed(0, "OPEN\n") + if cmd[0] == "git": + if cmd[1] == "rebase" and (len(cmd) < 3 or cmd[2] != "--abort"): + return _completed(1) # rebase fails with conflict + return _completed(0) # fetch and rebase --abort succeed + return _completed(0) + + with patch("rebase_on_prs.subprocess.run", side_effect=side_effect): + self.assertEqual(rebase_on_pr("123"), REBASE_FAILED) + + def test_rebase_conflict_calls_abort(self): + abort_called = [] + + def side_effect(cmd, **kwargs): + if cmd[0] == "gh": + field = cmd[5] + if field == "headRefName": + return _completed(0, "feature-branch\n") + if field == "state": + return _completed(0, "OPEN\n") + if cmd[0] == "git": + if cmd[1] == "rebase" and cmd[2:] == ["--abort"]: + abort_called.append(True) + return _completed(0) + if cmd[1] == "rebase": + return _completed(1) + return _completed(0) + return _completed(0) + + with patch("rebase_on_prs.subprocess.run", side_effect=side_effect): + rebase_on_pr("123") + + self.assertTrue(abort_called, "git rebase --abort should have been called") + + def test_fetch_uses_pr_ref_not_branch_name(self): + """Verify the exact git fetch refspec: refs/pull//head → local ref (not the branch name). + + This is what makes fork PRs work — the branch name lives on a different + repository, but GitHub always exposes refs/pull//head on the base repo. + """ + fetch_cmds: list[list[str]] = [] + + def side_effect(cmd, **kwargs): + if cmd[0] == "gh": + field = cmd[5] + if field == "headRefName": + return _completed(0, "feature-branch\n") + if field == "state": + return _completed(0, "OPEN\n") + if cmd[0] == "git" and cmd[1] == "fetch": + fetch_cmds.append(list(cmd)) + return _completed(0) + + with patch("rebase_on_prs.subprocess.run", side_effect=side_effect): + result = rebase_on_pr("42") + + self.assertEqual(result, REBASE_SUCCESS) + self.assertEqual(len(fetch_cmds), 1) + self.assertEqual( + fetch_cmds[0], + ["git", "fetch", "origin", "refs/pull/42/head:refs/remotes/origin/pr/42"], + ) + + +# --------------------------------------------------------------------------- +# Tests for write_github_output +# --------------------------------------------------------------------------- + + +class TestWriteGithubOutput(unittest.TestCase): + def _read(self, **kwargs) -> str: + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + path = f.name + write_github_output(path, **kwargs) + with open(path, encoding="utf-8") as f: + return f.read() + + def test_successful_rebase(self): + content = self._read(rebased=True, rebased_count=2, skipped_prs=[], failed_prs=[]) + self.assertIn("rebased=true\n", content) + self.assertIn("rebased_count=2\n", content) + self.assertIn("skipped_prs=\n", content) + self.assertIn("failed_prs=\n", content) + + def test_no_rebase_needed(self): + content = self._read(rebased=False, rebased_count=0, skipped_prs=["123"], failed_prs=[]) + self.assertIn("rebased=false\n", content) + self.assertIn("rebased_count=0\n", content) + self.assertIn("skipped_prs=123\n", content) + + def test_failed_prs(self): + content = self._read(rebased=False, rebased_count=0, skipped_prs=[], failed_prs=["42", "99"]) + self.assertIn("failed_prs=42,99\n", content) + + def test_appends_to_existing_content(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write("existing=value\n") + path = f.name + write_github_output(path, rebased=True, rebased_count=1, skipped_prs=[], failed_prs=[]) + with open(path, encoding="utf-8") as f: + content = f.read() + self.assertIn("existing=value\n", content) + self.assertIn("rebased=true\n", content) + + +# --------------------------------------------------------------------------- +# Tests for main +# --------------------------------------------------------------------------- + + +class TestMain(unittest.TestCase): + def _run_main(self, dependency_prs_str: str, side_effect) -> tuple[int, str]: + """Run main() with a subprocess mock; return (exit_code, github_output_content).""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + output_path = f.name + + with patch.dict(os.environ, {"GITHUB_OUTPUT": output_path}, clear=False): + with patch("rebase_on_prs.subprocess.run", side_effect=side_effect): + exit_code = main(dependency_prs_str) + + with open(output_path, encoding="utf-8") as f: + return exit_code, f.read() + + def test_single_successful_rebase(self): + code, output = self._run_main("123", _gh_side_effect("feature-branch", "OPEN")) + self.assertEqual(code, 0) + self.assertIn("rebased=true\n", output) + self.assertIn("rebased_count=1\n", output) + self.assertIn("skipped_prs=\n", output) + self.assertIn("failed_prs=\n", output) + + def test_multiple_successful_rebases(self): + code, output = self._run_main("111,222", _gh_side_effect("feature-branch", "OPEN")) + self.assertEqual(code, 0) + self.assertIn("rebased=true\n", output) + self.assertIn("rebased_count=2\n", output) + + def test_all_prs_merged_no_rebase(self): + code, output = self._run_main("123,456", _gh_side_effect("feature-branch", "MERGED")) + self.assertEqual(code, 0) + self.assertIn("rebased=false\n", output) + self.assertIn("rebased_count=0\n", output) + self.assertIn("skipped_prs=123,456\n", output) + + def test_pr_not_found_returns_error(self): + code, output = self._run_main("999", lambda cmd, **kw: _completed(0, "\n")) + self.assertEqual(code, 1) + self.assertIn("failed_prs=999\n", output) + self.assertIn("rebased=false\n", output) + + def test_mixed_open_and_merged(self): + states = {"111": "OPEN", "222": "MERGED"} + + def side_effect(cmd, **kwargs): + if cmd[0] == "gh": + pr = cmd[3] + field = cmd[5] + if field == "headRefName": + return _completed(0, "feature-branch\n") + if field == "state": + return _completed(0, f"{states.get(pr, 'OPEN')}\n") + return _completed(0) + + code, output = self._run_main("111,222", side_effect) + self.assertEqual(code, 0) + self.assertIn("rebased_count=1\n", output) + self.assertIn("skipped_prs=222\n", output) + self.assertIn("failed_prs=\n", output) + + def test_one_failed_pr_returns_exit_1(self): + states = {"100": "OPEN", "200": "OPEN"} + + def side_effect(cmd, **kwargs): + if cmd[0] == "gh": + pr = cmd[3] + field = cmd[5] + if field == "headRefName": + return _completed(0, f"branch-{pr}\n") + if field == "state": + return _completed(0, f"{states.get(pr, 'OPEN')}\n") + if cmd[0] == "git": + if cmd[1] == "rebase" and cmd[2:] != ["--abort"]: + # Fail the rebase when targeting PR 200's local ref + if "pr/200" in cmd[2]: + return _completed(1) + return _completed(0) + return _completed(0) + + code, output = self._run_main("100,200", side_effect) + self.assertEqual(code, 1) + self.assertIn("rebased_count=1\n", output) + self.assertIn("failed_prs=200\n", output) + + def test_fork_pr_succeeds(self): + """A fork PR (branch not on origin) still succeeds because refs/pull//head is used.""" + # The branch name coming from gh would normally be on the fork owner's repo. + # With the old branch-name-based fetch this would fail; the new approach always + # fetches via refs/pull//head from origin, which GitHub exposes for every PR. + code, output = self._run_main("99", _gh_side_effect("some-fork-user:my-feature", "OPEN")) + self.assertEqual(code, 0) + self.assertIn("rebased=true\n", output) + self.assertIn("rebased_count=1\n", output) + + def test_fetch_uses_pr_ref_not_branch_name(self): + """Verify the exact git fetch refspec is refs/pull//head:.""" + fetch_cmds: list[list[str]] = [] + + def side_effect(cmd, **kwargs): + if cmd[0] == "gh": + field = cmd[5] + if field == "headRefName": + return _completed(0, "feature-branch\n") + if field == "state": + return _completed(0, "OPEN\n") + if cmd[0] == "git" and cmd[1] == "fetch": + fetch_cmds.append(list(cmd)) + return _completed(0) + + self._run_main("42", side_effect) + + self.assertEqual(len(fetch_cmds), 1) + self.assertEqual( + fetch_cmds[0], + ["git", "fetch", "origin", "refs/pull/42/head:refs/remotes/origin/pr/42"], + ) + + +if __name__ == "__main__": + unittest.main() +