| # Licensed to the Apache Software Foundation (ASF) under one | |
| # or more contributor license agreements. See the NOTICE file | |
| # distributed with this work for additional information | |
| # regarding copyright ownership. The ASF licenses this file | |
| # to you under the Apache License, Version 2.0 (the | |
| # "License"); you may not use this file except in compliance | |
| # with the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, | |
| # software distributed under the License is distributed on an | |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| # KIND, either express or implied. See the License for the | |
| # specific language governing permissions and limitations | |
| # under the License. | |
| from __future__ import annotations | |
| import subprocess | |
| from unittest import mock | |
| import pytest | |
| from airflow_breeze.utils.github import ( | |
| env_without_github_tokens, | |
| format_github_token_scope_guidance, | |
| retrieve_github_token, | |
| run_gh_command, | |
| ) | |
| from airflow_breeze.utils.shared_options import set_dry_run | |
| def _completed_process(returncode: int, stdout: str = "") -> subprocess.CompletedProcess[str]: | |
| return subprocess.CompletedProcess(args=["gh"], returncode=returncode, stdout=stdout, stderr="") | |
| def test_env_without_github_tokens_removes_ambient_token_vars(monkeypatch): | |
| monkeypatch.setenv("GH_TOKEN", "gh-token") | |
| monkeypatch.setenv("GITHUB_TOKEN", "github-token") | |
| monkeypatch.setenv("OTHER_VAR", "kept") | |
| cleaned_env = env_without_github_tokens() | |
| assert "GH_TOKEN" not in cleaned_env | |
| assert "GITHUB_TOKEN" not in cleaned_env | |
| assert cleaned_env["OTHER_VAR"] == "kept" | |
| @mock.patch("airflow_breeze.utils.github.subprocess.run") | |
| def test_retrieve_github_token_prefers_clean_gh_auth_token(mock_run, monkeypatch): | |
| monkeypatch.setenv("GH_TOKEN", "env-gh-token") | |
| monkeypatch.setenv("GITHUB_TOKEN", "env-github-token") | |
| mock_run.return_value = _completed_process(returncode=0, stdout="stored-gh-token\n") | |
| assert retrieve_github_token(description="airflow-ci-upgrade", scopes="public_repo") == "stored-gh-token" | |
| mock_run.assert_called_once() | |
| call_env = mock_run.call_args.kwargs["env"] | |
| assert "GH_TOKEN" not in call_env | |
| assert "GITHUB_TOKEN" not in call_env | |
| @mock.patch("airflow_breeze.utils.github.subprocess.run") | |
| def test_retrieve_github_token_falls_back_to_env_token(mock_run, monkeypatch): | |
| monkeypatch.setenv("GH_TOKEN", "env-gh-token") | |
| monkeypatch.setenv("GITHUB_TOKEN", "env-github-token") | |
| mock_run.return_value = _completed_process(returncode=1) | |
| assert retrieve_github_token(description="airflow-ci-upgrade", scopes="public_repo") == "env-gh-token" | |
| @mock.patch("airflow_breeze.utils.github.subprocess.run") | |
| def test_retrieve_github_token_falls_back_to_env_token_when_gh_is_missing(mock_run, monkeypatch): | |
| monkeypatch.setenv("GITHUB_TOKEN", "env-github-token") | |
| mock_run.side_effect = FileNotFoundError | |
| assert retrieve_github_token() == "env-github-token" | |
| @mock.patch("airflow_breeze.utils.github.subprocess.run") | |
| def test_retrieve_github_token_falls_back_to_env_token_when_gh_returns_whitespace(mock_run, monkeypatch): | |
| monkeypatch.setenv("GITHUB_TOKEN", "env-github-token") | |
| mock_run.return_value = _completed_process(returncode=0, stdout=" \n") | |
| assert retrieve_github_token() == "env-github-token" | |
| @mock.patch("airflow_breeze.utils.github.subprocess.run") | |
| def test_retrieve_github_token_keeps_explicit_token(mock_run, monkeypatch): | |
| monkeypatch.setenv("GITHUB_TOKEN", "env-token") | |
| assert ( | |
| retrieve_github_token("explicit-token", description="airflow-ci-upgrade", scopes="public_repo") | |
| == "explicit-token" | |
| ) | |
| mock_run.assert_not_called() | |
| @mock.patch("airflow_breeze.utils.github.subprocess.run") | |
| def test_retrieve_github_token_does_not_treat_env_token_argument_as_explicit(mock_run, monkeypatch): | |
| monkeypatch.setenv("GITHUB_TOKEN", "env-token") | |
| mock_run.return_value = _completed_process(returncode=0, stdout="stored-gh-token\n") | |
| assert retrieve_github_token("env-token") == "stored-gh-token" | |
| def test_format_github_token_scope_guidance_includes_description_and_scope(): | |
| assert ( | |
| format_github_token_scope_guidance(description="airflow-ci-upgrade", scopes="public_repo") | |
| == "If creating a token manually for airflow-ci-upgrade, it needs public_repo scope." | |
| ) | |
| def test_format_github_token_scope_guidance_is_generic_without_metadata(): | |
| guidance = format_github_token_scope_guidance() | |
| assert ( | |
| guidance == "If creating a token manually, make sure it has the permissions required by the command." | |
| ) | |
| @mock.patch("airflow_breeze.utils.github.subprocess.run") | |
| def test_run_gh_command_retries_with_original_env_after_clean_env_failure(mock_run, monkeypatch): | |
| monkeypatch.setenv("GH_TOKEN", "env-gh-token") | |
| monkeypatch.setenv("GITHUB_TOKEN", "env-github-token") | |
| mock_run.side_effect = [ | |
| _completed_process(returncode=1), | |
| _completed_process(returncode=0), | |
| ] | |
| result = run_gh_command(["gh", "workflow", "run", "docs.yml"], capture_output=True) | |
| assert result.returncode == 0 | |
| assert mock_run.call_count == 2 | |
| first_env = mock_run.call_args_list[0].kwargs["env"] | |
| second_env = mock_run.call_args_list[1].kwargs["env"] | |
| assert "GH_TOKEN" not in first_env | |
| assert "GITHUB_TOKEN" not in first_env | |
| assert second_env["GH_TOKEN"] == "env-gh-token" | |
| assert second_env["GITHUB_TOKEN"] == "env-github-token" | |
| @mock.patch("airflow_breeze.utils.github.subprocess.run") | |
| def test_run_gh_command_does_not_retry_after_clean_env_success(mock_run, monkeypatch): | |
| monkeypatch.setenv("GITHUB_TOKEN", "env-token") | |
| mock_run.return_value = _completed_process(returncode=0) | |
| result = run_gh_command(["gh", "api", "repos/apache/airflow"], capture_output=True) | |
| assert result.returncode == 0 | |
| mock_run.assert_called_once() | |
| @mock.patch("airflow_breeze.utils.github.subprocess.run") | |
| def test_run_gh_command_raises_when_check_true_and_no_env_token_to_retry(mock_run, monkeypatch): | |
| monkeypatch.delenv("GH_TOKEN", raising=False) | |
| monkeypatch.delenv("GITHUB_TOKEN", raising=False) | |
| mock_run.return_value = _completed_process(returncode=1) | |
| with pytest.raises(subprocess.CalledProcessError) as ctx: | |
| run_gh_command(["gh", "api", "repos/apache/airflow"], capture_output=True, check=True) | |
| assert ctx.value.returncode == 1 | |
| @mock.patch("airflow_breeze.utils.github.subprocess.run") | |
| def test_run_gh_command_raises_when_gh_is_missing(mock_run): | |
| mock_run.side_effect = FileNotFoundError | |
| with pytest.raises(FileNotFoundError): | |
| run_gh_command(["gh", "api", "repos/apache/airflow"], capture_output=True) | |
| @mock.patch("airflow_breeze.utils.github.subprocess.run") | |
| def test_run_gh_command_skips_subprocess_in_dry_run(mock_run): | |
| set_dry_run(True) | |
| try: | |
| result = run_gh_command(["gh", "workflow", "run", "docs.yml"], capture_output=True) | |
| finally: | |
| set_dry_run(False) | |
| assert result.returncode == 0 | |
| mock_run.assert_not_called() |