Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions .github/scripts/pr-panel-preview.sh
Original file line number Diff line number Diff line change
@@ -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 <owner/repo> <pr-number> [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="<!-- vortex-pr-panel -->"
report_marker="<!-- vortex-pr-panel-report -->"
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)."
100 changes: 100 additions & 0 deletions .github/scripts/pr_panel/README.md
Original file line number Diff line number Diff line change
@@ -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 <!--c:suite.random_access-->
```

plus a trailing state blob that records what the panel last rendered:

```markdown
<!--vortex-pr-panel:state:{"controls":{...},"rev":3,"v":1}-->
```

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 `<details>` 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
```
4 changes: 4 additions & 0 deletions .github/scripts/pr_panel/__init__.py
Original file line number Diff line number Diff line change
@@ -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."""
138 changes: 138 additions & 0 deletions .github/scripts/pr_panel/__main__.py
Original file line number Diff line number Diff line change
@@ -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"<!--c:{control_id}-->"):
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())
Loading
Loading