From 5744b70ef2b49f9158d27ccf960676d814ad9e0e Mon Sep 17 00:00:00 2001 From: Yannik Tausch Date: Mon, 16 Sep 2024 09:49:44 +0200 Subject: [PATCH 1/3] refactor solvability check --- conda_forge_tick/auto_tick.py | 236 ++++++++++++++++++++-------------- 1 file changed, 139 insertions(+), 97 deletions(-) diff --git a/conda_forge_tick/auto_tick.py b/conda_forge_tick/auto_tick.py index df8862d0c..d6a5b8ee5 100644 --- a/conda_forge_tick/auto_tick.py +++ b/conda_forge_tick/auto_tick.py @@ -9,7 +9,6 @@ import traceback import typing from dataclasses import dataclass -from textwrap import dedent from typing import Literal, MutableMapping, cast from urllib.error import URLError from uuid import uuid4 @@ -289,6 +288,141 @@ def _run_rerender( return _RerenderInfo(nontrivial_changes=nontrivial_changes) +def _should_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: {_should_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 (_should_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 run_with_tmpdir( context: FeedstockContext, migrator: Migrator, @@ -416,6 +550,10 @@ def run( else: rerender_info = _RerenderInfo(nontrivial_changes=False) + if not _check_and_process_solvability(migrator, context, base_branch): + logger.warning("Skipping migration due to solvability check failure") + return False, False + # This is needed because we want to migrate to the new backend step-by-step repo: github3.repos.Repository | None = github3_client().repository( context.git_repo_owner, context.git_repo_name @@ -425,102 +563,6 @@ def run( feedstock_dir = str(context.local_clone_dir.resolve()) - if isinstance(migrator, Version): - has_automerge = context.automerge in [True, "version"] - else: - has_automerge = getattr(migrator, "automerge", False) and context.automerge in [ - True, - "migration", - ] - - migrator_check_solvable = getattr(migrator, "check_solvable", True) - feedstock_check_solvable = get_keys_default( - context.attrs, - ["conda-forge.yml", "bot", "check_solvable"], - {}, - False, - ) - pr_attempts = _get_pre_pr_migrator_attempts( - context.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: {context.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} -""" - ) - - if ( - 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 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=context.attrs["conda-forge.yml"].get( - "build_platform", - None, - ), - ) - 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( - context.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 context.attrs["version_pr_info"] as vpri: - _new_ver = vpri["new_version"] - vpri["new_version_attempts"][_new_ver] -= 0.8 - - return False, False - else: - _reset_pre_pr_migrator_fields( - context.attrs, migrator_name, is_version=is_version_migration - ) - # TODO: Better annotation here pr_json: typing.Union[MutableMapping, None, bool] if ( From 4c553c4539ea48141a6e2a3828d2d986677a34c2 Mon Sep 17 00:00:00 2001 From: Yannik Tausch Date: Mon, 16 Sep 2024 14:55:46 +0200 Subject: [PATCH 2/3] use git backend for entire auto_tick process added: open PR, comment on PR --- conda_forge_tick/auto_tick.py | 116 ++- conda_forge_tick/contexts.py | 7 + conda_forge_tick/git_utils.py | 580 +++++++---- conda_forge_tick/models/common.py | 26 +- conda_forge_tick/models/pr_json.py | 25 +- conda_forge_tick/status_report.py | 6 +- conda_forge_tick/utils.py | 33 +- .../create_issue_comment_pytest.json | 33 + tests/github_api/create_pull_duplicate.json | 12 + .../create_pull_validation_error.json | 12 + tests/github_api/get_pull_pytest.json | 360 +++++++ tests/github_api/get_repo_pytest.json | 130 +++ tests/github_api/github_response_headers.json | 28 + tests/test_auto_tick.py | 4 - tests/test_contexts.py | 11 + tests/test_git_utils.py | 933 ++++++++++++++++-- 16 files changed, 1910 insertions(+), 406 deletions(-) create mode 100644 tests/github_api/create_issue_comment_pytest.json create mode 100644 tests/github_api/create_pull_duplicate.json create mode 100644 tests/github_api/create_pull_validation_error.json create mode 100644 tests/github_api/get_pull_pytest.json create mode 100644 tests/github_api/get_repo_pytest.json create mode 100644 tests/github_api/github_response_headers.json diff --git a/conda_forge_tick/auto_tick.py b/conda_forge_tick/auto_tick.py index d6a5b8ee5..908e97a9e 100644 --- a/conda_forge_tick/auto_tick.py +++ b/conda_forge_tick/auto_tick.py @@ -9,7 +9,7 @@ import traceback import typing from dataclasses import dataclass -from typing import Literal, MutableMapping, cast +from typing import Literal, cast from urllib.error import URLError from uuid import uuid4 @@ -31,15 +31,13 @@ from conda_forge_tick.feedstock_parser import BOOTSTRAP_MAPPINGS from conda_forge_tick.git_utils import ( DryRunBackend, + DuplicatePullRequestError, GitCli, GitCliError, GitPlatformBackend, RepositoryNotFoundError, - comment_on_pr, - github3_client, github_backend, is_github_api_limit_reached, - push_repo, ) from conda_forge_tick.lazy_json_backends import ( LazyJson, @@ -72,6 +70,7 @@ ) from .migrators_types import MigrationUidTypedDict +from .models.pr_json import PullRequestData, PullRequestInfoSpecial, PullRequestState logger = logging.getLogger(__name__) @@ -423,13 +422,20 @@ def _check_and_process_solvability( 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", - dry_run: bool = False, **kwargs: typing.Any, ) -> tuple[MigrationUidTypedDict, dict] | tuple[Literal[False], Literal[False]]: """ @@ -448,7 +454,6 @@ def run_with_tmpdir( git_backend=git_backend, rerender=rerender, base_branch=base_branch, - dry_run=dry_run, **kwargs, ) @@ -459,7 +464,6 @@ def run( git_backend: GitPlatformBackend, rerender: bool = True, base_branch: str = "main", - dry_run: bool = False, **kwargs: typing.Any, ) -> tuple[MigrationUidTypedDict, dict] | tuple[Literal[False], Literal[False]]: """For a given feedstock and migration run the migration @@ -554,67 +558,64 @@ def run( logger.warning("Skipping migration due to solvability check failure") return False, False - # This is needed because we want to migrate to the new backend step-by-step - repo: github3.repos.Repository | None = github3_client().repository( - context.git_repo_owner, context.git_repo_name - ) - - assert repo is not None - - feedstock_dir = str(context.local_clone_dir.resolve()) - - # TODO: Better annotation here - pr_json: typing.Union[MutableMapping, None, bool] + pr_data: PullRequestData | PullRequestInfoSpecial | 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 rerender_info.nontrivial_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: - pr_json = push_repo( - fctx=context, - 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 + pr_data = 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 rerender_info.rerender_comment: - comment_on_pr( - pr_json, - rerender_info.rerender_comment, - 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 context.attrs["pr_info"] as pri: @@ -623,8 +624,7 @@ def run( context.attrs, migrator_name, is_version=is_version_migration ) - logger.info("Removing 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): @@ -707,7 +707,6 @@ def _run_migrator_on_feedstock_branch( migrator, fctx: FeedstockContext, git_backend: GitPlatformBackend, - dry_run, mctx, migrator_name, good_prs, @@ -723,9 +722,8 @@ def _run_migrator_on_feedstock_branch( migrator=migrator, git_backend=git_backend, rerender=migrator.rerender, - 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) @@ -758,6 +756,7 @@ def _run_migrator_on_feedstock_branch( ) except (github3.GitHubError, github.GithubException) as e: + # TODO: pull this down into run() - also check the other exceptions if hasattr(e, "msg") and e.msg == "Repository was archived so is read-only.": attrs["archived"] = True else: @@ -1011,7 +1010,6 @@ def _run_migrator( migrator=migrator, fctx=fctx, git_backend=git_backend, - dry_run=dry_run, mctx=mctx, migrator_name=migrator_name, good_prs=good_prs, diff --git a/conda_forge_tick/contexts.py b/conda_forge_tick/contexts.py index a0ba8c873..4721e0691 100644 --- a/conda_forge_tick/contexts.py +++ b/conda_forge_tick/contexts.py @@ -55,6 +55,13 @@ def git_repo_owner(self) -> str: def git_repo_name(self) -> str: return f"{self.feedstock_name}-feedstock" + @property + def git_href(self) -> str: + """ + A link to the feedstock's GitHub repository. + """ + return f"https://github.com/{self.git_repo_owner}/{self.git_repo_name}" + @property def automerge(self) -> bool | str: """ diff --git a/conda_forge_tick/git_utils.py b/conda_forge_tick/git_utils.py index ec4d71809..e890520ec 100644 --- a/conda_forge_tick/git_utils.py +++ b/conda_forge_tick/git_utils.py @@ -5,10 +5,13 @@ import logging import math import subprocess +import sys +import textwrap import threading import time 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 @@ -20,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 @@ -28,10 +33,16 @@ # 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 .executors import lock_git_operation -from .os_utils import pushd -from .utils import get_bot_run_url, run_command_hiding_token +from .models.pr_json import ( + GithubPullRequestBase, + GithubPullRequestMergeableState, + GithubRepository, + PullRequestDataValid, + PullRequestInfoHead, + PullRequestState, +) +from .utils import get_bot_run_url, replace_tokens, run_command_hiding_token logger = logging.getLogger(__name__) @@ -50,7 +61,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 PullRequestDataValid Pydantic model! PR_KEYS_TO_KEEP = { "ETag": None, "Last-Modified": None, @@ -72,13 +83,17 @@ } +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 @@ -87,11 +102,10 @@ 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 @@ -115,6 +129,26 @@ class GitConnectionMode(enum.StrEnum): class GitCliError(Exception): + """ + A generic error that occurred while running a git CLI command. + """ + + pass + + +class GitPlatformError(Exception): + """ + A generic error that occurred while interacting with a git platform. + """ + + pass + + +class DuplicatePullRequestError(GitPlatformError): + """ + Raised if a pull request already exists. + """ + pass @@ -134,22 +168,23 @@ class GitCli: If this does impact performance too much, we can consider a per-repository locking strategy. """ - @staticmethod + 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, - capture_text: bool = False, ) -> subprocess.CompletedProcess: """ - Run a git command. + Run a git command. stdout is only printed if the command fails. stderr is printed by default. + 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. - :param capture_text: If True, capture the output of the git command as text. The output will be in the - returned result object. :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. @@ -157,14 +192,44 @@ def _run_git_command( git_command = ["git"] + cmd logger.debug(f"Running git command: {git_command}") - capture_args = {"capture_output": True, "text": True} if capture_text else {} + + # we only need to capture stderr if we want to hide tokens + stderr_args = {"stderr": subprocess.PIPE} if self.__hidden_tokens else {} try: - return subprocess.run( - git_command, check=check_error, cwd=working_directory, **capture_args + p = subprocess.run( + git_command, + check=check_error, + cwd=working_directory, + stdout=subprocess.PIPE, + **stderr_args, + text=True, ) except subprocess.CalledProcessError as e: - raise GitCliError("Error running git command.") from e + e.stdout = replace_tokens(e.stdout, self.__hidden_tokens) + e.stderr = replace_tokens(e.stderr, self.__hidden_tokens) + logger.info( + f"Command '{' '.join(map(str, 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 + + 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): @@ -233,6 +298,18 @@ def clone_repo(self, origin_url: str, target_dir: Path): f"Error cloning repository from {origin_url}. Does the repository exist? Is target_dir empty?" ) from e + @lock_git_operation() + 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 add_remote(self, git_dir: Path, remote_name: str, remote_url: str): """ @@ -343,7 +420,6 @@ def diffed_files( ret = self._run_git_command( ["diff", "--name-only", "--relative", commit_a, commit_b], git_dir, - capture_text=True, ) return (git_dir / line for line in ret.stdout.splitlines()) @@ -467,12 +543,15 @@ def get_remote_url( 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. @@ -480,10 +559,24 @@ def get_remote_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://github.com/{owner}/{repo_name}.git" + 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): """ @@ -563,6 +656,71 @@ def is_api_limit_reached(self) -> bool: """ 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, + ) -> PullRequestDataValid: + """ + 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: + """ + 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) + class GitHubBackend(GitPlatformBackend): """ @@ -578,9 +736,28 @@ class GitHubBackend(GitPlatformBackend): The number of items to fetch per page from the GitHub API. """ - def __init__(self, github3_client: github3.GitHub, pygithub_client: github.Github): - super().__init__(GitCli()) + 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 @@ -588,10 +765,10 @@ 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 _get_repo(self, owner: str, repo_name: str) -> None | github3.repos.Repository: - repo = None try: repo = self.github3_client.repository(owner, repo_name) except github3.exceptions.NotFoundError: @@ -614,6 +791,16 @@ def does_repository_exist(self, owner: str, repo_name: str) -> bool: except RepositoryNotFoundError: return False + @lock_git_operation() + 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): @@ -686,6 +873,69 @@ def get_api_requests_left(self) -> int | None: return remaining_limit + def create_pull_request( + self, + target_owner: str, + target_repo: str, + base_branch: str, + head_branch: str, + title: str, + body: str, + ) -> PullRequestDataValid: + repo: github3.repos.Repository = self.github3_client.repository( + target_owner, target_repo + ) + + try: + 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 PullRequestDataValid.HEADER_FIELDS + } + + # note: this ignores extra fields in the response + return PullRequestDataValid.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: + raise RepositoryNotFoundError( + f"Repository {repo_owner}/{repo_name} not found." + ) + + try: + pr = repo.pull_request(pr_number) + except github3.exceptions.NotFoundError: + raise GitPlatformError( + f"Pull request {repo_owner}/{repo_name}#{pr_number} not found." + ) + + try: + pr.create_comment(comment) + except github3.GitHubError: + raise GitPlatformError( + f"Could not comment on pull request {repo_owner}/{repo_name}#{pr_number}." + ) + class DryRunBackend(GitPlatformBackend): """ @@ -700,7 +950,6 @@ class DryRunBackend(GitPlatformBackend): def __init__(self): super().__init__(GitCli()) - self._repos: set[str] = set() self._repos: dict[str, str] = {} """ _repos maps from repository name to the owner of the upstream repository. @@ -725,9 +974,10 @@ def get_remote_url( 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) + return super().get_remote_url(owner, repo_name, connection_mode, token) # redirect to the upstream repository try: upstream_owner = self._repos[repo_name] @@ -737,9 +987,15 @@ def get_remote_url( "forks are persistent only for the duration of the backend instance." ) - return super().get_remote_url(upstream_owner, repo_name, connection_mode) + 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 + ): + 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.") @@ -764,13 +1020,75 @@ def _sync_default_branch(self, upstream_owner: str, upstream_repo: str): 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, + ) -> PullRequestDataValid: + debug_out = 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: + """ + ) + debug_out += body + debug_out += "\n==============================================================" + logger.debug(debug_out) + + 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. """ - with sensitive_env() as env: - return GitHubBackend.from_token(env["BOT_TOKEN"]) + return GitHubBackend.from_token(get_bot_token()) def is_github_api_limit_reached() -> bool: @@ -784,33 +1102,6 @@ def is_github_api_limit_reached() -> bool: return backend.is_api_limit_reached() -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 = f"Unrecognized github protocol {protocol}, must be ssh, http, or https." - raise ValueError(msg) - return url - - -def feedstock_repo(fctx: FeedstockContext) -> str: - """Gets the name of the feedstock repository.""" - return fctx.feedstock_name + "-feedstock" - - @lock_git_operation() def delete_branch(pr_json: LazyJson, dry_run: bool = False) -> None: ref = pr_json["head"]["ref"] @@ -822,17 +1113,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" @@ -899,11 +1191,10 @@ def lazy_update_pr_json( 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"] @@ -1032,147 +1323,6 @@ def close_out_labels( return None -@lock_git_operation() -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 - ---------- - fctx : 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. - - Parameters - ---------- - pr_json : dict - A dict-like json blob with the PR information - comment : str - The comment to make. - repo : github3.repos.Repository - The feedstock repo as a github3 object. - """ - pr_obj = repo.pull_request(pr_json["number"]) - pr_obj.create_comment(comment) - - -@backoff.on_exception( - backoff.expo, - (RequestException, Timeout), - max_time=MAX_GITHUB_TIMEOUT, -) -def ensure_label_exists( - repo: github3.repos.Repository, - label_dict: dict, - dry_run: bool = False, -) -> None: - if dry_run: - print(f"dry run: ensure label exists {label_dict['name']}") - try: - repo.label(label_dict["name"]) - except github3.exceptions.NotFoundError: - repo.create_label(**label_dict) - - -def label_pr( - repo: github3.repos.Repository, - pr_json: LazyJson, - label_dict: dict, - dry_run: bool = False, -) -> None: - ensure_label_exists(repo, label_dict, dry_run) - if dry_run: - print(f"dry run: label pr {pr_json['number']} with {label_dict['name']}") - else: - iss = repo.issue(pr_json["number"]) - iss.add_labels(label_dict["name"]) - - def close_out_dirty_prs( pr_json: LazyJson, dry_run: bool = False, 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 a938e784f..034db5463 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", @@ -277,7 +275,7 @@ def graph_migrator_status( .get("PR", {}) .get( "html_url", - feedstock_url(fctx=feedstock_ctx, protocol="https").strip(".git"), + feedstock_ctx.git_href, ), ) @@ -304,7 +302,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", "") diff --git a/conda_forge_tick/utils.py b/conda_forge_tick/utils.py index d03cc7132..5d23e058f 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, Dict, Iterable, Optional, Set, Tuple, cast +from typing import Any, Dict, Iterable, Optional, Set, Tuple, cast, overload import jinja2 import jinja2.sandbox @@ -1207,12 +1207,35 @@ 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, @@ -1224,7 +1247,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. """ @@ -1240,7 +1263,7 @@ def print_subprocess_output_strip_token( "text=True." ) - captured = captured.replace(token, "*" * len(token)) + captured = 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_auto_tick.py b/tests/test_auto_tick.py index fdae0da6a..0b3482d0c 100644 --- a/tests/test_auto_tick.py +++ b/tests/test_auto_tick.py @@ -125,7 +125,6 @@ def test_prepare_feedstock_repository_complete_dry_run(): in backend.cli._run_git_command( ["status"], cloned_context.local_clone_dir, - capture_text=True, ).stdout ) @@ -185,7 +184,6 @@ def test_commit_migration_nonempty( in backend.cli._run_git_command( ["log", "-1", "--pretty=%B"], cloned_context.local_clone_dir, - capture_text=True, ).stdout ) @@ -234,7 +232,6 @@ def test_commit_migration_empty(raise_commit_errors: bool, allow_empty_commits: in backend.cli._run_git_command( ["log", "-1", "--pretty=%B"], cloned_context.local_clone_dir, - capture_text=True, ).stdout ) @@ -326,7 +323,6 @@ def test_run_with_tmpdir( git_backend=git_backend, rerender=rerender, base_branch=base_branch, - dry_run=dry_run, **kwargs, ) diff --git a/tests/test_contexts.py b/tests/test_contexts.py index e0ea82c82..c66dafdc0 100644 --- a/tests/test_contexts.py +++ b/tests/test_contexts.py @@ -56,6 +56,14 @@ def test_feedstock_context_git_repo_name(): 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( @@ -94,6 +102,9 @@ def test_feedstock_context_reserve_clone_directory( ) 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.local_clone_dir.exists() assert cloned_context.local_clone_dir.is_dir() diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py index 983d54a82..8e07a291c 100644 --- a/tests/test_git_utils.py +++ b/tests/test_git_utils.py @@ -1,3 +1,5 @@ +import datetime +import json import logging import subprocess import tempfile @@ -7,18 +9,27 @@ 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. @@ -39,7 +50,11 @@ def test_git_cli_run_git_command_no_error( ) subprocess_run_mock.assert_called_once_with( - ["git", "GIT_COMMAND", "ARG1", "ARG2"], check=check_error, cwd=working_directory + ["git", "GIT_COMMAND", "ARG1", "ARG2"], + check=check_error, + cwd=working_directory, + stdout=subprocess.PIPE, + text=True, ) @@ -57,6 +72,187 @@ def test_git_cli_run_git_command_error(subprocess_run_mock: MagicMock): 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 "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("*******") >= 1 + assert captured.err.count("*******") >= 1 + + +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-*******'" 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) @@ -85,9 +281,10 @@ def test_git_cli_outside_repo(): # noinspection PyProtectedMember -def init_temp_git_repo(git_dir: Path): +def init_temp_git_repo(git_dir: Path, bare: bool = False): cli = GitCli() - cli._run_git_command(["init"], working_directory=git_dir) + 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 ) @@ -147,7 +344,8 @@ def test_git_cli_add_success(n_paths: int, all_: bool): cli.add(git_dir, *pathspec, all_=all_) tracked_files = cli._run_git_command( - ["ls-files", "-s"], git_dir, capture_text=True + ["ls-files", "-s"], + git_dir, ).stdout for path in pathspec: @@ -208,7 +406,7 @@ def test_git_cli_commit(all_: bool, empty: bool, allow_empty: bool): cli.commit(git_dir, "Add Test", all_, allow_empty) - git_log = cli._run_git_command(["log"], git_dir, capture_text=True).stdout + git_log = cli._run_git_command(["log"], git_dir).stdout assert "Add Test" in git_log @@ -396,82 +594,88 @@ def test_git_cli_add_remote(): @mock.patch("conda_forge_tick.git_utils.GitCli._run_git_command") -def test_git_cli_fetch_all_mock(run_git_command_mock: MagicMock): +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.fetch_all(git_dir) + cli.push_to_url(git_dir, remote_url, "BRANCH_NAME") - run_git_command_mock.assert_called_once_with(["fetch", "--all", "--quiet"], git_dir) + run_git_command_mock.assert_called_once_with( + ["push", remote_url, "BRANCH_NAME"], git_dir + ) -def test_git_cli_fetch_all(): +@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() - git_url = "https://github.com/conda-forge/duckdb-feedstock.git" - - with tempfile.TemporaryDirectory() as tmpdir: - dir_path = Path(tmpdir) / "duckdb-feedstock" + run_git_command_mock.side_effect = GitCliError("Error") - cli.clone_repo(git_url, dir_path) - cli.fetch_all(dir_path) + with pytest.raises(GitCliError): + cli.push_to_url( + Path("TEST_DIR"), "https://git-repository.com/repo.git", "BRANCH_NAME" + ) -def test_git_cli_diffed_files(): +def test_git_cli_push_to_url_local_repository(): cli = GitCli() with tempfile.TemporaryDirectory() as tmpdir: dir_path = Path(tmpdir) - init_temp_git_repo(dir_path) + source_repo = dir_path / "source_repo" + source_repo.mkdir() + init_temp_git_repo(source_repo, bare=True) - 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") + local_repo = dir_path / "local_repo" + local_repo.mkdir() + cli._run_git_command(["clone", source_repo.resolve(), local_repo]) - 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() + # remove all references to the original repo + cli._run_git_command( + ["remote", "remove", "origin"], working_directory=local_repo + ) - with tempfile.TemporaryDirectory() as tmpdir: - dir_path = Path(tmpdir) + with local_repo.joinpath("test.txt").open("w") as f: + f.write("Hello, World!") - init_temp_git_repo(dir_path) + 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.commit(dir_path, "Initial commit", allow_empty=True) + cli.push_to_url(local_repo, str(source_repo.resolve()), "main") - diffed_files = list(cli.diffed_files(dir_path, "HEAD")) + source_git_log = subprocess.run( + "git log", cwd=source_repo, shell=True, capture_output=True + ).stdout.decode() - assert len(diffed_files) == 0 + assert "test.txt" in source_git_log @mock.patch("conda_forge_tick.git_utils.GitCli._run_git_command") -def test_git_cli_diffed_files_mock(run_git_command_mock: MagicMock): +def test_git_cli_fetch_all_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" - ) + cli.fetch_all(git_dir) - diffed_files = list(cli.diffed_files(git_dir, commit)) + run_git_command_mock.assert_called_once_with(["fetch", "--all", "--quiet"], git_dir) - 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_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(): @@ -607,6 +811,61 @@ def test_git_cli_checkout_branch_no_track(): ) +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, + ) + + 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" @@ -661,7 +920,7 @@ def test_git_cli_clone_fork_and_branch_mock( 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("DEBUG") + caplog.set_level(logging.DEBUG) cli = GitCli() @@ -750,7 +1009,7 @@ def test_git_cli_clone_fork_and_branch_non_existing_remote_existing_target_dir(c new_branch = "NEW_BRANCH" cli = GitCli() - caplog.set_level("DEBUG") + caplog.set_level(logging.DEBUG) with tempfile.TemporaryDirectory() as tmpdir: dir_path = Path(tmpdir) / "duckdb-feedstock" @@ -762,6 +1021,75 @@ def test_git_cli_clone_fork_and_branch_non_existing_remote_existing_target_dir(c assert "trying to reset hard" in caplog.text +@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 _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_github_backend_from_token(): token = "TOKEN" @@ -771,11 +1099,33 @@ def test_github_backend_from_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()) + backend = GitHubBackend(github3_client, MagicMock(), "") response = MagicMock() response.status_code = 200 if does_exist else 404 @@ -793,13 +1143,24 @@ def test_github_backend_does_repository_exist(does_exist: bool): def test_github_backend_get_remote_url_https(): owner = "OWNER" repo = "REPO" - backend = GitHubBackend(MagicMock(), MagicMock()) + 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 @@ -814,7 +1175,7 @@ def test_github_backend_fork_not_exists_repo_found( repository = MagicMock() github3_client.repository.return_value = repository - backend = GitHubBackend(github3_client, MagicMock()) + backend = GitHubBackend(github3_client, MagicMock(), "") user_mock.return_value = "USER" backend.fork("UPSTREAM-OWNER", "REPO") @@ -824,6 +1185,21 @@ def test_github_backend_fork_not_exists_repo_found( 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( @@ -837,7 +1213,7 @@ def test_github_backend_fork_exists( branch_already_synced: bool, caplog, ): - caplog.set_level("DEBUG") + caplog.set_level(logging.DEBUG) exists_mock.return_value = True user_mock.return_value = "USER" @@ -862,7 +1238,7 @@ def get_repo(full_name: str): upstream_repo.default_branch = "UPSTREAM_BRANCH_NAME" fork_repo.default_branch = "FORK_BRANCH_NAME" - backend = GitHubBackend(MagicMock(), pygithub_client) + backend = GitHubBackend(MagicMock(), pygithub_client, "") backend.fork("UPSTREAM-OWNER", "REPO") if not branch_already_synced: @@ -887,7 +1263,7 @@ def test_github_backend_remote_does_not_exist( response.status_code = 404 github3_client.repository.side_effect = github3.exceptions.NotFoundError(response) - backend = GitHubBackend(github3_client, MagicMock()) + backend = GitHubBackend(github3_client, MagicMock(), "") user_mock.return_value = "USER" @@ -904,7 +1280,7 @@ def test_github_backend_user(): user.login = "USER" pygithub_client.get_user.return_value = user - backend = GitHubBackend(MagicMock(), pygithub_client) + backend = GitHubBackend(MagicMock(), pygithub_client, "") for _ in range(4): # cached property @@ -919,7 +1295,7 @@ def test_github_backend_get_api_requests_left_github_exception(caplog): "API Error" ) - backend = GitHubBackend(github3_client, MagicMock()) + backend = GitHubBackend(github3_client, MagicMock(), "") assert backend.get_api_requests_left() is None assert "API error while fetching" in caplog.text @@ -931,7 +1307,7 @@ 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()) + backend = GitHubBackend(github3_client, MagicMock(), "") assert backend.get_api_requests_left() is None assert "API Error while parsing" @@ -943,7 +1319,7 @@ 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()) + backend = GitHubBackend(github3_client, MagicMock(), "") assert backend.get_api_requests_left() == 5 @@ -955,7 +1331,7 @@ def test_github_backend_get_api_requests_left_zero_invalid_reset_time(caplog): github3_client.rate_limit.return_value = {"resources": {"core": {"remaining": 0}}} - backend = GitHubBackend(github3_client, MagicMock()) + backend = GitHubBackend(github3_client, MagicMock(), "") assert backend.get_api_requests_left() == 0 @@ -964,7 +1340,7 @@ def test_github_backend_get_api_requests_left_zero_invalid_reset_time(caplog): def test_github_backend_get_api_requests_left_zero_valid_reset_time(caplog): - caplog.set_level("INFO") + caplog.set_level(logging.INFO) github3_client = MagicMock() @@ -975,48 +1351,327 @@ def test_github_backend_get_api_requests_left_zero_valid_reset_time(caplog): "resources": {"core": {"remaining": 0, "reset": reset_timestamp}} } - backend = GitHubBackend(github3_client, MagicMock()) + 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 # + assert f"will reset at {reset_timestamp_str}" in caplog.text -@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, +@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, ): - upstream_owner = "UPSTREAM-OWNER" - repo_name = "REPO" - target_dir = Path("TARGET_DIR") - new_branch = "NEW_BRANCH" - base_branch = "BASE_BRANCH" + 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", + ) - user_mock.return_value = "USER" + 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, + ) - backend = GitHubBackend(MagicMock(), MagicMock()) - backend.clone_fork_and_branch( - upstream_owner, repo_name, target_dir, new_branch, base_branch + 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 ) - 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, + +@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", + ) + + def test_dry_run_backend_get_api_requests_left(): backend = DryRunBackend() @@ -1042,32 +1697,63 @@ def test_dry_run_backend_does_repository_exist_other_repo(): ) -def test_dry_run_backend_get_remote_url_non_fork(): +@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) + url = backend.get_remote_url("OWNER", "REPO", GitConnectionMode.HTTPS, token) - assert url == "https://github.com/OWNER/REPO.git" + if token is None: + assert url == "https://github.com/OWNER/REPO.git" + else: + assert url == "https://TOKEN@github.com/OWNER/REPO.git" -def test_dry_run_backend_get_remote_url_non_existing_fork(): +@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) + backend.get_remote_url(backend.user, "REPO", GitConnectionMode.HTTPS, token) + backend.fork("conda-forge", "pytest-feedstock") -def test_dry_run_backend_get_remote_url_existing_fork(): + with pytest.raises(RepositoryNotFoundError, match="does not exist"): + backend.get_remote_url( + backend.user, "pydantic-feedstock", 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("conda-forge", "pytest-feedstock") url = backend.get_remote_url( - backend.user, "pytest-feedstock", GitConnectionMode.HTTPS + backend.user, "pytest-feedstock", GitConnectionMode.HTTPS, token ) # note that the URL does not indicate anymore that it is a fork - assert url == "https://github.com/conda-forge/pytest-feedstock.git" + assert ( + url + == f"https://{f'{token}@' if token else ''}github.com/conda-forge/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): @@ -1101,6 +1787,59 @@ def test_dry_run_backend_user(): 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(): pr_json = { "ETag": "blah", From d29da4a599a971cc29304c3ee7eba5e1ecebfc85 Mon Sep 17 00:00:00 2001 From: Yannik Tausch Date: Mon, 16 Sep 2024 16:07:02 +0200 Subject: [PATCH 3/3] fix tests --- tests/test_auto_tick.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_auto_tick.py b/tests/test_auto_tick.py index 0b3482d0c..1676e0b4b 100644 --- a/tests/test_auto_tick.py +++ b/tests/test_auto_tick.py @@ -332,7 +332,6 @@ def test_run_with_tmpdir( git_backend=git_backend, rerender=rerender, base_branch=base_branch, - dry_run=dry_run, **kwargs, )