diff --git a/conda_forge_tick/auto_tick.py b/conda_forge_tick/auto_tick.py
index 6ea8a2ead..7917ebc89 100644
--- a/conda_forge_tick/auto_tick.py
+++ b/conda_forge_tick/auto_tick.py
@@ -4,16 +4,12 @@
import logging
import os
import random
+import textwrap
import time
import traceback
import typing
-from subprocess import CalledProcessError
-from textwrap import dedent
-from typing import MutableMapping, Tuple, cast
-
-if typing.TYPE_CHECKING:
- from .migrators_types import MigrationUidTypedDict
-
+from dataclasses import dataclass
+from typing import AnyStr, Literal, cast
from urllib.error import URLError
from uuid import uuid4
@@ -24,19 +20,26 @@
from conda.models.version import VersionOrder
from conda_forge_tick.cli_context import CliContext
-from conda_forge_tick.contexts import FeedstockContext, MigratorSessionContext
+from conda_forge_tick.contexts import (
+ ClonedFeedstockContext,
+ FeedstockContext,
+ MigratorSessionContext,
+)
from conda_forge_tick.deploy import deploy
from conda_forge_tick.feedstock_parser import BOOTSTRAP_MAPPINGS
from conda_forge_tick.git_utils import (
- GIT_CLONE_DIR,
- comment_on_pr,
- get_github_api_requests_left,
- get_repo,
+ DryRunBackend,
+ DuplicatePullRequestError,
+ GitCli,
+ GitCliError,
+ GitPlatformBackend,
+ RepositoryNotFoundError,
+ github_backend,
is_github_api_limit_reached,
- push_repo,
)
from conda_forge_tick.lazy_json_backends import (
LazyJson,
+ does_key_exist_in_hashmap,
get_all_keys_for_hashmap,
lazy_json_transaction,
remove_key_for_hashmap,
@@ -46,10 +49,9 @@
PR_LIMIT,
load_migrators,
)
-from conda_forge_tick.migration_runner import run_migration
from conda_forge_tick.migrators import MigrationYaml, Migrator, Version
from conda_forge_tick.migrators.version import VersionMigrationError
-from conda_forge_tick.os_utils import eval_cmd, pushd
+from conda_forge_tick.os_utils import eval_cmd
from conda_forge_tick.rerender_feedstock import rerender_feedstock
from conda_forge_tick.solver_checks import is_recipe_solvable
from conda_forge_tick.utils import (
@@ -65,6 +67,11 @@
load_existing_graph,
sanitize_string,
)
+from .migration_runner import run_migration
+
+from .migrators_types import MigrationUidTypedDict
+from .models.pr_info import PullRequestInfoSpecial
+from .models.pr_json import PullRequestData, PullRequestState
logger = logging.getLogger(__name__)
@@ -139,35 +146,349 @@ def _get_pre_pr_migrator_attempts(attrs, migrator_name, *, is_version):
return pri.get("pre_pr_migrator_attempts", {}).get(migrator_name, 0)
+def _prepare_feedstock_repository(
+ backend: GitPlatformBackend,
+ context: ClonedFeedstockContext,
+ branch: str,
+ base_branch: str,
+) -> bool:
+ """
+ Prepare a feedstock repository for migration by forking and cloning it. The local clone will be present in
+ context.local_clone_dir.
+
+ Any errors are written to the pr_info attribute of the feedstock context and logged.
+
+ :param backend: The GitPlatformBackend instance to use.
+ :param context: The FeedstockContext instance.
+ :param branch: The branch to create in the forked repository.
+ :param base_branch: The base branch to branch from.
+ :return: True if the repository was successfully prepared, False otherwise.
+ """
+ try:
+ backend.fork(context.git_repo_owner, context.git_repo_name)
+ except RepositoryNotFoundError:
+ logger.warning(
+ f"Could not fork {context.git_repo_owner}/{context.git_repo_name}: Not Found"
+ )
+
+ error_message = f"{context.feedstock_name}: Git repository not found."
+ logger.critical(
+ f"Failed to migrate {context.feedstock_name}, {error_message}",
+ )
+
+ with context.attrs["pr_info"] as pri:
+ pri["bad"] = error_message
+
+ return False
+
+ backend.clone_fork_and_branch(
+ upstream_owner=context.git_repo_owner,
+ repo_name=context.git_repo_name,
+ target_dir=context.local_clone_dir,
+ new_branch=branch,
+ base_branch=base_branch,
+ )
+ return True
+
+
+def _commit_migration(
+ cli: GitCli,
+ context: ClonedFeedstockContext,
+ commit_message: str,
+ allow_empty_commits: bool = False,
+ raise_commit_errors: bool = True,
+) -> None:
+ """
+ Commit a migration that has been run in the local clone of a feedstock repository.
+ If an error occurs during the commit, it is logged.
+
+ :param cli: The GitCli instance to use.
+ :param context: The FeedstockContext instance.
+ :param commit_message: The commit message to use.
+ :param allow_empty_commits: Whether the migrator allows empty commits.
+ :param raise_commit_errors: Whether to raise an exception if an error occurs during the commit.
+
+ :raises GitCliError: If an error occurs during the commit and raise_commit_errors is True.
+ """
+ cli.add(
+ context.local_clone_dir,
+ all_=True,
+ )
+
+ try:
+ cli.commit(
+ context.local_clone_dir, commit_message, allow_empty=allow_empty_commits
+ )
+ except GitCliError as e:
+ logger.info("could not commit to feedstock - likely no changes", exc_info=e)
+
+ if raise_commit_errors:
+ raise
+
+
+@dataclass(frozen=True)
+class _RerenderInfo:
+ """
+ Additional information about a rerender operation.
+ """
+
+ nontrivial_migration_yaml_changes: bool
+ """
+ True if any files which are not in the following list were changed during the rerender, False otherwise:
+ 1. anything in the recipe directory
+ 2. anything in the migrators directory
+ 3. the README file
+
+ This is useful to discard MigrationYaml migrations that only drop a file in the migrations directory.
+ """
+ rerender_comment: str | None = None
+ """
+ If requested, a comment to be added to the PR to indicate an issue with the rerender.
+ None if no comment should be added.
+ """
+
+
+def _run_rerender(
+ git_cli: GitCli, context: ClonedFeedstockContext, suppress_errors: bool = False
+) -> _RerenderInfo:
+ logger.info("Rerendering the feedstock")
+
+ try:
+ rerender_msg = rerender_feedstock(str(context.local_clone_dir), timeout=900)
+ except Exception as e:
+ logger.error("RERENDER ERROR", exc_info=e)
+
+ if not suppress_errors:
+ raise
+
+ rerender_comment = textwrap.dedent(
+ """
+ Hi! This feedstock was not able to be rerendered after the version update changes. I
+ have pushed the version update changes anyways and am trying to rerender again with this
+ comment. Hopefully you all can fix this!
+
+ @conda-forge-admin rerender
+ """
+ )
+
+ return _RerenderInfo(
+ nontrivial_migration_yaml_changes=False, rerender_comment=rerender_comment
+ )
+
+ if rerender_msg is None:
+ return _RerenderInfo(nontrivial_migration_yaml_changes=False)
+
+ git_cli.commit(context.local_clone_dir, rerender_msg, all_=True, allow_empty=True)
+
+ # HEAD~ is the state before the last commit
+ changed_files = git_cli.diffed_files(context.local_clone_dir, "HEAD~")
+
+ recipe_dir = context.local_clone_dir / "recipe"
+ migrators_dir = context.local_clone_dir / "migrators"
+
+ nontrivial_migration_yaml_changes = any(
+ not file.is_relative_to(recipe_dir)
+ and not file.is_relative_to(migrators_dir)
+ and not file.name.startswith("README")
+ for file in changed_files
+ )
+
+ return _RerenderInfo(nontrivial_migration_yaml_changes)
+
+
+def _has_automerge(migrator: Migrator, context: FeedstockContext) -> bool:
+ """
+ Determine if a migration should be auto merged based on the feedstock and migrator settings.
+
+ :param migrator: The migrator to check.
+ :param context: The feedstock context.
+
+ :return: True if the migrator should be auto merged, False otherwise.
+ """
+ if isinstance(migrator, Version):
+ return context.automerge in [True, "version"]
+ else:
+ return getattr(migrator, "automerge", False) and context.automerge in [
+ True,
+ "migration",
+ ]
+
+
+def _is_solvability_check_needed(
+ migrator: Migrator, context: FeedstockContext, base_branch: str
+) -> bool:
+ migrator_check_solvable = getattr(migrator, "check_solvable", True)
+ pr_attempts = _get_pre_pr_migrator_attempts(
+ context.attrs,
+ migrator_name=get_migrator_name(migrator),
+ is_version=isinstance(migrator, Version),
+ )
+ max_pr_attempts = getattr(
+ migrator, "force_pr_after_solver_attempts", MAX_SOLVER_ATTEMPTS * 2
+ )
+
+ logger.info(
+ textwrap.dedent(
+ f"""
+ automerge and check_solvable status/settings:
+ automerge:
+ feedstock_automerge: {context.automerge}
+ migrator_automerge: {getattr(migrator, 'automerge', False)}
+ has_automerge: {_has_automerge(migrator, context)} (only considers feedstock if version migration)
+ check_solvable:
+ feedstock_check_solvable: {context.check_solvable}
+ migrator_check_solvable: {migrator_check_solvable}
+ pre_pr_migrator_attempts: {pr_attempts}
+ force_pr_after_solver_attempts: {max_pr_attempts}
+ """
+ )
+ )
+
+ return (
+ context.feedstock_name != "conda-forge-pinning"
+ and (base_branch == "master" or base_branch == "main")
+ # feedstocks that have problematic bootstrapping will not always be solvable
+ and context.feedstock_name not in BOOTSTRAP_MAPPINGS
+ # stuff in cycles always goes
+ and context.attrs["name"] not in getattr(migrator, "cycles", set())
+ # stuff at the top always goes
+ and context.attrs["name"] not in getattr(migrator, "top_level", set())
+ # either the migrator or the feedstock has to request solver checks
+ and (migrator_check_solvable or context.check_solvable)
+ # we try up to MAX_SOLVER_ATTEMPTS times, and then we just skip
+ # the solver check and issue the PR if automerge is off
+ and (_has_automerge(migrator, context) or (pr_attempts < max_pr_attempts))
+ )
+
+
+def _handle_solvability_error(
+ errors: list[str], context: FeedstockContext, migrator: Migrator, base_branch: str
+) -> None:
+ ci_url = get_bot_run_url()
+ ci_url = f"(bot CI job)" if ci_url else ""
+ _solver_err_str = textwrap.dedent(
+ f"""
+ not solvable {ci_url} @ {base_branch}
+
+
+
+ {'
'.join(sorted(set(errors)))}
+
+
+
+ """,
+ ).strip()
+
+ _set_pre_pr_migrator_error(
+ context.attrs,
+ get_migrator_name(migrator),
+ _solver_err_str,
+ is_version=isinstance(migrator, Version),
+ )
+
+ # remove part of a try for solver errors to make those slightly
+ # higher priority next time the bot runs
+ if isinstance(migrator, Version):
+ with context.attrs["version_pr_info"] as vpri:
+ _new_ver = vpri["new_version"]
+ vpri["new_version_attempts"][_new_ver] -= 0.8
+
+
+def _check_and_process_solvability(
+ migrator: Migrator, context: ClonedFeedstockContext, base_branch: str
+) -> bool:
+ """
+ If the migration needs a solvability check, perform the check. If the recipe is not solvable, handle the error
+ by setting the corresponding fields in the feedstock attributes.
+ If the recipe is solvable, reset the fields that track the solvability check status.
+
+ :param migrator: The migrator that was run
+ :param context: The current FeedstockContext of the feedstock that was migrated
+ :param base_branch: The branch of the feedstock repository that is the migration target
+
+ :returns: True if the migration can proceed normally, False if a required solvability check failed and the migration
+ needs to be aborted
+ """
+ if not _is_solvability_check_needed(migrator, context, base_branch):
+ return True
+
+ solvable, solvability_errors, _ = is_recipe_solvable(
+ str(context.local_clone_dir),
+ build_platform=context.attrs["conda-forge.yml"].get(
+ "build_platform",
+ None,
+ ),
+ )
+ if solvable:
+ _reset_pre_pr_migrator_fields(
+ context.attrs,
+ get_migrator_name(migrator),
+ is_version=isinstance(migrator, Version),
+ )
+ return True
+
+ _handle_solvability_error(solvability_errors, context, migrator, base_branch)
+ return False
+
+
+def get_spoofed_closed_pr_info() -> PullRequestInfoSpecial:
+ return PullRequestInfoSpecial(
+ id=str(uuid4()),
+ merged_at="never issued",
+ state="closed",
+ )
+
+
+def run_with_tmpdir(
+ context: FeedstockContext,
+ migrator: Migrator,
+ git_backend: GitPlatformBackend,
+ rerender: bool = True,
+ base_branch: str = "main",
+ **kwargs: typing.Any,
+) -> tuple[MigrationUidTypedDict, dict] | tuple[Literal[False], Literal[False]]:
+ """
+ For a given feedstock and migration run the migration in a temporary directory that will be deleted after the
+ migration is complete.
+
+ The parameters are the same as for the `run` function. The only difference is that you pass a FeedstockContext
+ instance instead of a ClonedFeedstockContext instance.
+
+ The exceptions are the same as for the `run` function.
+ """
+ with context.reserve_clone_directory() as cloned_context:
+ return run(
+ context=cloned_context,
+ migrator=migrator,
+ git_backend=git_backend,
+ rerender=rerender,
+ base_branch=base_branch,
+ **kwargs,
+ )
+
+
def run(
- feedstock_ctx: FeedstockContext,
+ context: ClonedFeedstockContext,
migrator: Migrator,
- protocol: str = "ssh",
- pull_request: bool = True,
+ git_backend: GitPlatformBackend,
rerender: bool = True,
- fork: bool = True,
base_branch: str = "main",
- dry_run: bool = False,
**kwargs: typing.Any,
-) -> Tuple["MigrationUidTypedDict", dict]:
+) -> tuple[MigrationUidTypedDict, dict] | tuple[Literal[False], Literal[False]]:
"""For a given feedstock and migration run the migration
Parameters
----------
- feedstock_ctx: FeedstockContext
- The node attributes
+ context: ClonedFeedstockContext
+ The current feedstock context, already containing information about a temporary directory for the feedstock.
migrator: Migrator instance
The migrator to run on the feedstock
- protocol : str, optional
- The git protocol to use, defaults to ``ssh``
- pull_request : bool, optional
- If true issue pull request, defaults to true
+ git_backend: GitPlatformBackend
+ The git backend to use. Use the DryRunBackend for testing.
rerender : bool
Whether to rerender
- fork : bool
- If true create a fork, defaults to true
base_branch : str, optional
- The base branch to which the PR will be targeted. Defaults to "main".
+ The base branch to which the PR will be targeted.
kwargs: dict
The keyword arguments to pass to the migrator.
@@ -177,310 +498,141 @@ def run(
The migration return dict used for tracking finished migrations
pr_json: dict
The PR json object for recreating the PR as needed
- """
+ Exceptions
+ ----------
+ GitCliError
+ If an error occurs during a git command which is not suppressed
+ """
# sometimes we get weird directory issues so make sure we reset
os.chdir(BOT_HOME_DIR)
- # get the repo
- branch_name = migrator.remote_branch(feedstock_ctx) + "_h" + uuid4().hex[0:6]
-
migrator_name = get_migrator_name(migrator)
is_version_migration = isinstance(migrator, Version)
_increment_pre_pr_migrator_attempt(
- feedstock_ctx.attrs,
+ context.attrs,
migrator_name,
is_version=is_version_migration,
)
- # TODO: run this in parallel
- feedstock_dir, repo = get_repo(
- fctx=feedstock_ctx,
- branch=branch_name,
- feedstock=feedstock_ctx.feedstock_name,
- protocol=protocol,
- pull_request=pull_request,
- fork=fork,
- base_branch=base_branch,
- )
- if not feedstock_dir or not repo:
- logger.critical(
- "Failed to migrate %s, %s",
- feedstock_ctx.feedstock_name,
- feedstock_ctx.attrs.get("pr_info", {}).get("bad"),
- )
+ branch_name = migrator.remote_branch(context) + "_h" + uuid4().hex[0:6]
+ if not _prepare_feedstock_repository(
+ git_backend, context, branch_name, base_branch
+ ):
+ # something went wrong during forking or cloning
return False, False
- # need to use an absolute path here
- feedstock_dir = os.path.abspath(feedstock_dir)
-
+ # feedstock_dir must be absolute
migration_run_data = run_migration(
migrator=migrator,
- feedstock_dir=feedstock_dir,
- feedstock_name=feedstock_ctx.feedstock_name,
- node_attrs=feedstock_ctx.attrs,
- default_branch=feedstock_ctx.default_branch,
+ feedstock_dir=str(context.local_clone_dir.resolve()),
+ feedstock_name=context.feedstock_name,
+ node_attrs=context.attrs,
+ default_branch=context.default_branch,
**kwargs,
)
if not migration_run_data["migrate_return_value"]:
logger.critical(
- "Failed to migrate %s, %s",
- feedstock_ctx.feedstock_name,
- feedstock_ctx.attrs.get("pr_info", {}).get("bad"),
+ f"Failed to migrate {context.feedstock_name}, {context.attrs.get('pr_info', {}).get('bad')}",
)
- eval_cmd(["rm", "-rf", feedstock_dir])
return False, False
- # rerender, maybe
- diffed_files: typing.List[str] = []
- with pushd(feedstock_dir):
- msg = migration_run_data["commit_message"]
- try:
- eval_cmd(["git", "add", "--all", "."])
- if migrator.allow_empty_commits:
- eval_cmd(["git", "commit", "--allow-empty", "-am", msg])
- else:
- eval_cmd(["git", "commit", "-am", msg])
- except CalledProcessError as e:
- logger.info(
- "could not commit to feedstock - "
- "likely no changes - error is '%s'" % (repr(e)),
- )
- # we bail here if we do not plan to rerender and we wanted an empty
- # commit
- # this prevents PRs that don't actually get made from getting marked as done
- if migrator.allow_empty_commits and not rerender:
- raise e
-
- if rerender:
- head_ref = eval_cmd(["git", "rev-parse", "HEAD"]).strip()
- logger.info("Rerendering the feedstock")
-
- try:
- rerender_msg = rerender_feedstock(feedstock_dir, timeout=900)
- if rerender_msg is not None:
- eval_cmd(["git", "commit", "--allow-empty", "-am", rerender_msg])
-
- make_rerender_comment = False
- except Exception as e:
- # I am trying this bit of code to force these errors
- # to be surfaced in the logs at the right time.
- print(f"RERENDER ERROR: {e}", flush=True)
- if not isinstance(migrator, Version):
- raise
- else:
- # for check solvable or automerge, we always raise rerender errors
- if get_keys_default(
- feedstock_ctx.attrs,
- ["conda-forge.yml", "bot", "check_solvable"],
- {},
- False,
- ) or get_keys_default(
- feedstock_ctx.attrs,
- ["conda-forge.yml", "bot", "automerge"],
- {},
- False,
- ):
- raise
- else:
- make_rerender_comment = True
-
- # If we tried to run the MigrationYaml and rerender did nothing (we only
- # bumped the build number and dropped a yaml file in migrations) bail
- # for instance platform specific migrations
- gdiff = eval_cmd(
- ["git", "diff", "--name-only", f"{head_ref.strip()}...HEAD"]
- )
-
- diffed_files = [
- _
- for _ in gdiff.split()
- if not (
- _.startswith("recipe")
- or _.startswith("migrators")
- or _.startswith("README")
- )
- ]
- else:
- make_rerender_comment = False
-
- feedstock_automerge = get_keys_default(
- feedstock_ctx.attrs,
- ["conda-forge.yml", "bot", "automerge"],
- {},
- False,
- )
- if isinstance(migrator, Version):
- has_automerge = feedstock_automerge in [True, "version"]
- else:
- has_automerge = getattr(
- migrator, "automerge", False
- ) and feedstock_automerge in [True, "migration"]
-
- migrator_check_solvable = getattr(migrator, "check_solvable", True)
- feedstock_check_solvable = get_keys_default(
- feedstock_ctx.attrs,
- ["conda-forge.yml", "bot", "check_solvable"],
- {},
- False,
- )
- pr_attempts = _get_pre_pr_migrator_attempts(
- feedstock_ctx.attrs,
- migrator_name,
- is_version=is_version_migration,
- )
- max_pr_attempts = getattr(
- migrator, "force_pr_after_solver_attempts", MAX_SOLVER_ATTEMPTS * 2
- )
-
- logger.info(
- f"""automerge and check_solvable status/settings:
- automerge:
- feedstock_automerge: {feedstock_automerge}
- migratror_automerge: {getattr(migrator, 'automerge', False)}
- has_automerge: {has_automerge} (only considers feedstock if version migration)
- check_solvable:
- feedstock_checksolvable: {feedstock_check_solvable}
- migrator_check_solvable: {migrator_check_solvable}
- pre_pr_migrator_attempts: {pr_attempts}
- force_pr_after_solver_attempts: {max_pr_attempts}
-"""
+ # We raise an exception if we don't plan to rerender and wanted an empty commit.
+ # This prevents PRs that don't actually get made from getting marked as done.
+ _commit_migration(
+ cli=git_backend.cli,
+ context=context,
+ commit_message=migration_run_data["commit_message"],
+ allow_empty_commits=migrator.allow_empty_commits,
+ raise_commit_errors=migrator.allow_empty_commits and not rerender,
)
- if (
- feedstock_ctx.feedstock_name != "conda-forge-pinning"
- and (base_branch == "master" or base_branch == "main")
- # feedstocks that have problematic bootstrapping will not always be solvable
- and feedstock_ctx.feedstock_name not in BOOTSTRAP_MAPPINGS
- # stuff in cycles always goes
- and feedstock_ctx.attrs["name"] not in getattr(migrator, "cycles", set())
- # stuff at the top always goes
- and feedstock_ctx.attrs["name"] not in getattr(migrator, "top_level", set())
- # either the migrator or the feedstock has to request solver checks
- and (migrator_check_solvable or feedstock_check_solvable)
- # we try up to MAX_SOLVER_ATTEMPTS times and then we just skip
- # the solver check and issue the PR if automerge is off
- and (has_automerge or (pr_attempts < max_pr_attempts))
- ):
- solvable, errors, _ = is_recipe_solvable(
- feedstock_dir,
- build_platform=feedstock_ctx.attrs["conda-forge.yml"].get(
- "build_platform",
- None,
- ),
+ if rerender:
+ # for version migrations, check solvable or automerge, we always raise rerender errors
+ suppress_errors = (
+ not is_version_migration
+ and not context.check_solvable
+ and not context.automerge
)
- if not solvable:
- ci_url = get_bot_run_url()
- ci_url = f"(bot CI job)" if ci_url else ""
- _solver_err_str = dedent(
- f"""
- not solvable {ci_url} @ {base_branch}
-
-
-
- {'
'.join(sorted(set(errors)))}
-
-
-
- """,
- ).strip()
-
- _set_pre_pr_migrator_error(
- feedstock_ctx.attrs,
- migrator_name,
- _solver_err_str,
- is_version=is_version_migration,
- )
- # remove part of a try for solver errors to make those slightly
- # higher priority next time the bot runs
- if isinstance(migrator, Version):
- with feedstock_ctx.attrs["version_pr_info"] as vpri:
- _new_ver = vpri["new_version"]
- vpri["new_version_attempts"][_new_ver] -= 0.8
+ rerender_info = _run_rerender(git_backend.cli, context, suppress_errors)
+ else:
+ rerender_info = _RerenderInfo(nontrivial_migration_yaml_changes=False)
- eval_cmd(["rm", "-rf", feedstock_dir])
- return False, False
- else:
- _reset_pre_pr_migrator_fields(
- feedstock_ctx.attrs, migrator_name, is_version=is_version_migration
- )
+ if not _check_and_process_solvability(migrator, context, base_branch):
+ logger.warning("Skipping migration due to solvability check failure")
+ return False, False
- # TODO: Better annotation here
- pr_json: typing.Union[MutableMapping, None, bool]
+ pr_data: PullRequestData | PullRequestInfoSpecial | None = None
+ """
+ The PR data for the PR that was created. The contents of this variable will be stored in the bot's database.
+ None means: We don't update the PR data.
+ """
if (
isinstance(migrator, MigrationYaml)
- and not diffed_files
- and feedstock_ctx.attrs["name"] != "conda-forge-pinning"
+ and not rerender_info.nontrivial_migration_yaml_changes
+ and context.attrs["name"] != "conda-forge-pinning"
):
# spoof this so it looks like the package is done
- pr_json = {
- "state": "closed",
- "merged_at": "never issued",
- "id": str(uuid4()),
- }
+ pr_data = get_spoofed_closed_pr_info()
else:
- # push up
+ # push and PR
+ git_backend.push_to_repository(
+ owner=git_backend.user,
+ repo_name=context.git_repo_name,
+ git_dir=context.local_clone_dir,
+ branch=branch_name,
+ )
try:
- # TODO: remove this hack, but for now this is the only way to get
- # the feedstock dir into pr_body
- feedstock_ctx.feedstock_dir = feedstock_dir
- pr_json = push_repo(
- fctx=feedstock_ctx,
- feedstock_dir=feedstock_dir,
- body=migration_run_data["pr_body"],
- repo=repo,
- title=migration_run_data["pr_title"],
- branch=branch_name,
+ pr_data = git_backend.create_pull_request(
+ target_owner=context.git_repo_owner,
+ target_repo=context.git_repo_name,
base_branch=base_branch,
- dry_run=dry_run,
+ head_branch=branch_name,
+ title=migration_run_data["pr_title"],
+ body=migration_run_data["pr_body"],
+ )
+ except DuplicatePullRequestError:
+ # This shouldn't happen too often anymore since we won't double PR
+ logger.warning(
+ f"Attempted to create a duplicate PR for merging {git_backend.user}:{branch_name} "
+ f"into {context.git_repo_owner}:{base_branch}. Ignoring."
)
+ # Don't update the PR data (keep pr_data as None)
- # This shouldn't happen too often any more since we won't double PR
- except github3.GitHubError as e:
- if e.msg != "Validation Failed":
- raise
- else:
- print(f"Error during push {e}")
- # If we just push to the existing PR then do nothing to the json
- pr_json = False
- ljpr = False
-
- if pr_json and pr_json["state"] != "closed" and make_rerender_comment:
- comment_on_pr(
- pr_json,
- """\
-Hi! This feedstock was not able to be rerendered after the version update changes. I
-have pushed the version update changes anyways and am trying to rerender again with this
-comment. Hopefully you all can fix this!
-
-@conda-forge-admin rerender""",
- repo,
+ if (
+ pr_data
+ and pr_data.state != PullRequestState.CLOSED
+ and rerender_info.rerender_comment
+ ):
+ git_backend.comment_on_pull_request(
+ repo_owner=context.git_repo_owner,
+ repo_name=context.git_repo_name,
+ pr_number=pr_data.number,
+ comment=rerender_info.rerender_comment,
)
- if pr_json:
- ljpr = LazyJson(
- os.path.join("pr_json", str(pr_json["id"]) + ".json"),
+ if pr_data:
+ pr_lazy_json = LazyJson(
+ os.path.join("pr_json", f"{pr_data.id}.json"),
)
- with ljpr as __ljpr:
- __ljpr.update(**pr_json)
+ with pr_lazy_json as __edit_pr_lazy_json:
+ __edit_pr_lazy_json.update(**pr_data.model_dump(mode="json"))
else:
- ljpr = False
+ pr_lazy_json = False
# If we've gotten this far then the node is good
- with feedstock_ctx.attrs["pr_info"] as pri:
+ with context.attrs["pr_info"] as pri:
pri["bad"] = False
_reset_pre_pr_migrator_fields(
- feedstock_ctx.attrs, migrator_name, is_version=is_version_migration
+ context.attrs, migrator_name, is_version=is_version_migration
)
- logger.info("Removing feedstock dir")
- eval_cmd(["rm", "-rf", feedstock_dir])
- return migration_run_data["migrate_return_value"], ljpr
+ return migration_run_data["migrate_return_value"], pr_lazy_json
-def _compute_time_per_migrator(mctx, migrators):
+def _compute_time_per_migrator(migrators):
# we weight each migrator by the number of available nodes to migrate
num_nodes = []
for migrator in tqdm.tqdm(migrators, ncols=80, desc="computing time per migrator"):
@@ -558,8 +710,8 @@ def _run_migrator_on_feedstock_branch(
attrs,
base_branch,
migrator,
- fctx,
- dry_run,
+ fctx: FeedstockContext,
+ git_backend: GitPlatformBackend,
mctx,
migrator_name,
good_prs,
@@ -570,14 +722,13 @@ def _run_migrator_on_feedstock_branch(
fctx.attrs["new_version"] = attrs.get("version_pr_info", {}).get(
"new_version", None
)
- migrator_uid, pr_json = run(
- feedstock_ctx=fctx,
+ migrator_uid, pr_json = run_with_tmpdir(
+ context=fctx,
migrator=migrator,
+ git_backend=git_backend,
rerender=migrator.rerender,
- protocol="https",
- hash_type=attrs.get("hash_type", "sha256"),
base_branch=base_branch,
- dry_run=dry_run,
+ hash_type=attrs.get("hash_type", "sha256"),
)
finally:
fctx.attrs.pop("new_version", None)
@@ -610,6 +761,8 @@ def _run_migrator_on_feedstock_branch(
)
except (github3.GitHubError, github.GithubException) as e:
+ # TODO: pull this down into run() - also check the other exceptions
+ # TODO: continue here, after that run locally and add tests, backend should be injected into run
if hasattr(e, "msg") and e.msg == "Repository was archived so is read-only.":
attrs["archived"] = True
else:
@@ -618,7 +771,8 @@ def _run_migrator_on_feedstock_branch(
fctx.feedstock_name,
)
- if is_github_api_limit_reached(e):
+ if is_github_api_limit_reached():
+ logger.warning("GitHub API error", exc_info=e)
break_loop = True
except VersionMigrationError as e:
@@ -697,9 +851,11 @@ def _run_migrator_on_feedstock_branch(
return good_prs, break_loop
-def _is_migrator_done(_mg_start, good_prs, time_per, pr_limit):
+def _is_migrator_done(
+ _mg_start, good_prs, time_per, pr_limit, git_backend: GitPlatformBackend
+):
curr_time = time.time()
- api_req = get_github_api_requests_left()
+ api_req = git_backend.get_api_requests_left()
if curr_time - START_TIME > TIMEOUT:
logger.info(
@@ -734,7 +890,26 @@ def _is_migrator_done(_mg_start, good_prs, time_per, pr_limit):
return False
-def _run_migrator(migrator, mctx, temp, time_per, dry_run):
+def _run_migrator(
+ migrator: Migrator,
+ mctx: MigratorSessionContext,
+ temp: list[AnyStr],
+ time_per: float,
+ git_backend: GitPlatformBackend,
+ package: str | None = None,
+) -> int:
+ """
+ Run a migrator.
+
+ :param migrator: The migrator to run.
+ :param mctx: The migrator session context.
+ :param temp: The list of temporary files.
+ :param time_per: The time limit of this migrator.
+ :param git_backend: The GitPlatformBackend instance to use.
+ :param package: The package to update, if None, all packages are updated.
+
+ :return: The number of "good" PRs created by the migrator.
+ """
_mg_start = time.time()
migrator_name = get_migrator_name(migrator)
@@ -756,6 +931,15 @@ def _run_migrator(migrator, mctx, temp, time_per, dry_run):
possible_nodes = list(migrator.order(effective_graph, mctx.graph))
+ if package:
+ if package not in possible_nodes:
+ logger.info(
+ f"Package {package} is not a candidate for migration of {migrator_name}. "
+ f"If you want to investigate this, run the make-migrators command."
+ )
+ return 0
+ possible_nodes = [package]
+
# version debugging info
if isinstance(migrator, Version):
print("possible version migrations:", flush=True)
@@ -788,7 +972,9 @@ def _run_migrator(migrator, mctx, temp, time_per, dry_run):
flush=True,
)
- if _is_migrator_done(_mg_start, good_prs, time_per, migrator.pr_limit):
+ if _is_migrator_done(
+ _mg_start, good_prs, time_per, migrator.pr_limit, git_backend
+ ):
return 0
for node_name in possible_nodes:
@@ -805,7 +991,9 @@ def _run_migrator(migrator, mctx, temp, time_per, dry_run):
):
# Don't let CI timeout, break ahead of the timeout so we make certain
# to write to the repo
- if _is_migrator_done(_mg_start, good_prs, time_per, migrator.pr_limit):
+ if _is_migrator_done(
+ _mg_start, good_prs, time_per, migrator.pr_limit, git_backend
+ ):
break
base_branches = migrator.get_possible_feedstock_branches(attrs)
@@ -852,14 +1040,14 @@ def _run_migrator(migrator, mctx, temp, time_per, dry_run):
)
):
good_prs, break_loop = _run_migrator_on_feedstock_branch(
- attrs,
- base_branch,
- migrator,
- fctx,
- dry_run,
- mctx,
- migrator_name,
- good_prs,
+ attrs=attrs,
+ base_branch=base_branch,
+ migrator=migrator,
+ fctx=fctx,
+ git_backend=git_backend,
+ mctx=mctx,
+ migrator_name=migrator_name,
+ good_prs=good_prs,
)
if break_loop:
break
@@ -875,11 +1063,9 @@ def _run_migrator(migrator, mctx, temp, time_per, dry_run):
os.chdir(BOT_HOME_DIR)
# Write graph partially through
- if not dry_run:
- dump_graph(mctx.graph)
+ dump_graph(mctx.graph)
with filter_reprinted_lines("rm-tmp"):
- eval_cmd(["rm", "-rf", f"{GIT_CLONE_DIR}/*"])
for f in glob.glob("/tmp/*"):
if f not in temp:
try:
@@ -902,18 +1088,26 @@ def _setup_limits():
resource.setrlimit(resource.RLIMIT_AS, (limit_int, limit_int))
-def _update_nodes_with_bot_rerun(gx: nx.DiGraph):
- """Go through all the open PRs and check if they are rerun"""
+def _update_nodes_with_bot_rerun(gx: nx.DiGraph, package: str | None = None):
+ """
+ Go through all the open PRs and check if they are rerun
+
+ :param gx: the dependency graph
+ :param package: the package to update, if None, all packages are updated
+ """
print("processing bot-rerun labels", flush=True)
- for i, (name, node) in enumerate(gx.nodes.items()):
+ nodes = gx.nodes.items() if not package else [(package, gx.nodes[package])]
+
+ for i, (name, node) in enumerate(nodes):
# logger.info(
# f"node: {i} memory usage: "
# f"{psutil.Process().memory_info().rss // 1024 ** 2}MB",
# )
with node["payload"] as payload:
if payload.get("archived", False):
+ logger.debug(f"skipping archived package {name}")
continue
with payload["pr_info"] as pri, payload["version_pr_info"] as vpri:
# reset bad
@@ -963,12 +1157,21 @@ def _filter_ignored_versions(attrs, version):
return version
-def _update_nodes_with_new_versions(gx):
- """Updates every node with it's new version (when available)"""
+def _update_nodes_with_new_versions(gx: nx.DiGraph, package: str | None = None):
+ """
+ Updates every node with its new version (when available)
+
+ :param gx: the dependency graph
+ :param package: the package to update, if None, all packages are updated
+ """
print("updating nodes with new versions", flush=True)
- version_nodes = get_all_keys_for_hashmap("versions")
+ if package and not does_key_exist_in_hashmap("versions", package):
+ logger.warning(f"Package {package} not found in versions hashmap")
+ return
+
+ version_nodes = get_all_keys_for_hashmap("versions") if not package else [package]
for node in version_nodes:
version_data = LazyJson(f"versions/{node}.json").data
@@ -994,13 +1197,35 @@ def _update_nodes_with_new_versions(gx):
vpri["new_version"] = version_from_data
-def _remove_closed_pr_json():
+def _remove_closed_pr_json(package: str | None = None):
+ """
+ Remove the pull request information for closed PRs.
+
+ :param package: The package to remove the PR information for. If None, all PR information is removed. If you pass
+ a package, closed pr_json files are not removed because this would require iterating all pr_json files.
+ """
print("collapsing closed PR json", flush=True)
+ if package:
+ pr_info_nodes = (
+ [package] if does_key_exist_in_hashmap("pr_info", package) else []
+ )
+ version_pr_info_nodes = (
+ [package] if does_key_exist_in_hashmap("version_pr_info", package) else []
+ )
+
+ if not pr_info_nodes:
+ logger.warning(f"Package {package} not found in pr_info hashmap")
+ if not version_pr_info_nodes:
+ logger.warning(f"Package {package} not found in version_pr_info hashmap")
+ else:
+ pr_info_nodes = get_all_keys_for_hashmap("pr_info")
+ version_pr_info_nodes = get_all_keys_for_hashmap("version_pr_info")
+
# first we go from nodes to pr json and update the pr info and remove the data
name_nodes = [
- ("pr_info", get_all_keys_for_hashmap("pr_info")),
- ("version_pr_info", get_all_keys_for_hashmap("version_pr_info")),
+ ("pr_info", pr_info_nodes),
+ ("version_pr_info", version_pr_info_nodes),
]
for name, nodes in name_nodes:
for node in nodes:
@@ -1033,6 +1258,11 @@ def _remove_closed_pr_json():
# at this point, any json blob referenced in the pr info is state != closed
# so we can remove anything that is empty or closed
+ if package:
+ logger.info(
+ "Since you requested a run for a specific package, we are not removing closed pr_json files."
+ )
+ return
nodes = get_all_keys_for_hashmap("pr_json")
for node in nodes:
pr = LazyJson(f"pr_json/{node}.json")
@@ -1043,22 +1273,22 @@ def _remove_closed_pr_json():
)
-def _update_graph_with_pr_info():
- _remove_closed_pr_json()
+def _update_graph_with_pr_info(package: str | None = None):
+ _remove_closed_pr_json(package)
gx = load_existing_graph()
- _update_nodes_with_bot_rerun(gx)
- _update_nodes_with_new_versions(gx)
+ _update_nodes_with_bot_rerun(gx, package)
+ _update_nodes_with_new_versions(gx, package)
dump_graph(gx)
-def main(ctx: CliContext) -> None:
+def main(ctx: CliContext, package: str | None = None) -> None:
global START_TIME
START_TIME = time.time()
_setup_limits()
with fold_log_lines("updating graph with PR info"):
- _update_graph_with_pr_info()
+ _update_graph_with_pr_info(package)
deploy(ctx, dirs_to_deploy=["version_pr_info", "pr_json", "pr_info"])
# record tmp dir so we can be sure to clean it later
@@ -1077,8 +1307,8 @@ def main(ctx: CliContext) -> None:
graph=gx,
smithy_version=smithy_version,
pinning_version=pinning_version,
- dry_run=ctx.dry_run,
)
+ # TODO: this does not support --online
migrators = load_migrators()
# compute the time per migrator
@@ -1089,7 +1319,6 @@ def main(ctx: CliContext) -> None:
time_per_migrator,
tot_time_per_migrator,
) = _compute_time_per_migrator(
- mctx,
migrators,
)
for i, migrator in enumerate(migrators):
@@ -1110,13 +1339,16 @@ def main(ctx: CliContext) -> None:
flush=True,
)
+ git_backend = github_backend() if not ctx.dry_run else DryRunBackend()
+
for mg_ind, migrator in enumerate(migrators):
good_prs = _run_migrator(
migrator,
mctx,
temp,
time_per_migrator[mg_ind],
- ctx.dry_run,
+ git_backend,
+ package,
)
if good_prs > 0:
pass
@@ -1131,5 +1363,5 @@ def main(ctx: CliContext) -> None:
# ],
# )
- logger.info("API Calls Remaining: %d", get_github_api_requests_left())
+ logger.info(f"API Calls Remaining: {git_backend.get_api_requests_left()}")
logger.info("Done")
diff --git a/conda_forge_tick/cli.py b/conda_forge_tick/cli.py
index 6afe95efa..2eb9f7d5a 100644
--- a/conda_forge_tick/cli.py
+++ b/conda_forge_tick/cli.py
@@ -149,11 +149,20 @@ def update_upstream_versions(
@main.command(name="auto-tick")
+@click.argument(
+ "package",
+ required=False,
+)
@pass_context
-def auto_tick(ctx: CliContext) -> None:
+def auto_tick(ctx: CliContext, package: str | None) -> None:
+ """
+ Run the main bot logic that runs all migrations, updates the graph accordingly, and opens the corresponding PRs.
+
+ If PACKAGE is given, only run the bot for that package, otherwise run the bot for all packages.
+ """
from . import auto_tick
- auto_tick.main(ctx)
+ auto_tick.main(ctx, package=package)
@main.command(name="make-status-report")
@@ -236,16 +245,22 @@ def make_import_to_package_mapping(
@main.command(name="make-migrators")
+@click.option(
+ "--version-only/--all",
+ default=False,
+ help="If given, only initialize the Version migrator.",
+)
@pass_context
def make_migrators(
ctx: CliContext,
+ version_only: bool,
) -> None:
"""
Make the migrators.
"""
from . import make_migrators as _make_migrators
- _make_migrators.main(ctx)
+ _make_migrators.main(ctx, version_only=version_only)
if __name__ == "__main__":
diff --git a/conda_forge_tick/contexts.py b/conda_forge_tick/contexts.py
index 5cc545831..baea644bf 100644
--- a/conda_forge_tick/contexts.py
+++ b/conda_forge_tick/contexts.py
@@ -1,10 +1,17 @@
+from __future__ import annotations
+
import os
+import tempfile
import typing
+from collections.abc import Iterator
+from contextlib import contextmanager
from dataclasses import dataclass
+from pathlib import Path
from networkx import DiGraph
from conda_forge_tick.lazy_json_backends import load
+from conda_forge_tick.utils import get_keys_default
if typing.TYPE_CHECKING:
from conda_forge_tick.migrators_types import AttrsTypedDict
@@ -24,13 +31,12 @@ class MigratorSessionContext:
graph: DiGraph = None
smithy_version: str = ""
pinning_version: str = ""
- dry_run: bool = True
-@dataclass
+@dataclass(frozen=True)
class FeedstockContext:
feedstock_name: str
- attrs: "AttrsTypedDict"
+ attrs: AttrsTypedDict
_default_branch: str = None
@property
@@ -40,6 +46,95 @@ def default_branch(self):
else:
return self._default_branch
- @default_branch.setter
- def default_branch(self, v):
- self._default_branch = v
+ @property
+ def git_repo_owner(self) -> str:
+ return "conda-forge"
+
+ @property
+ def git_repo_name(self) -> str:
+ return f"{self.feedstock_name}-feedstock"
+
+ @property
+ def git_href(self) -> str:
+ """
+ A link to the feedstocks GitHub repository.
+ """
+ return f"https://github.com/{self.git_repo_owner}/{self.git_repo_name}"
+
+ @property
+ def automerge(self) -> bool | str:
+ """
+ Get the automerge setting of the feedstock.
+
+ Note: A better solution to implement this is to use the NodeAttributes Pydantic
+ model for the attrs field. This will be done in the future.
+ """
+ return get_keys_default(
+ self.attrs,
+ ["conda-forge.yml", "bot", "automerge"],
+ {},
+ False,
+ )
+
+ @property
+ def check_solvable(self) -> bool:
+ """
+ Get the check_solvable setting of the feedstock.
+
+ Note: A better solution to implement this is to use the NodeAttributes Pydantic
+ model for the attrs field. This will be done in the future.
+ """
+ return get_keys_default(
+ self.attrs,
+ ["conda-forge.yml", "bot", "check_solvable"],
+ {},
+ False,
+ )
+
+ @contextmanager
+ def reserve_clone_directory(self) -> Iterator[ClonedFeedstockContext]:
+ """
+ Reserve a temporary directory for the feedstock repository that will be available within the context manager.
+ The returned context object will contain the path to the feedstock repository in local_clone_dir.
+ After the context manager exits, the temporary directory will be deleted.
+ """
+ with tempfile.TemporaryDirectory() as tmpdir:
+ local_clone_dir = Path(tmpdir) / self.git_repo_name
+ local_clone_dir.mkdir()
+ yield ClonedFeedstockContext(
+ **self.__dict__,
+ local_clone_dir=local_clone_dir,
+ )
+
+
+@dataclass(frozen=True, kw_only=True)
+class ClonedFeedstockContext(FeedstockContext):
+ """
+ A FeedstockContext object that has reserved a temporary directory for the feedstock repository.
+ """
+
+ # Implementation Note: Keep this class frozen or there will be consistency issues if someone modifies
+ # a ClonedFeedstockContext object in place - it will not be reflected in the original FeedstockContext object.
+ local_clone_dir: Path
+
+ @contextmanager
+ def reserve_clone_directory(self) -> Iterator[ClonedFeedstockContext]:
+ """
+ This method is a no-op for ClonedFeedstockContext objects because the directory has already been reserved.
+ """
+ yield self
+
+ @property
+ def git_repo_owner(self) -> str:
+ return "conda-forge"
+
+ @property
+ def git_repo_name(self) -> str:
+ return f"{self.feedstock_name}-feedstock"
+
+ @property
+ def git_href(self) -> str:
+ """
+ A link to the feedstocks GitHub repository.
+ """
+ return f"https://github.com/{self.git_repo_owner}/{self.git_repo_name}"
diff --git a/conda_forge_tick/executors.py b/conda_forge_tick/executors.py
index e11e08d03..5623e808d 100644
--- a/conda_forge_tick/executors.py
+++ b/conda_forge_tick/executors.py
@@ -16,9 +16,21 @@ def __exit__(self, *args, **kwargs):
pass
-TRLOCK = TRLock()
-PRLOCK = DummyLock()
-DRLOCK = DummyLock()
+GIT_LOCK_THREAD = TRLock()
+GIT_LOCK_PROCESS = DummyLock()
+GIT_LOCK_DASK = DummyLock()
+
+
+@contextlib.contextmanager
+def lock_git_operation():
+ """
+ A context manager to lock git operations - it can be acquired once per thread, once per process,
+ and once per dask worker.
+ Note that this is a reentrant lock, so it can be acquired multiple times by the same thread/process/worker.
+ """
+
+ with GIT_LOCK_THREAD, GIT_LOCK_PROCESS, GIT_LOCK_DASK:
+ yield
logger = logging.getLogger(__name__)
@@ -27,10 +39,12 @@ def __exit__(self, *args, **kwargs):
class DaskRLock(DaskLock):
"""A reentrant lock for dask that is always blocking and never times out."""
- def acquire(self):
- if not hasattr(self, "_rcount"):
- self._rcount = 0
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._rcount = 0
+ self._rdata = None
+ def acquire(self, *args):
self._rcount += 1
if self._rcount == 1:
@@ -39,29 +53,29 @@ def acquire(self):
return self._rdata
def release(self):
- if not hasattr(self, "_rcount") or self._rcount == 0:
+ if self._rcount == 0:
raise RuntimeError("Lock not acquired so cannot be released!")
self._rcount -= 1
if self._rcount == 0:
- delattr(self, "_rdata")
+ self._rdata = None
return super().release()
else:
return None
def _init_process(lock):
- global PRLOCK
- PRLOCK = lock
+ global GIT_LOCK_PROCESS
+ GIT_LOCK_PROCESS = lock
def _init_dask(lock):
- global DRLOCK
- # it appears we have to construct the locak by name instead
+ global GIT_LOCK_DASK
+ # it appears we have to construct the lock by name instead
# of passing the object itself
# otherwise dask uses a regular lock
- DRLOCK = DaskRLock(name=lock)
+ GIT_LOCK_DASK = DaskRLock(name=lock)
@contextlib.contextmanager
@@ -70,8 +84,8 @@ def executor(kind: str, max_workers: int, daemon=True) -> typing.Iterator[Execut
This allows us to easily use other executors as needed.
"""
- global DRLOCK
- global PRLOCK
+ global GIT_LOCK_DASK
+ global GIT_LOCK_PROCESS
if kind == "thread":
with ThreadPoolExecutor(max_workers=max_workers) as pool_t:
@@ -85,7 +99,7 @@ def executor(kind: str, max_workers: int, daemon=True) -> typing.Iterator[Execut
initargs=(lock,),
) as pool_p:
yield pool_p
- PRLOCK = DummyLock()
+ GIT_LOCK_PROCESS = DummyLock()
elif kind in ["dask", "dask-process", "dask-thread"]:
import dask
import distributed
@@ -101,6 +115,6 @@ def executor(kind: str, max_workers: int, daemon=True) -> typing.Iterator[Execut
with distributed.Client(cluster) as client:
client.run(_init_dask, "cftick")
yield ClientExecutor(client)
- DRLOCK = DummyLock()
+ GIT_LOCK_DASK = DummyLock()
else:
raise NotImplementedError("That kind is not implemented")
diff --git a/conda_forge_tick/git_utils.py b/conda_forge_tick/git_utils.py
index bded19127..ad51b2bab 100644
--- a/conda_forge_tick/git_utils.py
+++ b/conda_forge_tick/git_utils.py
@@ -1,14 +1,20 @@
"""Utilities for managing github repos"""
import copy
-import datetime
+import enum
import logging
-import os
+import math
import subprocess
import sys
+import textwrap
import threading
import time
-from typing import Dict, Optional, Tuple, Union
+from abc import ABC, abstractmethod
+from datetime import datetime
+from email import utils
+from functools import cached_property
+from pathlib import Path
+from typing import Dict, Iterator, Optional, Union
import backoff
import github
@@ -17,7 +23,9 @@
import github3.pulls
import github3.repos
import requests
+from github3.session import GitHubSession
from requests.exceptions import RequestException, Timeout
+from requests.structures import CaseInsensitiveDict
from conda_forge_tick import sensitive_env
@@ -25,9 +33,17 @@
# and pull all the needed info from the various source classes)
from conda_forge_tick.lazy_json_backends import LazyJson
-from .contexts import FeedstockContext
-from .os_utils import pushd
-from .utils import get_bot_run_url, run_command_hiding_token
+from .executors import lock_git_operation
+from .models.pr_json import (
+ GithubPullRequestBase,
+ GithubPullRequestMergeableState,
+ GithubRepository,
+ PullRequestData,
+ PullRequestDataValid,
+ PullRequestInfoHead,
+ PullRequestState,
+)
+from .utils import get_bot_run_url, replace_tokens, run_command_hiding_token
logger = logging.getLogger(__name__)
@@ -38,8 +54,6 @@
MAX_GITHUB_TIMEOUT = 60
-GIT_CLONE_DIR = "./feedstocks/"
-
BOT_RERUN_LABEL = {
"name": "bot-rerun",
}
@@ -48,7 +62,7 @@
# these keys are kept from github PR json blobs
# to add more keys to keep, put them in the right spot in the dict and
-# set them to None. Also add them to the PullRequestInfo Pydantic model!
+# set them to None. Also add them to the PullRequestData Pydantic model!
PR_KEYS_TO_KEEP = {
"ETag": None,
"Last-Modified": None,
@@ -70,302 +84,1027 @@
}
+def get_bot_token():
+ with sensitive_env() as env:
+ return env["BOT_TOKEN"]
+
+
def github3_client() -> github3.GitHub:
+ """
+ This will be removed in the future, use the GitHubBackend class instead.
+ """
if not hasattr(GITHUB3_CLIENT, "client"):
- with sensitive_env() as env:
- GITHUB3_CLIENT.client = github3.login(token=env["BOT_TOKEN"])
+ GITHUB3_CLIENT.client = github3.login(token=get_bot_token())
return GITHUB3_CLIENT.client
def github_client() -> github.Github:
+ """
+ This will be removed in the future, use the GitHubBackend class instead.
+ """
if not hasattr(GITHUB_CLIENT, "client"):
- with sensitive_env() as env:
- GITHUB_CLIENT.client = github.Github(
- auth=github.Auth.Token(env["BOT_TOKEN"]),
- per_page=100,
- )
+ GITHUB_CLIENT.client = github.Github(
+ auth=github.Auth.Token(get_bot_token()),
+ per_page=100,
+ )
return GITHUB_CLIENT.client
-def get_default_branch(feedstock_name):
- """Get the default branch for a feedstock
+class Bound(float, enum.Enum):
+ def __str__(self):
+ return str(self.value)
- Parameters
- ----------
- feedstock_name : str
- The feedstock without '-feedstock'.
+ INFINITY = math.inf
+ """
+ Python does not have support for a literal infinity type, so we use this enum for it.
+ """
- Returns
- -------
- branch : str
- The default branch (e.g., 'main').
+
+class GitConnectionMode(enum.StrEnum):
+ """
+ We don't need anything else than HTTPS for now, but this would be the place to
+ add more connection modes (e.g. SSH).
"""
- return (
- github_client()
- .get_repo(f"conda-forge/{feedstock_name}-feedstock")
- .default_branch
- )
+ HTTPS = "https"
-def get_github_api_requests_left() -> Union[int, None]:
- """Get the number of remaining GitHub API requests.
- Returns
- -------
- left : int or None
- The number of remaining requests, or None if there is an exception.
+class GitCliError(Exception):
+ """
+ A generic error that occurred while running a git CLI command.
"""
- gh = github3_client()
- try:
- left = gh.rate_limit()["resources"]["core"]["remaining"]
- except Exception:
- left = None
- return left
+ pass
-def is_github_api_limit_reached(
- e: Union[github3.GitHubError, github.GithubException],
-) -> bool:
- """Prints diagnostic information about a github exception.
+class GitPlatformError(Exception):
+ """
+ A generic error that occurred while interacting with a git platform.
+ """
- Parameters
- ----------
- e
- The exception to check.
+ pass
- Returns
- -------
- out_of_api_calls
- A flag to indicate that the api call limit has been reached.
+
+class DuplicatePullRequestError(GitPlatformError):
+ """
+ Raised if a pull request already exists.
"""
- gh = github3_client()
- logger.warning("GitHub API error:", exc_info=e)
+ pass
- try:
- c = gh.rate_limit()["resources"]["core"]
- except Exception:
- # if we can't connect to the rate limit API, let's assume it has been reached
- return True
-
- if c["remaining"] == 0:
- ts = c["reset"]
- logger.warning(
- "GitHub API timeout, API returns at %s",
- datetime.datetime.utcfromtimestamp(ts).strftime("%Y-%m-%dT%H:%M:%SZ"),
- )
- return True
-
- return False
-
-
-def feedstock_url(fctx: FeedstockContext, protocol: str = "ssh") -> str:
- """Returns the URL for a conda-forge feedstock."""
- feedstock = fctx.feedstock_name + "-feedstock"
- if feedstock.startswith("http://github.com/"):
- return feedstock
- elif feedstock.startswith("https://github.com/"):
- return feedstock
- elif feedstock.startswith("git@github.com:"):
- return feedstock
- protocol = protocol.lower()
- if protocol == "http":
- url = "http://github.com/conda-forge/" + feedstock + ".git"
- elif protocol == "https":
- url = "https://github.com/conda-forge/" + feedstock + ".git"
- elif protocol == "ssh":
- url = "git@github.com:conda-forge/" + feedstock + ".git"
- else:
- msg = "Unrecognized github protocol {0!r}, must be ssh, http, or https."
- raise ValueError(msg.format(protocol))
- return url
+class RepositoryNotFoundError(Exception):
+ """
+ Raised when a repository is not found.
+ """
-def feedstock_repo(fctx: FeedstockContext) -> str:
- """Gets the name of the feedstock repository."""
- repo = fctx.feedstock_name + "-feedstock"
- if repo.endswith(".git"):
- repo = repo[:-4]
- return repo
+ pass
-def fork_url(feedstock_url: str, username: str) -> str:
- """Creates the URL of the user's fork."""
- beg, end = feedstock_url.rsplit("/", 1)
- beg = beg[:-11] # chop off 'conda-forge'
- url = beg + username + "/" + end
- return url
+class GitCli:
+ """
+ A simple wrapper around the git command line interface.
+ Git operations are locked (globally) to prevent operations from interfering with each other.
+ If this does impact performance too much, we can consider a per-repository locking strategy.
+ """
-def fetch_repo(*, feedstock_dir, origin, upstream, branch, base_branch="main"):
- """fetch a repo and make a PR branch
+ def __init__(self):
+ self.__hidden_tokens: list[str] = []
+
+ @lock_git_operation()
+ def _run_git_command(
+ self,
+ cmd: list[str | Path],
+ working_directory: Path | None = None,
+ check_error: bool = True,
+ ) -> subprocess.CompletedProcess:
+ """
+ Run a git command. stdout is only printed if the command fails. stderr is always printed.
+ If outputs are captured, they are never printed.
+ stdout is always captured, we capture stderr only if tokens are hidden.
+
+ :param cmd: The command to run, as a list of strings.
+ :param working_directory: The directory to run the command in. If None, the command will be run in the current
+ working directory.
+ :param check_error: If True, raise a GitCliError if the git command fails.
+ :return: The result of the git command.
+ :raises GitCliError: If the git command fails and check_error is True.
+ :raises FileNotFoundError: If the working directory does not exist.
+ """
+ git_command = ["git"] + cmd
+
+ logger.debug(f"Running git command: {git_command}")
+
+ # we only need to capture stderr if we want to hide tokens
+ stderr_args = {"stderr": subprocess.PIPE} if self.__hidden_tokens else {}
- Parameters
- ----------
- feedstock_dir : str
- The directory where you want to clone the feedstock.
- origin : str
- The origin to clone from.
- upstream : str
- The upstream repo to add as a remote named `upstream`.
- branch : str
- The branch to make and checkout.
- base_branch : str, optional
- The branch from which to branch from to make `branch`. Defaults to "main".
+ try:
+ p = subprocess.run(
+ git_command,
+ check=check_error,
+ cwd=working_directory,
+ stdout=subprocess.PIPE,
+ **stderr_args,
+ text=True,
+ )
+ except subprocess.CalledProcessError as e:
+ e.stdout = replace_tokens(e.stdout, self.__hidden_tokens)
+ e.stderr = replace_tokens(e.stderr, self.__hidden_tokens)
+ logger.info(
+ f"Command '{' '.join(git_command)}' failed.\nstdout:\n{e.stdout}\nend of stdout"
+ )
+ if self.__hidden_tokens:
+ logger.info(f"stderr:\n{e.stderr}\nend of stderr")
+ raise GitCliError(f"Error running git command: {repr(e)}") from e
- Returns
- -------
- success : bool
- True if the fetch worked, False otherwise.
- """
- if not os.path.isdir(feedstock_dir):
- p = subprocess.run(
- ["git", "clone", "-q", origin, feedstock_dir],
+ p.stdout = replace_tokens(p.stdout, self.__hidden_tokens)
+ p.stderr = replace_tokens(p.stderr, self.__hidden_tokens)
+
+ if self.__hidden_tokens:
+ # we suppressed stderr, so we need to print it here
+ print(p.stderr, file=sys.stderr, end="")
+
+ return p
+
+ def add_hidden_token(self, token: str) -> None:
+ """
+ Permanently hide a token in the logs.
+
+ :param token: The token to hide.
+ """
+ self.__hidden_tokens.append(token)
+
+ @lock_git_operation()
+ def add(self, git_dir: Path, *pathspec: Path, all_: bool = False):
+ """
+ Add files to the git index with `git add`.
+
+ :param git_dir: The directory of the git repository.
+ :param pathspec: The files to add.
+ :param all_: If True, not only add the files in pathspec, but also where the index already has an entry.
+ If _all is set with empty pathspec, all files in the entire working tree are updated.
+ :raises ValueError: If pathspec is empty and all_ is False.
+ :raises GitCliError: If the git command fails.
+ """
+ if not pathspec and not all_:
+ raise ValueError("Either pathspec or all_ must be set.")
+
+ all_arg = ["--all"] if all_ else []
+
+ self._run_git_command(["add", *all_arg, *pathspec], git_dir)
+
+ @lock_git_operation()
+ def commit(
+ self, git_dir: Path, message: str, all_: bool = False, allow_empty: bool = False
+ ):
+ """
+ Commit changes to the git repository with `git commit`.
+ :param git_dir: The directory of the git repository.
+ :param message: The commit message.
+ :param allow_empty: If True, allow an empty commit.
+ :param all_: Automatically stage files that have been modified and deleted, but new files are not affected.
+ :raises GitCliError: If the git command fails.
+ """
+ all_arg = ["-a"] if all_ else []
+ allow_empty_arg = ["--allow-empty"] if allow_empty else []
+
+ self._run_git_command(
+ ["commit", *all_arg, *allow_empty_arg, "-m", message], git_dir
+ )
+
+ def rev_parse_head(self, git_dir: Path) -> str:
+ """
+ Get the commit hash of HEAD with `git rev-parse HEAD`.
+ :param git_dir: The directory of the git repository.
+ :return: The commit hash of HEAD.
+ :raises GitCliError: If the git command fails.
+ """
+ ret = self._run_git_command(["rev-parse", "HEAD"], git_dir)
+
+ return ret.stdout.strip()
+
+ @lock_git_operation()
+ def reset_hard(self, git_dir: Path, to_treeish: str = "HEAD"):
+ """
+ Reset the git index of a directory to the state of the last commit with `git reset --hard HEAD`.
+ :param git_dir: The directory to reset.
+ :param to_treeish: The treeish to reset to. Defaults to "HEAD".
+ :raises GitCliError: If the git command fails.
+ :raises FileNotFoundError: If the git_dir does not exist.
+ """
+ self._run_git_command(["reset", "--quiet", "--hard", to_treeish], git_dir)
+
+ @lock_git_operation()
+ def clone_repo(self, origin_url: str, target_dir: Path):
+ """
+ Clone a Git repository.
+ If target_dir exists and is non-empty, this method will fail with GitCliError.
+ If target_dir exists and is empty, it will work.
+ If target_dir does not exist, it will work.
+ :param target_dir: The directory to clone the repository into.
+ :param origin_url: The URL of the repository to clone.
+ :raises GitCliError: If the git command fails (e.g. because origin_url does not point to valid remote or
+ target_dir is not empty).
+ """
+ try:
+ self._run_git_command(["clone", "--quiet", origin_url, target_dir])
+ except GitCliError as e:
+ raise GitCliError(
+ f"Error cloning repository from {origin_url}. Does the repository exist? Is target_dir empty?"
+ ) from e
+
+ @lock_git_operation()
+ def add_remote(self, git_dir: Path, remote_name: str, remote_url: str):
+ """
+ Add a remote to a git repository.
+ :param remote_name: The name of the remote.
+ :param remote_url: The URL of the remote.
+ :param git_dir: The directory of the git repository.
+ :raises GitCliError: If the git command fails (e.g., the remote already exists).
+ :raises FileNotFoundError: If git_dir does not exist
+ """
+ self._run_git_command(["remote", "add", remote_name, remote_url], git_dir)
+
+ def push_to_url(self, git_dir: Path, remote_url: str, branch: str):
+ """
+ Push changes to a remote URL.
+
+ :param git_dir: The directory of the git repository.
+ :param remote_url: The URL of the remote.
+ :param branch: The branch to push to.
+ :raises GitCliError: If the git command fails.
+ """
+
+ self._run_git_command(["push", remote_url, branch], git_dir)
+
+ @lock_git_operation()
+ def fetch_all(self, git_dir: Path):
+ """
+ Fetch all changes from all remotes.
+ :param git_dir: The directory of the git repository.
+ :raises GitCliError: If the git command fails.
+ :raises FileNotFoundError: If git_dir does not exist
+ """
+ self._run_git_command(["fetch", "--all", "--quiet"], git_dir)
+
+ def does_branch_exist(self, git_dir: Path, branch_name: str):
+ """
+ Check if a branch exists in a git repository.
+ Note: If git_dir is not a git repository, this method will return False.
+ Note: This method is intentionally not locked with lock_git_operation, as it only reads the git repository and
+ does not modify it.
+ :param branch_name: The name of the branch.
+ :param git_dir: The directory of the git repository.
+ :return: True if the branch exists, False otherwise.
+ :raises GitCliError: If the git command fails.
+ :raises FileNotFoundError: If git_dir does not exist
+ """
+ ret = self._run_git_command(
+ ["show-ref", "--verify", "--quiet", f"refs/heads/{branch_name}"],
+ git_dir,
+ check_error=False,
+ )
+
+ return ret.returncode == 0
+
+ def does_remote_exist(self, remote_url: str) -> bool:
+ """
+ Check if a remote exists.
+ Note: This method is intentionally not locked with lock_git_operation, as it only reads a remote and does not
+ modify a git repository.
+ :param remote_url: The URL of the remote.
+ :return: True if the remote exists, False otherwise.
+ """
+ ret = self._run_git_command(["ls-remote", remote_url], check_error=False)
+
+ return ret.returncode == 0
+
+ @lock_git_operation()
+ def checkout_branch(
+ self,
+ git_dir: Path,
+ branch: str,
+ track: bool = False,
+ ):
+ """
+ Checkout a branch in a git repository.
+ :param git_dir: The directory of the git repository.
+ :param branch: The branch to check out.
+ :param track: If True, set the branch to track the remote branch with the same name (sets the --track flag).
+ A new local branch will be created with the name inferred from branch.
+ For example, if branch is "upstream/main", the new branch will be "main".
+ :raises GitCliError: If the git command fails.
+ :raises FileNotFoundError: If git_dir does not exist
+ """
+ track_flag = ["--track"] if track else []
+ self._run_git_command(["checkout", "--quiet"] + track_flag + [branch], git_dir)
+
+ @lock_git_operation()
+ def checkout_new_branch(
+ self, git_dir: Path, branch: str, start_point: str | None = None
+ ):
+ """
+ Checkout a new branch in a git repository.
+ :param git_dir: The directory of the git repository.
+ :param branch: The name of the new branch.
+ :param start_point: The name of the branch to branch from, or None to branch from the current branch.
+ :raises FileNotFoundError: If git_dir does not exist
+ """
+ start_point_option = [start_point] if start_point else []
+
+ self._run_git_command(
+ ["checkout", "--quiet", "-b", branch] + start_point_option, git_dir
+ )
+
+ def diffed_files(
+ self, git_dir: Path, commit_a: str, commit_b: str = "HEAD"
+ ) -> Iterator[Path]:
+ """
+ Get the files that are different between two commits.
+ :param git_dir: The directory of the git repository. This should be the root of the repository.
+ If it is a subdirectory, only the files in that subdirectory will be returned.
+ :param commit_a: The first commit.
+ :param commit_b: The second commit.
+ :return: An iterator over the files that are different between the two commits.
+ """
+
+ # --relative ensures that we do not assemble invalid paths below if git_dir is a subdirectory
+ ret = self._run_git_command(
+ ["diff", "--name-only", "--relative", commit_a, commit_b], git_dir
)
- if p.returncode != 0:
- msg = "Could not clone " + origin
- msg += ". Do you have a personal fork of the feedstock?"
- print(msg, file=sys.stderr)
- return False
- reset_hard = False
- else:
- reset_hard = True
- def _run_git_cmd(cmd, **kwargs):
- return subprocess.run(["git"] + cmd, check=True, **kwargs)
+ return (git_dir / line for line in ret.stdout.splitlines())
- quiet = "--quiet"
- with pushd(feedstock_dir):
- if reset_hard:
- _run_git_cmd(["reset", "--hard", "HEAD"])
+ @lock_git_operation()
+ def clone_fork_and_branch(
+ self,
+ origin_url: str,
+ target_dir: Path,
+ upstream_url: str,
+ new_branch: str,
+ base_branch: str = "main",
+ ):
+ """
+ Convenience method to do the following:
+ 1. Clone the repository at origin_url into target_dir (resetting the directory if it already exists).
+ 2. Add a remote named "upstream" with the URL upstream_url (ignoring if it already exists).
+ 3. Fetch all changes from all remotes.
+ 4. Checkout the base branch.
+ 5. Create a new branch from the base branch with the name new_branch.
+
+ This is usually used to create a new branch for a pull request. In this case, origin_url is the URL of the
+ user's fork, and upstream_url is the URL of the upstream repository.
+
+ :param origin_url: The URL of the repository (fork) to clone.
+ :param target_dir: The directory to clone the repository into.
+ :param upstream_url: The URL of the upstream repository.
+ :param new_branch: The name of the branch to create.
+ :param base_branch: The name of the base branch to branch from.
+
+ :raises GitCliError: If a git command fails.
+ """
+ try:
+ self.clone_repo(origin_url, target_dir)
+ except GitCliError:
+ if not target_dir.exists():
+ raise GitCliError(
+ f"Could not clone {origin_url} - does the remote exist?"
+ )
+ logger.debug(
+ f"Cloning {origin_url} into {target_dir} was not successful - "
+ f"trying to reset hard since the directory already exists. This will fail if the target directory is "
+ f"not a git repository."
+ )
+ self.reset_hard(target_dir)
- # doesn't work if the upstream already exists
try:
- # always run upstream
- _run_git_cmd(["remote", "add", "upstream", upstream])
- except subprocess.CalledProcessError:
+ self.add_remote(target_dir, "upstream", upstream_url)
+ except GitCliError as e:
+ logger.debug(
+ "It looks like remote 'upstream' already exists. Ignoring.", exc_info=e
+ )
pass
- # fetch remote changes
- _run_git_cmd(["fetch", "--all", quiet])
- if _run_git_cmd(
- ["branch", "--list", base_branch],
- capture_output=True,
- ).stdout:
- _run_git_cmd(["checkout", base_branch, quiet])
+ self.fetch_all(target_dir)
+
+ if self.does_branch_exist(target_dir, base_branch):
+ self.checkout_branch(target_dir, base_branch)
else:
try:
- _run_git_cmd(["checkout", "--track", f"upstream/{base_branch}", quiet])
- except subprocess.CalledProcessError:
- _run_git_cmd(
- ["checkout", "-b", base_branch, f"upstream/{base_branch}", quiet],
+ self.checkout_branch(target_dir, f"upstream/{base_branch}", track=True)
+ except GitCliError as e:
+ logger.debug(
+ "Could not check out with git checkout --track. Trying git checkout -b.",
+ exc_info=e,
+ )
+
+ # not sure why this is needed, but it was in the original code
+ self.checkout_new_branch(
+ target_dir,
+ base_branch,
+ start_point=f"upstream/{base_branch}",
)
- _run_git_cmd(["reset", "--hard", f"upstream/{base_branch}", quiet])
- # make and modify version branch
+ # not sure why this is needed, but it was in the original code
+ self.reset_hard(target_dir, f"upstream/{base_branch}")
+
try:
- _run_git_cmd(["checkout", branch, quiet])
- except subprocess.CalledProcessError:
- _run_git_cmd(["checkout", "-b", branch, base_branch, quiet])
+ logger.debug(
+ f"Trying to checkout branch {new_branch} without creating a new branch"
+ )
+ self.checkout_branch(target_dir, new_branch)
+ except GitCliError:
+ logger.debug(
+ f"It seems branch {new_branch} does not exist. Creating it.",
+ )
+ self.checkout_new_branch(target_dir, new_branch, start_point=base_branch)
- return True
+class GitPlatformBackend(ABC):
+ """
+ A backend for interacting with a git platform (e.g. GitHub).
-def get_repo(
- fctx: FeedstockContext,
- branch: str,
- feedstock: Optional[str] = None,
- protocol: str = "ssh",
- pull_request: bool = True,
- fork: bool = True,
- base_branch: str = "main",
-) -> Tuple[str, github3.repos.Repository]:
- """Get the feedstock repo
+ Implementation Note: If you wonder what should be in this class vs. the GitCli class, the GitPlatformBackend class
+ should contain the logic for interacting with the platform (e.g. GitHub), while the GitCli class should contain the
+ logic for interacting with the git repository itself. If you need to know anything specific about the platform,
+ it should be in the GitPlatformBackend class.
- Parameters
- ----------
- fcts : FeedstockContext
- Feedstock context used for constructing feedstock urls, etc.
- branch : str
- The branch to be made.
- feedstock : str, optional
- The feedstock to clone if None use $FEEDSTOCK
- protocol : str, optional
- The git protocol to use, defaults to ``ssh``
- pull_request : bool, optional
- If true issue pull request, defaults to true
- fork : bool
- If true create a fork, defaults to true
- base_branch : str, optional
- The base branch from which to make the new branch.
+ Git operations are locked (globally) to prevent operations from interfering with each other.
+ If this does impact performance too much, we can consider a per-repository locking strategy.
+ """
- Returns
- -------
- recipe_dir : str
- The recipe directory
- repo : github3 repository
- The github3 repository object.
+ def __init__(self, git_cli: GitCli):
+ """
+ Create a new GitPlatformBackend.
+ :param git_cli: The GitCli instance to use for interacting with git repositories.
+ """
+ self.cli = git_cli
+
+ @abstractmethod
+ def does_repository_exist(self, owner: str, repo_name: str) -> bool:
+ """
+ Check if a repository exists.
+ :param owner: The owner of the repository.
+ :param repo_name: The name of the repository.
+ """
+ pass
+
+ def get_remote_url(
+ self,
+ owner: str,
+ repo_name: str,
+ connection_mode: GitConnectionMode = GitConnectionMode.HTTPS,
+ token: str | None = None,
+ ) -> str:
+ """
+ Get the URL of a remote repository.
+ :param owner: The owner of the repository.
+ :param repo_name: The name of the repository.
+ :param connection_mode: The connection mode to use.
+ :param token: A token to use for authentication. If falsy, no token is used. Use get_authenticated_remote_url
+ instead if you want to use the token of the current user.
+ :raises ValueError: If the connection mode is not supported.
+ :raises RepositoryNotFoundError: If the repository does not exist. This is only raised if the backend relies on
+ the repository existing to generate the URL.
+ """
+ # Currently we don't need any abstraction for other platforms than GitHub, so we don't build such abstractions.
+ match connection_mode:
+ case GitConnectionMode.HTTPS:
+ return f"https://{f'{token}@' if token else ''}github.com/{owner}/{repo_name}.git"
+ case _:
+ raise ValueError(f"Unsupported connection mode: {connection_mode}")
+
+ @abstractmethod
+ def push_to_repository(
+ self, owner: str, repo_name: str, git_dir: Path, branch: str
+ ):
+ """
+ Push changes to a repository.
+ :param owner: The owner of the repository.
+ :param repo_name: The name of the repository.
+ :param git_dir: The directory of the git repository.
+ :param branch: The branch to push to.
+ :raises GitPlatformError: If the push fails.
+ """
+ pass
+
+ @abstractmethod
+ def fork(self, owner: str, repo_name: str):
+ """
+ Fork a repository. If the fork already exists, do nothing except syncing the default branch name.
+ Forks are created under the current user's account (see `self.user`).
+ The name of the forked repository is the same as the original repository.
+ :param owner: The owner of the repository.
+ :param repo_name: The name of the repository.
+ :raises RepositoryNotFoundError: If the repository does not exist.
+ """
+ pass
+
+ @lock_git_operation()
+ def clone_fork_and_branch(
+ self,
+ upstream_owner: str,
+ repo_name: str,
+ target_dir: Path,
+ new_branch: str,
+ base_branch: str = "main",
+ ):
+ """
+ Identical to `GitCli::clone_fork_and_branch`, but generates the URLs from the repository name.
+
+ :param upstream_owner: The owner of the upstream repository.
+ :param repo_name: The name of the repository.
+ :param target_dir: The directory to clone the repository into.
+ :param new_branch: The name of the branch to create.
+ :param base_branch: The name of the base branch to branch from.
+
+ :raises GitCliError: If a git command fails.
+ """
+ self.cli.clone_fork_and_branch(
+ origin_url=self.get_remote_url(self.user, repo_name),
+ target_dir=target_dir,
+ upstream_url=self.get_remote_url(upstream_owner, repo_name),
+ new_branch=new_branch,
+ base_branch=base_branch,
+ )
+
+ @property
+ @abstractmethod
+ def user(self) -> str:
+ """
+ The username of the logged-in user, i.e. the owner of forked repositories.
+ """
+ pass
+
+ @abstractmethod
+ def _sync_default_branch(self, upstream_owner: str, upstream_repo: str):
+ """
+ Sync the default branch of the forked repository with the upstream repository.
+ :param upstream_owner: The owner of the upstream repository.
+ :param upstream_repo: The name of the upstream repository.
+ """
+ pass
+
+ @abstractmethod
+ def get_api_requests_left(self) -> int | Bound | None:
+ """
+ Get the number of remaining API requests for the backend.
+ Returns `Bound.INFINITY` if the backend does not have a rate limit.
+ Returns None if an exception occurred while getting the rate limit.
+
+ Implementations may print diagnostic information about the API limit.
+ """
+ pass
+
+ def is_api_limit_reached(self) -> bool:
+ """
+ Returns True if the API limit has been reached, False otherwise.
+
+ If an exception occurred while getting the rate limit, this method returns True, assuming the limit has
+ been reached.
+
+ Additionally, implementations may print diagnostic information about the API limit.
+ """
+ return self.get_api_requests_left() in (0, None)
+
+ @abstractmethod
+ def create_pull_request(
+ self,
+ target_owner: str,
+ target_repo: str,
+ base_branch: str,
+ head_branch: str,
+ title: str,
+ body: str,
+ ) -> PullRequestData:
+ """
+ Create a pull request from a forked repository. It is assumed that the forked repository is owned by the
+ current user and has the same name as the target repository.
+
+ :param target_owner: The owner of the target repository.
+ :param target_repo: The name of the target repository.
+ :param base_branch: The base branch of the pull request, located in the target repository.
+ :param head_branch: The head branch of the pull request, located in the forked repository.
+ :param title: The title of the pull request.
+ :param body: The body of the pull request.
+
+ :returns: The data of the created pull request.
+
+ :raises GitPlatformError: If the pull request could not be created.
+ :raises DuplicatePullRequestError: If a pull request already exists and the backend checks for it.
+ """
+ pass
+
+ @abstractmethod
+ def comment_on_pull_request(
+ self, repo_owner: str, repo_name: str, pr_number: int, comment: str
+ ) -> None:
+ """
+ Comment on an existing pull request.
+ :param repo_owner: The owner of the repository.
+ :param repo_name: The name of the repository.
+ :param pr_number: The number of the pull request.
+ :param comment: The comment to post.
+
+ :raises RepositoryNotFoundError: If the repository does not exist.
+ :raises GitPlatformError: If the comment could not be posted, including if the pull request does not exist.
+ """
+ pass
+
+
+class _Github3SessionWrapper:
"""
- gh = github3_client()
- gh_username = gh.me().login
+ This is a wrapper around the github3.session.GitHubSession that allows us to intercept the response headers.
+ """
+
+ def __init__(self, session: GitHubSession):
+ super().__init__()
+ self._session = session
+ self.last_response_headers: CaseInsensitiveDict[str] = CaseInsensitiveDict()
+
+ def __getattr__(self, item):
+ return getattr(self._session, item)
+
+ def _forward_request(self, method, *args, **kwargs):
+ response = method(*args, **kwargs)
+ self.last_response_headers = copy.deepcopy(response.headers)
+ return response
+
+ def post(self, *args, **kwargs):
+ return self._forward_request(self._session.post, *args, **kwargs)
+
+ def get(self, *args, **kwargs):
+ return self._forward_request(self._session.get, *args, **kwargs)
- # first, let's grab the feedstock locally
- upstream = feedstock_url(fctx=fctx, protocol=protocol)
- origin = fork_url(upstream, gh_username)
- feedstock_reponame = feedstock_repo(fctx=fctx)
- if pull_request or fork:
- repo = gh.repository("conda-forge", feedstock_reponame)
+class GitHubBackend(GitPlatformBackend):
+ """
+ A git backend for GitHub, using both PyGithub and github3.py as clients.
+ Both clients are used for historical reasons. In the future, this should be refactored to use only one client.
+
+ Git operations are locked (globally) to prevent operations from interfering with each other.
+ If this does impact performance too much, we can consider a per-repository locking strategy.
+ """
+
+ _GITHUB_PER_PAGE = 100
+ """
+ The number of items to fetch per page from the GitHub API.
+ """
+
+ def __init__(
+ self,
+ github3_client: github3.GitHub,
+ pygithub_client: github.Github,
+ token: str,
+ ):
+ """
+ Create a new GitHubBackend.
+
+ Note: Because we need additional response headers, we wrap the github3 session of the github3 client
+ with our own session wrapper and replace the github3 client's session with it.
+
+ :param github3_client: The github3 client to use for interacting with the GitHub API.
+ :param pygithub_client: The PyGithub client to use for interacting with the GitHub API.
+ :param token: The token that will be hidden in CLI outputs and used for writing to git repositories. Note that
+ you need to authenticate github3 and PyGithub yourself. Use the `from_token` class method to create an instance
+ that has all necessary clients set up.
+ """
+ cli = GitCli()
+ cli.add_hidden_token(token)
+ super().__init__(cli)
+ self.__token = token
+ self.github3_client = github3_client
+ self._github3_session = _Github3SessionWrapper(self.github3_client.session)
+ self.github3_client.session = self._github3_session
+
+ self.pygithub_client = pygithub_client
+
+ @classmethod
+ def from_token(cls, token: str):
+ return cls(
+ github3.login(token=token),
+ github.Github(auth=github.Auth.Token(token), per_page=cls._GITHUB_PER_PAGE),
+ token=token,
+ )
+
+ def does_repository_exist(self, owner: str, repo_name: str) -> bool:
+ repo = self.github3_client.repository(owner, repo_name)
+ return repo is not None
+
+ def push_to_repository(
+ self, owner: str, repo_name: str, git_dir: Path, branch: str
+ ):
+ # we need an authenticated URL with write access
+ remote_url = self.get_remote_url(
+ owner, repo_name, GitConnectionMode.HTTPS, self.__token
+ )
+ self.cli.push_to_url(git_dir, remote_url, branch)
+
+ @lock_git_operation()
+ def fork(self, owner: str, repo_name: str):
+ if self.does_repository_exist(self.user, repo_name):
+ # The fork already exists, so we only sync the default branch.
+ self._sync_default_branch(owner, repo_name)
+ return
+
+ repo = self.github3_client.repository(owner, repo_name)
if repo is None:
- print("could not fork conda-forge/%s!" % feedstock_reponame, flush=True)
- with fctx.attrs["pr_info"] as pri:
- pri["bad"] = f"{fctx.feedstock_name}: could not find feedstock\n"
- return False, False
+ raise RepositoryNotFoundError(
+ f"Repository {owner}/{repo_name} does not exist."
+ )
+
+ logger.debug(f"Forking {owner}/{repo_name}.")
+ repo.create_fork()
+
+ # Sleep to make sure the fork is created before we go after it
+ time.sleep(5)
+
+ @lock_git_operation()
+ def _sync_default_branch(self, upstream_owner: str, repo_name: str):
+ fork_owner = self.user
+
+ upstream_repo = self.pygithub_client.get_repo(f"{upstream_owner}/{repo_name}")
+ fork = self.pygithub_client.get_repo(f"{fork_owner}/{repo_name}")
+
+ if upstream_repo.default_branch == fork.default_branch:
+ return
+
+ logger.info(
+ f"Syncing default branch of {fork_owner}/{repo_name} with {upstream_owner}/{repo_name}..."
+ )
+
+ fork.rename_branch(fork.default_branch, upstream_repo.default_branch)
+
+ # Sleep to wait for branch name change
+ time.sleep(5)
+
+ @cached_property
+ def user(self) -> str:
+ return self.pygithub_client.get_user().login
+
+ def get_api_requests_left(self) -> int | None:
+ try:
+ limit_info = self.github3_client.rate_limit()
+ except github3.exceptions.GitHubException as e:
+ logger.warning("GitHub API error while fetching rate limit.", exc_info=e)
+ return None
+
+ try:
+ core_resource = limit_info["resources"]["core"]
+ remaining_limit = core_resource["remaining"]
+ except KeyError as e:
+ logger.warning("GitHub API error while parsing rate limit.", exc_info=e)
+ return None
+
+ if remaining_limit != 0:
+ return remaining_limit
+
+ # try to log when the limit will be reset
+ try:
+ reset_timestamp = core_resource["reset"]
+ except KeyError as e:
+ logger.warning(
+ "GitHub API error while fetching rate limit reset time.",
+ exc_info=e,
+ )
+ return remaining_limit
+
+ logger.info(
+ "GitHub API limit reached, will reset at "
+ f"{datetime.utcfromtimestamp(reset_timestamp).strftime('%Y-%m-%dT%H:%M:%SZ')}"
+ )
+
+ return remaining_limit
+
+ def create_pull_request(
+ self,
+ target_owner: str,
+ target_repo: str,
+ base_branch: str,
+ head_branch: str,
+ title: str,
+ body: str,
+ ) -> PullRequestData:
+ repo: github3.repos.Repository = self.github3_client.repository(
+ target_owner, target_repo
+ )
- # Check if fork exists
- if fork:
try:
- fork_repo = gh.repository(gh_username, feedstock_reponame)
- except github3.GitHubError:
- fork_repo = None
- if fork_repo is None or (hasattr(fork_repo, "is_null") and fork_repo.is_null()):
- print("Fork doesn't exist creating feedstock fork...")
- repo.create_fork()
- # Sleep to make sure the fork is created before we go after it
- time.sleep(5)
-
- # sync the default branches if needed
- _sync_default_branches(feedstock_reponame)
-
- feedstock_dir = os.path.join(GIT_CLONE_DIR, fctx.feedstock_name + "-feedstock")
-
- if fetch_repo(
- feedstock_dir=feedstock_dir,
- origin=origin,
- upstream=upstream,
- branch=branch,
- base_branch=base_branch,
+ response: github3.pulls.ShortPullRequest | None = repo.create_pull(
+ title=title,
+ base=base_branch,
+ head=f"{self.user}:{head_branch}",
+ body=body,
+ )
+ except github3.exceptions.UnprocessableEntity as e:
+ if any("already exists" in error.get("message", "") for error in e.errors):
+ raise DuplicatePullRequestError(
+ f"Pull request from {self.user}:{head_branch} to {target_owner}:{base_branch} already exists."
+ ) from e
+ raise
+
+ if response is None:
+ raise GitPlatformError("Could not create pull request.")
+
+ # fields like ETag and Last-Modified are stored in the response headers, we need to extract them
+ header_fields = {
+ k: self._github3_session.last_response_headers[k]
+ for k in PullRequestData.HEADER_FIELDS
+ }
+
+ # note: this ignores extra fields in the response
+ return PullRequestData.model_validate(response.as_dict() | header_fields)
+
+ def comment_on_pull_request(
+ self, repo_owner: str, repo_name: str, pr_number: int, comment: str
+ ) -> None:
+ try:
+ repo = self.github3_client.repository(repo_owner, repo_name)
+ except github3.exceptions.NotFoundError as e:
+ raise RepositoryNotFoundError(
+ f"Repository {repo_owner}/{repo_name} not found."
+ ) from e
+
+ try:
+ pr = repo.pull_request(pr_number)
+ except github3.exceptions.NotFoundError as e:
+ raise GitPlatformError(
+ f"Pull request {repo_owner}/{repo_name}#{pr_number} not found."
+ ) from e
+
+ try:
+ pr.create_comment(comment)
+ except github3.GitHubError as e:
+ raise GitPlatformError(
+ f"Could not comment on pull request {repo_owner}/{repo_name}#{pr_number}."
+ ) from e
+
+
+class DryRunBackend(GitPlatformBackend):
+ """
+ A git backend that doesn't modify anything and only relies on public APIs that do not require authentication.
+ Useful for local testing with dry-run.
+
+ By default, the dry run backend assumes that the current user has not created any forks yet.
+ If forks are created, their names are stored in memory and can be checked with `does_repository_exist`.
+ """
+
+ _USER = "auto-tick-bot-dry-run"
+
+ def __init__(self):
+ super().__init__(GitCli())
+ self._repos: dict[str, str] = {}
+ """
+ _repos maps from repository name to the owner of the upstream repository.
+ If a remote URL of a fork is requested with get_remote_url, _USER (the virtual current user) is
+ replaced by the owner of the upstream repository. This allows cloning the forked repository.
+ """
+
+ def get_api_requests_left(self) -> Bound:
+ return Bound.INFINITY
+
+ def does_repository_exist(self, owner: str, repo_name: str) -> bool:
+ if owner == self._USER:
+ return repo_name in self._repos
+
+ # We do not use the GitHub API because unauthenticated requests are quite strictly rate-limited.
+ return self.cli.does_remote_exist(
+ self.get_remote_url(owner, repo_name, GitConnectionMode.HTTPS)
+ )
+
+ def get_remote_url(
+ self,
+ owner: str,
+ repo_name: str,
+ connection_mode: GitConnectionMode = GitConnectionMode.HTTPS,
+ token: str | None = None,
+ ) -> str:
+ if owner != self._USER:
+ return super().get_remote_url(owner, repo_name, connection_mode, token)
+ # redirect to the upstream repository
+ try:
+ upstream_owner = self._repos[repo_name]
+ except KeyError:
+ raise RepositoryNotFoundError(
+ f"Repository {owner}/{repo_name} appears to be a virtual fork but does not exist. Note that dry-run "
+ "forks are persistent only for the duration of the backend instance."
+ )
+
+ return super().get_remote_url(upstream_owner, repo_name, connection_mode, token)
+
+ def push_to_repository(
+ self, owner: str, repo_name: str, git_dir: Path, branch: str
):
- return feedstock_dir, repo
- else:
- return False, False
+ logger.debug(
+ f"Dry Run: Pushing changes from {git_dir} to {owner}/{repo_name} on branch {branch}."
+ )
+ @lock_git_operation()
+ def fork(self, owner: str, repo_name: str):
+ if repo_name in self._repos:
+ logger.debug(f"Fork of {repo_name} already exists. Doing nothing.")
+ return
-def _sync_default_branches(reponame):
- gh = github_client()
- forked_user = gh.get_user().login
- default_branch = gh.get_repo(f"conda-forge/{reponame}").default_branch
- forked_default_branch = gh.get_repo(f"{forked_user}/{reponame}").default_branch
- if default_branch != forked_default_branch:
- print("Fork's default branch doesn't match upstream, syncing...")
- forked_repo = gh.get_repo(f"{forked_user}/{reponame}")
- forked_repo.rename_branch(forked_default_branch, default_branch)
+ logger.debug(
+ f"Dry Run: Creating fork of {owner}/{repo_name} for user {self._USER}."
+ )
+ self._repos[repo_name] = owner
- # sleep to wait for branch name change
- time.sleep(5)
+ def _sync_default_branch(self, upstream_owner: str, upstream_repo: str):
+ logger.debug(
+ f"Dry Run: Syncing default branch of {upstream_owner}/{upstream_repo}."
+ )
+
+ @property
+ def user(self) -> str:
+ return self._USER
+
+ def create_pull_request(
+ self,
+ target_owner: str,
+ target_repo: str,
+ base_branch: str,
+ head_branch: str,
+ title: str,
+ body: str,
+ ) -> PullRequestData:
+ logger.debug(
+ textwrap.dedent(
+ f"""
+ ==============================================================
+ Dry Run: Create Pull Request
+ Title: "{title}"
+ Target Repository: {target_owner}/{target_repo}
+ Branches: {self.user}:{head_branch} -> {target_owner}:{base_branch}
+ Body:
+ """
+ )
+ )
+
+ logger.debug(body)
+ logger.debug("==============================================================")
+
+ now = datetime.now()
+ return PullRequestDataValid.model_validate(
+ {
+ "ETag": "GITHUB_PR_ETAG",
+ "Last-Modified": utils.format_datetime(now),
+ "id": 13371337,
+ "html_url": f"https://github.com/{target_owner}/{target_repo}/pulls/1337",
+ "created_at": now,
+ "mergeable_state": GithubPullRequestMergeableState.CLEAN,
+ "mergeable": True,
+ "merged": False,
+ "draft": False,
+ "number": 1337,
+ "state": PullRequestState.OPEN,
+ "head": PullRequestInfoHead(ref=head_branch),
+ "base": GithubPullRequestBase(repo=GithubRepository(name=target_repo)),
+ }
+ )
+ def comment_on_pull_request(
+ self, repo_owner: str, repo_name: str, pr_number: int, comment: str
+ ):
+ if not self.does_repository_exist(repo_owner, repo_name):
+ raise RepositoryNotFoundError(
+ f"Repository {repo_owner}/{repo_name} not found."
+ )
+ logger.debug(
+ textwrap.dedent(
+ f"""
+ ==============================================================
+ Dry Run: Comment on Pull Request
+ Pull Request: {repo_owner}/{repo_name}#{pr_number}
+ Comment:
+ {comment}
+ ==============================================================
+ """
+ )
+ )
+
+
+def github_backend() -> GitHubBackend:
+ """
+ This helper method will be removed in the future, use the GitHubBackend class directly.
+ """
+ return GitHubBackend.from_token(get_bot_token())
+
+
+def is_github_api_limit_reached() -> bool:
+ """
+ Return True if the GitHub API limit has been reached, False otherwise.
+
+ This method will be removed in the future, use the GitHubBackend class directly.
+ """
+ backend = github_backend()
+
+ return backend.is_api_limit_reached()
+
+
+@lock_git_operation()
def delete_branch(pr_json: LazyJson, dry_run: bool = False) -> None:
ref = pr_json["head"]["ref"]
if dry_run:
@@ -376,17 +1115,18 @@ def delete_branch(pr_json: LazyJson, dry_run: bool = False) -> None:
gh = github3_client()
deploy_repo = gh.me().login + "/" + name
- with sensitive_env() as env:
- run_command_hiding_token(
- [
- "git",
- "push",
- f"https://{env['BOT_TOKEN']}@github.com/{deploy_repo}.git",
- "--delete",
- ref,
- ],
- token=env["BOT_TOKEN"],
- )
+ token = get_bot_token()
+
+ run_command_hiding_token(
+ [
+ "git",
+ "push",
+ f"https://{token}@github.com/{deploy_repo}.git",
+ "--delete",
+ ref,
+ ],
+ token=token,
+ )
# Replace ref so we know not to try again
pr_json["head"]["ref"] = "this_is_not_a_branch"
@@ -447,20 +1187,16 @@ def lazy_update_pr_json(
force : bool, optional
If True, forcibly update the PR json even if it is not out of date
according to the ETag. Default is False.
- trim : bool, optional
- If True, trim the PR json keys to ones in the global PR_KEYS_TO_KEEP.
- Default is True.
Returns
-------
pr_json : dict-like
A dict-like object with the current PR information.
"""
- with sensitive_env() as env:
- hdrs = {
- "Authorization": f"token {env['BOT_TOKEN']}",
- "Accept": "application/vnd.github.v3+json",
- }
+ hdrs = {
+ "Authorization": f"token {get_bot_token()}",
+ "Accept": "application/vnd.github.v3+json",
+ }
if not force and "ETag" in pr_json:
hdrs["If-None-Match"] = pr_json["ETag"]
@@ -589,98 +1325,6 @@ def close_out_labels(
return None
-def push_repo(
- fctx: FeedstockContext,
- feedstock_dir: str,
- body: str,
- repo: github3.repos.Repository,
- title: str,
- branch: str,
- base_branch: str = "main",
- head: Optional[str] = None,
- dry_run: bool = False,
-) -> Union[dict, bool, None]:
- """Push a repo up to github
-
- Parameters
- ----------
- fcts : FeedstockContext
- Feedstock context used for constructing feedstock urls, etc.
- feedstock_dir : str
- The feedstock directory
- body : str
- The PR body.
- repo : github3.repos.Repository
- The feedstock repo as a github3 object.
- title : str
- The title of the PR.
- head : str, optional
- The github head for the PR in the form `username:branch`.
- branch : str
- The head branch of the PR.
- base_branch : str, optional
- The base branch or target branch of the PR.
-
- Returns
- -------
- pr_json: dict
- The dict representing the PR, can be used with `from_json`
- to create a PR instance.
- """
- with sensitive_env() as env, pushd(feedstock_dir):
- # Copyright (c) 2016 Aaron Meurer, Gil Forsyth
- token = env["BOT_TOKEN"]
- gh_username = github3_client().me().login
-
- if head is None:
- head = gh_username + ":" + branch
-
- deploy_repo = gh_username + "/" + fctx.feedstock_name + "-feedstock"
- if dry_run:
- repo_url = f"https://github.com/{deploy_repo}.git"
- print(f"dry run: adding remote and pushing up branch for {repo_url}")
- else:
- ecode = run_command_hiding_token(
- [
- "git",
- "remote",
- "add",
- "regro_remote",
- f"https://{token}@github.com/{deploy_repo}.git",
- ],
- token=token,
- )
- if ecode != 0:
- print("Failed to add git remote!")
- return False
-
- ecode = run_command_hiding_token(
- ["git", "push", "--set-upstream", "regro_remote", branch],
- token=token,
- )
- if ecode != 0:
- print("Failed to push to remote!")
- return False
-
- # lastly make a PR for the feedstock
- print("Creating conda-forge feedstock pull request...")
- if dry_run:
- print(f"dry run: create pr with title: {title}")
- return False
- else:
- pr = repo.create_pull(title, base_branch, head, body=body)
- if pr is None:
- print("Failed to create pull request!")
- return False
- else:
- print("Pull request created at " + pr.html_url)
-
- # Return a json object so we can remake the PR if needed
- pr_dict: dict = pr.as_dict()
-
- return trim_pr_json_keys(pr_dict)
-
-
def comment_on_pr(pr_json, comment, repo):
"""Make a comment on a PR.
diff --git a/conda_forge_tick/lazy_json_backends.py b/conda_forge_tick/lazy_json_backends.py
index 6e4751c45..8b19d0ac1 100644
--- a/conda_forge_tick/lazy_json_backends.py
+++ b/conda_forge_tick/lazy_json_backends.py
@@ -26,6 +26,7 @@
import requests
from .cli_context import CliContext
+from .executors import lock_git_operation
logger = logging.getLogger(__name__)
@@ -159,10 +160,8 @@ def hgetall(self, name: str, hashval: bool = False) -> Dict[str, str]:
}
def hdel(self, name: str, keys: Iterable[str]) -> None:
- from .executors import DRLOCK, PRLOCK, TRLOCK
-
lzj_names = [get_sharded_path(f"{name}/{key}.json") for key in keys]
- with PRLOCK, DRLOCK, TRLOCK:
+ with lock_git_operation():
subprocess.run(
["git", "rm", "--ignore-unmatch", "-f"] + lzj_names,
capture_output=True,
@@ -631,6 +630,17 @@ def get_all_keys_for_hashmap(name):
return backend.hkeys(name)
+def does_key_exist_in_hashmap(name: str, key: str) -> bool:
+ """
+ Check if a key exists in a hashmap, using the primary backend.
+ :param name: The hashmap name.
+ :param key: The key to check.
+ :return: True if the key exists, False otherwise.
+ """
+ backend = LAZY_JSON_BACKENDS[CF_TICK_GRAPH_DATA_PRIMARY_BACKEND]()
+ return backend.hexists(name, key)
+
+
@contextlib.contextmanager
def lazy_json_transaction():
try:
diff --git a/conda_forge_tick/make_migrators.py b/conda_forge_tick/make_migrators.py
index 519a88df2..e3d72361c 100644
--- a/conda_forge_tick/make_migrators.py
+++ b/conda_forge_tick/make_migrators.py
@@ -690,37 +690,9 @@ def create_migration_yaml_creator(
continue
-def initialize_migrators(
+def initialize_version_migrator(
gx: nx.DiGraph,
- dry_run: bool = False,
-) -> MutableSequence[Migrator]:
- migrators: List[Migrator] = []
-
- add_arch_migrate(migrators, gx)
-
- add_replacement_migrator(
- migrators,
- gx,
- cast("PackageName", "build"),
- cast("PackageName", "python-build"),
- "The conda package name 'build' is deprecated "
- "and too generic. Use 'python-build instead.'",
- )
-
- pinning_migrators: List[Migrator] = []
- migration_factory(pinning_migrators, gx)
- create_migration_yaml_creator(migrators=pinning_migrators, gx=gx)
-
- with fold_log_lines("migration graph sizes"):
- print("rebuild migration graph sizes:", flush=True)
- for m in migrators + pinning_migrators:
- if isinstance(m, GraphMigrator):
- print(
- f' {getattr(m, "name", m)} graph size: '
- f'{len(getattr(m, "graph", []))}',
- flush=True,
- )
-
+) -> Version:
with fold_log_lines("making version migrator"):
print("building package import maps and version migrator", flush=True)
python_nodes = {
@@ -755,8 +727,43 @@ def initialize_migrators(
],
)
- random.shuffle(pinning_migrators)
- migrators = [version_migrator] + migrators + pinning_migrators
+ return version_migrator
+
+
+def initialize_migrators(
+ gx: nx.DiGraph,
+) -> MutableSequence[Migrator]:
+ migrators: List[Migrator] = []
+
+ add_arch_migrate(migrators, gx)
+
+ add_replacement_migrator(
+ migrators,
+ gx,
+ cast("PackageName", "build"),
+ cast("PackageName", "python-build"),
+ "The conda package name 'build' is deprecated "
+ "and too generic. Use 'python-build instead.'",
+ )
+
+ pinning_migrators: List[Migrator] = []
+ migration_factory(pinning_migrators, gx)
+ create_migration_yaml_creator(migrators=pinning_migrators, gx=gx)
+
+ with fold_log_lines("migration graph sizes"):
+ print("rebuild migration graph sizes:", flush=True)
+ for m in migrators + pinning_migrators:
+ if isinstance(m, GraphMigrator):
+ print(
+ f' {getattr(m, "name", m)} graph size: '
+ f'{len(getattr(m, "graph", []))}',
+ flush=True,
+ )
+
+ version_migrator = initialize_version_migrator(gx)
+
+ random.shuffle(pinning_migrators)
+ migrators = [version_migrator] + migrators + pinning_migrators
return migrators
@@ -809,11 +816,12 @@ def load_migrators() -> MutableSequence[Migrator]:
return migrators
-def main(ctx: CliContext) -> None:
+def main(ctx: CliContext, version_only: bool = False) -> None:
gx = load_existing_graph()
- migrators = initialize_migrators(
- gx,
- dry_run=ctx.dry_run,
+ migrators = (
+ initialize_migrators(gx)
+ if not version_only
+ else [initialize_version_migrator(gx)]
)
with (
fold_log_lines("dumping migrators to JSON"),
diff --git a/conda_forge_tick/migration_runner.py b/conda_forge_tick/migration_runner.py
index d7cea3aa5..03c546ea9 100644
--- a/conda_forge_tick/migration_runner.py
+++ b/conda_forge_tick/migration_runner.py
@@ -3,8 +3,9 @@
import os
import shutil
import tempfile
+from pathlib import Path
-from conda_forge_tick.contexts import FeedstockContext
+from conda_forge_tick.contexts import ClonedFeedstockContext
from conda_forge_tick.lazy_json_backends import LazyJson, dumps
from conda_forge_tick.os_utils import (
chmod_plus_rwX,
@@ -221,12 +222,14 @@ def run_migration_local(
- pr_body: The PR body for the migration.
"""
- feedstock_ctx = FeedstockContext(
+ # it would be better if we don't re-instantiate ClonedFeedstockContext ourselves and let
+ # FeedstockContext.reserve_clone_directory be the only way to create a ClonedFeedstockContext
+ feedstock_ctx = ClonedFeedstockContext(
feedstock_name=feedstock_name,
attrs=node_attrs,
+ _default_branch=default_branch,
+ local_clone_dir=Path(feedstock_dir),
)
- feedstock_ctx.default_branch = default_branch
- feedstock_ctx.feedstock_dir = feedstock_dir
recipe_dir = os.path.join(feedstock_dir, "recipe")
data = {
diff --git a/conda_forge_tick/migrators/arch.py b/conda_forge_tick/migrators/arch.py
index bfe81dd6f..274ed3ad2 100644
--- a/conda_forge_tick/migrators/arch.py
+++ b/conda_forge_tick/migrators/arch.py
@@ -4,7 +4,7 @@
import networkx as nx
-from conda_forge_tick.contexts import FeedstockContext
+from conda_forge_tick.contexts import ClonedFeedstockContext, FeedstockContext
from conda_forge_tick.make_graph import (
get_deps_from_outputs_lut,
make_outputs_lut_from_graph,
@@ -213,7 +213,7 @@ def migrate(
def pr_title(self, feedstock_ctx: FeedstockContext) -> str:
return "Arch Migrator"
- def pr_body(self, feedstock_ctx: FeedstockContext) -> str:
+ def pr_body(self, feedstock_ctx: ClonedFeedstockContext) -> str:
body = super().pr_body(feedstock_ctx)
body = body.format(
dedent(
@@ -384,7 +384,7 @@ def migrate(
def pr_title(self, feedstock_ctx: FeedstockContext) -> str:
return "ARM OSX Migrator"
- def pr_body(self, feedstock_ctx: FeedstockContext) -> str:
+ def pr_body(self, feedstock_ctx: ClonedFeedstockContext) -> str:
body = super().pr_body(feedstock_ctx)
body = body.format(
dedent(
diff --git a/conda_forge_tick/migrators/broken_rebuild.py b/conda_forge_tick/migrators/broken_rebuild.py
index 66e6b1303..fb3d13718 100644
--- a/conda_forge_tick/migrators/broken_rebuild.py
+++ b/conda_forge_tick/migrators/broken_rebuild.py
@@ -2,6 +2,7 @@
import networkx as nx
+from conda_forge_tick.contexts import ClonedFeedstockContext
from conda_forge_tick.migrators.core import Migrator
BROKEN_PACKAGES = """\
@@ -380,7 +381,7 @@ def migrate(self, recipe_dir, attrs, **kwargs):
self.set_build_number(os.path.join(recipe_dir, "meta.yaml"))
return super().migrate(recipe_dir, attrs)
- def pr_body(self, feedstock_ctx) -> str:
+ def pr_body(self, feedstock_ctx: ClonedFeedstockContext) -> str:
body = super().pr_body(feedstock_ctx)
body = body.format(
"""\
diff --git a/conda_forge_tick/migrators/core.py b/conda_forge_tick/migrators/core.py
index 9f1227422..22b740426 100644
--- a/conda_forge_tick/migrators/core.py
+++ b/conda_forge_tick/migrators/core.py
@@ -10,7 +10,7 @@
import dateutil.parser
import networkx as nx
-from conda_forge_tick.contexts import FeedstockContext
+from conda_forge_tick.contexts import ClonedFeedstockContext, FeedstockContext
from conda_forge_tick.lazy_json_backends import LazyJson
from conda_forge_tick.make_graph import make_outputs_lut_from_graph
from conda_forge_tick.path_lengths import cyclic_topological_sort
@@ -455,7 +455,9 @@ def migrate(
"""
return self.migrator_uid(attrs)
- def pr_body(self, feedstock_ctx: FeedstockContext, add_label_text=True) -> str:
+ def pr_body(
+ self, feedstock_ctx: ClonedFeedstockContext, add_label_text=True
+ ) -> str:
"""Create a PR message body
Returns
diff --git a/conda_forge_tick/migrators/migration_yaml.py b/conda_forge_tick/migrators/migration_yaml.py
index ac2c47779..ecdf06ede 100644
--- a/conda_forge_tick/migrators/migration_yaml.py
+++ b/conda_forge_tick/migrators/migration_yaml.py
@@ -10,7 +10,7 @@
import networkx as nx
-from conda_forge_tick.contexts import FeedstockContext
+from conda_forge_tick.contexts import ClonedFeedstockContext, FeedstockContext
from conda_forge_tick.feedstock_parser import PIN_SEP_PAT
from conda_forge_tick.make_graph import get_deps_from_outputs_lut
from conda_forge_tick.migrators.core import GraphMigrator, Migrator, MiniMigrator
@@ -280,7 +280,7 @@ def migrate(
return super().migrate(recipe_dir, attrs)
- def pr_body(self, feedstock_ctx: "FeedstockContext") -> str:
+ def pr_body(self, feedstock_ctx: ClonedFeedstockContext) -> str:
body = super().pr_body(feedstock_ctx)
if feedstock_ctx.feedstock_name == "conda-forge-pinning":
additional_body = (
@@ -534,7 +534,7 @@ def migrate(
return super().migrate(recipe_dir, attrs)
- def pr_body(self, feedstock_ctx: "FeedstockContext") -> str:
+ def pr_body(self, feedstock_ctx: ClonedFeedstockContext) -> str:
body = (
"This PR has been triggered in an effort to update the pin for"
" **{name}**. The current pinned version is {current_pin}, "
diff --git a/conda_forge_tick/migrators/replacement.py b/conda_forge_tick/migrators/replacement.py
index 7df2be3f5..71ccf2dce 100644
--- a/conda_forge_tick/migrators/replacement.py
+++ b/conda_forge_tick/migrators/replacement.py
@@ -6,7 +6,7 @@
import networkx as nx
-from conda_forge_tick.contexts import FeedstockContext
+from conda_forge_tick.contexts import ClonedFeedstockContext, FeedstockContext
from conda_forge_tick.migrators.core import Migrator
if typing.TYPE_CHECKING:
@@ -127,7 +127,7 @@ def migrate(
self.set_build_number(os.path.join(recipe_dir, "meta.yaml"))
return super().migrate(recipe_dir, attrs)
- def pr_body(self, feedstock_ctx: FeedstockContext) -> str:
+ def pr_body(self, feedstock_ctx: ClonedFeedstockContext) -> str:
body = super().pr_body(feedstock_ctx)
body = body.format(
"I noticed that this recipe depends on `%s` instead of \n"
diff --git a/conda_forge_tick/migrators/version.py b/conda_forge_tick/migrators/version.py
index ecccc77d0..2d7bd5f0e 100644
--- a/conda_forge_tick/migrators/version.py
+++ b/conda_forge_tick/migrators/version.py
@@ -11,7 +11,7 @@
import networkx as nx
from conda.models.version import VersionOrder
-from conda_forge_tick.contexts import FeedstockContext
+from conda_forge_tick.contexts import ClonedFeedstockContext, FeedstockContext
from conda_forge_tick.migrators.core import Migrator
from conda_forge_tick.models.pr_info import MigratorName
from conda_forge_tick.os_utils import pushd
@@ -219,7 +219,7 @@ def migrate(
)
)
- def pr_body(self, feedstock_ctx: FeedstockContext) -> str:
+ def pr_body(self, feedstock_ctx: ClonedFeedstockContext) -> str:
if feedstock_ctx.feedstock_name in self.effective_graph.nodes:
pred = [
(
@@ -319,7 +319,7 @@ def pr_body(self, feedstock_ctx: FeedstockContext) -> str:
return super().pr_body(feedstock_ctx, add_label_text=False).format(body)
- def _hint_and_maybe_update_deps(self, feedstock_ctx):
+ def _hint_and_maybe_update_deps(self, feedstock_ctx: ClonedFeedstockContext):
update_deps = get_keys_default(
feedstock_ctx.attrs,
["conda-forge.yml", "bot", "inspection"],
@@ -340,7 +340,7 @@ def _hint_and_maybe_update_deps(self, feedstock_ctx):
try:
_, hint = get_dep_updates_and_hints(
update_deps,
- os.path.join(feedstock_ctx.feedstock_dir, "recipe"),
+ str(feedstock_ctx.local_clone_dir / "recipe"),
feedstock_ctx.attrs,
self.python_nodes,
"new_version",
diff --git a/conda_forge_tick/migrators_types.pyi b/conda_forge_tick/migrators_types.py
similarity index 98%
rename from conda_forge_tick/migrators_types.pyi
rename to conda_forge_tick/migrators_types.py
index a0005c23d..1296f5200 100644
--- a/conda_forge_tick/migrators_types.pyi
+++ b/conda_forge_tick/migrators_types.py
@@ -5,6 +5,7 @@
PackageName = typing.NewType("PackageName", str)
+
class AboutTypedDict(TypedDict, total=False):
description: str
dev_url: str
@@ -15,6 +16,7 @@ class AboutTypedDict(TypedDict, total=False):
license_file: str
summary: str
+
# PRStateOpen: Literal["open"]
# PRStateClosed: Literal["closed"]
# PRStateMerged: Literal["merged"]
@@ -22,35 +24,43 @@ class AboutTypedDict(TypedDict, total=False):
# PRState = Literal[PRStateClosed, PRStateMerged, PRStateOpen]
PRState = typing.NewType("PRState", str)
-class PRHead_TD(TypedDict, tota=False):
+
+class PRHead_TD(TypedDict, total=False):
ref: str
+
class PR_TD(TypedDict, total=False):
state: PRState
head: PRHead_TD
+
class BlasRebuildMigrateTypedDict(TypedDict):
bot_rerun: bool
migrator_name: str
migrator_version: int
name: str
+
class BuildRunExportsDict(TypedDict, total=False):
strong: List[PackageName]
weak: List[PackageName]
+
class BuildTypedDict(TypedDict, total=False):
noarch: str
number: str
script: str
run_exports: Union[List[PackageName], BuildRunExportsDict]
+
ExtraTypedDict = TypedDict("ExtraTypedDict", {"recipe-maintainers": List[str]})
+
# class HTypedDict(TypedDict):
# data: 'DataTypedDict'
# keys: List[str]
+
class MetaYamlOutputs(TypedDict, total=False):
name: str
requirements: "RequirementsTypedDict"
@@ -58,6 +68,7 @@ class MetaYamlOutputs(TypedDict, total=False):
# TODO: Not entirely sure this is right
build: BuildRunExportsDict
+
class MetaYamlTypedDict(TypedDict, total=False):
about: "AboutTypedDict"
build: "BuildTypedDict"
@@ -68,6 +79,7 @@ class MetaYamlTypedDict(TypedDict, total=False):
test: "TestTypedDict"
outputs: List[MetaYamlOutputs]
+
class MigrationUidTypedDict(TypedDict, total=False):
bot_rerun: bool
migrator_name: str
@@ -77,31 +89,37 @@ class MigrationUidTypedDict(TypedDict, total=False):
# Used by version migrators
version: str
+
class PackageTypedDict(TypedDict):
name: str
version: str
+
class RequirementsTypedDict(TypedDict, total=False):
build: List[str]
host: List[str]
run: List[str]
+
class SourceTypedDict(TypedDict, total=False):
fn: str
patches: List[str]
sha256: str
url: str
+
class TestTypedDict(TypedDict, total=False):
commands: List[str]
imports: List[str]
requires: List[str]
requirements: List[str]
+
class PRedElementTypedDict(TypedDict, total=False):
data: MigrationUidTypedDict
PR: PR_TD
+
class AttrsTypedDict_(TypedDict, total=False):
about: AboutTypedDict
build: BuildTypedDict
@@ -124,12 +142,15 @@ class AttrsTypedDict_(TypedDict, total=False):
# TODO: ADD in
# "conda-forge.yml":
+
class CondaForgeYamlContents(TypedDict, total=False):
provider: Dict[str, str]
+
CondaForgeYaml = TypedDict(
"CondaForgeYaml", {"conda-forge.yml": CondaForgeYamlContents}
)
+
class AttrsTypedDict(AttrsTypedDict_, CondaForgeYaml):
pass
diff --git a/conda_forge_tick/models/common.py b/conda_forge_tick/models/common.py
index beb445213..77b548fae 100644
--- a/conda_forge_tick/models/common.py
+++ b/conda_forge_tick/models/common.py
@@ -10,6 +10,7 @@
BeforeValidator,
ConfigDict,
Field,
+ PlainSerializer,
UrlConstraints,
)
from pydantic_core import Url
@@ -25,7 +26,7 @@ class StrictBaseModel(BaseModel):
class ValidatedBaseModel(BaseModel):
- model_config = ConfigDict(validate_assignment=True, extra="allow")
+ model_config = ConfigDict(validate_assignment=True, extra="ignore")
def before_validator_ensure_dict(value: Any) -> dict:
@@ -77,7 +78,7 @@ def none_to_empty_dict(value: T | None) -> T | dict[Never]:
return value
-NoneIsEmptyDict = Annotated[dict[T], BeforeValidator(none_to_empty_dict)]
+NoneIsEmptyDict = Annotated[dict[K, V], BeforeValidator(none_to_empty_dict)]
"""
A generic dict type that converts `None` to an empty dict.
This should not be needed if this proper data model is used in production.
@@ -151,22 +152,15 @@ def parse_rfc_2822_date(value: str) -> datetime:
return email.utils.parsedate_to_datetime(value)
-RFC2822Date = Annotated[datetime, BeforeValidator(parse_rfc_2822_date)]
-
-
-def none_to_empty_dict(value: T | None) -> T | dict[Never, Never]:
- """
- Convert `None` to an empty dictionary f, otherwise keep the value as is.
- """
- if value is None:
- return {}
- return value
+def serialize_rfc_2822_date(value: datetime) -> str:
+ return email.utils.format_datetime(value)
-NoneIsEmptyDict = Annotated[dict[K, V], BeforeValidator(none_to_empty_dict)]
-"""
-A generic dict type that converts `None` to an empty dict.
-"""
+RFC2822Date = Annotated[
+ datetime,
+ BeforeValidator(parse_rfc_2822_date),
+ PlainSerializer(serialize_rfc_2822_date),
+]
GitUrl = Annotated[Url, UrlConstraints(allowed_schemes=["git"])]
diff --git a/conda_forge_tick/models/pr_json.py b/conda_forge_tick/models/pr_json.py
index 261b3e8d7..4e1ef6f2f 100644
--- a/conda_forge_tick/models/pr_json.py
+++ b/conda_forge_tick/models/pr_json.py
@@ -1,11 +1,15 @@
from datetime import datetime
from enum import StrEnum
-from typing import Literal
+from typing import ClassVar, Literal
from pydantic import UUID4, AnyHttpUrl, Field, TypeAdapter
from pydantic_extra_types.color import Color
-from conda_forge_tick.models.common import RFC2822Date, StrictBaseModel
+from conda_forge_tick.models.common import (
+ RFC2822Date,
+ StrictBaseModel,
+ ValidatedBaseModel,
+)
class PullRequestLabelShort(StrictBaseModel):
@@ -48,7 +52,7 @@ class PullRequestState(StrEnum):
"""
-class PullRequestInfoHead(StrictBaseModel):
+class PullRequestInfoHead(ValidatedBaseModel):
ref: str
"""
The head branch of the pull request.
@@ -73,22 +77,31 @@ class GithubPullRequestMergeableState(StrEnum):
CLEAN = "clean"
-class GithubRepository(StrictBaseModel):
+class GithubRepository(ValidatedBaseModel):
name: str
-class GithubPullRequestBase(StrictBaseModel):
+class GithubPullRequestBase(ValidatedBaseModel):
repo: GithubRepository
-class PullRequestDataValid(StrictBaseModel):
+class PullRequestDataValid(ValidatedBaseModel):
"""
Information about a pull request, as retrieved from the GitHub API.
Refer to git_utils.PR_KEYS_TO_KEEP for the keys that are kept in the PR object.
+ ALSO UPDATE PR_KEYS_TO_KEEP IF YOU CHANGE THIS CLASS!
GitHub documentation: https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#get-a-pull-request
"""
+ HEADER_FIELDS: ClassVar[set[str]] = {
+ "ETag",
+ "Last-Modified",
+ }
+ """
+ A set of all header fields that are stored in the PR object.
+ """
+
e_tag: str | None = Field(None, alias="ETag")
"""
HTTP ETag header field, allowing us to quickly check if the PR has changed.
diff --git a/conda_forge_tick/status_report.py b/conda_forge_tick/status_report.py
index 77aebb673..bd6d13a41 100644
--- a/conda_forge_tick/status_report.py
+++ b/conda_forge_tick/status_report.py
@@ -39,8 +39,6 @@
load_existing_graph,
)
-from .git_utils import feedstock_url
-
GH_MERGE_STATE_STATUS = [
"behind",
"blocked",
@@ -273,7 +271,7 @@ def graph_migrator_status(
.get("PR", {})
.get(
"html_url",
- feedstock_url(fctx=feedstock_ctx, protocol="https").strip(".git"),
+ feedstock_ctx.git_href,
),
)
@@ -300,7 +298,7 @@ def graph_migrator_status(
# I needed to fake some PRs they don't have html_urls though
node_metadata["pr_url"] = pr_json["PR"].get(
"html_url",
- feedstock_url(fctx=feedstock_ctx, protocol="https").strip(".git"),
+ feedstock_ctx.git_href,
)
node_metadata["pr_status"] = pr_json["PR"].get("mergeable_state", "")
@@ -400,7 +398,6 @@ def main() -> None:
graph=gx,
smithy_version=smithy_version,
pinning_version=pinning_version,
- dry_run=False,
)
migrators = load_migrators()
diff --git a/conda_forge_tick/update_prs.py b/conda_forge_tick/update_prs.py
index 86b80be21..5c295c10f 100644
--- a/conda_forge_tick/update_prs.py
+++ b/conda_forge_tick/update_prs.py
@@ -89,7 +89,8 @@ def _update_pr(update_function, dry_run, gx, job, n_jobs):
except (github3.GitHubError, github.GithubException) as e:
logger.error(f"GITHUB ERROR ON FEEDSTOCK: {name}")
failed_refresh += 1
- if is_github_api_limit_reached(e):
+ if is_github_api_limit_reached():
+ logger.warning("GitHub API error", exc_info=e)
break
except (github3.exceptions.ConnectionError, github.GithubException):
logger.error(f"GITHUB ERROR ON FEEDSTOCK: {name}")
diff --git a/conda_forge_tick/utils.py b/conda_forge_tick/utils.py
index c911a0693..f7ad3aecb 100644
--- a/conda_forge_tick/utils.py
+++ b/conda_forge_tick/utils.py
@@ -14,7 +14,7 @@
import typing
import warnings
from collections import defaultdict
-from typing import Any, Callable, Dict, Iterable, Optional, Set, Tuple, cast
+from typing import Any, Callable, Dict, Iterable, Optional, Set, Tuple, cast, overload
import jinja2
import jinja2.sandbox
@@ -1004,12 +1004,38 @@ def change_log_level(logger, new_level):
logger.setLevel(saved_logger_level)
+@overload
+def replace_tokens(s: str, tokens: Iterable[str]) -> str: ...
+
+
+@overload
+def replace_tokens(s: None, tokens: Iterable[str]) -> None: ...
+
+
+def replace_tokens(s: str | None, tokens: Iterable[str]) -> str | None:
+ """
+ Replace tokens in a string with asterisks of the same length.
+
+ None values are passed through.
+
+ :param s: The string to replace tokens in.
+ :param tokens: The tokens to replace.
+
+ :return: The string with the tokens replaced.
+ """
+ if not s:
+ return s
+ for token in tokens:
+ s = s.replace(token, "*" * len(token))
+ return s
+
+
def print_subprocess_output_strip_token(
- completed_process: subprocess.CompletedProcess, token: str
+ completed_process: subprocess.CompletedProcess, *tokens: str
) -> None:
"""
Use this function to print the outputs (stdout and stderr) of a subprocess.CompletedProcess object
- that may contain sensitive information. The token will be replaced with a string
+ that may contain sensitive information. The token or tokens will be replaced with a string
of asterisks of the same length.
This function assumes that you have called subprocess.run() with the arguments text=True, stdout=subprocess.PIPE,
@@ -1021,7 +1047,7 @@ def print_subprocess_output_strip_token(
:param completed_process: The subprocess.CompletedProcess object to print the outputs of. You have probably
obtained this object by calling subprocess.run().
- :param token: The token to replace with asterisks.
+ :param tokens: The token or tokens to replace with asterisks.
:raises ValueError: If the completed_process object does not contain str in stdout or stderr.
"""
@@ -1037,7 +1063,7 @@ def print_subprocess_output_strip_token(
"text=True."
)
- captured = captured.replace(token, "*" * len(token))
+ replace_tokens(captured, tokens)
print(captured, file=out_dev, end="")
out_dev.flush()
diff --git a/tests/github_api/create_issue_comment_pytest.json b/tests/github_api/create_issue_comment_pytest.json
new file mode 100644
index 000000000..ba97b19f9
--- /dev/null
+++ b/tests/github_api/create_issue_comment_pytest.json
@@ -0,0 +1,33 @@
+{
+ "id": 1,
+ "node_id": "MDEyOklzc3VlQ29tbWVudDE=",
+ "url": "https://api.github.com/repos/conda-forge/pytest-feedstock/issues/comments/1",
+ "html_url": "https://github.com/conda-forge/pytest-feedstock/issues/1337#issuecomment-1",
+ "body": "ISSUE_COMMENT_BODY",
+ "body_html": "ISSUE_COMMENT_BODY_HTML",
+ "body_text": "ISSUE_COMMENT_BODY_TEXT",
+ "user": {
+ "login": "regro-cf-autotick-bot",
+ "id": 1,
+ "node_id": "MDQ6VXNlcjE=",
+ "avatar_url": "https://github.com/images/error/octocat_happy.gif",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/regro-cf-autotick-bot",
+ "html_url": "https://github.com/regro-cf-autotick-bot",
+ "followers_url": "https://api.github.com/users/regro-cf-autotick-bot/followers",
+ "following_url": "https://api.github.com/users/regro-cf-autotick-bot/following{/other_user}",
+ "gists_url": "https://api.github.com/users/regro-cf-autotick-bot/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/regro-cf-autotick-bot/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/regro-cf-autotick-bot/subscriptions",
+ "organizations_url": "https://api.github.com/users/regro-cf-autotick-bot/orgs",
+ "repos_url": "https://api.github.com/users/regro-cf-autotick-bot/repos",
+ "events_url": "https://api.github.com/users/regro-cf-autotick-bot/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/regro-cf-autotick-bot/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "created_at": "2011-04-14T16:00:49Z",
+ "updated_at": "2011-04-14T16:00:49Z",
+ "issue_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/issues/1337",
+ "author_association": "CONTRIBUTOR"
+}
diff --git a/tests/github_api/create_pull_duplicate.json b/tests/github_api/create_pull_duplicate.json
new file mode 100644
index 000000000..aff82e963
--- /dev/null
+++ b/tests/github_api/create_pull_duplicate.json
@@ -0,0 +1,12 @@
+{
+ "message": "Validation Failed",
+ "errors": [
+ {
+ "resource": "PullRequest",
+ "code": "custom",
+ "message": "A pull request already exists for OWNER:BRANCH."
+ }
+ ],
+ "documentation_url": "https://docs.github.com/rest/pulls/pulls#create-a-pull-request",
+ "status": "422"
+}
diff --git a/tests/github_api/create_pull_validation_error.json b/tests/github_api/create_pull_validation_error.json
new file mode 100644
index 000000000..ae6f3df17
--- /dev/null
+++ b/tests/github_api/create_pull_validation_error.json
@@ -0,0 +1,12 @@
+{
+ "message": "Validation Failed",
+ "errors": [
+ {
+ "resource": "PullRequest",
+ "field": "head",
+ "code": "invalid"
+ }
+ ],
+ "documentation_url": "https://docs.github.com/rest/pulls/pulls#create-a-pull-request",
+ "status": "422"
+}
diff --git a/tests/github_api/get_pull_pytest.json b/tests/github_api/get_pull_pytest.json
new file mode 100644
index 000000000..9bb5754db
--- /dev/null
+++ b/tests/github_api/get_pull_pytest.json
@@ -0,0 +1,360 @@
+{
+ "url": "https://api.github.com/repos/conda-forge/pytest-feedstock/pulls/1337",
+ "id": 1853804278,
+ "node_id": "PR_kwDOAgM_Js5ufs72",
+ "html_url": "https://github.com/conda-forge/pytest-feedstock/pull/1337",
+ "diff_url": "https://github.com/conda-forge/pytest-feedstock/pull/1337.diff",
+ "patch_url": "https://github.com/conda-forge/pytest-feedstock/pull/1337.patch",
+ "issue_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/issues/1337",
+ "number": 1337,
+ "state": "open",
+ "locked": false,
+ "title": "PR_TITLE",
+ "user": {
+ "login": "regro-cf-autotick-bot",
+ "id": 12345678,
+ "node_id": "MDQ6VXNlcjI1OTA2Mjcw",
+ "avatar_url": "https://avatars.githubusercontent.com/u/12345678?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/regro-cf-autotick-bot",
+ "html_url": "https://github.com/regro-cf-autotick-bot",
+ "followers_url": "https://api.github.com/users/regro-cf-autotick-bot/followers",
+ "following_url": "https://api.github.com/users/regro-cf-autotick-bot/following{/other_user}",
+ "gists_url": "https://api.github.com/users/regro-cf-autotick-bot/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/regro-cf-autotick-bot/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/regro-cf-autotick-bot/subscriptions",
+ "organizations_url": "https://api.github.com/users/regro-cf-autotick-bot/orgs",
+ "repos_url": "https://api.github.com/users/regro-cf-autotick-bot/repos",
+ "events_url": "https://api.github.com/users/regro-cf-autotick-bot/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/regro-cf-autotick-bot/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "body": "PR_BODY",
+ "created_at": "2024-05-03T17:04:20Z",
+ "updated_at": "2024-05-27T13:31:50Z",
+ "closed_at": null,
+ "merged_at": null,
+ "merge_commit_sha": "351d0b862d129b53b8c7db2260d208d3a27fb204",
+ "assignee": null,
+ "assignees": [],
+ "requested_reviewers": [
+ ],
+ "requested_teams": [],
+ "labels": [],
+ "milestone": null,
+ "draft": false,
+ "commits_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/pulls/1337/commits",
+ "review_comments_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/pulls/1337/comments",
+ "review_comment_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/pulls/comments{/number}",
+ "comments_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/issues/1337/comments",
+ "statuses_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/statuses/0eaa1de035b8720c8b85a7f435a0ae7037fe9095",
+ "head": {
+ "label": "regro-cf-autotick-bot:HEAD_BRANCH",
+ "ref": "HEAD_BRANCH",
+ "sha": "0eaa1de035b8720c8b85a7f435a0ae7037fe9095",
+ "user": {
+ "login": "regro-cf-autotick-bot",
+ "id": 12345678,
+ "node_id": "MDQ6VXNlcjI1OTA2Mjcw",
+ "avatar_url": "https://avatars.githubusercontent.com/u/12345678?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/regro-cf-autotick-bot",
+ "html_url": "https://github.com/regro-cf-autotick-bot",
+ "followers_url": "https://api.github.com/users/regro-cf-autotick-bot/followers",
+ "following_url": "https://api.github.com/users/regro-cf-autotick-bot/following{/other_user}",
+ "gists_url": "https://api.github.com/users/regro-cf-autotick-bot/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/regro-cf-autotick-bot/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/regro-cf-autotick-bot/subscriptions",
+ "organizations_url": "https://api.github.com/users/regro-cf-autotick-bot/orgs",
+ "repos_url": "https://api.github.com/users/regro-cf-autotick-bot/repos",
+ "events_url": "https://api.github.com/users/regro-cf-autotick-bot/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/regro-cf-autotick-bot/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "repo": {
+ "id": 772632103,
+ "node_id": "R_kgDOLg1uJw",
+ "name": "pytest-feedstock",
+ "full_name": "regro-cf-autotick-bot/pytest-feedstock",
+ "private": false,
+ "owner": {
+ "login": "regro-cf-autotick-bot",
+ "id": 12345678,
+ "node_id": "MDQ6VXNlcjI1OTA2Mjcw",
+ "avatar_url": "https://avatars.githubusercontent.com/u/12345678?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/regro-cf-autotick-bot",
+ "html_url": "https://github.com/regro-cf-autotick-bot",
+ "followers_url": "https://api.github.com/users/regro-cf-autotick-bot/followers",
+ "following_url": "https://api.github.com/users/regro-cf-autotick-bot/following{/other_user}",
+ "gists_url": "https://api.github.com/users/regro-cf-autotick-bot/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/regro-cf-autotick-bot/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/regro-cf-autotick-bot/subscriptions",
+ "organizations_url": "https://api.github.com/users/regro-cf-autotick-bot/orgs",
+ "repos_url": "https://api.github.com/users/regro-cf-autotick-bot/repos",
+ "events_url": "https://api.github.com/users/regro-cf-autotick-bot/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/regro-cf-autotick-bot/received_events",
+ "type": "User",
+ "site_admin": false
+ },
+ "html_url": "https://github.com/regro-cf-autotick-bot/pytest-feedstock",
+ "description": "The tool for managing conda-forge feedstocks.",
+ "fork": true,
+ "url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock",
+ "forks_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/forks",
+ "keys_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/keys{/key_id}",
+ "collaborators_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/collaborators{/collaborator}",
+ "teams_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/teams",
+ "hooks_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/hooks",
+ "issue_events_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/issues/events{/number}",
+ "events_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/events",
+ "assignees_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/assignees{/user}",
+ "branches_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/branches{/branch}",
+ "tags_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/tags",
+ "blobs_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/git/blobs{/sha}",
+ "git_tags_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/git/tags{/sha}",
+ "git_refs_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/git/refs{/sha}",
+ "trees_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/git/trees{/sha}",
+ "statuses_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/statuses/{sha}",
+ "languages_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/languages",
+ "stargazers_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/stargazers",
+ "contributors_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/contributors",
+ "subscribers_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/subscribers",
+ "subscription_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/subscription",
+ "commits_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/commits{/sha}",
+ "git_commits_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/git/commits{/sha}",
+ "comments_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/comments{/number}",
+ "issue_comment_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/issues/comments{/number}",
+ "contents_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/contents/{+path}",
+ "compare_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/compare/{base}...{head}",
+ "merges_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/merges",
+ "archive_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/{archive_format}{/ref}",
+ "downloads_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/downloads",
+ "issues_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/issues{/number}",
+ "pulls_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/pulls{/number}",
+ "milestones_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/milestones{/number}",
+ "notifications_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/notifications{?since,all,participating}",
+ "labels_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/labels{/name}",
+ "releases_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/releases{/id}",
+ "deployments_url": "https://api.github.com/repos/regro-cf-autotick-bot/pytest-feedstock/deployments",
+ "created_at": "2024-03-15T15:21:35Z",
+ "updated_at": "2024-05-16T15:11:20Z",
+ "pushed_at": "2024-05-16T15:20:39Z",
+ "git_url": "git://github.com/regro-cf-autotick-bot/pytest-feedstock.git",
+ "ssh_url": "git@github.com:regro-cf-autotick-bot/pytest-feedstock.git",
+ "clone_url": "https://github.com/regro-cf-autotick-bot/pytest-feedstock.git",
+ "svn_url": "https://github.com/regro-cf-autotick-bot/pytest-feedstock",
+ "homepage": "https://conda-forge.org/",
+ "size": 3959,
+ "stargazers_count": 0,
+ "watchers_count": 0,
+ "language": "Python",
+ "has_issues": false,
+ "has_projects": true,
+ "has_downloads": true,
+ "has_wiki": true,
+ "has_pages": false,
+ "has_discussions": false,
+ "forks_count": 0,
+ "mirror_url": null,
+ "archived": false,
+ "disabled": false,
+ "open_issues_count": 0,
+ "license": {
+ "key": "bsd-3-clause",
+ "name": "BSD 3-Clause \"New\" or \"Revised\" License",
+ "spdx_id": "BSD-3-Clause",
+ "url": "https://api.github.com/licenses/bsd-3-clause",
+ "node_id": "MDc6TGljZW5zZTU="
+ },
+ "allow_forking": true,
+ "is_template": false,
+ "web_commit_signoff_required": false,
+ "topics": [],
+ "visibility": "public",
+ "forks": 0,
+ "open_issues": 0,
+ "watchers": 0,
+ "default_branch": "main"
+ }
+ },
+ "base": {
+ "label": "conda-forge:main",
+ "ref": "main",
+ "sha": "59aa8df51b362904f0a8eb72274ad9458a5e4d8e",
+ "user": {
+ "login": "conda-forge",
+ "id": 11897326,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjExODk3MzI2",
+ "avatar_url": "https://avatars.githubusercontent.com/u/11897326?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/conda-forge",
+ "html_url": "https://github.com/conda-forge",
+ "followers_url": "https://api.github.com/users/conda-forge/followers",
+ "following_url": "https://api.github.com/users/conda-forge/following{/other_user}",
+ "gists_url": "https://api.github.com/users/conda-forge/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/conda-forge/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/conda-forge/subscriptions",
+ "organizations_url": "https://api.github.com/users/conda-forge/orgs",
+ "repos_url": "https://api.github.com/users/conda-forge/repos",
+ "events_url": "https://api.github.com/users/conda-forge/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/conda-forge/received_events",
+ "type": "Organization",
+ "site_admin": false
+ },
+ "repo": {
+ "id": 33767206,
+ "node_id": "MDEwOlJlcG9zaXRvcnkzMzc2NzIwNg==",
+ "name": "pytest-feedstock",
+ "full_name": "conda-forge/pytest-feedstock",
+ "private": false,
+ "owner": {
+ "login": "conda-forge",
+ "id": 11897326,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjExODk3MzI2",
+ "avatar_url": "https://avatars.githubusercontent.com/u/11897326?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/conda-forge",
+ "html_url": "https://github.com/conda-forge",
+ "followers_url": "https://api.github.com/users/conda-forge/followers",
+ "following_url": "https://api.github.com/users/conda-forge/following{/other_user}",
+ "gists_url": "https://api.github.com/users/conda-forge/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/conda-forge/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/conda-forge/subscriptions",
+ "organizations_url": "https://api.github.com/users/conda-forge/orgs",
+ "repos_url": "https://api.github.com/users/conda-forge/repos",
+ "events_url": "https://api.github.com/users/conda-forge/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/conda-forge/received_events",
+ "type": "Organization",
+ "site_admin": false
+ },
+ "html_url": "https://github.com/conda-forge/pytest-feedstock",
+ "description": "The tool for managing conda-forge feedstocks.",
+ "fork": false,
+ "url": "https://api.github.com/repos/conda-forge/pytest-feedstock",
+ "forks_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/forks",
+ "keys_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/keys{/key_id}",
+ "collaborators_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/collaborators{/collaborator}",
+ "teams_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/teams",
+ "hooks_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/hooks",
+ "issue_events_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/issues/events{/number}",
+ "events_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/events",
+ "assignees_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/assignees{/user}",
+ "branches_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/branches{/branch}",
+ "tags_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/tags",
+ "blobs_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/git/blobs{/sha}",
+ "git_tags_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/git/tags{/sha}",
+ "git_refs_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/git/refs{/sha}",
+ "trees_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/git/trees{/sha}",
+ "statuses_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/statuses/{sha}",
+ "languages_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/languages",
+ "stargazers_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/stargazers",
+ "contributors_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/contributors",
+ "subscribers_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/subscribers",
+ "subscription_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/subscription",
+ "commits_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/commits{/sha}",
+ "git_commits_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/git/commits{/sha}",
+ "comments_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/comments{/number}",
+ "issue_comment_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/issues/comments{/number}",
+ "contents_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/contents/{+path}",
+ "compare_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/compare/{base}...{head}",
+ "merges_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/merges",
+ "archive_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/{archive_format}{/ref}",
+ "downloads_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/downloads",
+ "issues_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/issues{/number}",
+ "pulls_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/pulls{/number}",
+ "milestones_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/milestones{/number}",
+ "notifications_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/notifications{?since,all,participating}",
+ "labels_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/labels{/name}",
+ "releases_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/releases{/id}",
+ "deployments_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/deployments",
+ "created_at": "2015-04-11T07:38:36Z",
+ "updated_at": "2024-05-28T09:55:10Z",
+ "pushed_at": "2024-05-28T09:55:05Z",
+ "git_url": "git://github.com/conda-forge/pytest-feedstock.git",
+ "ssh_url": "git@github.com:conda-forge/pytest-feedstock.git",
+ "clone_url": "https://github.com/conda-forge/pytest-feedstock.git",
+ "svn_url": "https://github.com/conda-forge/pytest-feedstock",
+ "homepage": "https://conda-forge.org/",
+ "size": 3808,
+ "stargazers_count": 147,
+ "watchers_count": 147,
+ "language": "Python",
+ "has_issues": true,
+ "has_projects": true,
+ "has_downloads": true,
+ "has_wiki": true,
+ "has_pages": false,
+ "has_discussions": false,
+ "forks_count": 166,
+ "mirror_url": null,
+ "archived": false,
+ "disabled": false,
+ "open_issues_count": 334,
+ "license": {
+ "key": "bsd-3-clause",
+ "name": "BSD 3-Clause \"New\" or \"Revised\" License",
+ "spdx_id": "BSD-3-Clause",
+ "url": "https://api.github.com/licenses/bsd-3-clause",
+ "node_id": "MDc6TGljZW5zZTU="
+ },
+ "allow_forking": true,
+ "is_template": false,
+ "web_commit_signoff_required": false,
+ "topics": [
+ "continuous-integration",
+ "hacktoberfest"
+ ],
+ "visibility": "public",
+ "forks": 166,
+ "open_issues": 334,
+ "watchers": 147,
+ "default_branch": "main"
+ }
+ },
+ "_links": {
+ "self": {
+ "href": "https://api.github.com/repos/conda-forge/pytest-feedstock/pulls/1337"
+ },
+ "html": {
+ "href": "https://github.com/conda-forge/pytest-feedstock/pull/1337"
+ },
+ "issue": {
+ "href": "https://api.github.com/repos/conda-forge/pytest-feedstock/issues/1337"
+ },
+ "comments": {
+ "href": "https://api.github.com/repos/conda-forge/pytest-feedstock/issues/1337/comments"
+ },
+ "review_comments": {
+ "href": "https://api.github.com/repos/conda-forge/pytest-feedstock/pulls/1337/comments"
+ },
+ "review_comment": {
+ "href": "https://api.github.com/repos/conda-forge/pytest-feedstock/pulls/comments{/number}"
+ },
+ "commits": {
+ "href": "https://api.github.com/repos/conda-forge/pytest-feedstock/pulls/1337/commits"
+ },
+ "statuses": {
+ "href": "https://api.github.com/repos/conda-forge/pytest-feedstock/statuses/0eaa1de035b8720c8b85a7f435a0ae7037fe9095"
+ }
+ },
+ "author_association": "MEMBER",
+ "auto_merge": null,
+ "body_html": "BODY_HTML",
+ "body_text": "BODY_TEXT",
+ "active_lock_reason": null,
+ "merged": false,
+ "mergeable": true,
+ "rebaseable": true,
+ "mergeable_state": "clean",
+ "merged_by": null,
+ "comments": 7,
+ "review_comments": 23,
+ "maintainer_can_modify": true,
+ "commits": 8,
+ "additions": 601,
+ "deletions": 752,
+ "changed_files": 32
+}
diff --git a/tests/github_api/get_repo_pytest.json b/tests/github_api/get_repo_pytest.json
new file mode 100644
index 000000000..4d4c25dc4
--- /dev/null
+++ b/tests/github_api/get_repo_pytest.json
@@ -0,0 +1,130 @@
+{
+ "id": 62477336,
+ "node_id": "MDEwOlJlcG9zaXRvcnk2MjQ3NzMzNg==",
+ "name": "pytest-feedstock",
+ "full_name": "conda-forge/pytest-feedstock",
+ "private": false,
+ "owner": {
+ "login": "conda-forge",
+ "id": 11897326,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjExODk3MzI2",
+ "avatar_url": "https://avatars.githubusercontent.com/u/11897326?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/conda-forge",
+ "html_url": "https://github.com/conda-forge",
+ "followers_url": "https://api.github.com/users/conda-forge/followers",
+ "following_url": "https://api.github.com/users/conda-forge/following{/other_user}",
+ "gists_url": "https://api.github.com/users/conda-forge/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/conda-forge/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/conda-forge/subscriptions",
+ "organizations_url": "https://api.github.com/users/conda-forge/orgs",
+ "repos_url": "https://api.github.com/users/conda-forge/repos",
+ "events_url": "https://api.github.com/users/conda-forge/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/conda-forge/received_events",
+ "type": "Organization",
+ "site_admin": false
+ },
+ "html_url": "https://github.com/conda-forge/pytest-feedstock",
+ "description": "A conda-smithy repository for pytest.",
+ "fork": false,
+ "url": "https://api.github.com/repos/conda-forge/pytest-feedstock",
+ "forks_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/forks",
+ "keys_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/keys{/key_id}",
+ "collaborators_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/collaborators{/collaborator}",
+ "teams_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/teams",
+ "hooks_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/hooks",
+ "issue_events_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/issues/events{/number}",
+ "events_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/events",
+ "assignees_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/assignees{/user}",
+ "branches_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/branches{/branch}",
+ "tags_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/tags",
+ "blobs_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/git/blobs{/sha}",
+ "git_tags_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/git/tags{/sha}",
+ "git_refs_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/git/refs{/sha}",
+ "trees_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/git/trees{/sha}",
+ "statuses_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/statuses/{sha}",
+ "languages_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/languages",
+ "stargazers_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/stargazers",
+ "contributors_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/contributors",
+ "subscribers_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/subscribers",
+ "subscription_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/subscription",
+ "commits_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/commits{/sha}",
+ "git_commits_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/git/commits{/sha}",
+ "comments_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/comments{/number}",
+ "issue_comment_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/issues/comments{/number}",
+ "contents_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/contents/{+path}",
+ "compare_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/compare/{base}...{head}",
+ "merges_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/merges",
+ "archive_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/{archive_format}{/ref}",
+ "downloads_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/downloads",
+ "issues_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/issues{/number}",
+ "pulls_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/pulls{/number}",
+ "milestones_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/milestones{/number}",
+ "notifications_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/notifications{?since,all,participating}",
+ "labels_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/labels{/name}",
+ "releases_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/releases{/id}",
+ "deployments_url": "https://api.github.com/repos/conda-forge/pytest-feedstock/deployments",
+ "created_at": "2016-07-03T02:02:04Z",
+ "updated_at": "2024-05-20T16:07:25Z",
+ "pushed_at": "2024-05-20T16:07:21Z",
+ "git_url": "git://github.com/conda-forge/pytest-feedstock.git",
+ "ssh_url": "git@github.com:conda-forge/pytest-feedstock.git",
+ "clone_url": "https://github.com/conda-forge/pytest-feedstock.git",
+ "svn_url": "https://github.com/conda-forge/pytest-feedstock",
+ "homepage": null,
+ "size": 310,
+ "stargazers_count": 2,
+ "watchers_count": 2,
+ "language": null,
+ "has_issues": true,
+ "has_projects": true,
+ "has_downloads": true,
+ "has_wiki": false,
+ "has_pages": false,
+ "has_discussions": false,
+ "forks_count": 27,
+ "mirror_url": null,
+ "archived": false,
+ "disabled": false,
+ "open_issues_count": 1,
+ "license": {
+ "key": "bsd-3-clause",
+ "name": "BSD 3-Clause \"New\" or \"Revised\" License",
+ "spdx_id": "BSD-3-Clause",
+ "url": "https://api.github.com/licenses/bsd-3-clause",
+ "node_id": "MDc6TGljZW5zZTU="
+ },
+ "allow_forking": true,
+ "is_template": false,
+ "web_commit_signoff_required": false,
+ "topics": [],
+ "visibility": "public",
+ "forks": 27,
+ "open_issues": 1,
+ "watchers": 2,
+ "default_branch": "main",
+ "temp_clone_token": null,
+ "custom_properties": {},
+ "organization": {
+ "login": "conda-forge",
+ "id": 11897326,
+ "node_id": "MDEyOk9yZ2FuaXphdGlvbjExODk3MzI2",
+ "avatar_url": "https://avatars.githubusercontent.com/u/11897326?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/conda-forge",
+ "html_url": "https://github.com/conda-forge",
+ "followers_url": "https://api.github.com/users/conda-forge/followers",
+ "following_url": "https://api.github.com/users/conda-forge/following{/other_user}",
+ "gists_url": "https://api.github.com/users/conda-forge/gists{/gist_id}",
+ "starred_url": "https://api.github.com/users/conda-forge/starred{/owner}{/repo}",
+ "subscriptions_url": "https://api.github.com/users/conda-forge/subscriptions",
+ "organizations_url": "https://api.github.com/users/conda-forge/orgs",
+ "repos_url": "https://api.github.com/users/conda-forge/repos",
+ "events_url": "https://api.github.com/users/conda-forge/events{/privacy}",
+ "received_events_url": "https://api.github.com/users/conda-forge/received_events",
+ "type": "Organization",
+ "site_admin": false
+ },
+ "network_count": 27,
+ "subscribers_count": 7
+}
diff --git a/tests/github_api/github_response_headers.json b/tests/github_api/github_response_headers.json
new file mode 100644
index 000000000..4ba208057
--- /dev/null
+++ b/tests/github_api/github_response_headers.json
@@ -0,0 +1,28 @@
+{
+ "Server": "GitHub.com",
+ "Date": "Wed, 29 May 2024 12:07:38 GMT",
+ "Content-Type": "application/json; charset=utf-8",
+ "Cache-Control": "public, max-age=60, s-maxage=60",
+ "Vary": "Accept, Accept-Encoding, Accept, X-Requested-With",
+ "ETag": "W/\"7ba8c0b529b1303243a8c4636a95ce2e337591d152d69f8e90608c202a166483\"",
+ "Last-Modified": "Wed, 10 Apr 2024 13:15:22 GMT",
+ "X-GitHub-Media-Type": "github.v3; format=json",
+ "x-github-api-version-selected": "2022-11-28",
+ "Access-Control-Expose-Headers": "ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset",
+ "Access-Control-Allow-Origin": "*",
+ "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload",
+ "X-Frame-Options": "deny",
+ "X-Content-Type-Options": "nosniff",
+ "X-XSS-Protection": "0",
+ "Referrer-Policy": "origin-when-cross-origin, strict-origin-when-cross-origin",
+ "Content-Security-Policy": "default-src 'none'",
+ "Content-Encoding": "gzip",
+ "X-RateLimit-Limit": "60",
+ "X-RateLimit-Remaining": "51",
+ "X-RateLimit-Reset": "1716987025",
+ "X-RateLimit-Resource": "core",
+ "X-RateLimit-Used": "9",
+ "Accept-Ranges": "bytes",
+ "Content-Length": "4456",
+ "X-GitHub-Request-Id": "F7B3:2D05B1:3948EFC:3998343:74382A8F"
+}
diff --git a/tests/test_contexts.py b/tests/test_contexts.py
new file mode 100644
index 000000000..34b843642
--- /dev/null
+++ b/tests/test_contexts.py
@@ -0,0 +1,122 @@
+import pytest
+
+from conda_forge_tick.contexts import DEFAULT_BRANCHES, FeedstockContext
+from conda_forge_tick.migrators_types import AttrsTypedDict
+
+# to make the typechecker happy, this satisfies the AttrsTypedDict type
+demo_attrs = AttrsTypedDict(
+ {"conda-forge.yml": {"provider": {"default_branch": "main"}}}
+)
+
+demo_attrs_automerge = AttrsTypedDict(
+ {
+ "conda-forge.yml": {
+ "provider": {"default_branch": "main"},
+ "bot": {"automerge": True},
+ }
+ }
+)
+
+demo_attrs_check_solvable = AttrsTypedDict(
+ {
+ "conda-forge.yml": {
+ "provider": {"default_branch": "main"},
+ "bot": {"check_solvable": True},
+ }
+ }
+)
+
+
+def test_feedstock_context_default_branch_not_set():
+ context = FeedstockContext("TEST-FEEDSTOCK-NAME", demo_attrs)
+ assert context.default_branch == "main"
+
+ DEFAULT_BRANCHES["TEST-FEEDSTOCK-NAME"] = "develop"
+ assert context.default_branch == "develop"
+
+
+def test_feedstock_context_default_branch_set():
+ context = FeedstockContext("TEST-FEEDSTOCK-NAME", demo_attrs, "feature")
+
+ DEFAULT_BRANCHES["TEST-FEEDSTOCK-NAME"] = "develop"
+ assert context.default_branch == "feature"
+
+ # reset the default branches
+ DEFAULT_BRANCHES.pop("TEST-FEEDSTOCK-NAME")
+
+ # test the default branch is still the same
+ assert context.default_branch == "feature"
+
+
+def test_feedstock_context_git_repo_owner():
+ context = FeedstockContext("TEST-FEEDSTOCK-NAME", demo_attrs)
+ assert context.git_repo_owner == "conda-forge"
+
+
+def test_feedstock_context_git_repo_name():
+ context = FeedstockContext("TEST-FEEDSTOCK-NAME", demo_attrs)
+ assert context.git_repo_name == "TEST-FEEDSTOCK-NAME-feedstock"
+
+
+def test_feedstock_context_git_href():
+ context = FeedstockContext("TEST-FEEDSTOCK-NAME", demo_attrs)
+ assert (
+ context.git_href
+ == "https://github.com/conda-forge/TEST-FEEDSTOCK-NAME-feedstock"
+ )
+
+
+@pytest.mark.parametrize("automerge", [True, False])
+def test_feedstock_context_automerge(automerge: bool):
+ context = FeedstockContext(
+ "TEST-FEEDSTOCK-NAME", demo_attrs_automerge if automerge else demo_attrs
+ )
+
+ assert context.automerge == automerge
+
+
+@pytest.mark.parametrize("check_solvable", [True, False])
+def test_feedstock_context_check_solvable(check_solvable: bool):
+ context = FeedstockContext(
+ "TEST-FEEDSTOCK-NAME",
+ demo_attrs_check_solvable if check_solvable else demo_attrs,
+ )
+
+ assert context.check_solvable == check_solvable
+
+
+@pytest.mark.parametrize("default_branch", [None, "feature"])
+@pytest.mark.parametrize(
+ "attrs", [demo_attrs, demo_attrs_automerge, demo_attrs_check_solvable]
+)
+def test_feedstock_context_reserve_clone_directory(
+ attrs: AttrsTypedDict, default_branch: str
+):
+ context = FeedstockContext("pytest", attrs, default_branch)
+
+ with context.reserve_clone_directory() as cloned_context:
+ assert cloned_context.feedstock_name == "pytest"
+ assert cloned_context.attrs == attrs
+ assert (
+ cloned_context.default_branch == default_branch
+ if default_branch
+ else "main"
+ )
+ assert cloned_context.git_repo_owner == "conda-forge"
+ assert cloned_context.git_repo_name == "pytest-feedstock"
+ assert (
+ cloned_context.git_href == "https://github.com/conda-forge/pytest-feedstock"
+ )
+ assert cloned_context.automerge == context.automerge
+ assert cloned_context.check_solvable == context.check_solvable
+
+ assert cloned_context.local_clone_dir.exists()
+ assert cloned_context.local_clone_dir.is_dir()
+ assert cloned_context.local_clone_dir.name == "pytest-feedstock"
+
+ with open(cloned_context.local_clone_dir / "test.txt", "w") as f:
+ f.write("test")
+
+ assert (cloned_context.local_clone_dir / "test.txt").exists()
+
+ assert not cloned_context.local_clone_dir.exists()
diff --git a/tests/test_executors.py b/tests/test_executors.py
index 15aeb1483..a71f9b9d5 100644
--- a/tests/test_executors.py
+++ b/tests/test_executors.py
@@ -8,10 +8,23 @@
def _square_with_lock(x):
- from conda_forge_tick.executors import DRLOCK, PRLOCK, TRLOCK
+ from conda_forge_tick.executors import (
+ GIT_LOCK_DASK,
+ GIT_LOCK_PROCESS,
+ GIT_LOCK_THREAD,
+ )
+
+ with GIT_LOCK_THREAD, GIT_LOCK_PROCESS, GIT_LOCK_DASK:
+ with GIT_LOCK_THREAD, GIT_LOCK_PROCESS, GIT_LOCK_DASK:
+ time.sleep(0.01)
+ return x * x
+
- with TRLOCK, PRLOCK, DRLOCK:
- with TRLOCK, PRLOCK, DRLOCK:
+def _square_with_lock_git_operation(x):
+ from conda_forge_tick.executors import lock_git_operation
+
+ with lock_git_operation():
+ with lock_git_operation():
time.sleep(0.01)
return x * x
@@ -46,6 +59,10 @@ def test_executor(kind):
assert np.allclose(tot, par_tot)
+@pytest.mark.parametrize(
+ "locked_square_function",
+ [_square_with_lock, _square_with_lock_git_operation],
+)
@pytest.mark.parametrize(
"kind",
[
@@ -56,7 +73,7 @@ def test_executor(kind):
"dask-thread",
],
)
-def test_executor_locking(kind):
+def test_executor_locking(kind, locked_square_function):
seed = 10
rng = np.random.RandomState(seed=seed)
nums = rng.uniform(size=100)
@@ -74,7 +91,7 @@ def test_executor_locking(kind):
par_tot = 0
t0lock = time.time()
with executor(kind, max_workers=4) as exe:
- futs = [exe.submit(_square_with_lock, num) for num in nums]
+ futs = [exe.submit(locked_square_function, num) for num in nums]
for fut in as_completed(futs):
par_tot += fut.result()
t0lock = time.time() - t0lock
diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py
index 47686fe43..2cd8dedf8 100644
--- a/tests/test_git_utils.py
+++ b/tests/test_git_utils.py
@@ -1,4 +1,1864 @@
-from conda_forge_tick.git_utils import trim_pr_json_keys
+import datetime
+import json
+import logging
+import subprocess
+import tempfile
+from pathlib import Path
+from unittest import mock
+from unittest.mock import MagicMock
+
+import github3.exceptions
+import pytest
+import requests
+from pydantic_core import Url
+from requests.structures import CaseInsensitiveDict
+
+from conda_forge_tick.git_utils import (
+ Bound,
+ DryRunBackend,
+ DuplicatePullRequestError,
+ GitCli,
+ GitCliError,
+ GitConnectionMode,
+ GitHubBackend,
+ GitPlatformBackend,
+ GitPlatformError,
+ RepositoryNotFoundError,
+ trim_pr_json_keys,
+)
+from conda_forge_tick.models.pr_json import (
+ GithubPullRequestMergeableState,
+ PullRequestState,
+)
+
+"""
+Note: You have to have git installed on your machine to run these tests.
+"""
+
+
+@mock.patch("subprocess.run")
+@pytest.mark.parametrize("check_error", [True, False])
+def test_git_cli_run_git_command_no_error(
+ subprocess_run_mock: MagicMock, check_error: bool
+):
+ cli = GitCli()
+
+ working_directory = Path("TEST_DIR")
+
+ cli._run_git_command(
+ ["GIT_COMMAND", "ARG1", "ARG2"], working_directory, check_error
+ )
+
+ subprocess_run_mock.assert_called_once_with(
+ ["git", "GIT_COMMAND", "ARG1", "ARG2"], check=check_error, cwd=working_directory
+ )
+
+
+@mock.patch("subprocess.run")
+def test_git_cli_run_git_command_error(subprocess_run_mock: MagicMock):
+ cli = GitCli()
+
+ working_directory = Path("TEST_DIR")
+
+ subprocess_run_mock.side_effect = subprocess.CalledProcessError(
+ returncode=1, cmd=""
+ )
+
+ with pytest.raises(GitCliError):
+ cli._run_git_command(["GIT_COMMAND"], working_directory)
+
+
+@pytest.mark.parametrize("token_hidden", [True, False])
+@pytest.mark.parametrize("check_error", [True, False])
+@mock.patch("subprocess.run")
+def test_git_cli_run_git_command_mock(
+ subprocess_run_mock: MagicMock, check_error: bool, token_hidden: bool
+):
+ """
+ This test checks if all parameters are passed correctly to the subprocess.run function.
+ """
+ cli = GitCli()
+
+ working_directory = Path("TEST_DIR")
+
+ if token_hidden:
+ cli.add_hidden_token("TOKEN")
+
+ cli._run_git_command(["COMMAND", "ARG1", "ARG2"], working_directory, check_error)
+
+ stderr_args = {"stderr": subprocess.PIPE} if token_hidden else {}
+
+ subprocess_run_mock.assert_called_once_with(
+ ["git", "COMMAND", "ARG1", "ARG2"],
+ check=check_error,
+ cwd=working_directory,
+ stdout=subprocess.PIPE,
+ **stderr_args,
+ text=True,
+ )
+
+
+@pytest.mark.parametrize("token_hidden", [True, False])
+@pytest.mark.parametrize("check_error", [True, False])
+def test_git_cli_run_git_command_stdout_captured(
+ capfd, check_error: bool, token_hidden: bool
+):
+ """
+ Verify that the stdout of the git command is captured and not printed to the console.
+ """
+ cli = GitCli()
+
+ if token_hidden:
+ cli.add_hidden_token("TOKEN")
+ p = cli._run_git_command(["version"], check_error=check_error)
+
+ captured = capfd.readouterr()
+
+ assert captured.out == ""
+ assert p.stdout.startswith("git version")
+
+
+def test_git_cli_run_git_command_stderr_not_captured(capfd):
+ """
+ Verify that the stderr of the git command is not captured if no token is hidden.
+ """
+ cli = GitCli()
+
+ p = cli._run_git_command(["non-existing-command"], check_error=False)
+
+ captured = capfd.readouterr()
+
+ assert captured.out == ""
+ assert "not a git command" in captured.err
+ assert p.stderr is None
+
+
+def test_git_cli_hide_token_stdout_no_error(capfd):
+ cli = GitCli()
+
+ cli.add_hidden_token("git")
+ p = cli._run_git_command(["help"])
+
+ captured = capfd.readouterr()
+
+ assert "git" not in captured.out
+ assert "git" not in captured.err
+ assert "git" not in p.stdout
+ assert "git" not in p.stderr
+
+ assert p.stdout.count("***") > 5
+
+
+def test_git_cli_hide_token_stdout_error_check_error(caplog, capfd):
+ cli = GitCli()
+
+ caplog.set_level(logging.DEBUG)
+
+ cli.add_hidden_token("all")
+ with pytest.raises(GitCliError):
+ # git help --a prints to stdout (!) and then exits with an error
+ cli._run_git_command(["help", "--a"])
+
+ captured = capfd.readouterr()
+
+ assert "all" not in captured.out
+ assert "all" not in captured.err
+ assert "all" not in caplog.text
+
+ assert "***" in caplog.text
+
+
+def test_git_cli_hide_token_stdout_error_no_check_error(caplog, capfd):
+ cli = GitCli()
+
+ caplog.set_level(logging.DEBUG)
+
+ cli.add_hidden_token("all")
+ p = cli._run_git_command(["help", "--a"], check_error=False)
+
+ captured = capfd.readouterr()
+
+ assert "all" not in captured.out
+ assert "all" not in captured.err
+ assert "all" not in p.stdout
+ assert "all" not in p.stderr
+ assert "all" not in caplog.text
+
+ assert "***" in p.stdout
+
+
+def test_git_cli_hide_token_stderr_no_check_error(capfd):
+ cli = GitCli()
+
+ cli.add_hidden_token("command")
+ p = cli._run_git_command(["non-existing-command"], check_error=False)
+
+ captured = capfd.readouterr()
+
+ assert "command" not in captured.out
+ assert "command" not in captured.err
+ assert "command" not in p.stdout
+ assert "command" not in p.stderr
+
+ assert p.stderr.count("*******") >= 2
+ assert captured.err.count("*******") >= 2
+
+
+def test_git_cli_hide_token_run_git_command_check_error(capfd, caplog):
+ cli = GitCli()
+
+ caplog.set_level(logging.INFO)
+
+ cli.add_hidden_token("command")
+ with pytest.raises(GitCliError):
+ cli._run_git_command(["non-existing-command"])
+
+ print(caplog.text)
+ assert "Command 'git non-existing-command' failed." in caplog.text
+ assert (
+ caplog.text.count("command") == 1
+ ) # only the command itself is printed directly by us
+
+ assert "'non-existing-*******' is not a git *******" in caplog.text
+
+
+def test_git_cli_hide_token_multiple(capfd, caplog):
+ cli = GitCli()
+
+ caplog.set_level(logging.DEBUG)
+
+ cli.add_hidden_token("clone")
+ cli.add_hidden_token("commit")
+ p1 = cli._run_git_command(["help"])
+
+ captured = capfd.readouterr()
+
+ assert "clone" not in captured.out
+ assert "clone" not in captured.err
+ assert "clone" not in p1.stdout
+ assert "clone" not in p1.stderr
+
+ assert "commit" not in captured.out
+ assert "commit" not in captured.err
+ assert "commit" not in p1.stdout
+ assert "commit" not in p1.stderr
+
+ assert "clone" not in caplog.text
+ assert "commit" not in caplog.text
+
+ assert p1.stdout.count("*****") >= 2
+
+
+def test_git_cli_outside_repo():
+ with tempfile.TemporaryDirectory() as tmpdir:
+ dir_path = Path(tmpdir)
+
+ with dir_path.joinpath("test.txt").open("w") as f:
+ f.write("Hello, World!")
+
+ cli = GitCli()
+
+ with pytest.raises(GitCliError):
+ cli._run_git_command(["status"], working_directory=dir_path)
+
+ with pytest.raises(GitCliError):
+ cli.reset_hard(dir_path)
+
+ with pytest.raises(GitCliError):
+ cli.add_remote(dir_path, "origin", "https://github.com/torvalds/linux.git")
+
+ with pytest.raises(GitCliError):
+ cli.fetch_all(dir_path)
+
+ assert not cli.does_branch_exist(dir_path, "main")
+
+ with pytest.raises(GitCliError):
+ cli.checkout_branch(dir_path, "main")
+
+
+# noinspection PyProtectedMember
+def init_temp_git_repo(git_dir: Path, bare: bool = False):
+ cli = GitCli()
+ bare_arg = ["--bare"] if bare else []
+ cli._run_git_command(["init", *bare_arg, "-b", "main"], working_directory=git_dir)
+ cli._run_git_command(
+ ["config", "user.name", "CI Test User"], working_directory=git_dir
+ )
+ cli._run_git_command(
+ ["config", "user.email", "ci-test-user-invalid@example.com"],
+ working_directory=git_dir,
+ )
+
+
+@pytest.mark.parametrize(
+ "n_paths,all_", [(0, True), (1, False), (1, True), (2, False), (2, True)]
+)
+@mock.patch("conda_forge_tick.git_utils.GitCli._run_git_command")
+def test_git_cli_add_success_mock(
+ run_git_command_mock: MagicMock, n_paths: int, all_: bool
+):
+ cli = GitCli()
+
+ git_dir = Path("TEST_DIR")
+ paths = [Path(f"test{i}.txt") for i in range(n_paths)]
+
+ cli.add(git_dir, *paths, all_=all_)
+
+ expected_all_arg = ["--all"] if all_ else []
+
+ run_git_command_mock.assert_called_once_with(
+ ["add", *expected_all_arg, *paths], git_dir
+ )
+
+
+@mock.patch("conda_forge_tick.git_utils.GitCli._run_git_command")
+def test_git_cli_add_no_arguments_error(run_git_command_mock: MagicMock):
+ cli = GitCli()
+
+ git_dir = Path("TEST_DIR")
+
+ with pytest.raises(ValueError, match="Either pathspec or all_ must be set"):
+ cli.add(git_dir)
+
+ run_git_command_mock.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ "n_paths,all_", [(0, True), (1, False), (1, True), (2, False), (2, True)]
+)
+def test_git_cli_add_success(n_paths: int, all_: bool):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ git_dir = Path(tmp_dir)
+ init_temp_git_repo(git_dir)
+
+ pathspec = [git_dir / f"test{i}.txt" for i in range(n_paths)]
+
+ for path in pathspec + [git_dir / "all_tracker.txt"]:
+ path.touch()
+
+ cli = GitCli()
+ cli.add(git_dir, *pathspec, all_=all_)
+
+ tracked_files = cli._run_git_command(["ls-files", "-s"], git_dir).stdout
+
+ for path in pathspec:
+ assert path.name in tracked_files
+
+ if all_ and n_paths == 0:
+ # note that n_paths has to be zero to add unknown files to the working tree
+ assert "all_tracker.txt" in tracked_files
+ else:
+ assert "all_tracker.txt" not in tracked_files
+
+
+@pytest.mark.parametrize("allow_empty", [True, False])
+@pytest.mark.parametrize("all_", [True, False])
+@mock.patch("conda_forge_tick.git_utils.GitCli._run_git_command")
+def test_git_cli_commit_success_mock(
+ run_git_command_mock: MagicMock, all_: bool, allow_empty: bool
+):
+ git_dir = Path("GIT_DIR")
+ message = "COMMIT_MESSAGE"
+
+ cli = GitCli()
+ cli.commit(git_dir, message, all_, allow_empty)
+
+ expected_all_arg = ["-a"] if all_ else []
+ expected_allow_empty_arg = ["--allow-empty"] if allow_empty else []
+
+ run_git_command_mock.assert_called_once_with(
+ ["commit", *expected_all_arg, *expected_allow_empty_arg, "-m", message], git_dir
+ )
+
+
+@pytest.mark.parametrize("allow_empty", [True, False])
+@pytest.mark.parametrize("empty", [True, False])
+@pytest.mark.parametrize("all_", [True, False])
+def test_git_cli_commit(all_: bool, empty: bool, allow_empty: bool):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ git_dir = Path(tmp_dir)
+ init_temp_git_repo(git_dir)
+
+ cli = GitCli()
+
+ test_file = git_dir.joinpath("test.txt")
+ with test_file.open("w") as f:
+ f.write("Hello, World!")
+ cli.add(git_dir, git_dir / "test.txt")
+ cli.commit(git_dir, "Add Test")
+
+ if not empty:
+ test_file.unlink()
+ if not all_:
+ cli.add(git_dir, git_dir / "test.txt")
+
+ if empty and not allow_empty:
+ with pytest.raises(GitCliError):
+ cli.commit(git_dir, "Add Test", all_, allow_empty)
+ return
+
+ cli.commit(git_dir, "Add Test", all_, allow_empty)
+
+ git_log = cli._run_git_command(["log"], git_dir).stdout
+
+ assert "Add Test" in git_log
+
+
+@mock.patch("conda_forge_tick.git_utils.GitCli._run_git_command")
+def test_git_cli_rev_parse_head_mock(run_git_command_mock: MagicMock):
+ cli = GitCli()
+
+ git_dir = Path("TEST_DIR")
+
+ run_git_command_mock.return_value = subprocess.CompletedProcess(
+ args=[], returncode=0, stdout="deadbeef\n"
+ )
+
+ head_rev = cli.rev_parse_head(git_dir)
+ run_git_command_mock.assert_called_once_with(
+ ["rev-parse", "HEAD"], git_dir, capture_text=True
+ )
+
+ assert head_rev == "deadbeef"
+
+
+def test_git_cli_rev_parse_head():
+ cli = GitCli()
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ dir_path = Path(tmpdir)
+ init_temp_git_repo(dir_path)
+ cli.commit(dir_path, "Initial commit", allow_empty=True)
+ head_rev = cli.rev_parse_head(dir_path)
+ assert len(head_rev) == 40
+ assert all(c in "0123456789abcdef" for c in head_rev)
+
+
+def test_git_cli_reset_hard_already_reset():
+ cli = GitCli()
+ with tempfile.TemporaryDirectory() as tmpdir:
+ dir_path = Path(tmpdir)
+
+ init_temp_git_repo(dir_path)
+ cli._run_git_command(
+ ["commit", "--allow-empty", "-m", "First commit"],
+ working_directory=dir_path,
+ )
+
+ cli._run_git_command(
+ ["commit", "--allow-empty", "-m", "Second commit"],
+ working_directory=dir_path,
+ )
+
+ cli.reset_hard(dir_path)
+
+ git_log = subprocess.run(
+ "git log", cwd=dir_path, shell=True, capture_output=True
+ ).stdout.decode()
+
+ assert "First commit" in git_log
+ assert "Second commit" in git_log
+
+
+@mock.patch("conda_forge_tick.git_utils.GitCli._run_git_command")
+def test_git_cli_reset_hard_mock(run_git_command_mock: MagicMock):
+ cli = GitCli()
+
+ git_dir = Path("TEST_DIR")
+
+ cli.reset_hard(git_dir)
+
+ run_git_command_mock.assert_called_once_with(
+ ["reset", "--quiet", "--hard", "HEAD"], git_dir
+ )
+
+
+def test_git_cli_reset_hard():
+ cli = GitCli()
+ with tempfile.TemporaryDirectory() as tmpdir:
+ dir_path = Path(tmpdir)
+
+ init_temp_git_repo(dir_path)
+ cli._run_git_command(
+ ["commit", "--allow-empty", "-m", "Initial commit"],
+ working_directory=dir_path,
+ )
+
+ with dir_path.joinpath("test.txt").open("w") as f:
+ f.write("Hello, World!")
+
+ cli._run_git_command(["add", "test.txt"], working_directory=dir_path)
+ cli._run_git_command(
+ ["commit", "-am", "Add test.txt"], working_directory=dir_path
+ )
+
+ with dir_path.joinpath("test.txt").open("w") as f:
+ f.write("Hello, World! Again!")
+
+ cli.reset_hard(dir_path)
+
+ with dir_path.joinpath("test.txt").open("r") as f:
+ assert f.read() == "Hello, World!"
+
+
+def test_git_cli_clone_repo_not_exists():
+ cli = GitCli()
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ dir_path = Path(tmpdir)
+
+ with pytest.raises(GitCliError):
+ cli.clone_repo(
+ "https://github.com/conda-forge/this-repo-does-not-exist.git", dir_path
+ )
+
+
+def test_git_cli_clone_repo_success():
+ cli = GitCli()
+
+ git_url = "https://github.com/conda-forge/duckdb-feedstock.git"
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ dir_path = Path(tmpdir) / "duckdb-feedstock"
+
+ # this is an archived feedstock that should not change
+ cli.clone_repo(git_url, dir_path)
+
+ readme_file = dir_path.joinpath("README.md")
+
+ assert readme_file.exists()
+
+
+def test_git_cli_clone_repo_existing_empty_dir():
+ cli = GitCli()
+
+ git_url = "https://github.com/conda-forge/duckdb-feedstock.git"
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ target = Path(tmpdir) / "duckdb-feedstock"
+
+ target.mkdir()
+
+ cli.clone_repo(git_url, target)
+
+ readme_file = target.joinpath("README.md")
+
+ assert readme_file.exists()
+
+
+@mock.patch("conda_forge_tick.git_utils.GitCli.reset_hard")
+@mock.patch("conda_forge_tick.git_utils.GitCli._run_git_command")
+def test_git_cli_clone_repo_mock_success(
+ run_git_command_mock: MagicMock, reset_hard_mock: MagicMock
+):
+ cli = GitCli()
+
+ git_url = "https://git-repository.com/repo.git"
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ dir_path = Path(tmpdir) / "repo"
+
+ cli.clone_repo(git_url, dir_path)
+
+ run_git_command_mock.assert_called_once_with(
+ ["clone", "--quiet", git_url, dir_path]
+ )
+
+
+@mock.patch("conda_forge_tick.git_utils.GitCli._run_git_command")
+def test_git_cli_clone_repo_mock_error(run_git_command_mock: MagicMock):
+ cli = GitCli()
+
+ git_url = "https://git-repository.com/repo.git"
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ dir_path = Path(tmpdir) / "repo"
+
+ run_git_command_mock.side_effect = GitCliError("Error")
+
+ with pytest.raises(GitCliError, match="Error cloning repository"):
+ cli.clone_repo(git_url, dir_path)
+
+
+@mock.patch("conda_forge_tick.git_utils.GitCli._run_git_command")
+def test_git_cli_add_remote_mock(run_git_command_mock: MagicMock):
+ cli = GitCli()
+
+ git_dir = Path("TEST_DIR")
+ remote_name = "origin"
+ remote_url = "https://git-repository.com/repo.git"
+
+ cli.add_remote(git_dir, remote_name, remote_url)
+
+ run_git_command_mock.assert_called_once_with(
+ ["remote", "add", remote_name, remote_url], git_dir
+ )
+
+
+def test_git_cli_add_remote():
+ cli = GitCli()
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ dir_path = Path(tmpdir)
+
+ init_temp_git_repo(dir_path)
+
+ remote_name = "remote24"
+ remote_url = "https://git-repository.com/repo.git"
+
+ cli.add_remote(dir_path, remote_name, remote_url)
+
+ output = subprocess.run(
+ "git remote -v", cwd=dir_path, shell=True, capture_output=True
+ )
+
+ assert remote_name in output.stdout.decode()
+ assert remote_url in output.stdout.decode()
+
+
+@mock.patch("conda_forge_tick.git_utils.GitCli._run_git_command")
+def test_git_cli_push_to_url_mock(run_git_command_mock: MagicMock):
+ cli = GitCli()
+
+ git_dir = Path("TEST_DIR")
+ remote_url = "https://git-repository.com/repo.git"
+
+ cli.push_to_url(git_dir, remote_url, "BRANCH_NAME")
+
+ run_git_command_mock.assert_called_once_with(
+ ["push", remote_url, "BRANCH_NAME"], git_dir
+ )
+
+
+@mock.patch("conda_forge_tick.git_utils.GitCli._run_git_command")
+def test_git_cli_push_to_url_mock_error(run_git_command_mock: MagicMock):
+ cli = GitCli()
+
+ run_git_command_mock.side_effect = GitCliError("Error")
+
+ with pytest.raises(GitCliError):
+ cli.push_to_url(
+ Path("TEST_DIR"), "https://git-repository.com/repo.git", "BRANCH_NAME"
+ )
+
+
+def test_git_cli_push_to_url_local_repository():
+ cli = GitCli()
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ dir_path = Path(tmpdir)
+
+ source_repo = dir_path / "source_repo"
+ source_repo.mkdir()
+ init_temp_git_repo(source_repo, bare=True)
+
+ local_repo = dir_path / "local_repo"
+ local_repo.mkdir()
+ cli._run_git_command(["clone", source_repo.resolve(), local_repo])
+
+ # remove all references to the original repo
+ cli._run_git_command(
+ ["remote", "remove", "origin"], working_directory=local_repo
+ )
+
+ with local_repo.joinpath("test.txt").open("w") as f:
+ f.write("Hello, World!")
+
+ cli._run_git_command(["add", "test.txt"], working_directory=local_repo)
+ cli._run_git_command(
+ ["commit", "-am", "Add test.txt"], working_directory=local_repo
+ )
+
+ cli.push_to_url(local_repo, str(source_repo.resolve()), "main")
+
+ source_git_log = subprocess.run(
+ "git log", cwd=source_repo, shell=True, capture_output=True
+ ).stdout.decode()
+
+ assert "test.txt" in source_git_log
+
+
+@mock.patch("conda_forge_tick.git_utils.GitCli._run_git_command")
+def test_git_cli_fetch_all_mock(run_git_command_mock: MagicMock):
+ cli = GitCli()
+
+ git_dir = Path("TEST_DIR")
+
+ cli.fetch_all(git_dir)
+
+ run_git_command_mock.assert_called_once_with(["fetch", "--all", "--quiet"], git_dir)
+
+
+def test_git_cli_fetch_all():
+ cli = GitCli()
+
+ git_url = "https://github.com/conda-forge/duckdb-feedstock.git"
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ dir_path = Path(tmpdir) / "duckdb-feedstock"
+
+ cli.clone_repo(git_url, dir_path)
+ cli.fetch_all(dir_path)
+
+
+def test_git_cli_does_branch_exist():
+ cli = GitCli()
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ dir_path = Path(tmpdir)
+
+ init_temp_git_repo(dir_path)
+
+ assert not cli.does_branch_exist(dir_path, "main")
+
+ cli._run_git_command(["checkout", "-b", "main"], working_directory=dir_path)
+ cli._run_git_command(
+ ["commit", "--allow-empty", "-m", "Initial commit"],
+ working_directory=dir_path,
+ )
+
+ assert cli.does_branch_exist(dir_path, "main")
+
+
+@mock.patch("conda_forge_tick.git_utils.GitCli._run_git_command")
+@pytest.mark.parametrize("does_exist", [True, False])
+def test_git_cli_does_branch_exist_mock(
+ run_git_command_mock: MagicMock, does_exist: bool
+):
+ cli = GitCli()
+
+ git_dir = Path("TEST_DIR")
+ branch_name = "main"
+
+ run_git_command_mock.return_value = (
+ subprocess.CompletedProcess(args=[], returncode=0)
+ if does_exist
+ else subprocess.CompletedProcess(args=[], returncode=1)
+ )
+
+ assert cli.does_branch_exist(git_dir, branch_name) is does_exist
+
+ run_git_command_mock.assert_called_once_with(
+ ["show-ref", "--verify", "--quiet", f"refs/heads/{branch_name}"],
+ git_dir,
+ check_error=False,
+ )
+
+
+def test_git_cli_does_remote_exist_false():
+ cli = GitCli()
+
+ remote_url = "https://github.com/conda-forge/this-repo-does-not-exist.git"
+
+ assert not cli.does_remote_exist(remote_url)
+
+
+def test_git_cli_does_remote_exist_true():
+ cli = GitCli()
+
+ remote_url = "https://github.com/conda-forge/pytest-feedstock.git"
+
+ assert cli.does_remote_exist(remote_url)
+
+
+@mock.patch("conda_forge_tick.git_utils.GitCli._run_git_command")
+@pytest.mark.parametrize("does_exist", [True, False])
+def test_git_cli_does_remote_exist_mock(
+ run_git_command_mock: MagicMock, does_exist: bool
+):
+ cli = GitCli()
+
+ remote_url = "https://git-repository.com/repo.git"
+
+ run_git_command_mock.return_value = (
+ subprocess.CompletedProcess(args=[], returncode=0)
+ if does_exist
+ else subprocess.CompletedProcess(args=[], returncode=1)
+ )
+
+ assert cli.does_remote_exist(remote_url) is does_exist
+
+ run_git_command_mock.assert_called_once_with(
+ ["ls-remote", remote_url], check_error=False
+ )
+
+
+@mock.patch("conda_forge_tick.git_utils.GitCli._run_git_command")
+@pytest.mark.parametrize("track", [True, False])
+def test_git_cli_checkout_branch_mock(run_git_command_mock: MagicMock, track: bool):
+ branch_name = "BRANCH_NAME"
+
+ cli = GitCli()
+ git_dir = Path("TEST_DIR")
+
+ cli.checkout_branch(git_dir, branch_name, track=track)
+
+ track_flag = ["--track"] if track else []
+
+ run_git_command_mock.assert_called_once_with(
+ ["checkout", "--quiet", *track_flag, branch_name], git_dir
+ )
+
+
+def test_git_cli_checkout_branch_no_track():
+ cli = GitCli()
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ dir_path = Path(tmpdir)
+
+ init_temp_git_repo(dir_path)
+ cli._run_git_command(["checkout", "-b", "main"], working_directory=dir_path)
+ cli._run_git_command(
+ ["commit", "--allow-empty", "-m", "Initial commit"],
+ working_directory=dir_path,
+ )
+
+ assert (
+ "main"
+ in subprocess.run(
+ "git status", cwd=dir_path, shell=True, capture_output=True
+ ).stdout.decode()
+ )
+
+ branch_name = "new-branch-name"
+
+ cli._run_git_command(["branch", branch_name], working_directory=dir_path)
+
+ cli.checkout_branch(dir_path, branch_name)
+
+ assert (
+ branch_name
+ in subprocess.run(
+ "git status", cwd=dir_path, shell=True, capture_output=True
+ ).stdout.decode()
+ )
+
+
+def test_git_cli_diffed_files():
+ cli = GitCli()
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ dir_path = Path(tmpdir)
+
+ init_temp_git_repo(dir_path)
+
+ cli.commit(dir_path, "Initial commit", allow_empty=True)
+ dir_path.joinpath("test.txt").touch()
+ cli.add(dir_path, dir_path / "test.txt")
+ cli.commit(dir_path, "Add test.txt")
+
+ diffed_files = list(cli.diffed_files(dir_path, "HEAD~1"))
+
+ assert (dir_path / "test.txt") in diffed_files
+ assert len(diffed_files) == 1
+
+
+def test_git_cli_diffed_files_no_diff():
+ cli = GitCli()
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ dir_path = Path(tmpdir)
+
+ init_temp_git_repo(dir_path)
+
+ cli.commit(dir_path, "Initial commit", allow_empty=True)
+
+ diffed_files = list(cli.diffed_files(dir_path, "HEAD"))
+
+ assert len(diffed_files) == 0
+
+
+@mock.patch("conda_forge_tick.git_utils.GitCli._run_git_command")
+def test_git_cli_diffed_files_mock(run_git_command_mock: MagicMock):
+ cli = GitCli()
+
+ git_dir = Path("TEST_DIR")
+ commit = "COMMIT"
+
+ run_git_command_mock.return_value = subprocess.CompletedProcess(
+ args=[], returncode=0, stdout="test.txt\n"
+ )
+
+ diffed_files = list(cli.diffed_files(git_dir, commit))
+
+ run_git_command_mock.assert_called_once_with(
+ ["diff", "--name-only", "--relative", commit, "HEAD"],
+ git_dir,
+ capture_text=True,
+ )
+
+ assert diffed_files == [git_dir / "test.txt"]
+
+
+def test_git_cli_clone_fork_and_branch_minimal():
+ fork_url = "https://github.com/regro-cf-autotick-bot/pytest-feedstock.git"
+ upstream_url = "https://github.com/conda-forge/pytest-feedstock.git"
+
+ cli = GitCli()
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ dir_path = Path(tmpdir) / "pytest-feedstock"
+
+ new_branch_name = "new_branch_name"
+
+ cli.clone_fork_and_branch(fork_url, dir_path, upstream_url, new_branch_name)
+
+ assert cli.does_branch_exist(dir_path, "main")
+ assert (
+ new_branch_name
+ in subprocess.run(
+ "git status", cwd=dir_path, shell=True, capture_output=True
+ ).stdout.decode()
+ )
+
+
+@pytest.mark.parametrize("remote_already_exists", [True, False])
+@pytest.mark.parametrize(
+ "base_branch_exists,git_checkout_track_error",
+ [(True, False), (False, False), (False, True)],
+)
+@pytest.mark.parametrize("new_branch_already_exists", [True, False])
+@pytest.mark.parametrize("target_repo_already_exists", [True, False])
+@mock.patch("conda_forge_tick.git_utils.GitCli.reset_hard")
+@mock.patch("conda_forge_tick.git_utils.GitCli.checkout_new_branch")
+@mock.patch("conda_forge_tick.git_utils.GitCli.checkout_branch")
+@mock.patch("conda_forge_tick.git_utils.GitCli.does_branch_exist")
+@mock.patch("conda_forge_tick.git_utils.GitCli.fetch_all")
+@mock.patch("conda_forge_tick.git_utils.GitCli.add_remote")
+@mock.patch("conda_forge_tick.git_utils.GitCli.clone_repo")
+def test_git_cli_clone_fork_and_branch_mock(
+ clone_repo_mock: MagicMock,
+ add_remote_mock: MagicMock,
+ fetch_all_mock: MagicMock,
+ does_branch_exist_mock: MagicMock,
+ checkout_branch_mock: MagicMock,
+ checkout_new_branch_mock: MagicMock,
+ reset_hard_mock: MagicMock,
+ remote_already_exists: bool,
+ base_branch_exists: bool,
+ git_checkout_track_error: bool,
+ new_branch_already_exists: bool,
+ target_repo_already_exists: bool,
+ caplog,
+):
+ fork_url = "https://github.com/regro-cf-autotick-bot/pytest-feedstock.git"
+ upstream_url = "https://github.com/conda-forge/pytest-feedstock.git"
+
+ caplog.set_level(logging.DEBUG)
+
+ cli = GitCli()
+
+ if target_repo_already_exists:
+ clone_repo_mock.side_effect = GitCliError(
+ "target_dir is not an empty directory"
+ )
+
+ if remote_already_exists:
+ add_remote_mock.side_effect = GitCliError("Remote already exists")
+
+ does_branch_exist_mock.return_value = base_branch_exists
+
+ def checkout_branch_side_effect(_git_dir: Path, branch: str, track: bool = False):
+ if track and git_checkout_track_error:
+ raise GitCliError("Error checking out branch with --track")
+
+ if new_branch_already_exists and branch == "new_branch_name":
+ raise GitCliError("Branch new_branch_name already exists")
+
+ checkout_branch_mock.side_effect = checkout_branch_side_effect
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ git_dir = Path(tmpdir) / "pytest-feedstock"
+
+ if target_repo_already_exists:
+ git_dir.mkdir()
+
+ cli.clone_fork_and_branch(
+ fork_url, git_dir, upstream_url, "new_branch_name", "base_branch"
+ )
+
+ clone_repo_mock.assert_called_once_with(fork_url, git_dir)
+ if target_repo_already_exists:
+ reset_hard_mock.assert_any_call(git_dir)
+
+ add_remote_mock.assert_called_once_with(git_dir, "upstream", upstream_url)
+ if remote_already_exists:
+ assert "remote 'upstream' already exists" in caplog.text
+
+ fetch_all_mock.assert_called_once_with(git_dir)
+
+ if base_branch_exists:
+ checkout_branch_mock.assert_any_call(git_dir, "base_branch")
+ else:
+ checkout_branch_mock.assert_any_call(
+ git_dir, "upstream/base_branch", track=True
+ )
+
+ if git_checkout_track_error:
+ assert "Could not check out with git checkout --track" in caplog.text
+
+ checkout_new_branch_mock.assert_any_call(
+ git_dir, "base_branch", start_point="upstream/base_branch"
+ )
+
+ reset_hard_mock.assert_called_with(git_dir, "upstream/base_branch")
+ checkout_branch_mock.assert_any_call(git_dir, "new_branch_name")
+
+ if not new_branch_already_exists:
+ return
+
+ assert "branch new_branch_name does not exist" in caplog.text
+ checkout_new_branch_mock.assert_called_with(
+ git_dir, "new_branch_name", start_point="base_branch"
+ )
+
+
+def test_git_cli_clone_fork_and_branch_non_existing_remote():
+ origin_url = "https://github.com/conda-forge/this-repo-does-not-exist.git"
+ upstream_url = "https://github.com/conda-forge/duckdb-feedstock.git"
+ new_branch = "NEW_BRANCH"
+
+ cli = GitCli()
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ dir_path = Path(tmpdir) / "duckdb-feedstock"
+
+ with pytest.raises(GitCliError, match="does the remote exist?"):
+ cli.clone_fork_and_branch(origin_url, dir_path, upstream_url, new_branch)
+
+
+def test_git_cli_clone_fork_and_branch_non_existing_remote_existing_target_dir(caplog):
+ origin_url = "https://github.com/conda-forge/this-repo-does-not-exist.git"
+ upstream_url = "https://github.com/conda-forge/duckdb-feedstock.git"
+ new_branch = "NEW_BRANCH"
+
+ cli = GitCli()
+ caplog.set_level(logging.DEBUG)
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ dir_path = Path(tmpdir) / "duckdb-feedstock"
+ dir_path.mkdir()
+
+ with pytest.raises(GitCliError):
+ cli.clone_fork_and_branch(origin_url, dir_path, upstream_url, new_branch)
+
+ assert "trying to reset hard" in caplog.text
+
+
+def _github_api_json_fixture(name: str) -> dict:
+ with Path(__file__).parent.joinpath(f"github_api/{name}.json").open() as f:
+ return json.load(f)
+
+
+@pytest.fixture()
+def github_response_create_issue_comment() -> dict:
+ return _github_api_json_fixture("create_issue_comment_pytest")
+
+
+@pytest.fixture()
+def github_response_create_pull_duplicate() -> dict:
+ return _github_api_json_fixture("create_pull_duplicate")
+
+
+@pytest.fixture()
+def github_response_create_pull_validation_error() -> dict:
+ return _github_api_json_fixture("create_pull_validation_error")
+
+
+@pytest.fixture()
+def github_response_get_pull() -> dict:
+ return _github_api_json_fixture("get_pull_pytest")
+
+
+@pytest.fixture()
+def github_response_get_repo() -> dict:
+ return _github_api_json_fixture("get_repo_pytest")
+
+
+@pytest.fixture()
+def github_response_headers() -> dict:
+ return _github_api_json_fixture("github_response_headers")
+
+
+def test_git_platform_backend_get_remote_url_token():
+ owner = "OWNER"
+ repo = "REPO"
+ token = "TOKEN"
+
+ url = GitPlatformBackend.get_remote_url(owner, repo, GitConnectionMode.HTTPS, token)
+
+ assert url == f"https://{token}@github.com/{owner}/{repo}.git"
+
+
+def test_github_backend_from_token():
+ token = "TOKEN"
+
+ backend = GitHubBackend.from_token(token)
+
+ assert backend.github3_client.session.auth.token == token
+ # we cannot verify the pygithub token trivially
+
+
+@pytest.mark.parametrize("from_token", [True, False])
+def test_github_backend_token_to_hide(caplog, capfd, from_token: bool):
+ caplog.set_level(logging.DEBUG)
+ token = "commit"
+
+ if from_token:
+ backend = GitHubBackend.from_token(token)
+ else:
+ backend = GitHubBackend(MagicMock(), MagicMock(), token)
+
+ # the token should be hidden by default, without any context manager
+ p = backend.cli._run_git_command(["help"])
+
+ captured = capfd.readouterr()
+
+ assert token not in caplog.text
+ assert token not in captured.out
+ assert token not in captured.err
+ assert token not in p.stdout
+ assert token not in p.stderr
+
+
+@pytest.mark.parametrize("does_exist", [True, False])
+def test_github_backend_does_repository_exist(does_exist: bool):
+ github3_client = MagicMock()
+
+ backend = GitHubBackend(github3_client, MagicMock(), "")
+
+ github3_client.repository.return_value = MagicMock() if does_exist else None
+
+ assert backend.does_repository_exist("OWNER", "REPO") is does_exist
+ github3_client.repository.assert_called_once_with("OWNER", "REPO")
+
+
+def test_github_backend_get_remote_url_https():
+ owner = "OWNER"
+ repo = "REPO"
+ backend = GitHubBackend(MagicMock(), MagicMock(), "")
+
+ url = backend.get_remote_url(owner, repo, GitConnectionMode.HTTPS)
+
+ assert url == f"https://github.com/{owner}/{repo}.git"
+
+
+def test_github_backend_get_remote_url_token():
+ owner = "OWNER"
+ repo = "REPO"
+ token = "TOKEN"
+ backend = GitHubBackend(MagicMock(), MagicMock(), "")
+
+ url = backend.get_remote_url(owner, repo, GitConnectionMode.HTTPS, token)
+
+ assert url == f"https://{token}@github.com/{owner}/{repo}.git"
+
+
+@mock.patch("time.sleep", return_value=None)
+@mock.patch(
+ "conda_forge_tick.git_utils.GitHubBackend.user", new_callable=mock.PropertyMock
+)
+@mock.patch("conda_forge_tick.git_utils.GitHubBackend.does_repository_exist")
+def test_github_backend_fork_not_exists_repo_found(
+ exists_mock: MagicMock, user_mock: MagicMock, sleep_mock: MagicMock
+):
+ exists_mock.return_value = False
+
+ github3_client = MagicMock()
+ repository = MagicMock()
+ github3_client.repository.return_value = repository
+
+ backend = GitHubBackend(github3_client, MagicMock(), "")
+ user_mock.return_value = "USER"
+ backend.fork("UPSTREAM-OWNER", "REPO")
+
+ exists_mock.assert_called_once_with("USER", "REPO")
+ github3_client.repository.assert_called_once_with("UPSTREAM-OWNER", "REPO")
+ repository.create_fork.assert_called_once()
+ sleep_mock.assert_called_once_with(5)
+
+
+@mock.patch("conda_forge_tick.git_utils.GitCli.push_to_url")
+def test_github_backend_push_to_repository(push_to_url_mock: MagicMock):
+ backend = GitHubBackend.from_token("THIS_IS_THE_TOKEN")
+
+ git_dir = Path("GIT_DIR")
+
+ backend.push_to_repository("OWNER", "REPO", git_dir, "BRANCH_NAME")
+
+ push_to_url_mock.assert_called_once_with(
+ git_dir,
+ "https://THIS_IS_THE_TOKEN@github.com/OWNER/REPO.git",
+ "BRANCH_NAME",
+ )
+
+
+@pytest.mark.parametrize("branch_already_synced", [True, False])
+@mock.patch("time.sleep", return_value=None)
+@mock.patch(
+ "conda_forge_tick.git_utils.GitHubBackend.user", new_callable=mock.PropertyMock
+)
+@mock.patch("conda_forge_tick.git_utils.GitHubBackend.does_repository_exist")
+def test_github_backend_fork_exists(
+ exists_mock: MagicMock,
+ user_mock: MagicMock,
+ sleep_mock: MagicMock,
+ branch_already_synced: bool,
+ caplog,
+):
+ caplog.set_level(logging.DEBUG)
+
+ exists_mock.return_value = True
+ user_mock.return_value = "USER"
+
+ pygithub_client = MagicMock()
+ upstream_repo = MagicMock()
+ fork_repo = MagicMock()
+
+ def get_repo(full_name: str):
+ if full_name == "UPSTREAM-OWNER/REPO":
+ return upstream_repo
+ if full_name == "USER/REPO":
+ return fork_repo
+ assert False, f"Unexpected repo full name: {full_name}"
+
+ pygithub_client.get_repo.side_effect = get_repo
+
+ if branch_already_synced:
+ upstream_repo.default_branch = "BRANCH_NAME"
+ fork_repo.default_branch = "BRANCH_NAME"
+ else:
+ upstream_repo.default_branch = "UPSTREAM_BRANCH_NAME"
+ fork_repo.default_branch = "FORK_BRANCH_NAME"
+
+ backend = GitHubBackend(MagicMock(), pygithub_client, "")
+ backend.fork("UPSTREAM-OWNER", "REPO")
+
+ if not branch_already_synced:
+ pygithub_client.get_repo.assert_any_call("UPSTREAM-OWNER/REPO")
+ pygithub_client.get_repo.assert_any_call("USER/REPO")
+
+ assert "Syncing default branch" in caplog.text
+ sleep_mock.assert_called_once_with(5)
+
+
+@mock.patch(
+ "conda_forge_tick.git_utils.GitHubBackend.user", new_callable=mock.PropertyMock
+)
+@mock.patch("conda_forge_tick.git_utils.GitHubBackend.does_repository_exist")
+def test_github_backend_remote_does_not_exist(
+ exists_mock: MagicMock, user_mock: MagicMock
+):
+ exists_mock.return_value = False
+
+ github3_client = MagicMock()
+ github3_client.repository.return_value = None
+
+ backend = GitHubBackend(github3_client, MagicMock(), "")
+
+ user_mock.return_value = "USER"
+
+ with pytest.raises(RepositoryNotFoundError):
+ backend.fork("UPSTREAM-OWNER", "REPO")
+
+ exists_mock.assert_called_once_with("USER", "REPO")
+ github3_client.repository.assert_called_once_with("UPSTREAM-OWNER", "REPO")
+
+
+def test_github_backend_user():
+ pygithub_client = MagicMock()
+ user = MagicMock()
+ user.login = "USER"
+ pygithub_client.get_user.return_value = user
+
+ backend = GitHubBackend(MagicMock(), pygithub_client, "")
+
+ for _ in range(4):
+ # cached property
+ assert backend.user == "USER"
+
+ pygithub_client.get_user.assert_called_once()
+
+
+def test_github_backend_get_api_requests_left_github_exception(caplog):
+ github3_client = MagicMock()
+ github3_client.rate_limit.side_effect = github3.exceptions.GitHubException(
+ "API Error"
+ )
+
+ backend = GitHubBackend(github3_client, MagicMock(), "")
+
+ assert backend.get_api_requests_left() is None
+ assert "API error while fetching" in caplog.text
+
+ github3_client.rate_limit.assert_called_once()
+
+
+def test_github_backend_get_api_requests_left_unexpected_response_schema(caplog):
+ github3_client = MagicMock()
+ github3_client.rate_limit.return_value = {"some": "gibberish data"}
+
+ backend = GitHubBackend(github3_client, MagicMock(), "")
+
+ assert backend.get_api_requests_left() is None
+ assert "API Error while parsing"
+
+ github3_client.rate_limit.assert_called_once()
+
+
+def test_github_backend_get_api_requests_left_nonzero():
+ github3_client = MagicMock()
+ github3_client.rate_limit.return_value = {"resources": {"core": {"remaining": 5}}}
+
+ backend = GitHubBackend(github3_client, MagicMock(), "")
+
+ assert backend.get_api_requests_left() == 5
+
+ github3_client.rate_limit.assert_called_once()
+
+
+def test_github_backend_get_api_requests_left_zero_invalid_reset_time(caplog):
+ github3_client = MagicMock()
+
+ github3_client.rate_limit.return_value = {"resources": {"core": {"remaining": 0}}}
+
+ backend = GitHubBackend(github3_client, MagicMock(), "")
+
+ assert backend.get_api_requests_left() == 0
+
+ github3_client.rate_limit.assert_called_once()
+ assert "GitHub API error while fetching rate limit reset time" in caplog.text
+
+
+def test_github_backend_get_api_requests_left_zero_valid_reset_time(caplog):
+ caplog.set_level("INFO")
+
+ github3_client = MagicMock()
+
+ reset_timestamp = 1716303697
+ reset_timestamp_str = "2024-05-21T15:01:37Z"
+
+ github3_client.rate_limit.return_value = {
+ "resources": {"core": {"remaining": 0, "reset": reset_timestamp}}
+ }
+
+ backend = GitHubBackend(github3_client, MagicMock(), "")
+
+ assert backend.get_api_requests_left() == 0
+
+ github3_client.rate_limit.assert_called_once()
+ assert f"will reset at {reset_timestamp_str}" in caplog.text
+
+
+@mock.patch("requests.Session.request")
+def test_github_backend_create_pull_request_mock(
+ request_mock: MagicMock,
+ github_response_get_repo: dict,
+ github_response_headers: dict,
+ github_response_get_pull: dict,
+):
+ def request_side_effect(method, _url, **_kwargs):
+ response = requests.Response()
+ if method == "GET":
+ response.status_code = 200
+ response.json = lambda: github_response_get_repo
+ return response
+ if method == "POST":
+ response.status_code = 201
+ # note that the "create pull" response body is identical to the "get pull" response body
+ response.json = lambda: github_response_get_pull
+ response.headers = CaseInsensitiveDict(github_response_headers)
+ return response
+ assert False, f"Unexpected method: {method}"
+
+ request_mock.side_effect = request_side_effect
+
+ pygithub_mock = MagicMock()
+ pygithub_mock.get_user.return_value.login = "CURRENT_USER"
+
+ backend = GitHubBackend(github3.login(token="TOKEN"), pygithub_mock, "")
+
+ pr_data = backend.create_pull_request(
+ "conda-forge",
+ "pytest-feedstock",
+ "BASE_BRANCH",
+ "HEAD_BRANCH",
+ "TITLE",
+ "BODY",
+ )
+
+ request_mock.assert_called_with(
+ "POST",
+ "https://api.github.com/repos/conda-forge/pytest-feedstock/pulls",
+ data='{"title": "TITLE", "body": "BODY", "base": "BASE_BRANCH", "head": "CURRENT_USER:HEAD_BRANCH"}',
+ json=None,
+ timeout=mock.ANY,
+ )
+
+ assert pr_data.base is not None
+ assert pr_data.base.repo.name == "pytest-feedstock"
+ assert pr_data.closed_at is None
+ assert pr_data.created_at is not None
+ assert pr_data.created_at == datetime.datetime(
+ 2024, 5, 3, 17, 4, 20, tzinfo=datetime.timezone.utc
+ )
+ assert pr_data.head is not None
+ assert pr_data.head.ref == "HEAD_BRANCH"
+ assert pr_data.html_url == Url(
+ "https://github.com/conda-forge/pytest-feedstock/pull/1337"
+ )
+ assert pr_data.id == 1853804278
+ assert pr_data.labels == []
+ assert pr_data.mergeable is True
+ assert pr_data.mergeable_state == GithubPullRequestMergeableState.CLEAN
+ assert pr_data.merged is False
+ assert pr_data.merged_at is None
+ assert pr_data.number == 1337
+ assert pr_data.state == PullRequestState.OPEN
+ assert pr_data.updated_at == datetime.datetime(
+ 2024, 5, 27, 13, 31, 50, tzinfo=datetime.timezone.utc
+ )
+
+
+@mock.patch("requests.Session.request")
+def test_github_backend_create_pull_request_duplicate(
+ request_mock: MagicMock,
+ github_response_get_repo: dict,
+ github_response_create_pull_duplicate: dict,
+):
+ def request_side_effect(method, _url, **_kwargs):
+ response = requests.Response()
+ if method == "GET":
+ response.status_code = 200
+ response.json = lambda: github_response_get_repo
+ return response
+ if method == "POST":
+ response.status_code = 422
+ # note that the "create pull" response body is identical to the "get pull" response body
+ response.json = lambda: github_response_create_pull_duplicate
+ return response
+ assert False, f"Unexpected method: {method}"
+
+ request_mock.side_effect = request_side_effect
+
+ pygithub_mock = MagicMock()
+ pygithub_mock.get_user.return_value.login = "CURRENT_USER"
+
+ backend = GitHubBackend(github3.login(token="TOKEN"), pygithub_mock, "")
+
+ with pytest.raises(
+ DuplicatePullRequestError,
+ match="Pull request from CURRENT_USER:HEAD_BRANCH to conda-forge:BASE_BRANCH already exists",
+ ):
+ backend.create_pull_request(
+ "conda-forge",
+ "pytest-feedstock",
+ "BASE_BRANCH",
+ "HEAD_BRANCH",
+ "TITLE",
+ "BODY",
+ )
+
+
+@mock.patch("requests.Session.request")
+def test_github_backend_create_pull_request_validation_error(
+ request_mock: MagicMock,
+ github_response_get_repo: dict,
+ github_response_create_pull_validation_error: dict,
+):
+ """
+ Test that other GitHub API 422 validation errors are not caught as DuplicatePullRequestError.
+ """
+
+ def request_side_effect(method, _url, **_kwargs):
+ response = requests.Response()
+ if method == "GET":
+ response.status_code = 200
+ response.json = lambda: github_response_get_repo
+ return response
+ if method == "POST":
+ response.status_code = 422
+ # note that the "create pull" response body is identical to the "get pull" response body
+ response.json = lambda: github_response_create_pull_validation_error
+ return response
+ assert False, f"Unexpected method: {method}"
+
+ request_mock.side_effect = request_side_effect
+
+ pygithub_mock = MagicMock()
+ pygithub_mock.get_user.return_value.login = "CURRENT_USER"
+
+ backend = GitHubBackend(github3.login(token="TOKEN"), pygithub_mock, "")
+
+ with pytest.raises(github3.exceptions.UnprocessableEntity):
+ backend.create_pull_request(
+ "conda-forge",
+ "pytest-feedstock",
+ "BASE_BRANCH",
+ "HEAD_BRANCH",
+ "TITLE",
+ "BODY",
+ )
+
+
+@mock.patch("requests.Session.request")
+def test_github_backend_comment_on_pull_request_success(
+ request_mock: MagicMock,
+ github_response_get_repo: dict,
+ github_response_get_pull: dict,
+ github_response_create_issue_comment: dict,
+):
+ def request_side_effect(method, url, **_kwargs):
+ response = requests.Response()
+ if (
+ method == "GET"
+ and url == "https://api.github.com/repos/conda-forge/pytest-feedstock"
+ ):
+ response.status_code = 200
+ response.json = lambda: github_response_get_repo
+ return response
+ if (
+ method == "GET"
+ and url
+ == "https://api.github.com/repos/conda-forge/pytest-feedstock/pulls/1337"
+ ):
+ response.status_code = 200
+ response.json = lambda: github_response_get_pull
+ return response
+ if (
+ method == "POST"
+ and url
+ == "https://api.github.com/repos/conda-forge/pytest-feedstock/issues/1337/comments"
+ ):
+ response.status_code = 201
+ response.json = lambda: github_response_create_issue_comment
+ return response
+ assert False, f"Unexpected endpoint: {method} {url}"
+
+ request_mock.side_effect = request_side_effect
+
+ backend = GitHubBackend(github3.login(token="TOKEN"), MagicMock(), "")
+
+ backend.comment_on_pull_request(
+ "conda-forge",
+ "pytest-feedstock",
+ 1337,
+ "COMMENT",
+ )
+
+ request_mock.assert_called_with(
+ "POST",
+ "https://api.github.com/repos/conda-forge/pytest-feedstock/issues/1337/comments",
+ data='{"body": "COMMENT"}',
+ json=None,
+ timeout=mock.ANY,
+ )
+
+
+@mock.patch("requests.Session.request")
+def test_github_backend_comment_on_pull_request_repo_not_found(request_mock: MagicMock):
+ def request_side_effect(method, url, **_kwargs):
+ response = requests.Response()
+ if (
+ method == "GET"
+ and url == "https://api.github.com/repos/conda-forge/pytest-feedstock"
+ ):
+ response.status_code = 404
+ return response
+ assert False, f"Unexpected endpoint: {method} {url}"
+
+ request_mock.side_effect = request_side_effect
+
+ backend = GitHubBackend(github3.login(token="TOKEN"), MagicMock(), "")
+
+ with pytest.raises(RepositoryNotFoundError):
+ backend.comment_on_pull_request(
+ "conda-forge",
+ "pytest-feedstock",
+ 1337,
+ "COMMENT",
+ )
+
+
+@mock.patch("requests.Session.request")
+def test_github_backend_comment_on_pull_request_pull_request_not_found(
+ request_mock: MagicMock,
+ github_response_get_repo: dict,
+):
+ def request_side_effect(method, url, **_kwargs):
+ response = requests.Response()
+ if (
+ method == "GET"
+ and url == "https://api.github.com/repos/conda-forge/pytest-feedstock"
+ ):
+ response.status_code = 200
+ response.json = lambda: github_response_get_repo
+ return response
+ if (
+ method == "GET"
+ and url
+ == "https://api.github.com/repos/conda-forge/pytest-feedstock/pulls/1337"
+ ):
+ response.status_code = 404
+ return response
+ assert False, f"Unexpected endpoint: {method} {url}"
+
+ request_mock.side_effect = request_side_effect
+ backend = GitHubBackend(github3.login(token="TOKEN"), MagicMock(), "")
+
+ with pytest.raises(
+ GitPlatformError,
+ match="Pull request conda-forge/pytest-feedstock#1337 not found",
+ ):
+ backend.comment_on_pull_request(
+ "conda-forge",
+ "pytest-feedstock",
+ 1337,
+ "COMMENT",
+ )
+
+
+@mock.patch("requests.Session.request")
+def test_github_backend_comment_on_pull_request_unexpected_response(
+ request_mock: MagicMock,
+ github_response_get_repo: dict,
+ github_response_get_pull: dict,
+):
+ def request_side_effect(method, url, **_kwargs):
+ response = requests.Response()
+ if (
+ method == "GET"
+ and url == "https://api.github.com/repos/conda-forge/pytest-feedstock"
+ ):
+ response.status_code = 200
+ response.json = lambda: github_response_get_repo
+ return response
+ if (
+ method == "GET"
+ and url
+ == "https://api.github.com/repos/conda-forge/pytest-feedstock/pulls/1337"
+ ):
+ response.status_code = 200
+ response.json = lambda: github_response_get_pull
+ return response
+ if (
+ method == "POST"
+ and url
+ == "https://api.github.com/repos/conda-forge/pytest-feedstock/issues/1337/comments"
+ ):
+ response.status_code = 500
+ return response
+ assert False, f"Unexpected endpoint: {method} {url}"
+
+ request_mock.side_effect = request_side_effect
+
+ backend = GitHubBackend(github3.login(token="TOKEN"), MagicMock(), "")
+
+ with pytest.raises(GitPlatformError, match="Could not comment on pull request"):
+ backend.comment_on_pull_request(
+ "conda-forge",
+ "pytest-feedstock",
+ 1337,
+ "COMMENT",
+ )
+
+
+@pytest.mark.parametrize(
+ "backend", [GitHubBackend(MagicMock(), MagicMock(), ""), DryRunBackend()]
+)
+@mock.patch(
+ "conda_forge_tick.git_utils.GitHubBackend.user", new_callable=mock.PropertyMock
+)
+@mock.patch("conda_forge_tick.git_utils.GitCli.clone_fork_and_branch")
+def test_git_platform_backend_clone_fork_and_branch(
+ convenience_method_mock: MagicMock,
+ user_mock: MagicMock,
+ backend: GitPlatformBackend,
+):
+ upstream_owner = "UPSTREAM-OWNER"
+ repo_name = "REPO"
+ target_dir = Path("TARGET_DIR")
+ new_branch = "NEW_BRANCH"
+ base_branch = "BASE_BRANCH"
+
+ user_mock.return_value = "USER"
+
+ backend = GitHubBackend(MagicMock(), MagicMock(), "")
+ backend.clone_fork_and_branch(
+ upstream_owner, repo_name, target_dir, new_branch, base_branch
+ )
+
+ convenience_method_mock.assert_called_once_with(
+ origin_url=f"https://github.com/USER/{repo_name}.git",
+ target_dir=target_dir,
+ upstream_url=f"https://github.com/{upstream_owner}/{repo_name}.git",
+ new_branch=new_branch,
+ base_branch=base_branch,
+ )
+
+
+def test_dry_run_backend_get_api_requests_left():
+ backend = DryRunBackend()
+
+ assert backend.get_api_requests_left() is Bound.INFINITY
+
+
+def test_dry_run_backend_does_repository_exist_own_repo():
+ backend = DryRunBackend()
+
+ assert not backend.does_repository_exist("auto-tick-bot-dry-run", "REPO")
+ backend.fork("UPSTREAM_OWNER", "REPO")
+ assert backend.does_repository_exist("auto-tick-bot-dry-run", "REPO")
+
+
+def test_dry_run_backend_does_repository_exist_other_repo():
+ backend = DryRunBackend()
+
+ assert backend.does_repository_exist("conda-forge", "pytest-feedstock")
+ assert not backend.does_repository_exist(
+ "conda-forge", "this-repository-does-not-exist"
+ )
+
+
+@pytest.mark.parametrize("token", [None, "TOKEN"])
+def test_dry_run_backend_get_remote_url_non_fork(token: str | None):
+ backend = DryRunBackend()
+
+ url = backend.get_remote_url("OWNER", "REPO", GitConnectionMode.HTTPS, token)
+
+ if token is None:
+ assert url == "https://github.com/OWNER/REPO.git"
+ else:
+ assert url == "https://TOKEN@github.com/OWNER/REPO.git"
+
+
+@pytest.mark.parametrize("token", [None, "TOKEN"])
+def test_dry_run_backend_get_remote_url_non_existing_fork(token: str | None):
+ backend = DryRunBackend()
+
+ with pytest.raises(RepositoryNotFoundError, match="does not exist"):
+ backend.get_remote_url(backend.user, "REPO", GitConnectionMode.HTTPS, token)
+
+ backend.fork("UPSTREAM_OWNER", "REPO2")
+
+ with pytest.raises(RepositoryNotFoundError, match="does not exist"):
+ backend.get_remote_url(backend.user, "REPO", GitConnectionMode.HTTPS, token)
+
+
+@pytest.mark.parametrize("token", [None, "TOKEN"])
+def test_dry_run_backend_get_remote_url_existing_fork(token: str | None):
+ backend = DryRunBackend()
+
+ backend.fork("UPSTREAM_OWNER", "pytest-feedstock")
+
+ url = backend.get_remote_url(
+ backend.user, "pytest-feedstock", GitConnectionMode.HTTPS, token
+ )
+
+ # note that the URL does not indicate anymore that it is a fork
+ assert (
+ url
+ == f"https://{f'{token}@' if token else ''}github.com/UPSTREAM_OWNER/pytest-feedstock.git"
+ )
+
+
+def test_dry_run_backend_push_to_repository(caplog):
+ caplog.set_level(logging.DEBUG)
+
+ backend = DryRunBackend()
+
+ git_dir = Path("GIT_DIR")
+
+ backend.push_to_repository("OWNER", "REPO", git_dir, "BRANCH_NAME")
+
+ assert (
+ "Dry Run: Pushing changes from GIT_DIR to OWNER/REPO on branch BRANCH_NAME"
+ in caplog.text
+ )
+
+
+def test_dry_run_backend_fork(caplog):
+ caplog.set_level(logging.DEBUG)
+
+ backend = DryRunBackend()
+
+ backend.fork("UPSTREAM_OWNER", "REPO")
+ assert (
+ "Dry Run: Creating fork of UPSTREAM_OWNER/REPO for user auto-tick-bot-dry-run"
+ in caplog.text
+ )
+
+ # this should not raise an error
+ backend.fork("UPSTREAM_OWNER", "REPO")
+
+
+def test_dry_run_backend_sync_default_branch(caplog):
+ caplog.set_level(logging.DEBUG)
+
+ backend = DryRunBackend()
+
+ backend._sync_default_branch("UPSTREAM_OWNER", "REPO")
+
+ assert "Dry Run: Syncing default branch of UPSTREAM_OWNER/REPO" in caplog.text
+
+
+def test_dry_run_backend_user():
+ backend = DryRunBackend()
+
+ assert backend.user == "auto-tick-bot-dry-run"
+
+
+def test_dry_run_backend_create_pull_request(caplog):
+ backend = DryRunBackend()
+ caplog.set_level(logging.DEBUG)
+
+ pr_data = backend.create_pull_request(
+ "conda-forge",
+ "pytest-feedstock",
+ "BASE_BRANCH",
+ "HEAD_BRANCH",
+ "TITLE",
+ "BODY_TEXT",
+ )
+
+ # caplog validation
+ assert "Create Pull Request" in caplog.text
+ assert 'Title: "TITLE"' in caplog.text
+ assert "Target Repository: conda-forge/pytest-feedstock" in caplog.text
+ assert (
+ f"Branches: {backend.user}:HEAD_BRANCH -> conda-forge:BASE_BRANCH"
+ in caplog.text
+ )
+ assert "Body:\nBODY_TEXT" in caplog.text
+
+ # pr_data validation
+ assert pr_data.e_tag == "GITHUB_PR_ETAG"
+ assert pr_data.last_modified is not None
+ assert pr_data.id == 13371337
+ assert pr_data.html_url == Url(
+ "https://github.com/conda-forge/pytest-feedstock/pulls/1337"
+ )
+ assert pr_data.created_at is not None
+ assert pr_data.number == 1337
+ assert pr_data.state == PullRequestState.OPEN
+ assert pr_data.head.ref == "HEAD_BRANCH"
+ assert pr_data.base.repo.name == "pytest-feedstock"
+
+
+def test_dry_run_backend_comment_on_pull_request(caplog):
+ backend = DryRunBackend()
+ caplog.set_level(logging.DEBUG)
+
+ backend.comment_on_pull_request(
+ "conda-forge",
+ "pytest-feedstock",
+ 1337,
+ "COMMENT",
+ )
+
+ assert "Comment on Pull Request" in caplog.text
+ assert "Comment:\nCOMMENT" in caplog.text
+ assert "Pull Request: conda-forge/pytest-feedstock#1337" in caplog.text
def test_trim_pr_json_keys():
diff --git a/tests/test_migrators.py b/tests/test_migrators.py
index 26e77196b..0db01132c 100644
--- a/tests/test_migrators.py
+++ b/tests/test_migrators.py
@@ -523,7 +523,6 @@ def run_test_migration(
feedstock_name=name,
attrs=pmy,
)
- fctx.feedstock_dir = os.path.dirname(tmpdir)
m.effective_graph.add_node(name)
m.effective_graph.nodes[name]["payload"] = MockLazyJson({})
m.pr_body(fctx)