diff --git a/.github/workflows/check_changelog.yml b/.github/workflows/check_changelog.yml new file mode 100644 index 00000000..40fc772e --- /dev/null +++ b/.github/workflows/check_changelog.yml @@ -0,0 +1,32 @@ +name: Check Changelog + +on: + push: + tags: + - "*" + branches: + - "*" + pull_request: + branches: + - main + +jobs: + changelog: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./src/py/ + steps: + - name: Run composite action + id: changelog + uses: geopozo/changelogtxt-parser@main + with: + file-path: ./src/py/ + get-tag: "from-push" + summarize-news: "from-pr" + - name: Check .editorconfig exists + run: | + test -e .editorconfig || exit 1 + - name: Check .pre-commit-config exists + run: | + test -e .pre-commit-config.y*ml || exit 1 diff --git a/.github/workflows/update_cdn.yml b/.github/workflows/update_cdn.yml new file mode 100644 index 00000000..1c6ae18e --- /dev/null +++ b/.github/workflows/update_cdn.yml @@ -0,0 +1,26 @@ +name: Update CDN + +permissions: + contents: write + pull-requests: write + issues: write + +on: + workflow_dispatch: + schedule: + - cron: "0 13 * * 1" + +jobs: + inspection: + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + defaults: + run: + working-directory: ./src/py/ + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - name: Update CDN & PR + run: uv run --python 3.12 scripts/update_cdn.py diff --git a/.gitignore b/.gitignore index 31e47007..4cb3eeda 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,7 @@ src/py/integration_tests/report* node_modules/ src/py/site/* -.hypothesis/ + +# Test +.hypothesis +.coverage diff --git a/src/py/scripts/update_cdn.py b/src/py/scripts/update_cdn.py new file mode 100644 index 00000000..5849f512 --- /dev/null +++ b/src/py/scripts/update_cdn.py @@ -0,0 +1,191 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "aiohttp", +# "changelogtxt_parser @ git+https://github.com/geopozo/changelogtxt-parser", +# "jq", +# "semver", +# ] +# /// + +import asyncio +import json +import os +import subprocess +import sys +from pathlib import Path + +import aiohttp +import changelogtxt_parser as changelog +import jq +import semver + +from py.kaleido._page_generator import DEFAULT_PLOTLY +from py.kaleido._page_generator import __file__ as FILE_PATH + +# ruff: noqa: T201 allow print in CLI + +REPO = os.environ["REPO"] +GITHUB_WORKSPACE = os.environ["GITHUB_WORKSPACE"] + + +async def run_cmd(commands: list[str]) -> tuple[bytes, bytes, int | None]: + proc = await asyncio.create_subprocess_exec( + *commands, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + stdout, stderr = await proc.communicate() + return stdout, stderr, proc.returncode + + +async def verify_url(url: str) -> bool: + try: + async with aiohttp.ClientSession() as session: + async with session.head(url) as response: + return response.status == 200 + except aiohttp.ClientError: + return False + + +async def get_latest_version() -> str: + out, err, _ = await run_cmd( + ["gh", "api", "repos/plotly/plotly.js/tags", "--paginate"] + ) + if err: + print(err.decode(), file=sys.stderr) + sys.exit(1) + + data = json.loads(out) + tags = jq.compile('map(.name | ltrimstr("v"))').input_value(data).first() + versions = [semver.VersionInfo.parse(v) for v in tags] + + return str(max(versions)) + + +async def create_pr(latest_version: str) -> None: + branch = f"bot/update-cdn-{latest_version}" + title = f"Update Plotly.js CDN to v{latest_version}" + body = f"This PR updates the CDN URL to v{latest_version}." + + _, brc_err, brc_eval = await run_cmd( + ["gh", "api", f"repos/{REPO}/branches/{branch}", "--silent"] + ) + branch_exists = brc_eval == 0 + + if branch_exists: + print(f"The branch {branch} already exists", file=sys.stderr) + sys.exit(1) + else: + msg = brc_err.decode() + if "HTTP 404" not in msg: + print(msg, file=sys.stderr) # unexpected errors + sys.exit(1) + + pr_exist, _, _ = await run_cmd( + ["gh", "pr", "list", "-R", REPO, "-H", branch, "--state", "all"] + ) + + if pr_exist: + print(f"Pull request for '{branch}' already exists", file=sys.stderr) + sys.exit(1) + + try: + changelog.update(f"v{latest_version}", title, GITHUB_WORKSPACE) + except (ValueError, RuntimeError): + print("Failed to update changelog", file=sys.stderr) + sys.exit(1) + + await run_cmd(["git", "checkout", "-b", branch]) + await run_cmd(["git", "add", "."]) + await run_cmd( + [ + "git", + "-c", + "user.name='github-actions'", + "-c", + "user.email='github-actions@github.com'", + "commit", + "-m", + f"chore: {title}", + ] + ) + _, push_err, push_eval = await run_cmd(["git", "push", "-u", "origin", branch]) + push_failed = push_eval == 1 + + if push_failed: + print(push_err.decode(), file=sys.stderr) + sys.exit(1) + + new_pr_out, pr_err, pr_eval = await run_cmd( + ["gh", "pr", "create", "-B", "master", "-H", branch, "-t", title, "-b", body] + ) + pr_failed = pr_eval == 1 + + if pr_failed: + print(pr_err.decode(), file=sys.stderr) + sys.exit(1) + + print("Pull request:", new_pr_out.decode().strip()) + + +async def verify_issue(title: str) -> None: + issues_out, _, _ = await run_cmd( + [ + "gh", + "issue", + "list", + "-R", + REPO, + "--search", + title, + "--state", + "all", + "--json", + "number,state", + ] + ) + issues = json.loads(issues_out.decode()) + + if issues: + print(f"Issue '{title}' already exists in:") + print(f"https://github.com/{REPO}/issues/{issues[0].get('number')}") + sys.exit(1) + + +async def create_issue(title: str, body: str) -> None: + new_issue, issue_err, _ = await run_cmd( + ["gh", "issue", "create", "-R", REPO, "-t", title, "-b", body] + ) + if issue_err: + print(issue_err.decode()) + sys.exit(1) + + print(f"The issue '{title}' was created in {new_issue.decode().strip()}") + + +async def main() -> None: + latest_version = await get_latest_version() + new_cdn = f"https://cdn.plot.ly/plotly-{latest_version}.js" + + if new_cdn == DEFAULT_PLOTLY: + print("Already up to date") + return + + cdn_exists = await verify_url(new_cdn) + + if cdn_exists: + file_path = Path(FILE_PATH) + content = file_path.read_text(encoding="utf-8") + updated = content.replace(DEFAULT_PLOTLY, new_cdn, 1) + + file_path.write_text(updated, encoding="utf-8") + + await create_pr(latest_version) + else: + title = f"CDN not reachable for Plotly.js v{latest_version}" + body = f"URL: {new_cdn} - invalid url" + + await verify_issue(title) + await create_issue(title, body) + + +asyncio.run(main()) diff --git a/src/py/uv.lock b/src/py/uv.lock index e37ac117..be5d558e 100644 --- a/src/py/uv.lock +++ b/src/py/uv.lock @@ -2900,4 +2900,4 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/93/8ebc19f0a31c44ea0e7348f9b0d4b326ed413b6575a3c6ff4ed50222abb6/zstandard-0.25.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89c4b48479a43f820b749df49cd7ba2dbc2b1b78560ecb5ab52985574fd40b27", size = 5362280, upload-time = "2025-09-14T22:18:45.625Z" }, { url = "https://files.pythonhosted.org/packages/b8/e9/29cc59d4a9d51b3fd8b477d858d0bd7ab627f700908bf1517f46ddd470ae/zstandard-0.25.0-cp39-cp39-win32.whl", hash = "sha256:1cd5da4d8e8ee0e88be976c294db744773459d51bb32f707a0f166e5ad5c8649", size = 436460, upload-time = "2025-09-14T22:18:49.077Z" }, { url = "https://files.pythonhosted.org/packages/41/b5/bc7a92c116e2ef32dc8061c209d71e97ff6df37487d7d39adb51a343ee89/zstandard-0.25.0-cp39-cp39-win_amd64.whl", hash = "sha256:37daddd452c0ffb65da00620afb8e17abd4adaae6ce6310702841760c2c26860", size = 506097, upload-time = "2025-09-14T22:18:47.342Z" }, -] +] \ No newline at end of file