-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathqa.py
More file actions
executable file
·473 lines (395 loc) · 18.2 KB
/
qa.py
File metadata and controls
executable file
·473 lines (395 loc) · 18.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""qa: local runner for pagerguild QA agents.
Bootstrap (no install needed):
uv run https://raw.githubusercontent.com/pagerguild/qa/main/qa.py \\
--target http://localhost:5173 --here
Discovers .qa/<agent>/ task folders in a target repo, runs them in
parallel matrix containers via act using the same qa-matrix.yml
workflow as production CI, and streams each agent's progress into
the team's Supabase reader.
Prereqs on the host: uv, gh (authed), doppler (with qa-team scope),
act, and Docker. On Apple Silicon, native arm64 containers are
selected automatically.
"""
from __future__ import annotations
import argparse
import atexit
import contextlib
import os
import platform
import re
import shutil
import signal
import subprocess
import sys
import tempfile
import textwrap
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
VERSION = "0.1.0"
CACHE_ROOT = Path.home() / ".cache" / "pagerguild-qa"
QA_TEAM_REPO = "pagerguild/qa-team"
QA_TEAM_CACHE = CACHE_ROOT / "qa-team"
RUNS_ROOT = CACHE_ROOT / "runs"
RUNNER_IMAGE_LOCAL = "pagerguild/qa-runner:local"
RUNNER_IMAGE_REGISTRY = "ghcr.io/pagerguild/qa-runner:latest" # populated when CI publishes
# ---------------------------------------------------------------------------
# Pretty-printing
# ---------------------------------------------------------------------------
def info(msg: str) -> None:
print(f"\033[36m›\033[0m {msg}", flush=True)
def warn(msg: str) -> None:
print(f"\033[33m!\033[0m {msg}", file=sys.stderr, flush=True)
def fatal(msg: str, code: int = 1) -> "None":
print(f"\033[31m✗\033[0m {msg}", file=sys.stderr, flush=True)
sys.exit(code)
# ---------------------------------------------------------------------------
# Pre-flight
# ---------------------------------------------------------------------------
def need(cmd: str, hint: str) -> None:
if shutil.which(cmd) is None:
fatal(f"{cmd!r} not found on PATH. {hint}")
def pre_flight() -> None:
need("gh", "Install with `brew install gh` and `gh auth login`.")
need("git", "Install Xcode CLT or `brew install git`.")
need("doppler", "Install with `brew install dopplerhq/cli/doppler` and `doppler login`.")
need("act", "Install with `brew install act`.")
need("docker", "Install Docker Desktop and ensure it's running.")
proc = subprocess.run(["docker", "info"], capture_output=True, text=True)
if proc.returncode != 0:
fatal("Docker daemon is not reachable. Start Docker Desktop and retry.")
# ---------------------------------------------------------------------------
# Doppler bridge — mint a 1-hour service token from the user's CLI session
# ---------------------------------------------------------------------------
@dataclass
class DopplerToken:
name: str
token: str
project: str
config: str
def revoke(self) -> None:
# `revoke [slug|token]` — pass the token itself (we don't capture
# the slug at create time). Idempotent enough for cleanup.
proc = subprocess.run(
["doppler", "configs", "tokens", "revoke", self.token,
"--project", self.project, "--config", self.config],
capture_output=True, text=True,
)
if proc.returncode != 0:
warn(f"failed to revoke Doppler token {self.name}: "
f"{proc.stderr.strip() or proc.stdout.strip()}")
def mint_doppler_token() -> DopplerToken:
project = subprocess.run(
["doppler", "configure", "get", "project", "--plain"],
capture_output=True, text=True,
).stdout.strip()
config = subprocess.run(
["doppler", "configure", "get", "config", "--plain"],
capture_output=True, text=True,
).stdout.strip()
if not project or not config:
fatal("No Doppler scope configured for this directory. "
"Run `doppler setup` from a directory with a .doppler.yaml, "
"or `doppler configure set project=qa-team config=prd --scope $PWD`.")
name = f"qa-local-{int(time.time())}"
proc = subprocess.run(
["doppler", "configs", "tokens", "create", name,
"--project", project, "--config", config,
"--max-age", "1h", "--plain"],
capture_output=True, text=True,
)
if proc.returncode != 0 or not proc.stdout.strip():
fatal("Failed to mint Doppler service token: "
f"{proc.stderr.strip() or proc.stdout.strip()}")
info(f"Minted Doppler service token {name} (project={project}, config={config}, max-age=1h)")
return DopplerToken(name=name, token=proc.stdout.strip(), project=project, config=config)
# ---------------------------------------------------------------------------
# qa-team checkout — always-latest from main, cached at ~/.cache/pagerguild-qa
# ---------------------------------------------------------------------------
def ensure_qa_team_cache() -> Path:
QA_TEAM_CACHE.parent.mkdir(parents=True, exist_ok=True)
if not QA_TEAM_CACHE.exists():
info(f"Cloning {QA_TEAM_REPO} → {QA_TEAM_CACHE}")
subprocess.run(
["gh", "repo", "clone", QA_TEAM_REPO, str(QA_TEAM_CACHE), "--", "--depth", "1"],
check=True,
)
else:
info(f"Refreshing {QA_TEAM_REPO} cache")
subprocess.run(["git", "-C", str(QA_TEAM_CACHE), "fetch", "--depth", "1", "origin", "main"], check=True)
subprocess.run(["git", "-C", str(QA_TEAM_CACHE), "reset", "--hard", "origin/main"], check=True)
return QA_TEAM_CACHE
# ---------------------------------------------------------------------------
# Target resolution — figure out which repo to test and at what SHA
# ---------------------------------------------------------------------------
@dataclass
class Target:
workspace: Path # local dir containing .qa/ and (for git repos) a .git/
owner: str
name: str
sha: str
ref: str
cleanup_dir: Optional[Path] = None # set when we cloned to a temp dir
def cleanup(self) -> None:
if self.cleanup_dir and self.cleanup_dir.exists():
shutil.rmtree(self.cleanup_dir, ignore_errors=True)
def _git(*args: str, cwd: Path) -> str:
return subprocess.run(["git", "-C", str(cwd), *args],
capture_output=True, text=True, check=True).stdout.strip()
def _origin_owner_name(cwd: Path) -> tuple[str, str]:
url = _git("remote", "get-url", "origin", cwd=cwd)
m = re.search(r"[:/]([^/:]+)/([^/]+?)(?:\.git)?$", url)
if not m:
fatal(f"Cannot parse owner/name from origin url: {url}")
return m.group(1), m.group(2)
def resolve_target(args: argparse.Namespace) -> Target:
if args.repo:
owner_name = args.repo
if "/" not in owner_name:
fatal("--repo must be OWNER/NAME")
owner, name = owner_name.split("/", 1)
tmp = Path(tempfile.mkdtemp(prefix="qa-target-", dir=str(CACHE_ROOT)))
info(f"Cloning {owner}/{name}@{args.branch} → {tmp}")
clone_args = ["gh", "repo", "clone", owner_name, str(tmp), "--", "--depth", "1"]
if args.branch:
clone_args[4:4] = [] # no-op; branch goes to git clone via --
clone_args = ["gh", "repo", "clone", owner_name, str(tmp), "--",
"--depth", "1", "--branch", args.branch]
subprocess.run(clone_args, check=True)
sha = _git("rev-parse", "HEAD", cwd=tmp)
ref = f"refs/heads/{args.branch}" if args.branch else _git(
"symbolic-ref", "--short", "HEAD", cwd=tmp)
return Target(workspace=tmp, owner=owner, name=name, sha=sha,
ref=f"refs/heads/{ref}" if not ref.startswith("refs/") else ref,
cleanup_dir=tmp)
workspace = Path(args.path or os.getcwd()).resolve()
if not (workspace / ".git").exists():
fatal(f"{workspace} is not a git repository (no .git/). "
"Use --repo OWNER/NAME if you don't have a local checkout.")
owner, name = _origin_owner_name(workspace)
sha = _git("rev-parse", "HEAD", cwd=workspace)
try:
ref = _git("symbolic-ref", "HEAD", cwd=workspace)
except subprocess.CalledProcessError:
ref = "refs/heads/HEAD"
return Target(workspace=workspace, owner=owner, name=name, sha=sha, ref=ref)
# ---------------------------------------------------------------------------
# Workspace prep — overlay qa-team's scripts/.github onto target's .qa/
# ---------------------------------------------------------------------------
def prepare_workspace(qa_team: Path, target: Target, qa_dir_name: str) -> Path:
RUNS_ROOT.mkdir(parents=True, exist_ok=True)
work = Path(tempfile.mkdtemp(prefix=f"run-{int(time.time())}-", dir=str(RUNS_ROOT)))
# Copy the target's .git first so `git rev-parse HEAD` in the workspace
# returns the target's SHA. act computes its own GITHUB_SHA from the
# workspace's HEAD and overrides --env passthrough — keeping the target's
# .git is the only way to make GITHUB_SHA propagate correctly through act.
shutil.copytree(target.workspace / ".git", work / ".git")
# Overlay qa-team's scripts + workflows on top.
shutil.copytree(qa_team / "scripts", work / "scripts")
shutil.copytree(qa_team / ".github", work / ".github")
# And the target's .qa task definitions.
qa_dir_src = target.workspace / qa_dir_name
if not qa_dir_src.exists():
fatal(f"Target has no {qa_dir_name}/ directory at {qa_dir_src}")
shutil.copytree(qa_dir_src, work / ".qa")
return work
# ---------------------------------------------------------------------------
# Localhost rewrite — make `localhost` reachable from inside the act container
# ---------------------------------------------------------------------------
LOCALHOST_RE = re.compile(r"^(https?://)(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|/|$)")
def rewrite_localhost(url: str) -> str:
rewritten = LOCALHOST_RE.sub(r"\1host.docker.internal\3", url)
if rewritten != url:
info(f"Rewriting --target {url} → {rewritten} (host.docker.internal)")
return rewritten
# ---------------------------------------------------------------------------
# act invocation
# ---------------------------------------------------------------------------
def detect_arch_flag() -> Optional[str]:
machine = platform.machine().lower()
if machine in ("arm64", "aarch64"):
return "linux/arm64"
if machine in ("x86_64", "amd64"):
return "linux/amd64"
warn(f"Unrecognized host arch {machine!r}; letting act pick its default.")
return None
def _ghcr_login_with_gh_token() -> bool:
"""Log in to ghcr.io using the user's gh CLI token. Idempotent.
Required when the package is private (GHCR's default for new packages).
Anyone with `gh auth login` already has the credentials we need.
"""
token_proc = subprocess.run(
["gh", "auth", "token"], capture_output=True, text=True,
)
token = token_proc.stdout.strip()
if token_proc.returncode != 0 or not token:
return False
user_proc = subprocess.run(
["gh", "api", "user", "--jq", ".login"],
capture_output=True, text=True,
)
user = user_proc.stdout.strip() or os.environ.get("USER", "github")
proc = subprocess.run(
["docker", "login", "ghcr.io", "-u", user, "--password-stdin"],
input=token, text=True, capture_output=True,
)
if proc.returncode != 0:
warn(f"docker login ghcr.io failed: {proc.stderr.strip() or proc.stdout.strip()}")
return False
info(f"Logged into ghcr.io as {user}")
return True
def ensure_runner_image() -> str:
"""Return the Docker image act should use as the ubuntu-latest runner.
Resolution order:
1. Already cached locally as RUNNER_IMAGE_LOCAL → use it.
2. Pull RUNNER_IMAGE_REGISTRY from GHCR. If the pull 401s (private
package), log in with `gh auth token` and retry once.
3. Dev fallback: build from a sibling Dockerfile (only present when
running from a qa-cli checkout, not when bootstrapped via `uv run`).
"""
inspect = subprocess.run(
["docker", "image", "inspect", RUNNER_IMAGE_LOCAL],
capture_output=True,
)
if inspect.returncode == 0:
info(f"Using cached runner image {RUNNER_IMAGE_LOCAL}")
return RUNNER_IMAGE_LOCAL
def _try_pull() -> bool:
return subprocess.run(["docker", "pull", RUNNER_IMAGE_REGISTRY]).returncode == 0
info(f"Pulling runner image from {RUNNER_IMAGE_REGISTRY}…")
if not _try_pull() and _ghcr_login_with_gh_token():
info("Retrying pull after ghcr.io login…")
_try_pull()
inspect2 = subprocess.run(
["docker", "image", "inspect", RUNNER_IMAGE_REGISTRY],
capture_output=True,
)
if inspect2.returncode == 0:
subprocess.run(
["docker", "tag", RUNNER_IMAGE_REGISTRY, RUNNER_IMAGE_LOCAL],
check=True,
)
return RUNNER_IMAGE_LOCAL
dockerfile = Path(__file__).resolve().parent / "Dockerfile"
if dockerfile.exists():
info(f"Pull failed; building runner image locally from {dockerfile} (~3 min)…")
proc = subprocess.run(
["docker", "build", "-t", RUNNER_IMAGE_LOCAL,
"-f", str(dockerfile), str(dockerfile.parent)],
)
if proc.returncode != 0:
fatal("docker build failed; see output above")
return RUNNER_IMAGE_LOCAL
fatal(
"Could not obtain runner image. Tried:\n"
f" • local cache: docker image inspect {RUNNER_IMAGE_LOCAL}\n"
f" • registry: docker pull {RUNNER_IMAGE_REGISTRY} (after gh-based login attempt)\n"
f" • local build: {dockerfile} (not found — uv-run scripts don't fetch siblings)\n"
"Check Docker is running, `gh auth status` works, and you can reach ghcr.io."
)
return "" # unreachable; appeases type checker
def run_act(work: Path, target: Target, target_url: str,
doppler_token: DopplerToken, args: argparse.Namespace) -> int:
# Write a one-off secrets file (chmod 600), unlinked in finally.
secrets_path = work / ".secrets"
secrets_path.write_text(f"DOPPLER_TOKEN={doppler_token.token}\n")
secrets_path.chmod(0o600)
arch = detect_arch_flag()
run_id = str(int(time.time() * 1000))
runner_image = ensure_runner_image()
cmd = [
"act", "workflow_dispatch",
"-W", ".github/workflows/test-action.yml",
"-P", f"ubuntu-latest={runner_image}",
"--pull=false",
"--secret-file", str(secrets_path),
"--input", f"target_url={target_url}",
"--input", "qa_dir=.qa",
"--env", f"GITHUB_REPOSITORY={target.owner}/{target.name}",
"--env", f"GITHUB_SHA={target.sha}",
"--env", f"GITHUB_REF={target.ref}",
"--env", f"GITHUB_RUN_ID={run_id}",
"--env", "GITHUB_EVENT_NAME=workflow_dispatch",
"--env", f"GITHUB_ACTOR={os.environ.get('USER', 'qa-local')}",
"--container-options", "--add-host=host.docker.internal:host-gateway",
]
if arch:
cmd += ["--container-architecture", arch]
if args.verbose:
cmd.append("--verbose")
info(f"Running act in {work} (arch={arch or 'default'})")
info(f" GITHUB_REPOSITORY={target.owner}/{target.name}")
info(f" GITHUB_SHA={target.sha[:12]}")
info(f" target-url={target_url}")
print(f"\033[2m$ cd {work} && {' '.join(cmd)}\033[0m", flush=True)
proc = subprocess.run(cmd, cwd=str(work))
return proc.returncode
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
prog="qa",
description="Local runner for pagerguild QA agents (act-driven).",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent("""\
examples:
qa --target http://localhost:5173 --here
qa --target https://staging.example.com --path ~/src/some-app
qa --target https://staging.example.com --repo pagerguild/foo --branch fix-login
"""),
)
p.add_argument("--target", required=True,
help="Base URL the agents will exercise (e.g. http://localhost:5173).")
src = p.add_mutually_exclusive_group(required=True)
src.add_argument("--here", action="store_true",
help="Use the current working directory as the target repo.")
src.add_argument("--path", help="Path to a local checkout of the target repo.")
src.add_argument("--repo",
help="OWNER/NAME of a github repo to clone fresh into a temp dir.")
p.add_argument("--branch",
help="Branch to clone when --repo is used (default: repo default).")
p.add_argument("--qa-dir", default=".qa",
help="Directory containing .qa/<agent>/ folders (default: .qa).")
p.add_argument("--verbose", action="store_true", help="Pass --verbose to act.")
p.add_argument("--version", action="version", version=f"qa {VERSION}")
args = p.parse_args()
if args.here:
args.path = os.getcwd()
return args
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
def main() -> int:
args = parse_args()
pre_flight()
qa_team = ensure_qa_team_cache()
target = resolve_target(args)
target_url = rewrite_localhost(args.target)
doppler = mint_doppler_token()
@atexit.register
def _cleanup() -> None:
with contextlib.suppress(Exception):
doppler.revoke()
with contextlib.suppress(Exception):
target.cleanup()
work = prepare_workspace(qa_team, target, args.qa_dir)
def _on_signal(signum: int, _frame) -> None:
warn(f"received signal {signum}, cleaning up")
sys.exit(128 + signum)
signal.signal(signal.SIGTERM, _on_signal)
signal.signal(signal.SIGINT, _on_signal)
try:
return run_act(work, target, target_url, doppler, args)
finally:
# Keep the workspace dir for post-mortem; runs/ accretes but it's
# under ~/.cache so it's safe to delete by hand.
info(f"Run workspace: {work}")
if __name__ == "__main__":
sys.exit(main())