]> git.madduck.net Git - etc/vim.git/blobdiff - scripts/diff_shades_gha_helper.py

madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

All patches and comments are welcome. Please squash your changes to logical commits before using git-format-patch and git-send-email to patches@git.madduck.net. If you'd read over the Git project's submission guidelines and adhered to them, I'd be especially grateful.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

[pre-commit.ci] pre-commit autoupdate (#3837)
[etc/vim.git] / scripts / diff_shades_gha_helper.py
index 21e04a590a11bff6d597bd2f16a8e68bd4ab9c38..7a58fbe9b2809a616c97ad594e2c678edf8b29fc 100644 (file)
@@ -21,9 +21,10 @@ import pprint
 import subprocess
 import sys
 import zipfile
+from base64 import b64encode
 from io import BytesIO
 from pathlib import Path
-from typing import Any, Dict, Optional, Tuple
+from typing import Any
 
 import click
 import urllib3
@@ -34,7 +35,7 @@ if sys.version_info >= (3, 8):
 else:
     from typing_extensions import Final, Literal
 
-COMMENT_BODY_FILE: Final = ".pr-comment-body.md"
+COMMENT_FILE: Final = ".pr-comment.json"
 DIFF_STEP_NAME: Final = "Generate HTML diff report"
 DOCS_URL: Final = (
     "https://black.readthedocs.io/en/latest/"
@@ -52,22 +53,29 @@ def set_output(name: str, value: str) -> None:
         print(f"[INFO]: setting '{name}' to '{value}'")
     else:
         print(f"[INFO]: setting '{name}' to [{len(value)} chars]")
-    print(f"::set-output name={name}::{value}")
 
+    if "GITHUB_OUTPUT" in os.environ:
+        if "\n" in value:
+            # https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings
+            delimiter = b64encode(os.urandom(16)).decode()
+            value = f"{delimiter}\n{value}\n{delimiter}"
+            command = f"{name}<<{value}"
+        else:
+            command = f"{name}={value}"
+        with open(os.environ["GITHUB_OUTPUT"], "a") as f:
+            print(command, file=f)
 
-def http_get(
-    url: str,
-    is_json: bool = True,
-    headers: Optional[Dict[str, str]] = None,
-    **kwargs: Any,
-) -> Any:
-    headers = headers or {}
+
+def http_get(url: str, *, is_json: bool = True, **kwargs: Any) -> Any:
+    headers = kwargs.get("headers") or {}
     headers["User-Agent"] = USER_AGENT
     if "github" in url:
         if GH_API_TOKEN:
             headers["Authorization"] = f"token {GH_API_TOKEN}"
         headers["Accept"] = "application/vnd.github.v3+json"
-    r = http.request("GET", url, headers=headers, **kwargs)
+    kwargs["headers"] = headers
+
+    r = http.request("GET", url, **kwargs)
     if is_json:
         data = json.loads(r.data.decode("utf-8"))
     else:
@@ -81,10 +89,10 @@ def http_get(
     return data
 
 
-def get_branch_or_tag_revision(sha: str = "main") -> str:
+def get_main_revision() -> str:
     data = http_get(
         f"https://api.github.com/repos/{REPO}/commits",
-        fields={"per_page": "1", "sha": sha},
+        fields={"per_page": "1", "sha": "main"},
     )
     assert isinstance(data[0]["sha"], str)
     return data[0]["sha"]
@@ -103,53 +111,18 @@ def get_pypi_version() -> Version:
     return sorted_versions[0]
 
 
-def resolve_custom_ref(ref: str) -> Tuple[str, str]:
-    if ref == ".pypi":
-        # Special value to get latest PyPI version.
-        version = str(get_pypi_version())
-        return version, f"git checkout {version}"
-
-    if ref.startswith(".") and ref[1:].isnumeric():
-        # Special format to get a PR.
-        number = int(ref[1:])
-        revision = get_pr_revision(number)
-        return (
-            f"pr-{number}-{revision[:SHA_LENGTH]}",
-            f"gh pr checkout {number} && git merge origin/main",
-        )
-
-    # Alright, it's probably a branch, tag, or a commit SHA, let's find out!
-    revision = get_branch_or_tag_revision(ref)
-    # We're cutting the revision short as we might be operating on a short commit SHA.
-    if revision == ref or revision[: len(ref)] == ref:
-        # It's *probably* a commit as the resolved SHA isn't different from the REF.
-        return revision[:SHA_LENGTH], f"git checkout {revision}"
-
-    # It's *probably* a pre-existing branch or tag, yay!
-    return f"{ref}-{revision[:SHA_LENGTH]}", f"git checkout {revision}"
-
-
 @click.group()
 def main() -> None:
     pass
 
 
 @main.command("config", help="Acquire run configuration and metadata.")
-@click.argument(
-    "event", type=click.Choice(["push", "pull_request", "workflow_dispatch"])
-)
-@click.argument("custom_baseline", required=False)
-@click.argument("custom_target", required=False)
-@click.option("--baseline-args", default="")
-def config(
-    event: Literal["push", "pull_request", "workflow_dispatch"],
-    custom_baseline: Optional[str],
-    custom_target: Optional[str],
-    baseline_args: str,
-) -> None:
+@click.argument("event", type=click.Choice(["push", "pull_request"]))
+def config(event: Literal["push", "pull_request"]) -> None:
     import diff_shades
 
     if event == "push":
+        jobs = [{"mode": "preview-changes", "force-flag": "--force-preview-style"}]
         # Push on main, let's use PyPI Black as the baseline.
         baseline_name = str(get_pypi_version())
         baseline_cmd = f"git checkout {baseline_name}"
@@ -159,11 +132,14 @@ def config(
         target_cmd = f"git checkout {target_rev}"
 
     elif event == "pull_request":
+        jobs = [
+            {"mode": "preview-changes", "force-flag": "--force-preview-style"},
+            {"mode": "assert-no-changes", "force-flag": "--force-stable-style"},
+        ]
         # PR, let's use main as the baseline.
-        baseline_rev = get_branch_or_tag_revision()
+        baseline_rev = get_main_revision()
         baseline_name = "main-" + baseline_rev[:SHA_LENGTH]
         baseline_cmd = f"git checkout {baseline_rev}"
-
         pr_ref = os.getenv("GITHUB_REF")
         assert pr_ref is not None
         pr_num = int(pr_ref[10:-6])
@@ -171,27 +147,20 @@ def config(
         target_name = f"pr-{pr_num}-{pr_rev[:SHA_LENGTH]}"
         target_cmd = f"gh pr checkout {pr_num} && git merge origin/main"
 
-        # These are only needed for the PR comment.
-        set_output("baseline-sha", baseline_rev)
-        set_output("target-sha", pr_rev)
-    else:
-        assert custom_baseline is not None and custom_target is not None
-        baseline_name, baseline_cmd = resolve_custom_ref(custom_baseline)
-        target_name, target_cmd = resolve_custom_ref(custom_target)
-        if baseline_name == target_name:
-            # Alright we're using the same revisions but we're (hopefully) using
-            # different command line arguments, let's support that too.
-            baseline_name += "-1"
-            target_name += "-2"
-
-    set_output("baseline-analysis", baseline_name + ".json")
-    set_output("baseline-setup-cmd", baseline_cmd)
-    set_output("target-analysis", target_name + ".json")
-    set_output("target-setup-cmd", target_cmd)
+    env = f"{platform.system()}-{platform.python_version()}-{diff_shades.__version__}"
+    for entry in jobs:
+        entry["baseline-analysis"] = f"{entry['mode']}-{baseline_name}.json"
+        entry["baseline-setup-cmd"] = baseline_cmd
+        entry["target-analysis"] = f"{entry['mode']}-{target_name}.json"
+        entry["target-setup-cmd"] = target_cmd
+        entry["baseline-cache-key"] = f"{env}-{baseline_name}-{entry['mode']}"
+        if event == "pull_request":
+            # These are only needed for the PR comment.
+            entry["baseline-sha"] = baseline_rev
+            entry["target-sha"] = pr_rev
 
-    key = f"{platform.system()}-{platform.python_version()}-{diff_shades.__version__}"
-    key += f"-{baseline_name}-{baseline_args.encode('utf-8').hex()}"
-    set_output("baseline-cache-key", key)
+    set_output("matrix", json.dumps(jobs, indent=None))
+    pprint.pprint(jobs)
 
 
 @main.command("comment-body", help="Generate the body for a summary PR comment.")
@@ -199,8 +168,9 @@ def config(
 @click.argument("target", type=click.Path(exists=True, path_type=Path))
 @click.argument("baseline-sha")
 @click.argument("target-sha")
+@click.argument("pr-num", type=int)
 def comment_body(
-    baseline: Path, target: Path, baseline_sha: str, target_sha: str
+    baseline: Path, target: Path, baseline_sha: str, target_sha: str, pr_num: int
 ) -> None:
     # fmt: off
     cmd = [
@@ -225,47 +195,41 @@ def comment_body(
         f"[**What is this?**]({DOCS_URL}) | [Workflow run]($workflow-run-url) |"
         " [diff-shades documentation](https://github.com/ichard26/diff-shades#readme)"
     )
-    print(f"[INFO]: writing half-completed comment body to {COMMENT_BODY_FILE}")
-    with open(COMMENT_BODY_FILE, "w", encoding="utf-8") as f:
-        f.write(body)
+    print(f"[INFO]: writing comment details to {COMMENT_FILE}")
+    with open(COMMENT_FILE, "w", encoding="utf-8") as f:
+        json.dump({"body": body, "pr-number": pr_num}, f)
 
 
 @main.command("comment-details", help="Get PR comment resources from a workflow run.")
 @click.argument("run-id")
 def comment_details(run_id: str) -> None:
     data = http_get(f"https://api.github.com/repos/{REPO}/actions/runs/{run_id}")
-    if data["event"] != "pull_request":
+    if data["event"] != "pull_request" or data["conclusion"] == "cancelled":
         set_output("needs-comment", "false")
         return
 
     set_output("needs-comment", "true")
-    pulls = data["pull_requests"]
-    assert len(pulls) == 1
-    pr_number = pulls[0]["number"]
-    set_output("pr-number", str(pr_number))
-
-    jobs_data = http_get(data["jobs_url"])
-    assert len(jobs_data["jobs"]) == 1, "multiple jobs not supported nor tested"
-    job = jobs_data["jobs"][0]
-    steps = {s["name"]: s["number"] for s in job["steps"]}
-    diff_step = steps[DIFF_STEP_NAME]
-    diff_url = job["html_url"] + f"#step:{diff_step}:1"
-
-    artifacts_data = http_get(data["artifacts_url"])["artifacts"]
-    artifacts = {a["name"]: a["archive_download_url"] for a in artifacts_data}
-    body_url = artifacts[COMMENT_BODY_FILE]
-    body_zip = BytesIO(http_get(body_url, is_json=False))
-    with zipfile.ZipFile(body_zip) as zfile:
-        with zfile.open(COMMENT_BODY_FILE) as rf:
-            body = rf.read().decode("utf-8")
+    jobs = http_get(data["jobs_url"])["jobs"]
+    job = next(j for j in jobs if j["name"] == "analysis / preview-changes")
+    diff_step = next(s for s in job["steps"] if s["name"] == DIFF_STEP_NAME)
+    diff_url = job["html_url"] + f"#step:{diff_step['number']}:1"
+
+    artifacts = http_get(data["artifacts_url"])["artifacts"]
+    comment_artifact = next(a for a in artifacts if a["name"] == COMMENT_FILE)
+    comment_url = comment_artifact["archive_download_url"]
+    comment_zip = BytesIO(http_get(comment_url, is_json=False))
+    with zipfile.ZipFile(comment_zip) as zfile:
+        with zfile.open(COMMENT_FILE) as rf:
+            comment_data = json.loads(rf.read().decode("utf-8"))
+
+    set_output("pr-number", str(comment_data["pr-number"]))
+    body = comment_data["body"]
     # It's more convenient to fill in these fields after the first workflow is done
     # since this command can access the workflows API (doing it in the main workflow
     # while it's still in progress seems impossible).
     body = body.replace("$workflow-run-url", data["html_url"])
     body = body.replace("$job-diff-url", diff_url)
-    # # https://github.community/t/set-output-truncates-multiline-strings/16852/3
-    escaped = body.replace("%", "%25").replace("\n", "%0A").replace("\r", "%0D")
-    set_output("comment-body", escaped)
+    set_output("comment-body", body)
 
 
 if __name__ == "__main__":