|
| 1 | +import json |
1 | 2 | import os |
2 | 3 | import re |
3 | 4 | import shutil |
4 | 5 | import subprocess |
5 | 6 | import tempfile |
| 7 | +from datetime import datetime, timedelta, timezone |
6 | 8 | from pathlib import Path |
7 | 9 |
|
8 | 10 | import pytest |
|
11 | 13 | REPO = Path(__file__).resolve().parents[2] |
12 | 14 | WORKFLOW = REPO / ".github/workflows/pr-preview.yml" |
13 | 15 | RESET_WORKFLOW = REPO / ".github/workflows/preview-reset.yml" |
| 16 | +PRUNE_WORKFLOW = REPO / ".github/workflows/preview-prune.yml" |
14 | 17 | PREVIEW = REPO / ".github/scripts/preview" |
15 | 18 | PREPARE = PREVIEW / "prepare_preview_database.sh" |
16 | 19 | COMPUTE_PLAN = PREVIEW / "compute_deployment_plan.sh" |
17 | 20 | DEPLOY = PREVIEW / "deploy_preview_backend.sh" |
| 21 | +PRUNE = PREVIEW / "prune_stale_supabase_branches.sh" |
18 | 22 | MODAL_APP = REPO / "backend/modal_app.py" |
19 | 23 |
|
20 | 24 | URL_FRAGMENT = "abundant-ai-preview--oddish-pr-{0}-api.modal.run" |
@@ -442,3 +446,165 @@ def test_reset_reuses_preview_scripts(): |
442 | 446 | ) |
443 | 447 | assert prepare_step["env"]["DEPLOY_BACKEND"] == "true" |
444 | 448 | assert prepare_step["env"]["RUN_MIGRATIONS"] == "true" |
| 449 | + |
| 450 | + |
| 451 | +def _prune_wf(): |
| 452 | + return yaml.safe_load(PRUNE_WORKFLOW.read_text()) |
| 453 | + |
| 454 | + |
| 455 | +def test_prune_runs_on_a_schedule(): |
| 456 | + on = _on(_prune_wf()) |
| 457 | + assert on["schedule"], "prune must run unattended, not only on dispatch" |
| 458 | + assert "workflow_dispatch" in on |
| 459 | + assert on["workflow_dispatch"]["inputs"]["max_age_days"]["default"] == "7" |
| 460 | + |
| 461 | + |
| 462 | +def test_prune_workflow_invokes_the_script(): |
| 463 | + job = _prune_wf()["jobs"]["prune"] |
| 464 | + steps = job["steps"] |
| 465 | + assert any("prune_stale_supabase_branches.sh" in s.get("run", "") for s in steps) |
| 466 | + for key in ("SUPABASE_ACCESS_TOKEN", "SUPABASE_PROJECT_REF", "MAX_AGE_DAYS"): |
| 467 | + assert key in job["env"] |
| 468 | + # Dispatch inputs reach the script through env, never interpolated into a |
| 469 | + # run: body where they would be shell injection. |
| 470 | + assert not any("${{" in s.get("run", "") for s in steps) |
| 471 | + |
| 472 | + |
| 473 | +def test_prune_script_is_executable(): |
| 474 | + # The workflow runs it by path, so a lost exec bit is a broken cron. |
| 475 | + assert os.access(PRUNE, os.X_OK) |
| 476 | + |
| 477 | + |
| 478 | +def _branch(name, days_old, *, persistent=False, created_at=None): |
| 479 | + if created_at is None: |
| 480 | + stamp = datetime.now(timezone.utc) - timedelta(days=days_old) |
| 481 | + created_at = stamp.strftime("%Y-%m-%dT%H:%M:%SZ") |
| 482 | + return { |
| 483 | + "id": f"id-{name}", |
| 484 | + "name": name, |
| 485 | + "persistent": persistent, |
| 486 | + "created_at": created_at, |
| 487 | + } |
| 488 | + |
| 489 | + |
| 490 | +def _run_prune(branches, *, env=None, fail_delete=""): |
| 491 | + tmp = Path(tempfile.mkdtemp()) |
| 492 | + bins = tmp / "bin" |
| 493 | + bins.mkdir() |
| 494 | + listing = tmp / "branches.json" |
| 495 | + listing.write_text(json.dumps(branches)) |
| 496 | + deleted = tmp / "deleted" |
| 497 | + deleted.write_text("") |
| 498 | + fake = bins / "supabase" |
| 499 | + # `delete` drains stdin the way the real interactive CLI does, so a script |
| 500 | + # that fed it the branch list would only ever delete the first branch. |
| 501 | + fake.write_text( |
| 502 | + "#!/usr/bin/env bash\n" |
| 503 | + 'case "$2" in\n' |
| 504 | + f' list) cat "{listing}" ;;\n' |
| 505 | + " delete)\n" |
| 506 | + " cat >/dev/null\n" |
| 507 | + f' echo "$3" >> "{deleted}"\n' |
| 508 | + f' [ "$3" = "{fail_delete}" ] && exit 1\n' |
| 509 | + " ;;\n" |
| 510 | + "esac\n" |
| 511 | + "exit 0\n" |
| 512 | + ) |
| 513 | + fake.chmod(0o755) |
| 514 | + proc = subprocess.run( |
| 515 | + ["bash", str(PRUNE)], |
| 516 | + env={ |
| 517 | + **os.environ, |
| 518 | + "PATH": f"{bins}:{os.environ['PATH']}", |
| 519 | + "SUPABASE_ACCESS_TOKEN": "token", |
| 520 | + "SUPABASE_PROJECT_REF": "ref", |
| 521 | + **(env or {}), |
| 522 | + }, |
| 523 | + stdin=subprocess.DEVNULL, |
| 524 | + capture_output=True, |
| 525 | + text=True, |
| 526 | + ) |
| 527 | + return proc, deleted.read_text().split() |
| 528 | + |
| 529 | + |
| 530 | +@needs_bash |
| 531 | +def test_prune_deletes_only_stale_pr_branches(): |
| 532 | + proc, deleted = _run_prune( |
| 533 | + [ |
| 534 | + _branch("pr-1", 10), |
| 535 | + _branch("pr-2", 2), |
| 536 | + _branch("pr-3", 30, persistent=True), |
| 537 | + _branch("main", 99, persistent=True), |
| 538 | + _branch("staging-preview", 99), |
| 539 | + ] |
| 540 | + ) |
| 541 | + assert proc.returncode == 0, proc.stderr |
| 542 | + assert deleted == ["id-pr-1"] |
| 543 | + |
| 544 | + |
| 545 | +@needs_bash |
| 546 | +def test_prune_honours_max_age_days(): |
| 547 | + proc, deleted = _run_prune( |
| 548 | + [_branch("pr-1", 10), _branch("pr-2", 2)], |
| 549 | + env={"MAX_AGE_DAYS": "1"}, |
| 550 | + ) |
| 551 | + assert proc.returncode == 0, proc.stderr |
| 552 | + assert sorted(deleted) == ["id-pr-1", "id-pr-2"] |
| 553 | + |
| 554 | + |
| 555 | +@needs_bash |
| 556 | +def test_prune_dry_run_deletes_nothing(): |
| 557 | + proc, deleted = _run_prune([_branch("pr-1", 10)], env={"DRY_RUN": "true"}) |
| 558 | + assert proc.returncode == 0, proc.stderr |
| 559 | + assert deleted == [] |
| 560 | + assert "would delete pr-1" in proc.stdout |
| 561 | + |
| 562 | + |
| 563 | +@needs_bash |
| 564 | +def test_prune_deletes_every_stale_branch(): |
| 565 | + # Regression: the delete CLI must not consume the loop's branch list. |
| 566 | + proc, deleted = _run_prune([_branch(f"pr-{n}", 10) for n in (1, 2, 3)]) |
| 567 | + assert proc.returncode == 0, proc.stderr |
| 568 | + assert sorted(deleted) == ["id-pr-1", "id-pr-2", "id-pr-3"] |
| 569 | + |
| 570 | + |
| 571 | +@needs_bash |
| 572 | +def test_prune_accepts_fractional_second_timestamps(): |
| 573 | + stamp = datetime.now(timezone.utc) - timedelta(days=10) |
| 574 | + proc, deleted = _run_prune( |
| 575 | + [_branch("pr-1", 0, created_at=stamp.strftime("%Y-%m-%dT%H:%M:%S.123456Z"))] |
| 576 | + ) |
| 577 | + assert proc.returncode == 0, proc.stderr |
| 578 | + assert deleted == ["id-pr-1"] |
| 579 | + |
| 580 | + |
| 581 | +@needs_bash |
| 582 | +def test_prune_fails_closed_on_unreadable_timestamp(): |
| 583 | + proc, deleted = _run_prune( |
| 584 | + [_branch("pr-1", 10), _branch("pr-2", 0, created_at="whenever")] |
| 585 | + ) |
| 586 | + assert proc.returncode != 0 |
| 587 | + assert deleted == [] |
| 588 | + |
| 589 | + |
| 590 | +@needs_bash |
| 591 | +def test_prune_reports_a_failed_delete_and_keeps_going(): |
| 592 | + proc, deleted = _run_prune( |
| 593 | + [_branch("pr-1", 10), _branch("pr-2", 10)], fail_delete="id-pr-1" |
| 594 | + ) |
| 595 | + assert proc.returncode == 1 |
| 596 | + assert sorted(deleted) == ["id-pr-1", "id-pr-2"] |
| 597 | + |
| 598 | + |
| 599 | +@needs_bash |
| 600 | +def test_prune_rejects_a_non_numeric_age(): |
| 601 | + proc, deleted = _run_prune([_branch("pr-1", 10)], env={"MAX_AGE_DAYS": "7 days"}) |
| 602 | + assert proc.returncode == 1 |
| 603 | + assert deleted == [] |
| 604 | + |
| 605 | + |
| 606 | +@needs_bash |
| 607 | +def test_prune_is_quiet_when_nothing_is_stale(): |
| 608 | + proc, deleted = _run_prune([_branch("pr-1", 2)]) |
| 609 | + assert proc.returncode == 0, proc.stderr |
| 610 | + assert deleted == [] |
0 commit comments