From 62d17d79c2fcc466e89b60b901468b5298b31d2f Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 8 Sep 2026 15:20:09 +0300 Subject: [PATCH 1/2] Select CI checks from the full PR diff and add Python tooling --- .github/workflows/ci.yml | 58 ++++++ .github/workflows/pr-artifacts-comment.yml | 8 + .gitignore | 3 + AGENTS.md | 6 + README.md | 7 + docs/en/fastlane.md | 43 +++++ docs/ru/fastlane.md | 44 +++++ fastlane/Fastfile | 25 +++ fastlane/README.md | 3 + pyproject.toml | 8 + requirements-dev.txt | 2 + scripts/ci_changes.py | 122 +++++++++++++ scripts/github_actions.py | 195 ++++++++++++++++++++ scripts/tests/test_ci_changes.py | 118 ++++++++++++ scripts/tests/test_github_actions.py | 201 +++++++++++++++++++++ 15 files changed, 843 insertions(+) create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 scripts/ci_changes.py create mode 100755 scripts/github_actions.py create mode 100644 scripts/tests/test_ci_changes.py create mode 100644 scripts/tests/test_github_actions.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6564c7..72e0ad8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,5 @@ name: CI +run-name: CI · ${{ github.event.pull_request.title || github.ref_name }} on: pull_request: @@ -14,7 +15,34 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: Change scope + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + android: ${{ steps.scope.outputs.android }} + native: ${{ steps.scope.outputs.native }} + python: ${{ steps.scope.outputs.python }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Classify the complete change set + id: scope + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + EVENT_NAME: ${{ github.event_name }} + run: | + args=(--base "$BASE_SHA" --head "$HEAD_SHA" --github-output "$GITHUB_OUTPUT" --summary "$GITHUB_STEP_SUMMARY") + if [[ "$EVENT_NAME" == push ]]; then args+=(--push); fi + python3 scripts/ci_changes.py "${args[@]}" + native-tests: + needs: changes + if: needs.changes.outputs.native == 'true' name: Native Go tests runs-on: ubuntu-latest timeout-minutes: 20 @@ -37,7 +65,37 @@ jobs: - name: Run native tests with the race detector run: bundle exec fastlane android native_tests + python-tests: + needs: changes + if: needs.changes.outputs.python == 'true' + name: Python tests and style + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Ruby and Fastlane + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.10" + bundler-cache: true + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: requirements-dev.txt + + - name: Install Python formatting tools + run: python -m pip install -r requirements-dev.txt + + - name: Check Python formatting and Python tests + run: bundle exec fastlane android python_checks + android-tests: + needs: changes + if: needs.changes.outputs.android == 'true' name: Android tests and checks runs-on: ubuntu-latest timeout-minutes: 45 diff --git a/.github/workflows/pr-artifacts-comment.yml b/.github/workflows/pr-artifacts-comment.yml index 7191515..f3c36dc 100644 --- a/.github/workflows/pr-artifacts-comment.yml +++ b/.github/workflows/pr-artifacts-comment.yml @@ -33,6 +33,14 @@ jobs: return; } + const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { + ...context.repo, run_id: run.id, filter: 'latest', per_page: 100 + }); + if (jobs.some(job => job.name === 'Android tests and checks' && job.conclusion === 'skipped')) { + core.notice('Android checks were skipped by the change filter; no APK artifacts are expected'); + return; + } + const pullNumber = pullRequests[0].number; const expectedArtifacts = { debug: `megaproxy-pr-${pullNumber}-debug-apk`, diff --git a/.gitignore b/.gitignore index b8ad376..82d2f2d 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ native/*.aar app/libs/*.aar app/libs/megaproxy-sources.jar *.idsig + +__pycache__/ +.venv/ diff --git a/AGENTS.md b/AGENTS.md index cb6deaa..937344a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,11 +16,17 @@ branch names, credentials, signing material, or other secrets. - Direct scripts and Gradle tasks may remain implementation details behind Fastlane lanes, but documentation and CI should normally expose the Fastlane commands. +- Python runtime scripts use the standard library and native `gh` for GitHub access. Format with + pinned Black/isort through `python_format`; `python_tests` and `python_checks` use `PYTHON`. + ## CI and artifacts - Pull requests must run native tests and Android JVM unit/lint/build checks. Do not require an Android emulator in GitHub Actions: hosted-runner KVM availability proved too unreliable for a trustworthy required check. +- Classify the full PR diff, not only the latest push. Skip unrelated suites; unknown paths and + shared build/CI inputs enable all suites. Require `Change scope` and `Python tests and style` + alongside native/Android checks when this workflow is adopted. - PR builds may publish debug and unsigned APK artifacts. They must never have access to release signing material and must never produce or publish a signed release APK. - Surface downloadable APK artifacts in the GitHub Actions job summary in addition to uploading diff --git a/README.md b/README.md index d0b339d..6a90ce3 100644 --- a/README.md +++ b/README.md @@ -351,3 +351,10 @@ Android unit-test suites before opening a pull request. ## License MegaProxy is released under the [MIT License](LICENSE). + +### CI tools + +CI selects checks from the full PR diff. Use `python3 scripts/github_actions.py` to choose an open +PR and rerun all CI jobs or only failed jobs through GitHub CLI. Supports `--dry-run` and `--yes`/`-y`. +See the [English](docs/en/fastlane.md) or [Russian](docs/ru/fastlane.md) reference for scope rules, +Python formatting/tests and launcher setup. diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index 8eb1645..7879e64 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -73,3 +73,46 @@ Review changes to both `Gemfile` and `Gemfile.lock`. The official Fastlane docum committing the lock file and using `bundle exec fastlane` locally and in CI. [Русская версия](../ru/fastlane.md) + +## Selective CI and Python tooling + +CI classifies the **full PR diff against the merge base**, not only the latest commit. Pushes to +main compare the push endpoints. Python-only changes run Python checks; documentation-only changes +skip test jobs. Android source/resources/build inputs enable Android checks; native production +changes enable Go and Android, while Go test-only changes enable Go. Shared CI/Fastlane inputs and +unknown paths enable all suites. Failed diff calculation fails `Change scope` instead of silently +skipping tests. Skipped Android builds do not publish APK artifacts. + +Install the pinned development tools in a virtual environment: + +```sh +python3 -m venv .venv +. .venv/bin/activate +python -m pip install -r requirements-dev.txt +bundle exec fastlane android python_format +bundle exec fastlane android python_checks +``` + +`python_format` applies isort and Black. `python_tests` runs Python unit tests only; +`python_checks` checks formatting/import order and runs those tests. All three respect `PYTHON` +(default: `python3`). Runtime scripts use only Python's standard library. The CI job +`Python tests and style` runs independently of Android builds. + +## Interactive GitHub Actions launcher + +```sh +python3 scripts/github_actions.py +python3 scripts/github_actions.py --dry-run +python3 scripts/github_actions.py --yes +``` + +Choose an open PR and either rerun all CI jobs or only failed jobs. Requires GitHub CLI (`gh`) +and its existing authentication (`gh auth login`, `GH_TOKEN` or `GITHUB_TOKEN`). The launcher uses +native gh commands, no custom HTTP client or token storage. `--repo OWNER/REPO` overrides the repo. +`--yes` / `-y` skips final confirmation but retains menu selection and the stale-head check; +`--dry-run` always prevents launching. `q` or Ctrl+C cancels. Only open same-repository PRs are listed. +The script targets an existing completed CI run for the exact current PR commit. Running/queued +jobs and missing runs are rejected; CI normally starts on pushes. Failed-only mode requires a failed +run; cancelled runs can be rerun with all jobs. Launch failures/timeouts are never retried automatically. +Rerunning preserves that run's original commit and diff baseline; push a new commit to reassess scope +against an updated PR base. No device or release workflows are offered. diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index 14ca945..be3866a 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -75,3 +75,47 @@ bundle exec fastlane android test хранить lock-файл в репозитории и использовать `bundle exec fastlane` локально и в CI. [English version](../en/fastlane.md) + +## Выбор проверок CI и инструменты Python + +CI анализирует **полный diff PR относительно общей базы**, а не только последний коммит. Пуши в +main сравниваются по началу и концу пуша. Изменения только Python запускают Python-проверки; +изменения только документации пропускают тестовые задания. Исходники, ресурсы и сборка Android +включают Android-проверки; production-код Go включает Go и Android, изменения только Go-тестов — Go. +Общие файлы CI/Fastlane и неизвестные пути включают все проверки. Ошибка вычисления diff приводит +к ошибке `Change scope`, а не к тихому пропуску тестов. При пропуске Android-сборки APK не публикуются. + +Установите закреплённые версии инструментов в виртуальное окружение: + +```sh +python3 -m venv .venv +. .venv/bin/activate +python -m pip install -r requirements-dev.txt +bundle exec fastlane android python_format +bundle exec fastlane android python_checks +``` + +`python_format` применяет isort и Black. `python_tests` запускает только Python-тесты; +`python_checks` проверяет форматирование/импорты и запускает тесты. Все три команды учитывают `PYTHON` +(по умолчанию `python3`). Скрипты используют только стандартную библиотеку Python. Задание CI +`Python tests and style` выполняется независимо от Android-сборки. + +## Интерактивный запуск GitHub Actions + +```sh +python3 scripts/github_actions.py +python3 scripts/github_actions.py --dry-run +python3 scripts/github_actions.py --yes +``` + +Выберите открытый PR и повтор всего CI либо только упавших заданий. Нужен GitHub CLI (`gh`) +с авторизацией (`gh auth login`, `GH_TOKEN` или `GITHUB_TOKEN`). Скрипт вызывает штатные команды gh, +без своего HTTP-клиента и хранения токенов. `--repo OWNER/REPO` переопределяет репозиторий. +`--yes` / `-y` пропускает последнее подтверждение, сохраняя меню и проверку актуальности коммита; +`--dry-run` всегда запрещает запуск. `q` или Ctrl+C отменяет операцию. В меню только открытые PR +этого репозитория, без форков. Используется существующий завершённый CI-прогон текущего коммита PR. +Активные задания и отсутствие прогона приводят к отказу; обычно CI начинается после пуша. +Повтор только упавших заданий требует failed-прогона; cancelled можно повторить целиком. +Ошибки/таймауты запуска не приводят к автоматической повторной отправке. +Повтор сохраняет исходный коммит и базу diff того прогона; для пересчёта относительно обновлённой +базы PR нужен новый пуш. В меню нет запуска устройств или release-workflow. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 04e016a..ed6ae82 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -70,6 +70,31 @@ platform :android do android_checks end + desc "Run Python unit tests" + lane :python_tests do + python = ENV.fetch("PYTHON", "python3") + sh(python, "-m", "unittest", "discover", "-s", File.join(project_root, "scripts", "tests")) + end + + desc "Format Python scripts with isort and Black" + lane :python_format do + python = ENV.fetch("PYTHON", "python3") + Dir.chdir(project_root) do + sh(python, "-m", "isort", "scripts") + sh(python, "-m", "black", "scripts") + end + end + + desc "Check Python formatting, import order, and unit tests" + lane :python_checks do + python = ENV.fetch("PYTHON", "python3") + Dir.chdir(project_root) do + sh(python, "-m", "isort", "--check-only", "scripts") + sh(python, "-m", "black", "--check", "scripts") + end + python_tests + end + desc "Build a debug APK after preparing the native library" lane :debug_artifact do native_library diff --git a/fastlane/README.md b/fastlane/README.md index f691648..5c1e129 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -5,6 +5,9 @@ Fastlane is the supported entry point for tests and build artifacts. Install Rub | Command | Purpose | | --- | --- | +| `bundle exec fastlane android python_format` | Format Python with Black and isort | +| `bundle exec fastlane android python_tests` | Run Python unit tests | +| `bundle exec fastlane android python_checks` | Check Python style and run unit tests | | `bundle exec fastlane android native_tests` | Run Go tests with the race detector | | `bundle exec fastlane android android_checks` | Run Android tests and lint, build a debug APK and release APK, and prove that the release APK is unsigned | | `bundle exec fastlane android test` | Run all native and Android checks | diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..df37bfe --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[tool.black] +line-length = 88 +target-version = ["py310"] + +[tool.isort] +profile = "black" +line_length = 88 +py_version = 310 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..80e7482 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +black==26.5.1 +isort==9.0.1 diff --git a/scripts/ci_changes.py b/scripts/ci_changes.py new file mode 100644 index 0000000..394a621 --- /dev/null +++ b/scripts/ci_changes.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Classify the full PR diff for CI; unknown paths conservatively run every suite.""" + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path, PurePosixPath + +SUITES = ("android", "native", "python") + + +def classify(paths): + selected = set() + for path in paths: + # Markdown packaged in the app is runtime content, unlike repository docs. + if path.startswith(("app/src/main/assets/", "app/src/main/res/")): + selected.add("android") + elif ( + PurePosixPath(path).suffix.lower() == ".md" + or path.startswith(("docs/", "fastlane/metadata/", ".vscode/")) + or path in ("LICENSE", ".gitignore", ".gitattributes") + ): + continue + elif path.startswith((".github/", "fastlane/")) or path in ( + "Gemfile", + "Gemfile.lock", + ".ruby-version", + ): + selected.update(SUITES) + elif path.startswith("native/"): + selected.add("native") + if not path.endswith("_test.go"): + selected.add("android") + elif path.endswith(".py") or path in ( + "pyproject.toml", + "requirements-dev.txt", + ): + selected.add("python") + elif ( + path.startswith(("app/", "gradle/")) + or path.endswith((".gradle", ".gradle.kts")) + or path in ("gradlew", "gradlew.bat", "gradle.properties") + ): + selected.add("android") + elif path.startswith("scripts/") and path.endswith(".sh"): + selected.add("android") + else: + selected.update(SUITES) + return {suite: suite in selected for suite in SUITES} + + +def changed_files(base, head, pull_request=True): + if not all(re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", sha) for sha in (base, head)): + raise RuntimeError("Expected full base and head commit SHAs") + if not base.strip("0"): + return None # Initial push has no reliable comparison point: run everything. + commits = [f"{base}...{head}"] if pull_request else [base, head] + try: + output = subprocess.run( + ["git", "diff", "--name-only", "--no-renames", "-z", *commits, "--"], + capture_output=True, + check=True, + timeout=60, + ).stdout + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + raise RuntimeError( + "Could not compute the full diff; CI scope must not be skipped" + ) from None + # --no-renames includes both paths when a code file moves into a docs-only directory. + return [ + name.decode("utf-8", errors="surrogateescape") + for name in output.split(b"\0") + if name + ] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", required=True) + parser.add_argument("--head", required=True) + parser.add_argument( + "--push", + action="store_true", + help="Compare push endpoints instead of the PR merge base", + ) + parser.add_argument("--github-output", type=Path) + parser.add_argument("--summary", type=Path) + args = parser.parse_args() + try: + paths = changed_files(args.base, args.head, not args.push) + result = classify(paths) if paths is not None else dict.fromkeys(SUITES, True) + print( + json.dumps( + {**result, "changed_files": len(paths) if paths is not None else None} + ) + ) + if args.github_output: + with args.github_output.open("a") as output: + for suite, enabled in result.items(): + output.write(f"{suite}={str(enabled).lower()}\n") + if args.summary: + with args.summary.open("a") as summary: + summary.write( + "## CI change scope\n\nCompared the full PR diff" + if not args.push + else "## CI change scope\n\nCompared the push endpoints" + ) + summary.write(f" (`{args.base[:12]}` → `{args.head[:12]}`).\n\n") + for suite, enabled in result.items(): + summary.write( + f"- {suite}: {'run' if enabled else 'skip — no relevant changes'}\n" + ) + return 0 + except RuntimeError as error: + print(str(error), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/github_actions.py b/scripts/github_actions.py new file mode 100755 index 0000000..cc93c6c --- /dev/null +++ b/scripts/github_actions.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Choose a PR and launch its tests through the authenticated GitHub CLI.""" + +import argparse +import json +import os +import re +import shlex +import subprocess +import sys + +REPOSITORY = "andre487/AndroidMegaProxy" +MODES = [ + ("ci", "Re-run all CI jobs"), + ("failed", "Re-run failed CI jobs only"), +] +PR_FIELDS = "number,title,state,headRefName,headRefOid,isCrossRepository" + + +class GitHub: + def __init__(self, repository): + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise RuntimeError("Repository must be OWNER/REPO.") + self.repository = repository + + def argv(self, *args): + return ["gh", *args, "--repo", self.repository] + + def run(self, *args): + mutating = args[:2] == ("run", "rerun") + uncertain = ( + " Check Actions before retrying; the launch may have been accepted." + if mutating + else "" + ) + env = os.environ.copy() + env.pop("GH_DEBUG", None) + try: + result = subprocess.run( + self.argv(*args), capture_output=True, text=True, timeout=60, env=env + ) + except FileNotFoundError: + raise RuntimeError( + "GitHub CLI is required. Install gh and run: gh auth login" + ) from None + except subprocess.TimeoutExpired: + raise RuntimeError("GitHub CLI timed out." + uncertain) from None + if result.returncode: + # Authentication is owned by gh; never echo tokens from its diagnostics. + diagnostic = result.stderr.strip() + for name in ("GH_TOKEN", "GITHUB_TOKEN"): + if env.get(name): + diagnostic = diagnostic.replace(env[name], "") + diagnostic = "".join(c if c.isprintable() else " " for c in diagnostic)[ + :500 + ] + raise RuntimeError(f"GitHub CLI failed: {diagnostic}" + uncertain) + return result.stdout.strip() + + def read_json(self, *args): + try: + return json.loads(self.run(*args)) + except json.JSONDecodeError: + raise RuntimeError( + "GitHub CLI returned invalid JSON. Update gh and retry." + ) from None + + def open_prs(self): + return self.read_json( + "pr", "list", "--state", "open", "--limit", "1000", "--json", PR_FIELDS + ) + + def pr(self, number): + return self.read_json("pr", "view", str(number), "--json", PR_FIELDS) + + +def choose(title, options): + print("\n" + title) + for index, (_, label) in enumerate(options, 1): + print(f" {index}. " + "".join(c if c.isprintable() else " " for c in label)) + while True: + value = input("Choose a number (q to cancel): ").strip() + if value.lower() == "q": + raise KeyboardInterrupt + if value.isdecimal() and 1 <= int(value) <= len(options): + return options[int(value) - 1][0] + print("Enter a number from the list.") + + +def plan_run(client, pr, mode): + if pr["state"] != "OPEN" or pr["isCrossRepository"]: + raise RuntimeError( + "Select an open PR from this repository; forks are unsupported." + ) + if mode not in ("ci", "failed"): + raise RuntimeError("Unknown test selection.") + runs = client.read_json( + "run", + "list", + "--workflow", + "ci.yml", + "--commit", + pr["headRefOid"], + "--branch", + pr["headRefName"], + "--event", + "pull_request", + "--limit", + "1", + "--json", + "databaseId,status,conclusion,url,headSha,headBranch", + ) + if ( + not runs + or runs[0]["headSha"] != pr["headRefOid"] + or runs[0]["headBranch"] != pr["headRefName"] + ): + raise RuntimeError( + "No CI run exists for this PR commit yet. CI starts automatically on pushes." + ) + run = runs[0] + if run["status"] != "completed": + raise RuntimeError("CI is already queued or running: " + run["url"]) + command = ["run", "rerun", str(run["databaseId"])] + if mode == "failed": + if run["conclusion"] != "failure": + raise RuntimeError( + "This CI run has no failed conclusion; choose all jobs instead." + ) + command.append("--failed") + return command, run["url"] + + +def launch(client, pr, mode, dry_run=False, yes=False): + command, url = plan_run(client, pr, mode) + print(f'\nPR #{pr["number"]}, commit {pr["headRefOid"][:12]}') + print(shlex.join(client.argv(*command))) + if dry_run: + print("Dry run: no workflow started.\n" + url) + return + if not yes and input("Start this run? [y/N]: ").strip().lower() != "y": + print("Cancelled.") + return + current = client.pr(pr["number"]) + if ( + current["state"] != "OPEN" + or current["isCrossRepository"] + or current["headRefOid"] != pr["headRefOid"] + or current["headRefName"] != pr["headRefName"] + ): + raise RuntimeError( + "The PR changed while choosing. Start again to review its current commit." + ) + # Rerun the selected immutable run ID; never retry an ambiguous launch. + output = client.run(*command) + print("Launch accepted.\n" + (output or url)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", default=REPOSITORY, help="GitHub OWNER/REPO") + parser.add_argument( + "--dry-run", action="store_true", help="Preview without starting workflows" + ) + parser.add_argument( + "--yes", + "-y", + action="store_true", + help="Skip final launch confirmation; still choose a PR and CI action", + ) + args = parser.parse_args() + try: + client = GitHub(args.repo) + prs = [pr for pr in client.open_prs() if not pr["isCrossRepository"]] + if not prs: + print("No open PRs from repository branches.") + return 0 + number = choose( + "Open pull requests", + [(pr["number"], f'#{pr["number"]} {pr["title"]}') for pr in prs], + ) + pr = client.pr(number) + mode = choose("Tests to run", MODES) + launch(client, pr, mode, args.dry_run, args.yes) + return 0 + except (KeyboardInterrupt, EOFError): + print("\nCancelled.") + return 130 + except RuntimeError as error: + print(str(error), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_ci_changes.py b/scripts/tests/test_ci_changes.py new file mode 100644 index 0000000..019fd32 --- /dev/null +++ b/scripts/tests/test_ci_changes.py @@ -0,0 +1,118 @@ +import importlib.util +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location( + "ci_changes", Path(__file__).parents[1] / "ci_changes.py" +) +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) + + +class ChangeScopeTest(unittest.TestCase): + def test_python_and_markdown_do_not_run_android(self): + self.assertEqual( + {"android": False, "native": False, "python": True}, + m.classify(["scripts/github_actions.py", "docs/ru/fastlane.md"]), + ) + + def test_docs_only_skip_all_suites(self): + self.assertFalse( + any( + m.classify( + ["README.md", "native/README.md", "docs/assets/preview.png"] + ).values() + ) + ) + + def test_runtime_markdown_is_android_content(self): + self.assertTrue(m.classify(["app/src/main/assets/help.md"])["android"]) + + def test_native_production_changes_rebuild_android(self): + self.assertEqual( + {"android": True, "native": True, "python": False}, + m.classify(["native/mobile/dialer.go"]), + ) + self.assertEqual( + {"android": False, "native": True, "python": False}, + m.classify(["native/mobile/dialer_test.go"]), + ) + + def test_build_inputs_and_unknown_paths_are_not_silently_skipped(self): + for name in [ + "app/build.gradle.kts", + "gradle.properties", + "gradle/wrapper/gradle-wrapper.jar", + "scripts/build-fdroid-native.sh", + ]: + self.assertTrue(m.classify([name])["android"]) + for name in [ + ".github/workflows/ci.yml", + "fastlane/Fastfile", + "new-build-input.conf", + ]: + self.assertTrue(all(m.classify([name]).values())) + + def test_first_push_runs_everything_and_diff_failure_is_not_empty_diff(self): + self.assertIsNone(m.changed_files("0" * 40, "a" * 40, False)) + with ( + patch.object( + m.subprocess, "run", side_effect=subprocess.CalledProcessError(1, "git") + ), + self.assertRaises(RuntimeError), + ): + m.changed_files("a" * 40, "b" * 40) + + def test_full_pr_diff_includes_earlier_commits_and_deleted_code(self): + with tempfile.TemporaryDirectory() as root: + + def git(*args): + return subprocess.check_output( + ["git", "-C", root, *args], text=True + ).strip() + + git("init", "-q") + git("config", "user.name", "Test") + git("config", "user.email", "test@example.invalid") + git("config", "commit.gpgsign", "false") + path = Path(root) / "README.md" + path.write_text("base") + git("add", ".") + git("commit", "-qm", "base") + base = git("rev-parse", "HEAD") + code = Path(root) / "app/src/main/example.kt" + code.parent.mkdir(parents=True) + code.write_text("code") + git("add", ".") + git("commit", "-qm", "Android change") + path.write_text("docs only in latest commit") + git("add", ".") + git("commit", "-qm", "docs") + head = git("rev-parse", "HEAD") + original = m.subprocess.run + with patch.object( + m.subprocess, + "run", + side_effect=lambda *a, **kw: original(*a, cwd=root, **kw), + ): + self.assertTrue(m.classify(m.changed_files(base, head))["android"]) + previous = head + code.rename(Path(root) / "moved.md") + git("add", "-A") + git("commit", "-qm", "move code to docs") + head = git("rev-parse", "HEAD") + with patch.object( + m.subprocess, + "run", + side_effect=lambda *a, **kw: original(*a, cwd=root, **kw), + ): + files = m.changed_files(previous, head) + self.assertIn("app/src/main/example.kt", files) + self.assertTrue(m.classify(files)["android"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_github_actions.py b/scripts/tests/test_github_actions.py new file mode 100644 index 0000000..5098d2d --- /dev/null +++ b/scripts/tests/test_github_actions.py @@ -0,0 +1,201 @@ +import copy +import importlib.util +import os +import subprocess +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + +spec = importlib.util.spec_from_file_location( + "github_actions", Path(__file__).parents[1] / "github_actions.py" +) +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) + + +class LauncherTest(unittest.TestCase): + def setUp(self): + self.client = m.GitHub(m.REPOSITORY) + self.pr = { + "number": 31, + "state": "OPEN", + "headRefOid": "abc", + "headRefName": "feature/test", + "isCrossRepository": False, + } + + self.discovery = patch.object( + self.client, + "read_json", + return_value=[ + { + "databaseId": 20, + "headSha": "abc", + "headBranch": "feature/test", + "status": "completed", + "conclusion": "failure", + "url": "https://github.com/run", + } + ], + ).start() + self.addCleanup(patch.stopall) + + def test_all_and_failed_use_distinct_rerun_arguments(self): + self.assertEqual( + ["run", "rerun", "20"], m.plan_run(self.client, self.pr, "ci")[0] + ) + self.assertEqual( + ["run", "rerun", "20", "--failed"], + m.plan_run(self.client, self.pr, "failed")[0], + ) + + def test_failed_mode_rejects_successful_run(self): + self.discovery.return_value[0]["conclusion"] = "success" + with self.assertRaisesRegex(RuntimeError, "no failed conclusion"): + m.plan_run(self.client, self.pr, "failed") + + def test_dry_run_never_launches_or_requests_confirmation(self): + with ( + patch.object(self.client, "run", side_effect=AssertionError), + patch("builtins.input", side_effect=AssertionError), + ): + m.launch(self.client, self.pr, "failed", dry_run=True) + + def test_changed_or_closed_pr_prevents_dispatch(self): + for change in ("sha", "closed", "fork"): + current = copy.deepcopy(self.pr) + if change == "sha": + current["headRefOid"] = "new" + elif change == "closed": + current["state"] = "CLOSED" + else: + current["isCrossRepository"] = True + with ( + patch.object(self.client, "pr", return_value=current), + patch.object(self.client, "run", side_effect=AssertionError), + patch("builtins.input", return_value="y"), + self.assertRaisesRegex(RuntimeError, "PR changed"), + ): + m.launch(self.client, self.pr, "ci") + + def test_cancel_never_launches(self): + with ( + patch.object(self.client, "run", side_effect=AssertionError), + patch("builtins.input", return_value="n"), + ): + m.launch(self.client, self.pr, "ci") + + def test_confirmed_dispatch_launches_exactly_once(self): + with ( + patch.object(self.client, "pr", return_value=self.pr), + patch.object(self.client, "run", return_value="") as run, + patch("builtins.input", return_value="y"), + ): + m.launch(self.client, self.pr, "ci") + run.assert_called_once_with("run", "rerun", "20") + + def test_yes_skips_confirmation_but_still_rechecks_the_commit(self): + with ( + patch.object(self.client, "pr", return_value=self.pr) as pr, + patch.object(self.client, "run", return_value="") as run, + patch("builtins.input", side_effect=AssertionError), + ): + m.launch(self.client, self.pr, "ci", yes=True) + pr.assert_called_once_with(31) + run.assert_called_once() + changed = dict(self.pr, headRefOid="new") + with ( + patch.object(self.client, "pr", return_value=changed), + patch.object(self.client, "run", side_effect=AssertionError), + patch("builtins.input", side_effect=AssertionError), + self.assertRaisesRegex(RuntimeError, "PR changed"), + ): + m.launch(self.client, self.pr, "ci", yes=True) + + def test_dry_run_wins_over_yes(self): + with ( + patch.object(self.client, "run", side_effect=AssertionError), + patch("builtins.input", side_effect=AssertionError), + ): + m.launch(self.client, self.pr, "failed", dry_run=True, yes=True) + + def test_fork_is_rejected(self): + self.pr["isCrossRepository"] = True + with self.assertRaisesRegex(RuntimeError, "forks"): + m.plan_run(self.client, self.pr, "failed") + + def test_ci_filters_current_commit_and_branch(self): + run = { + "databaseId": 20, + "headSha": "abc", + "headBranch": "feature/test", + "status": "completed", + "url": "https://github.com/run", + } + with patch.object(self.client, "read_json", return_value=[run]) as read: + self.assertEqual( + ["run", "rerun", "20"], m.plan_run(self.client, self.pr, "ci")[0] + ) + args = read.call_args.args + self.assertEqual("abc", args[args.index("--commit") + 1]) + self.assertEqual("feature/test", args[args.index("--branch") + 1]) + run["headSha"] = "old" + with self.assertRaisesRegex(RuntimeError, "No CI run"): + m.plan_run(self.client, self.pr, "ci") + run.update(headSha="abc", status="in_progress") + with self.assertRaisesRegex(RuntimeError, "already queued or running"): + m.plan_run(self.client, self.pr, "ci") + + def test_arguments_are_passed_without_a_shell(self): + with patch.object( + m.subprocess, "run", return_value=Mock(returncode=0, stdout="[]", stderr="") + ) as run: + self.client.run("pr", "view", "branch/$(not-a-command)", "--json", "number") + args, kwargs = run.call_args + self.assertEqual( + [ + "gh", + "pr", + "view", + "branch/$(not-a-command)", + "--json", + "number", + "--repo", + m.REPOSITORY, + ], + args[0], + ) + self.assertFalse(kwargs.get("shell", False)) + + def test_timeout_never_retries_a_launch(self): + with patch.object( + m.subprocess, "run", side_effect=subprocess.TimeoutExpired(["gh"], 60) + ) as run: + with self.assertRaisesRegex(RuntimeError, "Check Actions before retrying"): + self.client.run("run", "rerun", "20") + self.assertEqual(1, run.call_count) + + def test_missing_cli_is_explained(self): + with ( + patch.object(m.subprocess, "run", side_effect=FileNotFoundError), + self.assertRaisesRegex(RuntimeError, "gh auth login"), + ): + self.client.run("pr", "list") + + def test_tokens_are_not_echoed_from_cli_errors(self): + with ( + patch.dict(os.environ, {"GH_TOKEN": "test-token", "GH_DEBUG": "api"}), + patch.object( + m.subprocess, + "run", + return_value=Mock(returncode=1, stderr="failed test-token"), + ) as run, + ): + with self.assertRaises(RuntimeError) as raised: + self.client.run("pr", "list") + self.assertNotIn("test-token", str(raised.exception)) + self.assertNotIn("GH_DEBUG", run.call_args.kwargs["env"]) + + +if __name__ == "__main__": + unittest.main() From 84e59f4b0a899ac7a0a43fa36328c7a4dbe7cebf Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 8 Sep 2026 15:29:40 +0300 Subject: [PATCH 2/2] Compare CI suites against their last successful ancestor checks --- .github/workflows/ci.yml | 12 +++- AGENTS.md | 5 +- README.md | 2 +- docs/en/fastlane.md | 12 +++- docs/ru/fastlane.md | 15 +++-- scripts/ci_changes.py | 80 ++++++++++++++++++----- scripts/ci_history.py | 93 +++++++++++++++++++++++++++ scripts/tests/test_ci_changes.py | 43 +++++++++++++ scripts/tests/test_ci_history.py | 107 +++++++++++++++++++++++++++++++ 9 files changed, 342 insertions(+), 27 deletions(-) create mode 100644 scripts/ci_history.py create mode 100644 scripts/tests/test_ci_history.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72e0ad8..9753195 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,9 @@ concurrency: jobs: changes: name: Change scope + permissions: + contents: read + actions: read runs-on: ubuntu-latest timeout-minutes: 5 outputs: @@ -29,15 +32,20 @@ jobs: ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 persist-credentials: false - - name: Classify the complete change set + - name: "Comparison base: ${{ github.event.pull_request.base.sha || github.event.before }}" + run: ":" + - name: Classify changes since successful checks id: scope env: BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} EVENT_NAME: ${{ github.event_name }} + PR_BRANCH: ${{ github.head_ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ github.token }} run: | args=(--base "$BASE_SHA" --head "$HEAD_SHA" --github-output "$GITHUB_OUTPUT" --summary "$GITHUB_STEP_SUMMARY") - if [[ "$EVENT_NAME" == push ]]; then args+=(--push); fi + if [[ "$EVENT_NAME" == push ]]; then args+=(--push); else args+=(--history); fi python3 scripts/ci_changes.py "${args[@]}" native-tests: diff --git a/AGENTS.md b/AGENTS.md index 937344a..985a43f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,8 +24,9 @@ branch names, credentials, signing material, or other secrets. - Pull requests must run native tests and Android JVM unit/lint/build checks. Do not require an Android emulator in GitHub Actions: hosted-runner KVM availability proved too unreliable for a trustworthy required check. -- Classify the full PR diff, not only the latest push. Skip unrelated suites; unknown paths and - shared build/CI inputs enable all suites. Require `Change scope` and `Python tests and style` +- Compare each suite against its last successful ancestor check in the same PR and base. + Failed/skipped/cancelled jobs do not advance coverage. Fall back to the full PR diff when + history is unavailable; unknown paths and shared build/CI inputs enable all suites. Require `Change scope` and `Python tests and style` alongside native/Android checks when this workflow is adopted. - PR builds may publish debug and unsigned APK artifacts. They must never have access to release signing material and must never produce or publish a signed release APK. diff --git a/README.md b/README.md index 6a90ce3..3c64ef9 100644 --- a/README.md +++ b/README.md @@ -354,7 +354,7 @@ MegaProxy is released under the [MIT License](LICENSE). ### CI tools -CI selects checks from the full PR diff. Use `python3 scripts/github_actions.py` to choose an open +CI selects checks from changes since each suite’s last successful ancestor check, with a full PR diff fallback. Use `python3 scripts/github_actions.py` to choose an open PR and rerun all CI jobs or only failed jobs through GitHub CLI. Supports `--dry-run` and `--yes`/`-y`. See the [English](docs/en/fastlane.md) or [Russian](docs/ru/fastlane.md) reference for scope rules, Python formatting/tests and launcher setup. diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index 7879e64..7c3067a 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -76,9 +76,15 @@ committing the lock file and using `bundle exec fastlane` locally and in CI. ## Selective CI and Python tooling -CI classifies the **full PR diff against the merge base**, not only the latest commit. Pushes to -main compare the push endpoints. Python-only changes run Python checks; documentation-only changes -skip test jobs. Android source/resources/build inputs enable Android checks; native production +For each suite, CI compares the current PR head with the last successful ancestor check for that +suite. Failed, cancelled and skipped jobs do not count as successful coverage. Candidates must +belong to the same PR and repository, use the same recorded PR base and precede the current run. +Rebased-away commits are ignored. The history search examines the latest 30 completed CI runs on +the branch through gh; missing history, API errors and old runs without a recorded base fall back +to the full PR diff. Pushes to main compare push endpoints. Each suite's baseline and decision are +shown in the Actions summary. Reruns exclude their own run ID from baseline selection. + +Python-only changes run Python checks; documentation-only changes skip test jobs. Native production changes enable Go and Android, while Go test-only changes enable Go. Shared CI/Fastlane inputs and unknown paths enable all suites. Failed diff calculation fails `Change scope` instead of silently skipping tests. Skipped Android builds do not publish APK artifacts. diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index be3866a..f18f7ca 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -78,10 +78,17 @@ bundle exec fastlane android test ## Выбор проверок CI и инструменты Python -CI анализирует **полный diff PR относительно общей базы**, а не только последний коммит. Пуши в -main сравниваются по началу и концу пуша. Изменения только Python запускают Python-проверки; -изменения только документации пропускают тестовые задания. Исходники, ресурсы и сборка Android -включают Android-проверки; production-код Go включает Go и Android, изменения только Go-тестов — Go. +Для каждого набора CI сравнивает текущий head PR с последним успешно проверенным коммитом-предком +для этого набора. Упавшие, отменённые и пропущенные задания не считаются успешной проверкой. +Кандидат должен относиться к тому же PR и репозиторию, иметь ту же сохранённую базу PR и быть старше +текущего прогона. Коммиты из отброшенной после rebase истории не используются. Через gh проверяются +последние 30 завершённых CI-прогонов ветки. Если истории нет, API недоступен или старый прогон не +сохранял базу, используется полный diff PR. Пуши в main сравниваются по началу и концу пуша. +База сравнения и решение для каждого набора видны в summary Actions. При повторе собственный run ID +не используется как предыдущая проверка. + +Изменения только Python запускают Python-проверки; изменения только документации пропускают +тестовые задания. Production-код Go включает Go и Android, изменения только Go-тестов — Go. Общие файлы CI/Fastlane и неизвестные пути включают все проверки. Ошибка вычисления diff приводит к ошибке `Change scope`, а не к тихому пропуску тестов. При пропуске Android-сборки APK не публикуются. diff --git a/scripts/ci_changes.py b/scripts/ci_changes.py index 394a621..a67c11a 100644 --- a/scripts/ci_changes.py +++ b/scripts/ci_changes.py @@ -1,13 +1,16 @@ #!/usr/bin/env python3 -"""Classify the full PR diff for CI; unknown paths conservatively run every suite.""" +"""Select CI from the last successful ancestor check; unknown paths conservatively run every suite.""" import argparse import json +import os import re import subprocess import sys from pathlib import Path, PurePosixPath +from ci_history import successful_baselines + SUITES = ("android", "native", "python") @@ -27,6 +30,8 @@ def classify(paths): "Gemfile", "Gemfile.lock", ".ruby-version", + "scripts/ci_changes.py", + "scripts/ci_history.py", ): selected.update(SUITES) elif path.startswith("native/"): @@ -76,6 +81,23 @@ def changed_files(base, head, pull_request=True): ] +def select_suites(base, head, pull_request=True, baselines=None): + baselines = baselines or {} + result = {} + details = {} + fallback = changed_files(base, head, pull_request) + for suite in SUITES: + previous = baselines.get(suite) + paths = changed_files(previous["sha"], head, False) if previous else fallback + result[suite] = paths is None or classify(paths)[suite] + details[suite] = { + "base": previous["sha"] if previous else base, + "run_id": previous["run_id"] if previous else None, + "changed_files": len(paths) if paths is not None else None, + } + return result, details + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--base", required=True) @@ -85,32 +107,60 @@ def main(): action="store_true", help="Compare push endpoints instead of the PR merge base", ) + parser.add_argument( + "--history", + action="store_true", + help="Reuse successful ancestor checks for this PR", + ) parser.add_argument("--github-output", type=Path) parser.add_argument("--summary", type=Path) args = parser.parse_args() try: - paths = changed_files(args.base, args.head, not args.push) - result = classify(paths) if paths is not None else dict.fromkeys(SUITES, True) - print( - json.dumps( - {**result, "changed_files": len(paths) if paths is not None else None} - ) - ) + baselines = {} + if args.history and not args.push: + try: + baselines = successful_baselines( + os.environ["GITHUB_REPOSITORY"], + os.environ["PR_BRANCH"], + int(os.environ["PR_NUMBER"]), + args.base, + args.head, + int(os.environ["GITHUB_RUN_ID"]), + ) + except ( + RuntimeError, + OSError, + ValueError, + KeyError, + TypeError, + subprocess.TimeoutExpired, + ): + print( + "Check history unavailable; using the full PR diff", file=sys.stderr + ) + result, details = select_suites(args.base, args.head, not args.push, baselines) + print(json.dumps({**result, "comparisons": details})) if args.github_output: with args.github_output.open("a") as output: for suite, enabled in result.items(): output.write(f"{suite}={str(enabled).lower()}\n") if args.summary: with args.summary.open("a") as summary: - summary.write( - "## CI change scope\n\nCompared the full PR diff" - if not args.push - else "## CI change scope\n\nCompared the push endpoints" - ) - summary.write(f" (`{args.base[:12]}` → `{args.head[:12]}`).\n\n") + summary.write("## CI change scope\n\n") for suite, enabled in result.items(): + detail = details[suite] + origin = ( + f"successful run {detail['run_id']}" + if detail["run_id"] + else ( + "full PR diff (no reusable success)" + if not args.push + else "push endpoints" + ) + ) summary.write( - f"- {suite}: {'run' if enabled else 'skip — no relevant changes'}\n" + f"- {suite}: {'run' if enabled else 'skip'}; {origin}; " + f"`{detail['base'][:12]}` → `{args.head[:12]}`\n" ) return 0 except RuntimeError as error: diff --git a/scripts/ci_history.py b/scripts/ci_history.py new file mode 100644 index 0000000..c2539ce --- /dev/null +++ b/scripts/ci_history.py @@ -0,0 +1,93 @@ +"""Find successful ancestor checks through gh; never treat skipped jobs as coverage.""" + +import json +import subprocess +import urllib.parse + +CHECKS = { + "android": "Android tests and checks", + "native": "Native Go tests", + "python": "Python tests and style", +} +BASE_STEP = "Comparison base: " + + +def github_json(endpoint): + result = subprocess.run( + ["gh", "api", endpoint], capture_output=True, text=True, timeout=30 + ) + if result.returncode: + raise RuntimeError("GitHub check history is unavailable") + return json.loads(result.stdout) + + +def is_ancestor(base, head): + result = subprocess.run( + ["git", "merge-base", "--is-ancestor", base, head], + capture_output=True, + timeout=15, + ) + return result.returncode == 0 + + +def successful_baselines( + repository, + branch, + pr_number, + base, + head, + run_id, + read=github_json, + ancestor=is_ancestor, +): + query = urllib.parse.urlencode( + { + "branch": branch, + "event": "pull_request", + "status": "completed", + "per_page": 30, + } + ) + runs = read(f"repos/{repository}/actions/workflows/ci.yml/runs?{query}")[ + "workflow_runs" + ] + baselines = {} + for run in sorted(runs, key=lambda run: run["id"], reverse=True): + sha = run["head_sha"] + # Exclude this run (including reruns) and later runs, forks, other PRs and rebased history. + if ( + run["id"] >= run_id + or run["head_branch"] != branch + or not any( + pr["number"] == pr_number + and pr["head"]["repo"]["id"] == pr["base"]["repo"]["id"] + for pr in run.get("pull_requests", []) + ) + or not ancestor(sha, head) + ): + continue + jobs = read( + f"repos/{repository}/actions/runs/{run['id']}/jobs?filter=latest&per_page=100" + )["jobs"] + # API PR references can change; this successful step records the actual event base. + if not any( + job["name"] == "Change scope" + and job["conclusion"] == "success" + and any( + step["name"] == BASE_STEP + base and step["conclusion"] == "success" + for step in job.get("steps", []) + ) + for job in jobs + ): + continue + for suite, name in CHECKS.items(): + matching = [job for job in jobs if job["name"] == name] + if ( + suite not in baselines + and len(matching) == 1 + and matching[0]["conclusion"] == "success" + ): + baselines[suite] = {"sha": sha, "run_id": run["id"]} + if len(baselines) == len(CHECKS): + break + return baselines diff --git a/scripts/tests/test_ci_changes.py b/scripts/tests/test_ci_changes.py index 019fd32..7bc607e 100644 --- a/scripts/tests/test_ci_changes.py +++ b/scripts/tests/test_ci_changes.py @@ -1,10 +1,16 @@ import importlib.util +import io +import json +import os import subprocess +import sys import tempfile import unittest from pathlib import Path from unittest.mock import patch +sys.path.insert(0, str(Path(__file__).parents[1])) + spec = importlib.util.spec_from_file_location( "ci_changes", Path(__file__).parents[1] / "ci_changes.py" ) @@ -53,6 +59,8 @@ def test_build_inputs_and_unknown_paths_are_not_silently_skipped(self): ".github/workflows/ci.yml", "fastlane/Fastfile", "new-build-input.conf", + "scripts/ci_changes.py", + "scripts/ci_history.py", ]: self.assertTrue(all(m.classify([name]).values())) @@ -66,6 +74,35 @@ def test_first_push_runs_everything_and_diff_failure_is_not_empty_diff(self): ): m.changed_files("a" * 40, "b" * 40) + def test_history_failure_falls_back_to_full_diff(self): + output = io.StringIO() + with ( + patch.object( + sys, + "argv", + ["ci_changes.py", "--base", "a" * 40, "--head", "b" * 40, "--history"], + ), + patch.dict( + os.environ, + { + "GITHUB_REPOSITORY": "owner/repo", + "PR_BRANCH": "feature", + "PR_NUMBER": "32", + "GITHUB_RUN_ID": "40", + }, + ), + patch.object( + m, "successful_baselines", side_effect=RuntimeError("unavailable") + ), + patch.object( + m, "changed_files", return_value=["app/src/main/Main.kt"] + ) as diff, + patch.object(sys, "stdout", output), + ): + self.assertEqual(0, m.main()) + self.assertTrue(json.loads(output.getvalue())["android"]) + diff.assert_called_once_with("a" * 40, "b" * 40, True) + def test_full_pr_diff_includes_earlier_commits_and_deleted_code(self): with tempfile.TemporaryDirectory() as root: @@ -88,6 +125,7 @@ def git(*args): code.write_text("code") git("add", ".") git("commit", "-qm", "Android change") + checked = git("rev-parse", "HEAD") path.write_text("docs only in latest commit") git("add", ".") git("commit", "-qm", "docs") @@ -99,6 +137,11 @@ def git(*args): side_effect=lambda *a, **kw: original(*a, cwd=root, **kw), ): self.assertTrue(m.classify(m.changed_files(base, head))["android"]) + result, detail = m.select_suites( + base, head, baselines={"android": {"sha": checked, "run_id": 1}} + ) + self.assertFalse(result["android"]) + self.assertEqual(1, detail["android"]["run_id"]) previous = head code.rename(Path(root) / "moved.md") git("add", "-A") diff --git a/scripts/tests/test_ci_history.py b/scripts/tests/test_ci_history.py new file mode 100644 index 0000000..43f70d3 --- /dev/null +++ b/scripts/tests/test_ci_history.py @@ -0,0 +1,107 @@ +import copy +import sys +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).parents[1])) +import ci_history as m + + +class HistoryTest(unittest.TestCase): + def setUp(self): + self.base = "b" * 40 + self.runs = [self.run_data(30, "3" * 40), self.run_data(20, "2" * 40)] + self.jobs = { + 30: self.job_data("failure", "skipped", "success"), + 20: self.job_data("success", "success", "success"), + } + self.ancestor = Mock(return_value=True) + + def run_data(self, number, sha): + return { + "id": number, + "head_sha": sha, + "head_branch": "feature/test", + "pull_requests": [ + {"number": 32, "head": {"repo": {"id": 1}}, "base": {"repo": {"id": 1}}} + ], + } + + def job_data(self, android, native, python): + return [ + { + "name": "Change scope", + "conclusion": "success", + "steps": [{"name": m.BASE_STEP + self.base, "conclusion": "success"}], + } + ] + [ + {"name": name, "conclusion": result} + for name, result in zip(m.CHECKS.values(), (android, native, python)) + ] + + def read(self, endpoint): + if "/workflows/" in endpoint: + return {"workflow_runs": self.runs} + return {"jobs": self.jobs[int(endpoint.split("/runs/")[1].split("/")[0])]} + + def select(self): + return m.successful_baselines( + "owner/repo", + "feature/test", + 32, + self.base, + "h" * 40, + 40, + self.read, + self.ancestor, + ) + + def test_each_suite_uses_its_last_actual_success(self): + result = self.select() + self.assertEqual(20, result["android"]["run_id"]) + self.assertEqual(20, result["native"]["run_id"]) + self.assertEqual(30, result["python"]["run_id"]) + + def test_cancelled_failed_and_skipped_jobs_do_not_advance_baseline(self): + self.jobs[30] = self.job_data("cancelled", "failure", "skipped") + self.assertEqual({20}, {item["run_id"] for item in self.select().values()}) + + def test_other_base_or_missing_marker_is_not_reused(self): + self.jobs[30][0]["steps"][0]["name"] = m.BASE_STEP + "other-base" + self.jobs[20][0]["steps"] = [] + self.assertEqual({}, self.select()) + + def test_nonancestor_is_not_reused(self): + self.ancestor.return_value = False + self.assertEqual({}, self.select()) + + def test_current_future_other_pr_branch_and_fork_are_excluded(self): + original = copy.deepcopy(self.runs[0]) + for kind in ("current", "future", "pr", "branch", "fork"): + run = copy.deepcopy(original) + if kind == "current": + run["id"] = 40 + if kind == "future": + run["id"] = 50 + if kind == "pr": + run["pull_requests"][0]["number"] = 99 + if kind == "branch": + run["head_branch"] = "other" + if kind == "fork": + run["pull_requests"][0]["head"]["repo"]["id"] = 2 + self.runs = [run] + self.assertEqual({}, self.select(), kind) + + def test_gh_failure_does_not_expose_token_or_response(self): + with patch.object( + m.subprocess, "run", return_value=Mock(returncode=1, stderr="secret") + ) as run: + with self.assertRaisesRegex( + RuntimeError, "history is unavailable" + ) as raised: + m.github_json("repos/owner/repo/actions/runs") + self.assertNotIn("secret", str(raised.exception)) + self.assertEqual( + ["gh", "api", "repos/owner/repo/actions/runs"], run.call_args.args[0] + )