diff --git a/.github/scripts/pr-panel-preview.sh b/.github/scripts/pr-panel-preview.sh new file mode 100755 index 00000000000..7ee936a0753 --- /dev/null +++ b/.github/scripts/pr-panel-preview.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright the Vortex contributors +# +# Bounded stand-in for the `issue_comment: edited` webhook. +# +# GitHub only ever runs `issue_comment` workflows from the default branch, so a pull +# request that changes the panel cannot exercise a real click through the normal path. +# This script closes that gap by polling the panel comment for a while and applying +# whatever it finds, using the PR's own version of the code. Everything downstream of +# "we noticed the body changed" is identical to `pr-panel.yml`; only the trigger differs. +# +# Usage: pr-panel-preview.sh [window-seconds] [poll-seconds] [notice] +# Requires: gh (authenticated via GH_TOKEN), jq, python3. + +set -Eeuo pipefail + +repo="$1" +pr="$2" +window="${3:-1800}" +interval="${4:-15}" +notice="${5:-}" + +panel_marker="" +report_marker="" +scripts_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +work="$(mktemp -d)" +trap 'rm -rf "${work}"' EXIT + +current="${work}/current.md" +next="${work}/next.md" +report="${work}/report.md" +outputs="${work}/outputs" + +gh api "/repos/${repo}/issues/${pr}/comments" --paginate >"${work}/comments.json" +comment_id="$(jq -r --arg marker "${panel_marker}" \ + '.[] | select(.body | contains($marker)) | .id' "${work}/comments.json" | head -n 1)" + +if [ -z "${comment_id}" ]; then + echo "::error::no panel comment found on #${pr}" + exit 1 +fi + +fetch_body() { + gh api "/repos/${repo}/issues/comments/${comment_id}" --jq .body >"${current}" +} + +patch_body() { + jq -n --rawfile body "${next}" '{body: $body}' | + gh api --silent --method PATCH "/repos/${repo}/issues/comments/${comment_id}" --input - +} + +# Runs `pr_panel apply` and exports its GitHub-Actions outputs as shell variables. +apply() { + : >"${outputs}" + ( + cd "${scripts_dir}" + GITHUB_OUTPUT="${outputs}" python3 -m pr_panel apply \ + --body "${current}" \ + --out "${next}" \ + --timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + "$@" + ) + changed="$(sed -n 's/^changed=//p' "${outputs}")" + dispatch="$(sed -n 's/^dispatch=//p' "${outputs}")" + actions="$(sed -n 's/^actions=//p' "${outputs}")" + state="$(sed -n 's/^state=//p' "${outputs}")" +} + +echo "Watching panel comment ${comment_id} on #${pr} for ${window}s (every ${interval}s)" + +# Tell whoever is looking at the PR that their clicks are live, and for how long. +if [ -n "${notice}" ]; then + fetch_body + apply --notice "${notice}" + patch_body +fi + +deadline=$((SECONDS + window)) +applied=0 + +while [ "${SECONDS}" -lt "${deadline}" ]; do + fetch_body + apply + + if [ "${changed}" = "true" ]; then + patch_body + applied=$((applied + 1)) + echo "redrew the panel (dispatch=${dispatch}, actions=${actions:-none})" + fi + + if [ "${dispatch}" = "true" ]; then + # The production path dispatches `pr-panel-run.yml`, which is only dispatchable + # from the default branch. Rendering the report here exercises the same code. + ( + cd "${scripts_dir}" + python3 -m pr_panel report \ + --state "${state}" \ + --actions "${actions}" \ + --run-url "${RUN_URL:-}" \ + --out "${report}" + ) + bash "${scripts_dir}/upsert-comment.sh" "${repo}" "${pr}" "${report_marker}" "${report}" + fi + + sleep "${interval}" +done + +fetch_body +apply --notice "Preview window closed. Push to this branch to reopen it." +patch_body + +echo "Preview finished after applying ${applied} edit(s)." diff --git a/.github/scripts/pr_panel/README.md b/.github/scripts/pr_panel/README.md new file mode 100644 index 00000000000..3b647299c12 --- /dev/null +++ b/.github/scripts/pr_panel/README.md @@ -0,0 +1,100 @@ +# PR control panel + +An interactive form, rendered as a bot comment on every pull request. Clicking a +checkbox changes a setting; clicking a **button** dispatches a separate workflow run +with the whole settings blob attached. + +This exists to replace the current "add a label, the bot runs, the bot removes the +label" pattern for benchmarks, where the combinations outgrew what a label list can +show. Nothing here is wired to a real benchmark yet — the downstream workflow only +renders the state it received. + +## How it works + +GitHub gives markdown exactly one clickable control: the task list checkbox. Ticking +one in a comment rewrites that comment's body and delivers an `issue_comment: edited` +webhook. Everything else is built on top of that single primitive. + +``` +user ticks a box + │ + ▼ +issue_comment: edited ──► pr-panel.yml (apply) + │ parse checkboxes, diff against embedded state + │ canonicalize (radios, momentary buttons) + ├─► PATCH the comment ──► panel redraws + └─► workflow_dispatch ──► pr-panel-run.yml + renders state back to the PR +``` + +The comment body is the only storage. It holds one tagged task-list line per control: + +```markdown +- [x] Random access +``` + +plus a trailing state blob that records what the panel last rendered: + +```markdown + +``` + +Diffing the checkboxes against that blob is what identifies the click, and that is what +makes controls richer than a plain checkbox possible: + +| Control | Behaviour | +| --- | --- | +| `Toggle` | Persistent on/off. The click is the new value. | +| `Radio` | Pick-one. Checking an option unchecks its siblings on redraw; unchecking the selected one restores it, so a group is never empty. | +| `Button` | Momentary. A tick is an edge: it dispatches an action and is cleared on redraw. | +| `Section` | Grouping. A closed section renders as a folded `
` block. | + +Add or change controls in `spec.py`; `panel.py` needs no edits. + +## Properties worth knowing + +- **Authorization is GitHub's.** Only users with write access can tick a checkbox on + someone else's comment, so no separate permission check is needed. +- **No feedback loop.** The redraw is written with `GITHUB_TOKEN`, and edits made with + that token do not trigger workflows. +- **Races are serialized.** `pr-panel.yml` uses a per-PR concurrency group that never + cancels, and re-reads the comment body from the API rather than trusting the event + payload, so clicks that queue up are all observed. +- **Hand edits self-heal.** The panel is redrawn from canonical state, so prose someone + typed into the comment is replaced on the next click. +- **Redraws are skipped when they would be a no-op.** Ticking a toggle already leaves the + comment correct, so no write-back happens; only radios and buttons force a redraw. + +## Testing a change to the panel + +`issue_comment` and `pull_request_target` workflows always run from the **default +branch**, so a PR that changes the panel cannot exercise its own apply path. Two things +close that gap: + +- The panel is **posted** by the `pull_request` trigger for same-repo branches, which + does run the PR's version of the code. Fork PRs fall back to `pull_request_target`, + because a fork gets a read-only token under `pull_request`. +- Clicks are **applied** by the `preview` job, which runs only on PRs that touch + `.github/scripts/pr_panel/**` or `.github/workflows/pr-panel*.yml`. It polls the panel + comment for 30 minutes and applies whatever it finds, using `pr-panel-preview.sh`. + Everything downstream of "the body changed" is the same code the webhook path runs; + only the trigger differs. Push again to reopen the window. + +The preview job renders the run report inline rather than dispatching `pr-panel-run.yml`, +since `workflow_dispatch` also only works from the default branch. That hop is the one +part of the flow that genuinely cannot be exercised before merge. + +## Working on it locally + +```bash +cd .github/scripts + +# Render the panel as it would first appear. +python3 -m pr_panel demo + +# Simulate a click sequence; the trailing state blob shows the result. +python3 -m pr_panel demo --click suite.sql --click runner.machine:g5.xlarge --click action.run + +# Tests (pure stdlib, no install needed). +uv run --no-project --with pytest pytest tests/test_pr_panel.py +``` diff --git a/.github/scripts/pr_panel/__init__.py b/.github/scripts/pr_panel/__init__.py new file mode 100644 index 00000000000..51f6d1bcfad --- /dev/null +++ b/.github/scripts/pr_panel/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright the Vortex contributors + +"""Interactive PR control panel rendered as a GitHub comment.""" diff --git a/.github/scripts/pr_panel/__main__.py b/.github/scripts/pr_panel/__main__.py new file mode 100644 index 00000000000..f7e06728fd7 --- /dev/null +++ b/.github/scripts/pr_panel/__main__.py @@ -0,0 +1,138 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright the Vortex contributors + +"""CLI used by the PR control panel workflows. + +Subcommands: + +* ``render`` — emit the body of a brand-new panel comment. +* ``apply`` — read an edited panel comment, emit the redrawn body, and report which + buttons were pressed via ``$GITHUB_OUTPUT``. +* ``report`` — render a state blob as a table (run by the downstream workflow). +* ``demo`` — render the panel locally and optionally simulate clicks, so the layout + can be eyeballed without pushing to CI. +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +from .panel import apply_edit, initial_state, parse_checkboxes, render, render_report + + +def _write_output(pairs: dict[str, str]) -> None: + """Append ``key=value`` pairs to ``$GITHUB_OUTPUT`` if we are inside Actions.""" + path = os.environ.get("GITHUB_OUTPUT") + if not path: + for key, value in pairs.items(): + print(f"{key}={value}", file=sys.stderr) + return + with open(path, "a", encoding="utf-8") as handle: + for key, value in pairs.items(): + if "\n" in value: + raise ValueError(f"output {key!r} must be single-line") + handle.write(f"{key}={value}\n") + + +def _cmd_render(args: argparse.Namespace) -> int: + Path(args.out).write_text(render(initial_state(notice=args.notice)), encoding="utf-8") + return 0 + + +def _cmd_apply(args: argparse.Namespace) -> int: + body = Path(args.body).read_text(encoding="utf-8") + result = apply_edit( + body, + actor=args.actor, + timestamp=args.timestamp, + notice=args.notice, + ) + Path(args.out).write_text(result.body, encoding="utf-8") + _write_output( + { + "changed": "true" if result.changed else "false", + "dispatch": "true" if result.dispatched else "false", + "actions": ",".join(result.dispatched), + "state": result.state.to_json(), + } + ) + return 0 + + +def _cmd_report(args: argparse.Namespace) -> int: + report = render_report(args.state, run_url=args.run_url, actions=args.actions) + Path(args.out).write_text(report, encoding="utf-8") + return 0 + + +def _cmd_demo(args: argparse.Namespace) -> int: + body = render(initial_state()) + for click in args.click: + checked = parse_checkboxes(body) + if click not in checked: + raise SystemExit(f"unknown control {click!r}; known: {', '.join(sorted(checked))}") + body = _toggle_line(body, click) + result = apply_edit(body, actor="demo", timestamp="2026-01-01T00:00:00Z") + print( + f"--- click {click} -> pressed={result.pressed} changed={result.changed}", + file=sys.stderr, + ) + body = result.body + print(body, end="") + return 0 + + +def _toggle_line(body: str, control_id: str) -> str: + """Flip one checkbox, mimicking what GitHub writes when a user clicks it.""" + lines = body.splitlines() + for i, line in enumerate(lines): + if line.rstrip().endswith(f""): + if "- [ ]" in line: + lines[i] = line.replace("- [ ]", "- [x]", 1) + else: + lines[i] = line.replace("- [x]", "- [ ]", 1) + break + return "\n".join(lines) + "\n" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="pr_panel") + sub = parser.add_subparsers(dest="command", required=True) + + render_cmd = sub.add_parser("render", help="write a fresh panel body") + render_cmd.add_argument("--out", required=True) + render_cmd.add_argument("--notice", default="", help="banner shown above the controls") + render_cmd.set_defaults(func=_cmd_render) + + apply_cmd = sub.add_parser("apply", help="interpret an edited panel body") + apply_cmd.add_argument("--body", required=True, help="file holding the current comment body") + apply_cmd.add_argument("--out", required=True, help="file to write the redrawn body to") + apply_cmd.add_argument("--actor", default="") + apply_cmd.add_argument("--timestamp", default="") + apply_cmd.add_argument( + "--notice", + default=None, + help="replace the banner; omit to carry the existing one over", + ) + apply_cmd.set_defaults(func=_cmd_apply) + + report_cmd = sub.add_parser("report", help="render received state as a table") + report_cmd.add_argument("--state", required=True) + report_cmd.add_argument("--actions", default="") + report_cmd.add_argument("--run-url", default="") + report_cmd.add_argument("--out", required=True) + report_cmd.set_defaults(func=_cmd_report) + + demo_cmd = sub.add_parser("demo", help="render locally, optionally simulating clicks") + demo_cmd.add_argument("--click", action="append", default=[], metavar="CONTROL_ID") + demo_cmd.set_defaults(func=_cmd_demo) + + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/pr_panel/panel.py b/.github/scripts/pr_panel/panel.py new file mode 100644 index 00000000000..d20f0d42128 --- /dev/null +++ b/.github/scripts/pr_panel/panel.py @@ -0,0 +1,351 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright the Vortex contributors + +"""Render, parse, and apply clicks for the PR control panel. + +The comment body is the entire persistence layer. It carries: + +* one task-list line per control, each tagged with an invisible ```` marker, +* a trailing ```` blob holding the canonical state as + of the last render. + +A click rewrites a single ``- [ ]`` into ``- [x]`` (or back), which GitHub delivers as an +``issue_comment.edited`` event. Diffing the parsed checkboxes against the embedded state +tells us exactly which control the user touched, which is what makes momentary buttons +and pick-one radio groups expressible in plain markdown. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from typing import Any + +from .spec import PANEL, Button, Control, Panel, Radio, Toggle + +# Identifies a panel comment. Kept on its own line so `grep`/`contains()` in a workflow +# `if:` expression can cheaply pre-filter events. +MARKER = "" + +STATE_VERSION = 1 + +_CONTROL_RE = re.compile( + r"^\s*- \[(?P[ xX])\]\s*(?P
", ""]) + return out + + +def _render_control(control: Control, state: PanelState) -> list[str]: + if isinstance(control, Radio): + selected = state.controls.get(control.id, control.resolved_default) + out = [f"**{control.label}** — pick one:", ""] + for option in control.options: + out.append( + _checkbox(marker_id(control, option.id), _label(option), option.id == selected) + ) + out.append("") + return out + + if isinstance(control, Toggle): + checked = bool(state.controls.get(control.id, control.default)) + return [_checkbox(control.id, _label(control), checked)] + + # Buttons always render unchecked: they are edges, not state. + return [_checkbox(control.id, _label(control), False)] + + +def _label(control: Control) -> str: + return f"{control.label} — {control.help}" if control.help else control.label + + +def _checkbox(cid: str, label: str, checked: bool) -> str: + box = "x" if checked else " " + return f"- [{box}] {label} " + + +def render_report( + state_json: str, + *, + panel: Panel = PANEL, + run_url: str = "", + actions: str = "", +) -> str: + """Render received state as a table. + + This is what the downstream workflow prints: it proves the panel's state crosses the + workflow boundary intact, and stands in for whatever the run will eventually do. + """ + raw = json.loads(state_json) if state_json.strip() else {} + controls = dict(raw.get("controls") or {}) if isinstance(raw, dict) else {} + + out = ["", "", "## Panel run report", ""] + if actions: + out.append(f"Dispatched by: {', '.join(f'`{a}`' for a in actions.split(',') if a)}") + out.append("") + if run_url: + out.append(f"Rendered by [this workflow run]({run_url}).") + out.append("") + + out.extend(["| Control | Value |", "| --- | --- |"]) + for section in panel.sections: + for control in section.controls: + if isinstance(control, Button): + continue + value = controls.get(control.id) + out.append(f"| {section.title} / `{control.id}` | {_format_value(control, value)} |") + + out.extend(["", f"Panel revision {raw.get('rev', '?')}.", ""]) + return "\n".join(out) + "\n" + + +def _format_value(control: Control, value: Any) -> str: + if isinstance(control, Toggle): + return "✅ on" if value else "⬜ off" + return f"`{value}`" if value is not None else "_unset_" diff --git a/.github/scripts/pr_panel/spec.py b/.github/scripts/pr_panel/spec.py new file mode 100644 index 00000000000..e8c501c1b95 --- /dev/null +++ b/.github/scripts/pr_panel/spec.py @@ -0,0 +1,213 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright the Vortex contributors + +"""Declarative definition of the PR control panel. + +The panel is a single bot-authored PR comment whose markdown task list doubles as a +form. Every control owns a stable id that is embedded in the comment as an invisible +HTML comment, so labels can be reworded without breaking state. + +This module contains *only* the description of the UI. Rendering, parsing, and click +semantics live in ``panel.py``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +CONTROL_ID = str + + +@dataclass(frozen=True) +class Toggle: + """A persistent on/off control. Clicking it flips the stored value.""" + + id: CONTROL_ID + label: str + default: bool = False + help: str = "" + + +@dataclass(frozen=True) +class RadioOption: + """One choice within a `Radio` group.""" + + id: CONTROL_ID + label: str + help: str = "" + + +@dataclass(frozen=True) +class Radio: + """A pick-one group. Checking an option unchecks its siblings on the next render. + + Markdown has no `