diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8e0245e74..7ed3b14fc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -52,9 +52,6 @@ jobs: git config --global pull.rebase false mkdir -p ~/.conda-smithy/ && echo $GH_TOKEN > ~/.conda-smithy/github.token pip install --no-deps -e . - - version=$(python -c "import conda_forge_webservices; print(conda_forge_webservices.__version__.replace('+', '.'))") - echo "version=${version}" >> "$GITHUB_OUTPUT" env: GH_TOKEN: ${{ steps.generate_token.outputs.token }} @@ -83,6 +80,40 @@ jobs: CF_WEBSERVICES_FEEDSTOCK_PRIVATE_KEY: ${{ secrets.CF_CURATOR_PRIVATE_KEY }} ACTION_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}" + docker-build: + name: docker-build + runs-on: "ubuntu-latest" + concurrency: + group: ${{ github.workflow }}-docker-build-${{ github.ref }} + cancel-in-progress: true + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + with: + fetch-depth: 0 + + - name: setup conda + uses: mamba-org/setup-micromamba@f8b8a1e23a26f60a44c853292711bacfd3eac822 # v1 + with: + environment-file: conda-lock.yml + environment-name: webservices + condarc: | + show_channel_urls: true + channel_priority: strict + channels: + - conda-forge + + - name: install code + id: install-code + shell: bash -l {0} + run: | + git config --global user.email "79913779+conda-forge-curator[bot]@users.noreply.github.com" + git config --global user.name "conda-forge-curator[bot]" + git config --global pull.rebase false + pip install --no-deps --no-build-isolation -e . + + version=$(python -c "import conda_forge_webservices; print(conda_forge_webservices.__version__.replace('+', '.'))") + echo "version=${version}" >> "$GITHUB_OUTPUT" + - name: set up docker buildx if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'conda-forge/conda-forge-webservices' uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3 @@ -106,7 +137,9 @@ jobs: live-tests-upload: name: live-tests-upload runs-on: "ubuntu-latest" - needs: tests + needs: + - tests + - docker-build concurrency: group: ${{ github.event.pull_request.head.repo.fork != 'true' && 'live-tests-upload' || format('{0}-{1}', github.workflow, github.ref) }} steps: @@ -166,7 +199,9 @@ jobs: live-tests-rerender: name: live-tests-rerender runs-on: "ubuntu-latest" - needs: tests + needs: + - tests + - docker-build concurrency: group: ${{ github.event.pull_request.head.repo.fork != 'true' && 'live-tests-rerender' || format('{0}-{1}', github.workflow, github.ref) }} steps: @@ -226,3 +261,69 @@ jobs: pytest -vvs --branch=${branch} test_live_rerender.py env: GH_TOKEN: ${{ secrets.CF_ADMIN_GITHUB_TOKEN }} + + live-tests-linter: + name: live-tests-linter + runs-on: "ubuntu-latest" + needs: + - tests + - docker-build + concurrency: + group: ${{ github.event.pull_request.head.repo.fork != 'true' && 'live-tests-linter' || format('{0}-{1}', github.workflow, github.ref) }} + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + if: ${{ !github.event.pull_request.head.repo.fork }} + with: + fetch-depth: 0 + + - name: setup conda + if: ${{ !github.event.pull_request.head.repo.fork }} + uses: mamba-org/setup-micromamba@f8b8a1e23a26f60a44c853292711bacfd3eac822 # v1 + with: + environment-file: conda-lock.yml + environment-name: webservices + condarc: | + show_channel_urls: true + channel_priority: strict + channels: + - conda-forge + + - name: generate token + if: ${{ !github.event.pull_request.head.repo.fork }} + id: generate_token + uses: actions/create-github-app-token@31c86eb3b33c9b601a1f60f98dcbfd1d70f379b4 # v1 + with: + app-id: ${{ secrets.CF_CURATOR_APP_ID }} + private-key: ${{ secrets.CF_CURATOR_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: install code + shell: bash -l {0} + if: ${{ !github.event.pull_request.head.repo.fork }} + run: | + git config --global user.email "79913779+conda-forge-curator[bot]@users.noreply.github.com" + git config --global user.name "conda-forge-curator[bot]" + git config --global pull.rebase false + mkdir -p ~/.conda-smithy/ && echo $GH_TOKEN > ~/.conda-smithy/github.token + pip install --no-deps -e . + env: + GH_TOKEN: ${{ steps.generate_token.outputs.token }} + + - name: run linter tests + shell: bash -l {0} + if: ${{ !github.event.pull_request.head.repo.fork }} + run: | + if [[ "${GITHUB_HEAD_REF}" != "" ]]; then + branch="${GITHUB_HEAD_REF}" + else + branch="${GITHUB_REF_NAME}" + fi + + version=$(python -c "import conda_forge_webservices; print(conda_forge_webservices.__version__.replace('+', '.'))") + export CF_FEEDSTOCK_OPS_CONTAINER_NAME=condaforge/webservices-dispatch-action + export CF_FEEDSTOCK_OPS_CONTAINER_TAG="${version}" + + cd tests + pytest -vvs --branch=${branch} test_live_linter.py + env: + GH_TOKEN: ${{ secrets.CF_ADMIN_GITHUB_TOKEN }} diff --git a/.github/workflows/webservices-workflow-dispatch.yml b/.github/workflows/webservices-workflow-dispatch.yml index a55ea4e67..7d1ec70a4 100644 --- a/.github/workflows/webservices-workflow-dispatch.yml +++ b/.github/workflows/webservices-workflow-dispatch.yml @@ -30,9 +30,51 @@ defaults: permissions: {} jobs: + init-task: + name: init-task + runs-on: ubuntu-latest + steps: + - name: checkout code + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 + with: + fetch-depth: 0 + ref: ${{ github.ref }} + + - name: setup conda + uses: mamba-org/setup-micromamba@f8b8a1e23a26f60a44c853292711bacfd3eac822 + with: + environment-file: conda-lock.yml + environment-name: webservices + condarc: | + show_channel_urls: true + channel_priority: strict + channels: + - conda-forge + + - name: install code + run: | + pip install --no-deps --no-build-isolation -e . + + - name: init task + run: | + git config --global user.name "conda-forge-webservices[bot]" + git config --global user.email "91080706+conda-forge-webservices[bot]@users.noreply.github.com" + + export CF_FEEDSTOCK_OPS_CONTAINER_NAME=condaforge/webservices-dispatch-action + export CF_FEEDSTOCK_OPS_CONTAINER_TAG="${{ inputs.container_tag }}" + + conda-forge-webservices-init-task \ + --task=${{ inputs.task }} \ + --repo=${{ inputs.repo }} \ + --pr-number=${{ inputs.pr_number }} + env: + GH_TOKEN: ${{ secrets.CF_ADMIN_GITHUB_TOKEN }} + run-task: name: run-task runs-on: ubuntu-latest + needs: + - init-task steps: - name: checkout code uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 diff --git a/conda_forge_webservices/github_actions_integration/__main__.py b/conda_forge_webservices/github_actions_integration/__main__.py index fb7e38ed9..cd4bcc9a7 100644 --- a/conda_forge_webservices/github_actions_integration/__main__.py +++ b/conda_forge_webservices/github_actions_integration/__main__.py @@ -5,8 +5,10 @@ import subprocess import sys import tempfile +import traceback import click +from conda_forge_feedstock_ops.lint import lint as lint_feedstock from conda_forge_feedstock_ops.os_utils import sync_dirs from git import Repo @@ -14,10 +16,12 @@ comment_and_push_if_changed, dedent_with_escaped_continue, flush_logger, + get_gha_run_link, mark_pr_as_ready_for_review, ) from .api_sessions import create_api_sessions from .rerendering import rerender +from .linting import make_lint_comment, build_and_make_lint_comment, set_pr_status LOGGER = logging.getLogger(__name__) @@ -39,6 +43,26 @@ def _pull_docker_image(): print("::endgroup::", flush=True) +@click.command(name="conda-forge-webservices-init-task") +@click.option("--task", required=True, type=str) +@click.option("--repo", required=True, type=str) +@click.option("--pr-number", required=True, type=str) +def main_init_task(task, repo, pr_number): + logging.basicConfig(level=logging.INFO) + + LOGGER.info("initializing task %s for conda-forge/%s#%s", task, repo, pr_number) + + if task == "rerender": + pass + elif task == "lint": + _, gh = create_api_sessions() + gh_repo = gh.get_repo(f"conda-forge/{repo}") + pr = gh_repo.get_pull(int(pr_number)) + set_pr_status(pr.base.repo, pr.head.sha, "pending", target_url=None) + else: + raise ValueError(f"Task `{task}` is not valid!") + + @click.command(name="conda-forge-webservices-run-task") @click.option("--task", required=True, type=str) @click.option("--repo", required=True, type=str) @@ -71,6 +95,21 @@ def main_run_task(task, repo, pr_number, task_data_dir): task_data["task_results"]["rerender_error"] = rerender_error task_data["task_results"]["info_message"] = info_message task_data["task_results"]["commit_message"] = commit_message + elif task == "lint": + _pull_docker_image() + try: + lints, hints = lint_feedstock(feedstock_dir, use_container=True) + lint_error = False + except Exception as err: + LOGGER.warning("LINTING ERROR: %s", repr(err)) + LOGGER.warning("LINTING ERROR TRACEBACK: %s", traceback.format_exc()) + lint_error = True + lints = None + hints = None + + task_data["task_results"]["lint_error"] = lint_error + task_data["task_results"]["lints"] = lints + task_data["task_results"]["hints"] = hints else: raise ValueError(f"Task `{task}` is not valid!") @@ -82,6 +121,12 @@ def main_run_task(task, repo, pr_number, task_data_dir): check=True, capture_output=True, ) + if task == "lint": + subprocess.run( + ["rm", "-rf", feedstock_dir], + check=True, + capture_output=True, + ) def _push_rerender_changes( @@ -160,11 +205,12 @@ def main_finalize_task(task_data_dir): flush_logger(LOGGER) with tempfile.TemporaryDirectory() as tmpdir: - # commit the changes + _, gh = create_api_sessions() + gh_repo = gh.get_repo(f"conda-forge/{repo}") + pr = gh_repo.get_pull(int(pr_number)) + + # commit the changes if needed if task in ["rerender"]: - _, gh = create_api_sessions() - gh_repo = gh.get_repo(f"conda-forge/{repo}") - pr = gh_repo.get_pull(int(pr_number)) pr_branch = pr.head.ref pr_owner = pr.head.repo.owner.login pr_repo = pr.head.repo.name @@ -227,5 +273,38 @@ def main_finalize_task(task_data_dir): # if the pr was made by the bot, mark it as ready for review if pr.title == "MNT: rerender" and pr.user.login == "conda-forge-admin": mark_pr_as_ready_for_review(pr) + + elif task == "lint": + if pr.state == "closed": + raise RuntimeError("Closed PRs are not linted!") + + if task_results["lint_error"]: + _message = dedent_with_escaped_continue( + """ + Hi! This is the friendly automated conda-forge-linting service. + + I Failed to even lint the recipe, probably because of a conda-smithy + bug :cry:. This likely indicates a problem in your `meta.yaml`, \\ + though. To get a traceback to help figure out what's going on, \\ + install conda-smithy and run \\ + `conda smithy recipe-lint --conda-forge .` from the recipe \\ + directory. + """ + ) + run_link = get_gha_run_link() + _message += ( + "\n\nThis message was generated by " + f"GitHub actions workflow run [{run_link}]({run_link}).\n" + ) + msg = make_lint_comment(gh_repo, pr.number, _message) + status = "bad" + else: + msg, status = build_and_make_lint_comment( + gh, gh_repo, pr.number, task_results["lints"], task_results["hints"] + ) + + set_pr_status(pr.base.repo, pr.head.sha, status, target_url=msg.html_url) + print(f"Linter status: {status}") + print(f"Linter message:\n{msg.body}") else: raise ValueError(f"Task `{task}` is not valid!") diff --git a/conda_forge_webservices/github_actions_integration/linting.py b/conda_forge_webservices/github_actions_integration/linting.py new file mode 100644 index 000000000..f54c55554 --- /dev/null +++ b/conda_forge_webservices/github_actions_integration/linting.py @@ -0,0 +1,251 @@ +import time + +from .utils import dedent_with_escaped_continue + + +def _is_mergeable(repo, pr_id): + mergeable = None + while mergeable is None: + time.sleep(1.0) + pull_request = repo.get_pull(pr_id) + if pull_request.state != "open": + return False + mergeable = pull_request.mergeable + return mergeable + + +def _get_comment_state(comment): + if "and found it was in an excellent condition." in comment: + has_lints = False + else: + has_lints = True + + if "but it appears we have a merge conflict." in comment: + merge_conflict = True + else: + merge_conflict = False + + if "I do have some suggestions for making it better though..." in comment: + has_hints = True + else: + has_hints = False + + if "recipes to lint for you, but couldn't find any." in comment: + no_recipes = True + else: + no_recipes = False + + if merge_conflict: + return "merge_conflict" + + if no_recipes: + return "no recipes" + + if has_lints: + return "bad" + + if not has_lints and has_hints: + return "mixed" + + if not has_lints and not has_hints: + return "good" + + +def make_lint_comment(repo, pr_id, message): + pr = repo.get_pull(pr_id) + comment = None + for _comment in pr.get_issue_comments(): + if ( + "Hi! This is the friendly automated conda-forge-linting service." + ) in _comment.body: + comment = _comment + + if comment: + if comment.body != message: + if _get_comment_state(comment.body) == _get_comment_state(message): + comment.edit(message) + msg = comment + else: + msg = pr.create_issue_comment(message) + else: + msg = comment + else: + msg = pr.create_issue_comment(message) + + return msg + + +def build_and_make_lint_comment(gh, repo, pr_id, lints, hints): + mergeable = _is_mergeable(repo, pr_id) + if not mergeable: + message = dedent_with_escaped_continue( + """ + Hi! This is the friendly automated conda-forge-linting service. + + I was trying to look for recipes to lint for you, but it appears we \\ + have a merge conflict. Please try to merge or rebase with the base \\ + branch to resolve this conflict. + + Please ping the 'conda-forge/core' team (using the @ notation in a \\ + comment) if you believe this is a bug. + """, + ) + status = "merge_conflict" + else: + fnames = set(hints.keys()) | set(lints.keys()) + + if repo.name == "staged-recipes": + pr = repo.get_pull(pr_id) + recipes_to_lint = set(f.filename for f in pr.get_files()) + recipes_to_lint = set( + fname + for fname in recipes_to_lint + if fname + not in ["recipes/example/meta.yaml", "recipes/example-v1/recipe.yaml"] + ) + else: + recipes_to_lint = set(fnames) + + linted_recipes = [] + all_pass = True + messages = [] + hints_found = False + for fname in fnames: + if fname not in recipes_to_lint: + continue + + linted_recipes.append(fname) + + _lints = lints.get(fname, []) + _hints = hints.get(fname, []) + + if _lints: + all_pass = False + messages.append( + "\nFor **{}**:\n\n{}".format( + fname, "\n".join(f" * {lint}" for lint in _lints) + ) + ) + if _hints: + hints_found = True + messages.append( + "\nFor **{}**:\n\n{}".format( + fname, "\n".join(f" * {hint}" for hint in _hints) + ) + ) + + # Put the recipes in the form "```recipe/a```, ```recipe/b```". + recipe_code_blocks = ", ".join(f"```{r}```" for r in linted_recipes) + + good = dedent_with_escaped_continue( + f""" + Hi! This is the friendly automated conda-forge-linting service. + + I just wanted to let you know that I linted all conda-recipes in your \\ + PR ({recipe_code_blocks}) and found it was in an excellent condition. + """, + ) + + mixed = good + dedent_with_escaped_continue( + """ + I do have some suggestions for making it better though... + + {} + """.format("\n".join(messages)), + ) + + bad = dedent_with_escaped_continue( + f""" + Hi! This is the friendly automated conda-forge-linting service. + + I wanted to let you know that I linted all conda-recipes in your \\ + PR ({recipe_code_blocks}) and found some lint. + + Here's what I've got... + + {{}} + """.format("\n".join(messages)), + ) + + if not fnames: + message = dedent_with_escaped_continue( + """ + Hi! This is the friendly automated conda-forge-linting service. + + I was trying to look for recipes to lint for you, but couldn't find any. + Please ping the 'conda-forge/core' team (using the @ notation in a \\ + comment) if you believe this is a bug. + """, + ) + status = "no recipes" + elif all_pass and hints_found: + message = mixed + status = "mixed" + elif all_pass: + message = good + status = "good" + else: + message = bad + status = "bad" + + msg = make_lint_comment(repo, pr_id, message) + + return msg, status + + +def set_pr_status(repo, sha, status, target_url=None): + if target_url is not None: + kwargs = {"target_url": target_url} + else: + kwargs = {} + + commit = repo.get_commit(sha) + + # get the last github status by the linter, if any + # API emits these in reverse time order so first is latest + statuses = commit.get_statuses() + last_status = None + for _status in statuses: + if _status.context == "conda-forge-linter": + last_status = _status + break + + # convert the linter status to a state + lint_status_to_state = {"good": "success", "mixed": "success", "pending": "pending"} + lint_new_state = lint_status_to_state.get(status, "failure") + + # make a status only if it is different or we have not ever done it + # for this commit + if ( + last_status is None + or last_status.state != lint_new_state + or last_status.target_url != target_url + ): + if status == "good": + commit.create_status( + "success", + description="All recipes are excellent.", + context="conda-forge-linter", + **kwargs, + ) + elif status == "mixed": + commit.create_status( + "success", + description="Some recipes have hints.", + context="conda-forge-linter", + **kwargs, + ) + elif status == "pending": + commit.create_status( + "pending", + description="Linting in progress...", + context="conda-forge-linter", + **kwargs, + ) + else: + commit.create_status( + "failure", + description="Some recipes need some changes.", + context="conda-forge-linter", + **kwargs, + ) diff --git a/pyproject.toml b/pyproject.toml index 1c97923ae..fb794b962 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ classifiers = [ [project.scripts] update-webservices = "conda_forge_webservices.update_me:main" cache-status-data = "conda_forge_webservices.status_monitor:cache_status_data" +conda-forge-webservices-init-task = "conda_forge_webservices.github_actions_integration.__main__:main_init_task" conda-forge-webservices-run-task = "conda_forge_webservices.github_actions_integration.__main__:main_run_task" conda-forge-webservices-finalize-task = "conda_forge_webservices.github_actions_integration.__main__:main_finalize_task" diff --git a/tests/test_live_linter.py b/tests/test_live_linter.py new file mode 100644 index 000000000..b3ebd7438 --- /dev/null +++ b/tests/test_live_linter.py @@ -0,0 +1,135 @@ +import os +import time + +import github + +import conda_forge_webservices + +TEST_CASES = [ + ( + 632, + "failure", + [ + "and found some lint.", + "feedstock has no `.ci_support` files and thus will not build any packages", + ], + ), + ( + 523, + "failure", + [ + "I was trying to look for recipes to lint for " + "you, but couldn't find any.", + ], + ), + ( + 217, + "success", + [ + "I do have some suggestions for making it better though...", + ], + ), + ( + 62, + "success", + [ + "I do have some suggestions for making it better though...", + ], + ), + ( + 57, + "failure", + [ + "I was trying to look for recipes to lint for you, but it " + "appears we have a merge conflict.", + ], + ), + ( + 56, + "failure", + [ + "I was trying to look for recipes to lint for you, but it appears " + "we have a merge conflict.", + ], + ), + ( + 54, + "success", + [ + "I do have some suggestions for making it better though...", + ], + ), + ( + 17, + "failure", + [ + "and found some lint.", + ], + ), + ( + 16, + "success", + [ + "and found it was in an excellent condition.", + ], + ), +] + + +def test_linter_pr(pytestconfig): + branch = pytestconfig.getoption("branch") + + gh = github.Github(auth=github.Auth.Token(os.environ["GH_TOKEN"])) + repo = gh.get_repo("conda-forge/conda-forge-webservices") + + for pr_number, _, _ in TEST_CASES: + pr = repo.get_pull(pr_number) + workflow = repo.get_workflow("webservices-workflow-dispatch.yml") + workflow.create_dispatch( + ref=branch, + inputs={ + "task": "lint", + "repo": "conda-forge-webservices", + "pr_number": str(pr_number), + "container_tag": conda_forge_webservices.__version__.replace("+", "."), + }, + ) + + print("\nsleeping for four minutes to let the linter work...", flush=True) + tot = 0 + while tot < 240: + time.sleep(10) + tot += 10 + print(f" slept {tot} seconds out of 240", flush=True) + + for pr_number, expected_status, expected_msgs in TEST_CASES: + pr = repo.get_pull(pr_number) + commit = repo.get_commit(pr.head.sha) + + status = None + for _status in commit.get_statuses(): + if _status.context == "conda-forge-linter": + status = _status + break + + assert status is not None + + comment = None + for _comment in pr.get_issue_comments(): + if ( + "Hi! This is the friendly automated conda-forge-linting service." + in _comment.body + ): + comment = _comment + + assert comment is not None + + assert status.state == expected_status, ( + pr_number, + status.state, + expected_status, + comment.body, + ) + + for expected_msg in expected_msgs: + assert expected_msg in comment.body diff --git a/tests/test_live_rerender.py b/tests/test_live_rerender.py index 673a88db4..570e88c84 100644 --- a/tests/test_live_rerender.py +++ b/tests/test_live_rerender.py @@ -26,12 +26,12 @@ def _run_test(branch): }, ) - print("sleeping for a few minutes to let the rerender happen...", flush=True) + print("sleeping for four minutes to let the rerender happen...", flush=True) tot = 0 - while tot < 180: + while tot < 240: time.sleep(10) tot += 10 - print(f" slept {tot} seconds out of 180", flush=True) + print(f" slept {tot} seconds out of 240", flush=True) print("checking repo for the rerender...", flush=True) with tempfile.TemporaryDirectory() as tmpdir: