Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions dev/breeze/src/airflow_breeze/commands/ci_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
fix_ownership_using_docker,
perform_environment_checks,
)
from airflow_breeze.utils.github import retrieve_github_token
from airflow_breeze.utils.github import format_github_token_scope_guidance, retrieve_github_token
from airflow_breeze.utils.path_utils import AIRFLOW_HOME_PATH, AIRFLOW_ROOT_PATH
from airflow_breeze.utils.run_utils import run_command

Expand Down Expand Up @@ -779,7 +779,7 @@ def upgrade(

console_print("[info]Running upgrade of important CI environment.[/]")

github_token = retrieve_github_token(github_token)
github_token = retrieve_github_token(github_token, description="airflow-ci-upgrade", scopes="public_repo")

# Create a copy of the environment to pass to commands
command_env = os.environ.copy()
Expand All @@ -790,7 +790,8 @@ def upgrade(
else:
console_print(
"[warning]Could not retrieve GitHub token from --github-token, gh CLI, or token env. "
"Commands may fail if they require authentication.[/]"
"Commands may fail if they require authentication. "
f"{format_github_token_scope_guidance(description='airflow-ci-upgrade', scopes='public_repo')}[/]"
)

# All upgrade commands run locally with check=False to continue on errors.
Expand Down
7 changes: 4 additions & 3 deletions dev/breeze/src/airflow_breeze/commands/issues_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from airflow_breeze.utils.click_utils import BreezeGroup
from airflow_breeze.utils.confirm import Answer, user_confirm
from airflow_breeze.utils.console import console_print
from airflow_breeze.utils.github import retrieve_github_token
from airflow_breeze.utils.github import format_github_token_scope_guidance, retrieve_github_token
from airflow_breeze.utils.shared_options import get_dry_run


Expand All @@ -42,7 +42,7 @@ def issues_group():

def _resolve_github_token(github_token: str | None) -> str | None:
"""Resolve GitHub token from option, environment, or gh CLI."""
return retrieve_github_token(github_token)
return retrieve_github_token(github_token, description="airflow-issues-unassign", scopes="public_repo")


def _get_collaborator_logins(repo) -> set[str]:
Expand Down Expand Up @@ -159,7 +159,8 @@ def unassign(
if not token:
console_print(
"[error]GitHub token not found. Provide --github-token, "
"set GITHUB_TOKEN, or authenticate with `gh auth login`.[/]"
"set GITHUB_TOKEN, or authenticate with `gh auth login`. "
f"{format_github_token_scope_guidance(description='airflow-issues-unassign', scopes='public_repo')}[/]"
)
sys.exit(1)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2678,7 +2678,11 @@ class ProviderPRInfo(NamedTuple):
all_prs.update(prs)
provider_prs[provider_id] = filtered_prs
all_retrieved_prs.update(provider_prs[provider_id])
github_token = retrieve_github_token(github_token)
github_token = retrieve_github_token(
github_token,
description="airflow-generate-provider-release-issue",
scopes="repo:status",
)
g = Github(github_token)
repo = g.get_repo("apache/airflow")
pull_requests: dict[int, PullRequest.PullRequest | Issue.Issue] = {}
Expand Down Expand Up @@ -3264,7 +3268,14 @@ def generate_airflowctl_changelog(
verbose = get_verbose()

prs = _get_airflowctl_prs(verbose, previous_release, current_release, excluded_pr_list)
github_token = retrieve_github_token(github_token) or ""
github_token = (
retrieve_github_token(
github_token,
description="airflow-generate-airflowctl-changelog",
scopes="repo:status",
)
or ""
)

g = Github(github_token)
repo = g.get_repo("apache/airflow")
Expand Down Expand Up @@ -4428,7 +4439,14 @@ def generate_issue_content(
excluded_prs = []
prs = [pr for pr in change_prs if pr is not None and pr not in excluded_prs]

github_token = retrieve_github_token(github_token) or ""
github_token = (
retrieve_github_token(
github_token,
description="airflow-generate-release-issue",
scopes="repo:status",
)
or ""
)
g = Github(github_token)
repo = g.get_repo("apache/airflow")
pull_requests: dict[int, PullRequestOrIssue] = {}
Expand Down
18 changes: 17 additions & 1 deletion dev/breeze/src/airflow_breeze/utils/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,26 @@ def run_gh_command(
return subprocess.run(command, env=command_env, check=check, **kwargs)


def retrieve_github_token(token: str | None = None, *, env: Mapping[str, str] | None = None) -> str | None:
def format_github_token_scope_guidance(*, description: str | None = None, scopes: str | None = None) -> str:
purpose = f" for {description}" if description else ""
if scopes:
return f"If creating a token manually{purpose}, it needs {scopes} scope."
return f"If creating a token manually{purpose}, make sure it has the permissions required by the command."


def retrieve_github_token(
token: str | None = None,
*,
description: str | None = None,
scopes: str | None = None,
env: Mapping[str, str] | None = None,
) -> str | None:
"""
Resolve a GitHub token for local Breeze commands.

``description`` and ``scopes`` document what the caller needs from the token. Retrieval itself
does not validate scopes.

Non-empty token arguments are preserved when they do not match ``GH_TOKEN`` or
``GITHUB_TOKEN`` from the environment. Matching values are treated as ambient env input because
Click can populate ``--github-token`` from ``envvar="GITHUB_TOKEN"``. Ambient env tokens are used
Expand Down
10 changes: 6 additions & 4 deletions dev/breeze/src/airflow_breeze/utils/provider_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from airflow_breeze.utils.console import console_print
from airflow_breeze.utils.github import (
download_constraints_file,
format_github_token_scope_guidance,
get_active_airflow_versions,
get_tag_date,
retrieve_github_token,
Expand Down Expand Up @@ -252,15 +253,16 @@ def get_all_constraint_files_and_airflow_releases(
shutil.rmtree(CONSTRAINTS_CACHE_PATH, ignore_errors=True)
if not CONSTRAINTS_CACHE_PATH.exists():
if not github_token:
github_token = retrieve_github_token()
github_token = retrieve_github_token(
description="airflow-refresh-constraints", scopes="public_repo"
)
if github_token:
console_print("\n[info]Resolved GitHub token for constraints refresh[/]\n")
else:
console_print(
"[error]You need to provide GITHUB_TOKEN to generate providers metadata.[/]\n\n"
"You can generate it with this URL: "
"Please set it to a valid GitHub token with public_repo scope. You can create one by clicking "
"the URL:\n\n"
f"{format_github_token_scope_guidance(description='airflow-refresh-constraints', scopes='public_repo')} "
"You can create one by clicking the URL:\n\n"
"https://github.com/settings/tokens/new?scopes=public_repo&description=airflow-refresh-constraints\n\n"
"Once you have the token you can prepend prek command with GITHUB_TOKEN='<your token>' or"
"set it in your environment with export GITHUB_TOKEN='<your token>'\n\n"
Expand Down
25 changes: 22 additions & 3 deletions dev/breeze/tests/test_github_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

from airflow_breeze.utils.github import (
env_without_github_tokens,
format_github_token_scope_guidance,
retrieve_github_token,
run_gh_command,
)
Expand Down Expand Up @@ -51,7 +52,7 @@ def test_retrieve_github_token_prefers_clean_gh_auth_token(mock_run, monkeypatch
monkeypatch.setenv("GITHUB_TOKEN", "env-github-token")
mock_run.return_value = _completed_process(returncode=0, stdout="stored-gh-token\n")

assert retrieve_github_token() == "stored-gh-token"
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"]
Expand All @@ -65,7 +66,7 @@ def test_retrieve_github_token_falls_back_to_env_token(mock_run, monkeypatch):
monkeypatch.setenv("GITHUB_TOKEN", "env-github-token")
mock_run.return_value = _completed_process(returncode=1)

assert retrieve_github_token() == "env-gh-token"
assert retrieve_github_token(description="airflow-ci-upgrade", scopes="public_repo") == "env-gh-token"


@mock.patch("airflow_breeze.utils.github.subprocess.run")
Expand All @@ -88,7 +89,10 @@ def test_retrieve_github_token_falls_back_to_env_token_when_gh_returns_whitespac
def test_retrieve_github_token_keeps_explicit_token(mock_run, monkeypatch):
monkeypatch.setenv("GITHUB_TOKEN", "env-token")

assert retrieve_github_token("explicit-token") == "explicit-token"
assert (
retrieve_github_token("explicit-token", description="airflow-ci-upgrade", scopes="public_repo")
== "explicit-token"
)

mock_run.assert_not_called()

Expand All @@ -101,6 +105,21 @@ def test_retrieve_github_token_does_not_treat_env_token_argument_as_explicit(moc
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")
Expand Down
Loading