From a14ace948a1c813976b82913c2af5c4699e28637 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Thu, 21 Nov 2024 19:35:36 -0500 Subject: [PATCH 01/26] add chia dev gh test for launching configured test runs --- chia/cmds/dev.py | 2 ++ chia/cmds/gh.py | 91 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 chia/cmds/gh.py diff --git a/chia/cmds/dev.py b/chia/cmds/dev.py index c23d05570d32..04c3b8dc11b0 100644 --- a/chia/cmds/dev.py +++ b/chia/cmds/dev.py @@ -2,6 +2,7 @@ import click +from chia.cmds.gh import gh_group from chia.cmds.installers import installers_group from chia.cmds.sim import sim_cmd @@ -14,3 +15,4 @@ def dev_cmd(ctx: click.Context) -> None: dev_cmd.add_command(sim_cmd) dev_cmd.add_command(installers_group) +dev_cmd.add_command(gh_group) diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py new file mode 100644 index 000000000000..a29247f31610 --- /dev/null +++ b/chia/cmds/gh.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import shlex +from typing import Literal, Optional, Union + +import anyio +import click +import yaml + +from chia.cmds.cmd_classes import chia_command, option + + +@click.group("gh", help="For working with GitHub") +def gh_group() -> None: + pass + + +@chia_command( + gh_group, + name="test", + # TODO: welp, yeah, help + help="", + # short_help="helpy help", + # help="""docstring help + # and + # more + # lines + # + # blue + # """, +) +class TestCMD: + owner: str = option("-o", "--owner", help="Owner of the repo", type=str, default="Chia-Network") + repository: str = option("-r", "--repository", help="Repository name", type=str, default="chia-blockchain") + ref: Optional[str] = option("-f", "--ref", help="Branch or tag name (commit SHA not supported", type=str) + per: Union[Literal["directory"], Literal["file"]] = option( + "-p", "--per", help="Per", type=click.Choice(["directory", "file"]), default="directory" + ) + only: Optional[str] = option("-o", "--only", help="Only run this item", type=str) + duplicates: int = option("-d", "--duplicates", help="Number of duplicates", type=int, default=1) + run_linux: bool = option("--run-linux/--skip-linux", help="Run on Linux", default=True) + run_macos_intel: bool = option("--run-macos-intel/--skip-macos-intel", help="Run on macOS Intel", default=True) + run_macos_arm: bool = option("--run-macos-arm/--skip-macos-arm", help="Run on macOS ARM", default=True) + run_windows: bool = option("--run-windows/--skip-windows", help="Run on Windows", default=True) + full_python_matrix: bool = option( + "--full-python-matrix/--default-python-matrix", help="Run on all Python versions", default=False + ) + + async def run(self) -> None: + def input_arg(name: str, value: object, cond: bool = True) -> list[str]: + dumped = yaml.safe_dump(value).partition("\n")[0] + return ["-f", f"inputs[{name}]={dumped}"] if cond else [] + + workflow_id = "test.yml" + + if self.ref is None: + process = await anyio.run_process( + command=["git", "rev-parse", "--abbrev-ref", "HEAD"], check=False, stderr=None + ) + if process.returncode != 0: + raise click.ClickException("Failed to get current branch") + ref = process.stdout.decode(encoding="utf-8").strip() + else: + ref = self.ref + + command = [ + "gh", + "api", + "--method", + "POST", + "-H", + "Accept: application/vnd.github+json", + "-H", + "X-GitHub-Api-Version: 2022-11-28", + f"/repos/{self.owner}/{self.repository}/actions/workflows/{workflow_id}/dispatches", + "-f", + f"ref={ref}", + *input_arg("per", self.per), + *input_arg("only", self.only, self.only is not None), + *input_arg("duplicates", self.duplicates), + *input_arg("run-linux", self.run_linux), + *input_arg("run-macos-intel", self.run_macos_intel), + *input_arg("run-macos-arm", self.run_macos_arm), + *input_arg("run-windows", self.run_windows), + *input_arg("full-python-matrix", self.full_python_matrix), + ] + + print(f"running command: {shlex.join(command)}") + process = await anyio.run_process(command=command, check=False, stdout=None, stderr=None) + if process.returncode != 0: + raise click.ClickException("Failed to dispatch workflow") From acaaddd968417f69ec5574d96f4ac8d01043a8af Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 22 Nov 2024 09:35:07 -0500 Subject: [PATCH 02/26] empty again From 775ba200d972a305c7d0507e477228db4d9b849b Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 22 Nov 2024 10:46:43 -0500 Subject: [PATCH 03/26] better --- chia/cmds/gh.py | 97 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 73 insertions(+), 24 deletions(-) diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index a29247f31610..83f7994451cc 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -1,7 +1,10 @@ from __future__ import annotations +import json import shlex -from typing import Literal, Optional, Union +import uuid +import webbrowser +from typing import ClassVar, Literal, Optional, Union import anyio import click @@ -30,6 +33,7 @@ def gh_group() -> None: # """, ) class TestCMD: + workflow_id: ClassVar[str] = "test.yml" owner: str = option("-o", "--owner", help="Owner of the repo", type=str, default="Chia-Network") repository: str = option("-r", "--repository", help="Repository name", type=str, default="chia-blockchain") ref: Optional[str] = option("-f", "--ref", help="Branch or tag name (commit SHA not supported", type=str) @@ -47,34 +51,55 @@ class TestCMD: ) async def run(self) -> None: - def input_arg(name: str, value: object, cond: bool = True) -> list[str]: - dumped = yaml.safe_dump(value).partition("\n")[0] - return ["-f", f"inputs[{name}]={dumped}"] if cond else [] - - workflow_id = "test.yml" + if self.ref is not None: + await self.trigger_workflow(self.ref) + else: + task_uuid = uuid.uuid4() + temp_branch_name = f"tmp/altendky/{task_uuid}" - if self.ref is None: - process = await anyio.run_process( - command=["git", "rev-parse", "--abbrev-ref", "HEAD"], check=False, stderr=None + await anyio.run_process( + command=["git", "push", "origin", f"HEAD:{temp_branch_name}"], check=False, stdout=None, stderr=None ) - if process.returncode != 0: - raise click.ClickException("Failed to get current branch") - ref = process.stdout.decode(encoding="utf-8").strip() - else: - ref = self.ref + try: + await self.trigger_workflow(temp_branch_name) + while True: + await anyio.sleep(1) + + try: + run_url = await self.find_run(temp_branch_name) + print(f"run found at: {run_url}") + except Exception as e: + print(e) + continue + + break + finally: + print(f"deleting temporary branch: {temp_branch_name}") + process = await anyio.run_process( + command=["git", "push", "origin", "-d", temp_branch_name], check=False, stdout=None, stderr=None + ) + if process.returncode != 0: + raise click.ClickException("Failed to dispatch workflow") + print(f"temporary branch deleted: {temp_branch_name}") + + print(f"run url: {run_url}") + webbrowser.open(run_url) + + async def trigger_workflow(self, ref: str) -> None: + def input_arg(name: str, value: object, cond: bool = True) -> list[str]: + dumped = yaml.safe_dump(value).partition("\n")[0] + return [f"-f=inputs[{name}]={dumped}"] if cond else [] + + # https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28#create-a-workflow-dispatch-event command = [ "gh", "api", - "--method", - "POST", - "-H", - "Accept: application/vnd.github+json", - "-H", - "X-GitHub-Api-Version: 2022-11-28", - f"/repos/{self.owner}/{self.repository}/actions/workflows/{workflow_id}/dispatches", - "-f", - f"ref={ref}", + "--method=POST", + "-H=Accept: application/vnd.github+json", + "-H=X-GitHub-Api-Version: 2022-11-28", + f"/repos/{self.owner}/{self.repository}/actions/workflows/{self.workflow_id}/dispatches", + f"-f=ref={ref}", *input_arg("per", self.per), *input_arg("only", self.only, self.only is not None), *input_arg("duplicates", self.duplicates), @@ -84,8 +109,32 @@ def input_arg(name: str, value: object, cond: bool = True) -> list[str]: *input_arg("run-windows", self.run_windows), *input_arg("full-python-matrix", self.full_python_matrix), ] - print(f"running command: {shlex.join(command)}") process = await anyio.run_process(command=command, check=False, stdout=None, stderr=None) if process.returncode != 0: raise click.ClickException("Failed to dispatch workflow") + print(f"workflow triggered on branch: {ref}") + + async def find_run(self, ref: str) -> str: + # https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#list-workflow-runs-for-a-workflow + command = [ + "gh", + "api", + "--method=GET", + "-H=Accept: application/vnd.github+json", + "-H=X-GitHub-Api-Version: 2022-11-28", + f"-f=branch={ref}", + f"/repos/{self.owner}/{self.repository}/actions/workflows/{self.workflow_id}/runs", + ] + print(f"running command: {shlex.join(command)}") + process = await anyio.run_process(command=command, check=False, stderr=None) + if process.returncode != 0: + raise click.ClickException("Failed to query workflow runs") + + response = json.loads(process.stdout) + [run] = response["workflow_runs"] + + url = run["html_url"] + + assert isinstance(url, str), f"expected url to be a string, got: {url!r}" + return url From d88ab4043549615c7ba8bc2b7affed617410a2db Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 22 Nov 2024 10:52:41 -0500 Subject: [PATCH 04/26] use the actual authenticated username --- chia/cmds/gh.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index 83f7994451cc..2fdffae45236 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -55,7 +55,8 @@ async def run(self) -> None: await self.trigger_workflow(self.ref) else: task_uuid = uuid.uuid4() - temp_branch_name = f"tmp/altendky/{task_uuid}" + username = await self.get_username() + temp_branch_name = f"tmp/{username}/{task_uuid}" await anyio.run_process( command=["git", "push", "origin", f"HEAD:{temp_branch_name}"], check=False, stdout=None, stderr=None @@ -138,3 +139,25 @@ async def find_run(self, ref: str) -> str: assert isinstance(url, str), f"expected url to be a string, got: {url!r}" return url + + async def get_username(self) -> str: + # https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-the-authenticated-user + process = await anyio.run_process( + command=[ + "gh", + "api", + "--method=GET", + "-H=Accept: application/vnd.github+json", + "-H=X-GitHub-Api-Version: 2022-11-28", + "/user", + ], + check=False, + stderr=None, + ) + if process.returncode != 0: + raise click.ClickException("Failed to get username") + + response = json.loads(process.stdout) + username = response["login"] + assert isinstance(username, str), f"expected username to be a string, got: {username!r}" + return username From 335c838b0fbedcd0c4db92f4a2ba0880bfbb9053 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 22 Nov 2024 11:08:04 -0500 Subject: [PATCH 05/26] more output --- chia/cmds/gh.py | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index 2fdffae45236..97405cba6eae 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -13,6 +13,14 @@ from chia.cmds.cmd_classes import chia_command, option +class UnexpectedFormError(Exception): + pass + + +def report(*args: str) -> None: + print(" ====", *args) + + @click.group("gh", help="For working with GitHub") def gh_group() -> None: pass @@ -64,27 +72,30 @@ async def run(self) -> None: try: await self.trigger_workflow(temp_branch_name) - while True: + for _ in range(10): await anyio.sleep(1) try: + report("looking for run") run_url = await self.find_run(temp_branch_name) - print(f"run found at: {run_url}") - except Exception as e: - print(e) + report(f"run found at: {run_url}") + except UnexpectedFormError: + report("run not found") continue break + else: + raise click.ClickException("Failed to find run url") finally: - print(f"deleting temporary branch: {temp_branch_name}") + report(f"deleting temporary branch: {temp_branch_name}") process = await anyio.run_process( command=["git", "push", "origin", "-d", temp_branch_name], check=False, stdout=None, stderr=None ) if process.returncode != 0: raise click.ClickException("Failed to dispatch workflow") - print(f"temporary branch deleted: {temp_branch_name}") + report(f"temporary branch deleted: {temp_branch_name}") - print(f"run url: {run_url}") + report(f"run url: {run_url}") webbrowser.open(run_url) async def trigger_workflow(self, ref: str) -> None: @@ -110,11 +121,11 @@ def input_arg(name: str, value: object, cond: bool = True) -> list[str]: *input_arg("run-windows", self.run_windows), *input_arg("full-python-matrix", self.full_python_matrix), ] - print(f"running command: {shlex.join(command)}") + report(f"running command: {shlex.join(command)}") process = await anyio.run_process(command=command, check=False, stdout=None, stderr=None) if process.returncode != 0: raise click.ClickException("Failed to dispatch workflow") - print(f"workflow triggered on branch: {ref}") + report(f"workflow triggered on branch: {ref}") async def find_run(self, ref: str) -> str: # https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#list-workflow-runs-for-a-workflow @@ -127,13 +138,17 @@ async def find_run(self, ref: str) -> str: f"-f=branch={ref}", f"/repos/{self.owner}/{self.repository}/actions/workflows/{self.workflow_id}/runs", ] - print(f"running command: {shlex.join(command)}") + report(f"running command: {shlex.join(command)}") process = await anyio.run_process(command=command, check=False, stderr=None) if process.returncode != 0: raise click.ClickException("Failed to query workflow runs") response = json.loads(process.stdout) - [run] = response["workflow_runs"] + runs = response["workflow_runs"] + try: + [run] = runs + except ValueError: + raise UnexpectedFormError(f"expected 1 run, got: {len(runs)}") url = run["html_url"] From b10ab376ac480b1b651e3b8ecff6e8cfa20ce22f Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 22 Nov 2024 11:24:42 -0500 Subject: [PATCH 06/26] helpful errors for invalid --only value --- chia/cmds/gh.py | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index 97405cba6eae..56b11bc03f8b 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -4,7 +4,8 @@ import shlex import uuid import webbrowser -from typing import ClassVar, Literal, Optional, Union +from pathlib import Path +from typing import Callable, ClassVar, Literal, Optional, Union import anyio import click @@ -17,6 +18,9 @@ class UnexpectedFormError(Exception): pass +Per = Union[Literal["directory"], Literal["file"]] + + def report(*args: str) -> None: print(" ====", *args) @@ -30,7 +34,7 @@ def gh_group() -> None: gh_group, name="test", # TODO: welp, yeah, help - help="", + help="launch a test run in ci from the local commit", # short_help="helpy help", # help="""docstring help # and @@ -44,11 +48,16 @@ class TestCMD: workflow_id: ClassVar[str] = "test.yml" owner: str = option("-o", "--owner", help="Owner of the repo", type=str, default="Chia-Network") repository: str = option("-r", "--repository", help="Repository name", type=str, default="chia-blockchain") - ref: Optional[str] = option("-f", "--ref", help="Branch or tag name (commit SHA not supported", type=str) - per: Union[Literal["directory"], Literal["file"]] = option( - "-p", "--per", help="Per", type=click.Choice(["directory", "file"]), default="directory" + ref: Optional[str] = option( + "-f", + "--ref", + help="Branch or tag name (commit SHA not supported), if not specified will push HEAD to a temporary branch", + type=str, + ) + per: Per = option("-p", "--per", help="Per", type=click.Choice(["directory", "file"]), default="directory") + only: Optional[Path] = option( + "-o", "--only", help="Only run this item, a file or directory depending on --per", type=Path ) - only: Optional[str] = option("-o", "--only", help="Only run this item", type=str) duplicates: int = option("-d", "--duplicates", help="Number of duplicates", type=int, default=1) run_linux: bool = option("--run-linux/--skip-linux", help="Run on Linux", default=True) run_macos_intel: bool = option("--run-macos-intel/--skip-macos-intel", help="Run on macOS Intel", default=True) @@ -59,6 +68,8 @@ class TestCMD: ) async def run(self) -> None: + await self.check_only() + if self.ref is not None: await self.trigger_workflow(self.ref) else: @@ -98,6 +109,22 @@ async def run(self) -> None: report(f"run url: {run_url}") webbrowser.open(run_url) + async def check_only(self) -> None: + if self.only is not None: + import chia._tests + + test_path = Path(chia._tests.__file__).parent + effective_path = test_path.joinpath(self.only) + checks: dict[Per, Callable[[], bool]] = {"directory": effective_path.is_dir, "file": effective_path.is_file} + check = checks[self.per] + if not check(): + if effective_path.exists(): + explanation = "wrong type" + else: + explanation = "does not exist" + message = f"expected requested --only to be a {self.per}, {explanation} at: {effective_path.as_posix()}" + raise click.ClickException(message) + async def trigger_workflow(self, ref: str) -> None: def input_arg(name: str, value: object, cond: bool = True) -> list[str]: dumped = yaml.safe_dump(value).partition("\n")[0] From 9cc25e6cac7b38ecd6c1ae539b854a432dc8d8f5 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 22 Nov 2024 11:27:38 -0500 Subject: [PATCH 07/26] tidy short help --- chia/cmds/gh.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index 56b11bc03f8b..411e4c31b2c3 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -34,7 +34,7 @@ def gh_group() -> None: gh_group, name="test", # TODO: welp, yeah, help - help="launch a test run in ci from the local commit", + help="launch a test run in ci from HEAD or existing remote ref", # short_help="helpy help", # help="""docstring help # and From 8af149b79c0ac1499c6d0aebed71cc44f41dd735 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 22 Nov 2024 11:53:13 -0500 Subject: [PATCH 08/26] add at least an invalid --only test case --- chia/_tests/cmds/test_dev_gh.py | 50 +++++++++++++++++++++++++++++++++ chia/cmds/gh.py | 1 + 2 files changed, 51 insertions(+) create mode 100644 chia/_tests/cmds/test_dev_gh.py diff --git a/chia/_tests/cmds/test_dev_gh.py b/chia/_tests/cmds/test_dev_gh.py new file mode 100644 index 000000000000..9dff9b829870 --- /dev/null +++ b/chia/_tests/cmds/test_dev_gh.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import click +import pytest + +import chia._tests +from chia._tests.util.misc import Marks, datacases +from chia.cmds.gh import Per, TestCMD + +test_root = Path(chia._tests.__file__).parent + + +@dataclass +class InvalidOnlyCase: + only: Path + per: Per + exists: bool + marks: Marks = () + + @property + def id(self) -> str: + return f"{self.per}: {self.only}" + + +@datacases( + InvalidOnlyCase(only=Path("does_not_exist.py"), per="directory", exists=False), + InvalidOnlyCase(only=Path("pools/test_pool_rpc.py"), per="directory", exists=True), + InvalidOnlyCase(only=Path("does_not_exist/"), per="file", exists=False), + InvalidOnlyCase(only=Path("pools/"), per="file", exists=True), +) +@pytest.mark.anyio +async def test_invalid_only(case: InvalidOnlyCase) -> None: + cmd = TestCMD(only=case.only, per=case.per) + + if case.exists: + assert test_root.joinpath(case.only).exists() + explanation = "wrong type" + if case.per == "directory": + assert test_root.joinpath(case.only).is_file() + else: + assert test_root.joinpath(case.only).is_dir() + else: + assert not test_root.joinpath(case.only).exists() + explanation = "does not exist" + + with pytest.raises(click.ClickException, match=f"to be a {case.per}, {explanation}"): + await cmd.run() diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index 411e4c31b2c3..5fd7bb6b478f 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -53,6 +53,7 @@ class TestCMD: "--ref", help="Branch or tag name (commit SHA not supported), if not specified will push HEAD to a temporary branch", type=str, + default=None, ) per: Per = option("-p", "--per", help="Per", type=click.Choice(["directory", "file"]), default="directory") only: Optional[Path] = option( From f5dbfcd388d073d6e347409b531eac8beac56ca6 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 22 Nov 2024 11:59:01 -0500 Subject: [PATCH 09/26] prepare a long help --- chia/cmds/gh.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index 5fd7bb6b478f..329df2ece130 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -33,15 +33,16 @@ def gh_group() -> None: @chia_command( gh_group, name="test", - # TODO: welp, yeah, help - help="launch a test run in ci from HEAD or existing remote ref", - # short_help="helpy help", - # help="""docstring help - # and - # more - # lines + help="launch a test run in CI from HEAD or existing remote ref", + # help="""Allows easy triggering and viewing of test workflow runs in CI including + # configuration of parameters. If a ref is specified then it must exist on the + # remote and a run will be launched for it. If ref is not specified then the local + # HEAD will be pushed to a temporary remote branch and a run will be launched for + # that. There is no need to push the local commit first. The temporary remote + # branch will automatically be deleted in most cases. # - # blue + # After launching the workflow run the remote will be queried for the run and the + # URL will be opened in the default browser. # """, ) class TestCMD: From e516e0c31ed809963ce674c4f70735473ae723ef Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 22 Nov 2024 12:33:18 -0500 Subject: [PATCH 10/26] add gh api runner helper --- chia/cmds/gh.py | 127 +++++++++++++++++++++++++++++------------------- 1 file changed, 76 insertions(+), 51 deletions(-) diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index 329df2ece130..fd5334d5e232 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -1,11 +1,12 @@ from __future__ import annotations import json +import os import shlex import uuid import webbrowser from pathlib import Path -from typing import Callable, ClassVar, Literal, Optional, Union +from typing import Callable, ClassVar, Literal, Optional, Union, overload import anyio import click @@ -18,6 +19,7 @@ class UnexpectedFormError(Exception): pass +Method = Union[Literal["GET"], Literal["POST"]] Per = Union[Literal["directory"], Literal["file"]] @@ -25,6 +27,39 @@ def report(*args: str) -> None: print(" ====", *args) +@overload +async def run_gh_api(method: Method, args: list[str], error: str) -> None: ... +@overload +async def run_gh_api(method: Method, args: list[str], error: str, capture_stdout: Literal[False]) -> None: ... +@overload +async def run_gh_api(method: Method, args: list[str], error: str, capture_stdout: Literal[True]) -> str: ... + + +async def run_gh_api(method: Method, args: list[str], error: str, capture_stdout: bool = False) -> Optional[str]: + command = [ + "gh", + "api", + f"--method={method}", + "-H=Accept: application/vnd.github+json", + "-H=X-GitHub-Api-Version: 2022-11-28", + *args, + ] + report(f"running command: {shlex.join(command)}") + + if capture_stdout: + process = await anyio.run_process(command=command, check=False, stderr=None) + else: + process = await anyio.run_process(command=command, check=False, stderr=None, stdout=None) + + if process.returncode != 0: + raise click.ClickException(error) + + if capture_stdout: + return process.stdout.decode("utf-8") + + return None + + @click.group("gh", help="For working with GitHub") def gh_group() -> None: pass @@ -129,50 +164,48 @@ async def check_only(self) -> None: async def trigger_workflow(self, ref: str) -> None: def input_arg(name: str, value: object, cond: bool = True) -> list[str]: + if not cond: + return [] + + assert value is not None + + if isinstance(value, os.PathLike): + value = os.fspath(value) dumped = yaml.safe_dump(value).partition("\n")[0] - return [f"-f=inputs[{name}]={dumped}"] if cond else [] + return [f"-f=inputs[{name}]={dumped}"] # https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28#create-a-workflow-dispatch-event - command = [ - "gh", - "api", - "--method=POST", - "-H=Accept: application/vnd.github+json", - "-H=X-GitHub-Api-Version: 2022-11-28", - f"/repos/{self.owner}/{self.repository}/actions/workflows/{self.workflow_id}/dispatches", - f"-f=ref={ref}", - *input_arg("per", self.per), - *input_arg("only", self.only, self.only is not None), - *input_arg("duplicates", self.duplicates), - *input_arg("run-linux", self.run_linux), - *input_arg("run-macos-intel", self.run_macos_intel), - *input_arg("run-macos-arm", self.run_macos_arm), - *input_arg("run-windows", self.run_windows), - *input_arg("full-python-matrix", self.full_python_matrix), - ] - report(f"running command: {shlex.join(command)}") - process = await anyio.run_process(command=command, check=False, stdout=None, stderr=None) - if process.returncode != 0: - raise click.ClickException("Failed to dispatch workflow") + await run_gh_api( + method="POST", + args=[ + f"/repos/{self.owner}/{self.repository}/actions/workflows/{self.workflow_id}/dispatches", + f"-f=ref={ref}", + *input_arg("per", self.per), + *input_arg("only", self.only, self.only is not None), + *input_arg("duplicates", self.duplicates), + *input_arg("run-linux", self.run_linux), + *input_arg("run-macos-intel", self.run_macos_intel), + *input_arg("run-macos-arm", self.run_macos_arm), + *input_arg("run-windows", self.run_windows), + *input_arg("full-python-matrix", self.full_python_matrix), + ], + error="Failed to dispatch workflow", + ) report(f"workflow triggered on branch: {ref}") async def find_run(self, ref: str) -> str: # https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#list-workflow-runs-for-a-workflow - command = [ - "gh", - "api", - "--method=GET", - "-H=Accept: application/vnd.github+json", - "-H=X-GitHub-Api-Version: 2022-11-28", - f"-f=branch={ref}", - f"/repos/{self.owner}/{self.repository}/actions/workflows/{self.workflow_id}/runs", - ] - report(f"running command: {shlex.join(command)}") - process = await anyio.run_process(command=command, check=False, stderr=None) - if process.returncode != 0: - raise click.ClickException("Failed to query workflow runs") + stdout = await run_gh_api( + method="GET", + args=[ + f"-f=branch={ref}", + f"/repos/{self.owner}/{self.repository}/actions/workflows/{self.workflow_id}/runs", + ], + error="Failed to query workflow runs", + capture_stdout=True, + ) - response = json.loads(process.stdout) + response = json.loads(stdout) runs = response["workflow_runs"] try: [run] = runs @@ -186,22 +219,14 @@ async def find_run(self, ref: str) -> str: async def get_username(self) -> str: # https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-the-authenticated-user - process = await anyio.run_process( - command=[ - "gh", - "api", - "--method=GET", - "-H=Accept: application/vnd.github+json", - "-H=X-GitHub-Api-Version: 2022-11-28", - "/user", - ], - check=False, - stderr=None, + stdout = await run_gh_api( + method="GET", + args=["/user"], + error="Failed to get username", + capture_stdout=True, ) - if process.returncode != 0: - raise click.ClickException("Failed to get username") - response = json.loads(process.stdout) + response = json.loads(stdout) username = response["login"] assert isinstance(username, str), f"expected username to be a string, got: {username!r}" return username From 7419fc38d6c9355a31e64017e8208252cd0e7faf Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 22 Nov 2024 12:43:24 -0500 Subject: [PATCH 11/26] use the commit hash for the temporary branch --- chia/cmds/gh.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index fd5334d5e232..75ebd2a56153 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -3,7 +3,6 @@ import json import os import shlex -import uuid import webbrowser from pathlib import Path from typing import Callable, ClassVar, Literal, Optional, Union, overload @@ -110,13 +109,20 @@ async def run(self) -> None: if self.ref is not None: await self.trigger_workflow(self.ref) else: - task_uuid = uuid.uuid4() + process = await anyio.run_process(command=["git", "rev-parse", "HEAD"], check=True, stderr=None) + if process.returncode != 0: + raise click.ClickException("Failed to get current commit SHA") + + commit_sha = process.stdout.decode("utf-8").strip() + username = await self.get_username() - temp_branch_name = f"tmp/{username}/{task_uuid}" + temp_branch_name = f"tmp/{username}/{commit_sha}" - await anyio.run_process( + process = await anyio.run_process( command=["git", "push", "origin", f"HEAD:{temp_branch_name}"], check=False, stdout=None, stderr=None ) + if process.returncode != 0: + raise click.ClickException("Failed to push temporary branch") try: await self.trigger_workflow(temp_branch_name) From 75d496563cce74c6ec291270b1f00deba2ebad94 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 22 Nov 2024 13:22:46 -0500 Subject: [PATCH 12/26] and uuid too --- chia/cmds/gh.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index 75ebd2a56153..3948c76b3a10 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -3,6 +3,7 @@ import json import os import shlex +import uuid import webbrowser from pathlib import Path from typing import Callable, ClassVar, Literal, Optional, Union, overload @@ -116,7 +117,7 @@ async def run(self) -> None: commit_sha = process.stdout.decode("utf-8").strip() username = await self.get_username() - temp_branch_name = f"tmp/{username}/{commit_sha}" + temp_branch_name = f"tmp/{username}/{commit_sha}/{uuid.uuid4()}" process = await anyio.run_process( command=["git", "push", "origin", f"HEAD:{temp_branch_name}"], check=False, stdout=None, stderr=None From e1140c954b2a61d5022883103f292096003b887a Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 22 Nov 2024 13:29:35 -0500 Subject: [PATCH 13/26] fix up os selection --- chia/_tests/cmds/test_dev_gh.py | 3 ++- chia/cmds/cmd_classes.py | 1 + chia/cmds/gh.py | 21 ++++++++++++--------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/chia/_tests/cmds/test_dev_gh.py b/chia/_tests/cmds/test_dev_gh.py index 9dff9b829870..18acd9e3b3a2 100644 --- a/chia/_tests/cmds/test_dev_gh.py +++ b/chia/_tests/cmds/test_dev_gh.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from dataclasses import dataclass from pathlib import Path @@ -46,5 +47,5 @@ async def test_invalid_only(case: InvalidOnlyCase) -> None: assert not test_root.joinpath(case.only).exists() explanation = "does not exist" - with pytest.raises(click.ClickException, match=f"to be a {case.per}, {explanation}"): + with pytest.raises(click.ClickException, match=f"\bto be a {re.escape(case.per)}\b.*\b{re.escape(explanation)}\b"): await cmd.run() diff --git a/chia/cmds/cmd_classes.py b/chia/cmds/cmd_classes.py index 04462a5a6f36..dd7fa1096293 100644 --- a/chia/cmds/cmd_classes.py +++ b/chia/cmds/cmd_classes.py @@ -204,6 +204,7 @@ def _generate_command_parser(cls: type[ChiaCommand]) -> _CommandParsingStage: option_decorators.append( click.option( *option_args["param_decls"], + field_name, type=type_arg, **{k: v for k, v in option_args.items() if k not in {"param_decls", "type"}}, ) diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index 3948c76b3a10..0fea61eaf8b2 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -6,7 +6,7 @@ import uuid import webbrowser from pathlib import Path -from typing import Callable, ClassVar, Literal, Optional, Union, overload +from typing import Callable, ClassVar, Literal, Optional, Sequence, Union, overload import anyio import click @@ -19,9 +19,12 @@ class UnexpectedFormError(Exception): pass +Oses = Union[Literal["linux"], Literal["macos-arm"], Literal["macos-intel"], Literal["windows"]] Method = Union[Literal["GET"], Literal["POST"]] Per = Union[Literal["directory"], Literal["file"]] +all_oses: Sequence[Oses] = ("linux", "macos-arm", "macos-intel", "windows") + def report(*args: str) -> None: print(" ====", *args) @@ -96,10 +99,13 @@ class TestCMD: "-o", "--only", help="Only run this item, a file or directory depending on --per", type=Path ) duplicates: int = option("-d", "--duplicates", help="Number of duplicates", type=int, default=1) - run_linux: bool = option("--run-linux/--skip-linux", help="Run on Linux", default=True) - run_macos_intel: bool = option("--run-macos-intel/--skip-macos-intel", help="Run on macOS Intel", default=True) - run_macos_arm: bool = option("--run-macos-arm/--skip-macos-arm", help="Run on macOS ARM", default=True) - run_windows: bool = option("--run-windows/--skip-windows", help="Run on Windows", default=True) + oses: Sequence[Oses] = option( + "--os", + help="Operating systems to run on", + type=click.Choice(all_oses), + multiple=True, + default=all_oses, + ) full_python_matrix: bool = option( "--full-python-matrix/--default-python-matrix", help="Run on all Python versions", default=False ) @@ -190,10 +196,7 @@ def input_arg(name: str, value: object, cond: bool = True) -> list[str]: *input_arg("per", self.per), *input_arg("only", self.only, self.only is not None), *input_arg("duplicates", self.duplicates), - *input_arg("run-linux", self.run_linux), - *input_arg("run-macos-intel", self.run_macos_intel), - *input_arg("run-macos-arm", self.run_macos_arm), - *input_arg("run-windows", self.run_windows), + *(arg for os_name in all_oses for arg in input_arg(f"run-{os_name}", os_name in self.oses)), *input_arg("full-python-matrix", self.full_python_matrix), ], error="Failed to dispatch workflow", From 49e2e043975a0a8e23dc5f00d74d46871d2f9f62 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 22 Nov 2024 13:40:59 -0500 Subject: [PATCH 14/26] add `--remote` --- chia/cmds/gh.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index 0fea61eaf8b2..b0b135ca9820 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -109,6 +109,7 @@ class TestCMD: full_python_matrix: bool = option( "--full-python-matrix/--default-python-matrix", help="Run on all Python versions", default=False ) + remote: str = option("-r", "--remote", help="Name of git remote", type=str, default="origin") async def run(self) -> None: await self.check_only() @@ -126,7 +127,7 @@ async def run(self) -> None: temp_branch_name = f"tmp/{username}/{commit_sha}/{uuid.uuid4()}" process = await anyio.run_process( - command=["git", "push", "origin", f"HEAD:{temp_branch_name}"], check=False, stdout=None, stderr=None + command=["git", "push", self.remote, f"HEAD:{temp_branch_name}"], check=False, stdout=None, stderr=None ) if process.returncode != 0: raise click.ClickException("Failed to push temporary branch") @@ -150,7 +151,7 @@ async def run(self) -> None: finally: report(f"deleting temporary branch: {temp_branch_name}") process = await anyio.run_process( - command=["git", "push", "origin", "-d", temp_branch_name], check=False, stdout=None, stderr=None + command=["git", "push", self.remote, "-d", temp_branch_name], check=False, stdout=None, stderr=None ) if process.returncode != 0: raise click.ClickException("Failed to dispatch workflow") From 8cc7b3bf03484cd6423bd8ae1bcad43d9e251187 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 22 Nov 2024 13:58:24 -0500 Subject: [PATCH 15/26] load a url for a specified `--ref` as well --- chia/cmds/gh.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index b0b135ca9820..8b041bae3fa3 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -3,6 +3,7 @@ import json import os import shlex +import urllib import uuid import webbrowser from pathlib import Path @@ -114,8 +115,20 @@ class TestCMD: async def run(self) -> None: await self.check_only() + username = await self.get_username() + if self.ref is not None: await self.trigger_workflow(self.ref) + query = "+".join( + [ + "event=workflow_dispatch", + f"branch={self.ref}", + f"actor={username}", + ] + ) + run_url = f"https://github.com/Chia-Network/chia-blockchain/actions/workflows/test.yml?query={urllib.parse.quote(query)}" + report(f"waiting a few seconds to load: {run_url}") + await anyio.sleep(10) else: process = await anyio.run_process(command=["git", "rev-parse", "HEAD"], check=True, stderr=None) if process.returncode != 0: @@ -123,7 +136,6 @@ async def run(self) -> None: commit_sha = process.stdout.decode("utf-8").strip() - username = await self.get_username() temp_branch_name = f"tmp/{username}/{commit_sha}/{uuid.uuid4()}" process = await anyio.run_process( @@ -157,8 +169,8 @@ async def run(self) -> None: raise click.ClickException("Failed to dispatch workflow") report(f"temporary branch deleted: {temp_branch_name}") - report(f"run url: {run_url}") - webbrowser.open(run_url) + report(f"run url: {run_url}") + webbrowser.open(run_url) async def check_only(self) -> None: if self.only is not None: From 41a7740cdd189a61d463b8915df485339a868d89 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 22 Nov 2024 14:00:43 -0500 Subject: [PATCH 16/26] both helps --- chia/cmds/gh.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index 8b041bae3fa3..c918360fa1ef 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -72,17 +72,17 @@ def gh_group() -> None: @chia_command( gh_group, name="test", - help="launch a test run in CI from HEAD or existing remote ref", - # help="""Allows easy triggering and viewing of test workflow runs in CI including - # configuration of parameters. If a ref is specified then it must exist on the - # remote and a run will be launched for it. If ref is not specified then the local - # HEAD will be pushed to a temporary remote branch and a run will be launched for - # that. There is no need to push the local commit first. The temporary remote - # branch will automatically be deleted in most cases. - # - # After launching the workflow run the remote will be queried for the run and the - # URL will be opened in the default browser. - # """, + short_help="launch a test run in CI from HEAD or existing remote ref", + help="""Allows easy triggering and viewing of test workflow runs in CI including + configuration of parameters. If a ref is specified then it must exist on the + remote and a run will be launched for it. If ref is not specified then the local + HEAD will be pushed to a temporary remote branch and a run will be launched for + that. There is no need to push the local commit first. The temporary remote + branch will automatically be deleted in most cases. + + After launching the workflow run the remote will be queried for the run and the + URL will be opened in the default browser. + """, ) class TestCMD: workflow_id: ClassVar[str] = "test.yml" From 5d7f4868e068db871fae12b79640e266c9eb2160 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 22 Nov 2024 14:03:05 -0500 Subject: [PATCH 17/26] show defaults in `--help` --- chia/cmds/gh.py | 58 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index c918360fa1ef..891309fb6b4f 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -86,31 +86,75 @@ def gh_group() -> None: ) class TestCMD: workflow_id: ClassVar[str] = "test.yml" - owner: str = option("-o", "--owner", help="Owner of the repo", type=str, default="Chia-Network") - repository: str = option("-r", "--repository", help="Repository name", type=str, default="chia-blockchain") + owner: str = option( + "-o", + "--owner", + help="Owner of the repo", + type=str, + default="Chia-Network", + show_default=True, + ) + repository: str = option( + "-r", + "--repository", + help="Repository name", + type=str, + default="chia-blockchain", + show_default=True, + ) ref: Optional[str] = option( "-f", "--ref", help="Branch or tag name (commit SHA not supported), if not specified will push HEAD to a temporary branch", type=str, default=None, + show_default=True, + ) + per: Per = option( + "-p", + "--per", + help="Per", + type=click.Choice(["directory", "file"]), + default="directory", + show_default=True, ) - per: Per = option("-p", "--per", help="Per", type=click.Choice(["directory", "file"]), default="directory") only: Optional[Path] = option( - "-o", "--only", help="Only run this item, a file or directory depending on --per", type=Path + "-o", + "--only", + help="Only run this item, a file or directory depending on --per", + type=Path, + show_default=True, + ) + duplicates: int = option( + "-d", + "--duplicates", + help="Number of duplicates", + type=int, + default=1, + show_default=True, ) - duplicates: int = option("-d", "--duplicates", help="Number of duplicates", type=int, default=1) oses: Sequence[Oses] = option( "--os", help="Operating systems to run on", type=click.Choice(all_oses), multiple=True, default=all_oses, + show_default=True, ) full_python_matrix: bool = option( - "--full-python-matrix/--default-python-matrix", help="Run on all Python versions", default=False + "--full-python-matrix/--default-python-matrix", + help="Run on all Python versions", + default=False, + show_default=True, + ) + remote: str = option( + "-r", + "--remote", + help="Name of git remote", + type=str, + default="origin", + show_default=True, ) - remote: str = option("-r", "--remote", help="Name of git remote", type=str, default="origin") async def run(self) -> None: await self.check_only() From 320f1cd63d71b822981a10d153105c6d2c24526e Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 22 Nov 2024 14:03:37 -0500 Subject: [PATCH 18/26] help touchup --- chia/cmds/gh.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index 891309fb6b4f..c32b3ce48178 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -80,8 +80,8 @@ def gh_group() -> None: that. There is no need to push the local commit first. The temporary remote branch will automatically be deleted in most cases. - After launching the workflow run the remote will be queried for the run and the - URL will be opened in the default browser. + After launching the workflow run GitHub will be queried for the run and the URL + will be opened in the default browser. """, ) class TestCMD: From 95df8568281005a86ffe9609ed6568167d51022e Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 22 Nov 2024 14:28:58 -0500 Subject: [PATCH 19/26] Revert "show defaults in `--help`" This reverts commit 5d7f4868e068db871fae12b79640e266c9eb2160. --- chia/cmds/gh.py | 58 ++++++------------------------------------------- 1 file changed, 7 insertions(+), 51 deletions(-) diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index c32b3ce48178..e06548413728 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -86,75 +86,31 @@ def gh_group() -> None: ) class TestCMD: workflow_id: ClassVar[str] = "test.yml" - owner: str = option( - "-o", - "--owner", - help="Owner of the repo", - type=str, - default="Chia-Network", - show_default=True, - ) - repository: str = option( - "-r", - "--repository", - help="Repository name", - type=str, - default="chia-blockchain", - show_default=True, - ) + owner: str = option("-o", "--owner", help="Owner of the repo", type=str, default="Chia-Network") + repository: str = option("-r", "--repository", help="Repository name", type=str, default="chia-blockchain") ref: Optional[str] = option( "-f", "--ref", help="Branch or tag name (commit SHA not supported), if not specified will push HEAD to a temporary branch", type=str, default=None, - show_default=True, - ) - per: Per = option( - "-p", - "--per", - help="Per", - type=click.Choice(["directory", "file"]), - default="directory", - show_default=True, ) + per: Per = option("-p", "--per", help="Per", type=click.Choice(["directory", "file"]), default="directory") only: Optional[Path] = option( - "-o", - "--only", - help="Only run this item, a file or directory depending on --per", - type=Path, - show_default=True, - ) - duplicates: int = option( - "-d", - "--duplicates", - help="Number of duplicates", - type=int, - default=1, - show_default=True, + "-o", "--only", help="Only run this item, a file or directory depending on --per", type=Path ) + duplicates: int = option("-d", "--duplicates", help="Number of duplicates", type=int, default=1) oses: Sequence[Oses] = option( "--os", help="Operating systems to run on", type=click.Choice(all_oses), multiple=True, default=all_oses, - show_default=True, ) full_python_matrix: bool = option( - "--full-python-matrix/--default-python-matrix", - help="Run on all Python versions", - default=False, - show_default=True, - ) - remote: str = option( - "-r", - "--remote", - help="Name of git remote", - type=str, - default="origin", - show_default=True, + "--full-python-matrix/--default-python-matrix", help="Run on all Python versions", default=False ) + remote: str = option("-r", "--remote", help="Name of git remote", type=str, default="origin") async def run(self) -> None: await self.check_only() From 439e89ab4fdb57b817c2f998fa9fb4016aed35e1 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Mon, 25 Nov 2024 20:41:36 -0500 Subject: [PATCH 20/26] r --- chia/_tests/cmds/test_dev_gh.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chia/_tests/cmds/test_dev_gh.py b/chia/_tests/cmds/test_dev_gh.py index 18acd9e3b3a2..2a16e6f8b4fb 100644 --- a/chia/_tests/cmds/test_dev_gh.py +++ b/chia/_tests/cmds/test_dev_gh.py @@ -47,5 +47,5 @@ async def test_invalid_only(case: InvalidOnlyCase) -> None: assert not test_root.joinpath(case.only).exists() explanation = "does not exist" - with pytest.raises(click.ClickException, match=f"\bto be a {re.escape(case.per)}\b.*\b{re.escape(explanation)}\b"): + with pytest.raises(click.ClickException, match=rf"\bto be a {re.escape(case.per)}\b.*\b{re.escape(explanation)}\b"): await cmd.run() From ba69fd288ff34cecf50e2d2e9e1924088def26bc Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 3 Dec 2024 17:17:50 -0500 Subject: [PATCH 21/26] add an actual dispatch test skeleton --- chia/_tests/cmds/test_dev_gh.py | 39 +++++++++++++++++++++++++++++++++ chia/cmds/gh.py | 4 +++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/chia/_tests/cmds/test_dev_gh.py b/chia/_tests/cmds/test_dev_gh.py index 2a16e6f8b4fb..9fe49b95c779 100644 --- a/chia/_tests/cmds/test_dev_gh.py +++ b/chia/_tests/cmds/test_dev_gh.py @@ -4,9 +4,13 @@ from dataclasses import dataclass from pathlib import Path +import aiohttp import click import pytest +# TODO: update after resolution in https://github.com/pytest-dev/pytest/issues/7469 +from _pytest.capture import CaptureFixture + import chia._tests from chia._tests.util.misc import Marks, datacases from chia.cmds.gh import Per, TestCMD @@ -49,3 +53,38 @@ async def test_invalid_only(case: InvalidOnlyCase) -> None: with pytest.raises(click.ClickException, match=rf"\bto be a {re.escape(case.per)}\b.*\b{re.escape(explanation)}\b"): await cmd.run() + + +@pytest.mark.anyio +async def test_successfully_dispatches( + capsys: CaptureFixture[str], +) -> None: + cmd = TestCMD( + # TODO: stop hardcoding here + owner="chia-network", + repository="chia-blockchain", + per="file", + only=Path("util/test_errors.py"), + duplicates=2, + oses=["linux", "macos-arm"], + full_python_matrix=True, + open_browser=False, + ) + + capsys.readouterr() + await cmd.run() + stdout, stderr = capsys.readouterr() + + assert len(stderr.strip()) == 0 + for line in stdout.splitlines(): + match = re.search(r"(?<=\brun url: )(?P.*)", line) + if match is None: + continue + url = match.group("url") + break + else: + pytest.fail(f"Failed to find run url in: {stdout}") + + async with aiohttp.ClientSession(raise_for_status=True) as client: + async with client.get(url): + pass diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index e06548413728..bbaecf22c2ef 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -111,6 +111,7 @@ class TestCMD: "--full-python-matrix/--default-python-matrix", help="Run on all Python versions", default=False ) remote: str = option("-r", "--remote", help="Name of git remote", type=str, default="origin") + open_browser: bool = option("--open-browser/--no-open-browser", help="Open browser", default=True) async def run(self) -> None: await self.check_only() @@ -170,7 +171,8 @@ async def run(self) -> None: report(f"temporary branch deleted: {temp_branch_name}") report(f"run url: {run_url}") - webbrowser.open(run_url) + if self.open_browser: + webbrowser.open(run_url) async def check_only(self) -> None: if self.only is not None: From b0fce3f4be3798e0bf6df7f1a0ce26e321a15060 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 3 Dec 2024 21:28:48 -0500 Subject: [PATCH 22/26] do not conflict check `tmp/**` --- .github/workflows/conflict-check.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/conflict-check.yml b/.github/workflows/conflict-check.yml index 3782b764854a..6182e31f6bc3 100644 --- a/.github/workflows/conflict-check.yml +++ b/.github/workflows/conflict-check.yml @@ -2,6 +2,8 @@ name: 🩹 Conflict Check on: # So that PRs touching the same files as the push are updated push: + branches-ignore: + - "tmp/**" # So that the `dirtyLabel` is removed if conflicts are resolve # We recommend `pull_request_target` so that github secrets are available. # In `pull_request` we wouldn't be able to change labels of fork PRs From a2b06c18bfca4dea75b5626fd73fa0a06e678435 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 3 Dec 2024 21:47:47 -0500 Subject: [PATCH 23/26] assert a bit more about the resulting run --- chia/_tests/cmds/test_dev_gh.py | 50 +++++++++++++++++++++++++++---- chia/cmds/gh.py | 53 ++++++++++++++++++++++----------- 2 files changed, 80 insertions(+), 23 deletions(-) diff --git a/chia/_tests/cmds/test_dev_gh.py b/chia/_tests/cmds/test_dev_gh.py index 9fe49b95c779..668b9e34d6b1 100644 --- a/chia/_tests/cmds/test_dev_gh.py +++ b/chia/_tests/cmds/test_dev_gh.py @@ -5,6 +5,7 @@ from pathlib import Path import aiohttp +import anyio import click import pytest @@ -13,7 +14,7 @@ import chia._tests from chia._tests.util.misc import Marks, datacases -from chia.cmds.gh import Per, TestCMD +from chia.cmds.gh import Per, TestCMD, get_gh_token test_root = Path(chia._tests.__file__).parent @@ -77,7 +78,7 @@ async def test_successfully_dispatches( assert len(stderr.strip()) == 0 for line in stdout.splitlines(): - match = re.search(r"(?<=\brun url: )(?P.*)", line) + match = re.search(r"(?<=\brun api url: )(?P.*)", line) if match is None: continue url = match.group("url") @@ -85,6 +86,45 @@ async def test_successfully_dispatches( else: pytest.fail(f"Failed to find run url in: {stdout}") - async with aiohttp.ClientSession(raise_for_status=True) as client: - async with client.get(url): - pass + token = await get_gh_token() + headers = {"Authorization": f"Bearer {token}"} + + async with aiohttp.ClientSession(raise_for_status=True, headers=headers) as client: + while True: + async with client.get(url) as response: + d = await response.json() + jobs_url = d["jobs_url"] + conclusion = d["conclusion"] + + print("conclusion:", conclusion) + if conclusion is None: + await anyio.sleep(5) + continue + + break + + async with client.get(jobs_url) as response: + d = await response.json() + jobs = d["jobs"] + + by_name = {job["name"]: job for job in jobs} + + assert by_name["Configure matrix"]["conclusion"] == "success" + assert by_name["macos-intel"]["conclusion"] == "skipped" + assert by_name["windows"]["conclusion"] == "skipped" + + versions = ["3.9", "3.10", "3.11", "3.12"] + runs_by_name: dict[str, list[str]] = {name: [] for name in ["ubuntu", "macos-arm"]} + for name in by_name: + platform, _, rest = name.partition(" / ") + + jobs = runs_by_name.get(platform) + if jobs is None: + continue + + jobs.append(rest) + + expected = len(versions) * cmd.duplicates + print("expected:", expected) + print("runs_by_name:", runs_by_name) + assert len({expected, *(len(runs) for runs in runs_by_name.values())}) == 1 diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index bbaecf22c2ef..e7d949aea12f 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -31,6 +31,17 @@ def report(*args: str) -> None: print(" ====", *args) +async def get_gh_token() -> str: + command = ["gh", "auth", "token"] + report(f"running command: {shlex.join(command)}") + process = await anyio.run_process(command=command, check=False, stderr=None) + + if process.returncode != 0: + raise click.ClickException("failed to get gh cli personal access token") + + return process.stdout.decode("utf-8").strip() + + @overload async def run_gh_api(method: Method, args: list[str], error: str) -> None: ... @overload @@ -64,6 +75,18 @@ async def run_gh_api(method: Method, args: list[str], error: str, capture_stdout return None +def input_arg(name: str, value: object, cond: bool = True) -> list[str]: + if not cond: + return [] + + assert value is not None + + if isinstance(value, os.PathLike): + value = os.fspath(value) + dumped = yaml.safe_dump(value).partition("\n")[0] + return [f"-f=inputs[{name}]={dumped}"] + + @click.group("gh", help="For working with GitHub") def gh_group() -> None: pass @@ -152,8 +175,9 @@ async def run(self) -> None: try: report("looking for run") - run_url = await self.find_run(temp_branch_name) - report(f"run found at: {run_url}") + html_url, api_url = await self.find_run(temp_branch_name) + report(f"run found at: {html_url}") + report(f"run found at: {api_url}") except UnexpectedFormError: report("run not found") continue @@ -170,7 +194,8 @@ async def run(self) -> None: raise click.ClickException("Failed to dispatch workflow") report(f"temporary branch deleted: {temp_branch_name}") - report(f"run url: {run_url}") + report(f"run html url: {html_url}") + report(f"run api url: {api_url}") if self.open_browser: webbrowser.open(run_url) @@ -191,17 +216,6 @@ async def check_only(self) -> None: raise click.ClickException(message) async def trigger_workflow(self, ref: str) -> None: - def input_arg(name: str, value: object, cond: bool = True) -> list[str]: - if not cond: - return [] - - assert value is not None - - if isinstance(value, os.PathLike): - value = os.fspath(value) - dumped = yaml.safe_dump(value).partition("\n")[0] - return [f"-f=inputs[{name}]={dumped}"] - # https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28#create-a-workflow-dispatch-event await run_gh_api( method="POST", @@ -218,7 +232,7 @@ def input_arg(name: str, value: object, cond: bool = True) -> list[str]: ) report(f"workflow triggered on branch: {ref}") - async def find_run(self, ref: str) -> str: + async def find_run(self, ref: str) -> tuple[str, str]: # https://docs.github.com/en/rest/actions/workflow-runs?apiVersion=2022-11-28#list-workflow-runs-for-a-workflow stdout = await run_gh_api( method="GET", @@ -237,10 +251,13 @@ async def find_run(self, ref: str) -> str: except ValueError: raise UnexpectedFormError(f"expected 1 run, got: {len(runs)}") - url = run["html_url"] + html_url = run["html_url"] + assert isinstance(html_url, str), f"expected html url to be a string, got: {html_url!r}" + + api_url = run["url"] + assert isinstance(api_url, str), f"expected url to be a string, got: {api_url!r}" - assert isinstance(url, str), f"expected url to be a string, got: {url!r}" - return url + return html_url, api_url async def get_username(self) -> str: # https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-the-authenticated-user From d5a9c20da05ca52a99a228d4f0f4c90b3067101c Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Tue, 3 Dec 2024 21:49:06 -0500 Subject: [PATCH 24/26] oops --- chia/cmds/gh.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index e7d949aea12f..75284b3a5b1a 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -197,7 +197,7 @@ async def run(self) -> None: report(f"run html url: {html_url}") report(f"run api url: {api_url}") if self.open_browser: - webbrowser.open(run_url) + webbrowser.open(html_url) async def check_only(self) -> None: if self.only is not None: From 5a6e872a46483fedb6998765738612be1057b527 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 13 Dec 2024 10:56:14 -0500 Subject: [PATCH 25/26] skip the test for now --- chia/_tests/cmds/test_dev_gh.py | 1 + 1 file changed, 1 insertion(+) diff --git a/chia/_tests/cmds/test_dev_gh.py b/chia/_tests/cmds/test_dev_gh.py index 668b9e34d6b1..1d397b1b6355 100644 --- a/chia/_tests/cmds/test_dev_gh.py +++ b/chia/_tests/cmds/test_dev_gh.py @@ -56,6 +56,7 @@ async def test_invalid_only(case: InvalidOnlyCase) -> None: await cmd.run() +@pytest.mark.skip("considering inclusion, but not yet") @pytest.mark.anyio async def test_successfully_dispatches( capsys: CaptureFixture[str], From 081715db7461727a173a06c9123e21fc1cba6bf7 Mon Sep 17 00:00:00 2001 From: Kyle Altendorf Date: Fri, 13 Dec 2024 11:05:14 -0500 Subject: [PATCH 26/26] pre-commit --- chia/cmds/gh.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/chia/cmds/gh.py b/chia/cmds/gh.py index 75284b3a5b1a..0386f4bcda2c 100644 --- a/chia/cmds/gh.py +++ b/chia/cmds/gh.py @@ -6,8 +6,9 @@ import urllib import uuid import webbrowser +from collections.abc import Sequence from pathlib import Path -from typing import Callable, ClassVar, Literal, Optional, Sequence, Union, overload +from typing import Callable, ClassVar, Literal, Optional, Union, overload import anyio import click