From 859c9453a4f539a5fe7d85f26aa21e620766dc76 Mon Sep 17 00:00:00 2001 From: phernandez Date: Wed, 10 Jun 2026 10:21:55 -0500 Subject: [PATCH] chore(ci): delete bossbot script and workflow guard tests CI tooling doesn't need product-suite tests burning time on every matrix leg. The bossbot status script is exercised by every PR run. Signed-off-by: phernandez --- .github/workflows/test.yml | 4 - justfile | 8 +- tests/ci/test_bm_bossbot_workflow.py | 205 ----- tests/ci/test_project_updates.py | 718 ------------------ tests/ci/test_testmon_cache.py | 114 --- tests/ci/test_validate_skills.py | 92 --- tests/scripts/test_bm_bossbot_status.py | 204 ----- tests/scripts/test_generate_pr_infographic.py | 360 --------- 8 files changed, 2 insertions(+), 1703 deletions(-) delete mode 100644 tests/ci/test_bm_bossbot_workflow.py delete mode 100644 tests/ci/test_project_updates.py delete mode 100644 tests/ci/test_testmon_cache.py delete mode 100644 tests/ci/test_validate_skills.py delete mode 100644 tests/scripts/test_bm_bossbot_status.py delete mode 100644 tests/scripts/test_generate_pr_infographic.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 656182e6..328cf420 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -54,10 +54,6 @@ jobs: run: | just typecheck - - name: Run CI tooling tests - run: | - just test-ci-tooling - - name: Run linting run: | just lint diff --git a/justfile b/justfile index b124a7a3..aa54f665 100644 --- a/justfile +++ b/justfile @@ -40,15 +40,11 @@ test-postgres: test-unit-postgres test-int-postgres # Run unit tests against SQLite test-unit-sqlite: testmon-seed - BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=unit-sqlite --ignore=tests/scripts --ignore=tests/ci tests + BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=unit-sqlite tests # Run unit tests against Postgres test-unit-postgres: testmon-seed - BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=unit-postgres --ignore=tests/scripts --ignore=tests/ci tests - -# Run CI-tooling tests (bossbot scripts, workflow guards) — once, not per matrix leg -test-ci-tooling: - BASIC_MEMORY_ENV=test uv run pytest -p pytest_mock -q --no-cov tests/scripts tests/ci + BASIC_MEMORY_ENV=test BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov {{TESTMON_FLAGS}} --testmon-env=unit-postgres tests # Run integration tests against SQLite (excludes semantic tests and on-demand benchmarks — # use just test-semantic / run benchmark files explicitly) diff --git a/tests/ci/test_bm_bossbot_workflow.py b/tests/ci/test_bm_bossbot_workflow.py deleted file mode 100644 index 9b43db9d..00000000 --- a/tests/ci/test_bm_bossbot_workflow.py +++ /dev/null @@ -1,205 +0,0 @@ -from pathlib import Path - -import yaml - - -WORKFLOW_PATH = Path(".github/workflows/bm-bossbot.yml") -PROMPT_PATH = Path(".github/basic-memory/bm-bossbot-review.md") - - -def _workflow() -> dict: - return yaml.safe_load(WORKFLOW_PATH.read_text(encoding="utf-8")) - - -def test_bm_bossbot_runs_after_successful_tests_workflow() -> None: - workflow = _workflow() - review_job = workflow["jobs"]["review"] - - assert workflow["name"] == "BM Bossbot" - assert "pull_request_target" not in workflow["on"] - assert workflow["on"]["workflow_run"]["workflows"] == ["Tests"] - assert workflow["on"]["workflow_run"]["types"] == ["completed"] - assert "workflow_dispatch" in workflow["on"] - assert "github.event.workflow_run.conclusion == 'success'" in review_job["if"] - assert "github.event.workflow_run.pull_requests[0].number != ''" in review_job["if"] - assert review_job["outputs"]["should_review"] == "${{ steps.pr.outputs.should_review }}" - - permissions = workflow["permissions"] - assert permissions["contents"] == "read" - assert permissions["pull-requests"] == "write" - assert permissions["statuses"] == "write" - - asset_permissions = workflow["jobs"]["assets"]["permissions"] - assert asset_permissions["contents"] == "write" - assert asset_permissions["pull-requests"] == "write" - - -def test_bm_bossbot_workflow_never_checks_out_untrusted_head() -> None: - workflow = _workflow() - checkout_steps = [ - step - for job in workflow["jobs"].values() - for step in job["steps"] - if step.get("uses") == "actions/checkout@v6" - ] - - assert checkout_steps - for checkout_step in checkout_steps: - assert checkout_step["with"]["ref"] == "${{ github.event.repository.default_branch }}" - assert "${{ github.event.pull_request.head.sha }}" not in str(checkout_step) - assert "github.event.pull_request" not in WORKFLOW_PATH.read_text(encoding="utf-8") - assert "cancel-in-progress: true" in WORKFLOW_PATH.read_text(encoding="utf-8") - - -def test_bm_bossbot_workflow_has_deterministic_status_steps() -> None: - workflow = _workflow() - steps = workflow["jobs"]["review"]["steps"] - names = [step["name"] for step in steps] - - assert "Set up uv" in names - assert "Mark BM Bossbot approval pending" in names - assert "Run BM Bossbot review with Codex" in names - assert "Finalize BM Bossbot approval" in names - - run_codex = next(step for step in steps if step["name"] == "Run BM Bossbot review with Codex") - assert run_codex["uses"] == "openai/codex-action@v1" - assert run_codex["with"]["openai-api-key"] == "${{ secrets.OPENAI_API_KEY }}" - assert "--output-schema" in run_codex["with"]["codex-args"] - assert "steps.pr.outputs.should_review == 'true'" in run_codex["if"] - - pending = next(step for step in steps if step["name"] == "Mark BM Bossbot approval pending") - assert pending["if"] == "steps.pr.outputs.should_review == 'true'" - finalize = next(step for step in steps if step["name"] == "Finalize BM Bossbot approval") - assert finalize["if"] == "always() && steps.pr.outputs.should_review == 'true'" - assert "BM Bossbot Approval" in WORKFLOW_PATH.read_text(encoding="utf-8") - assert "uv run --script scripts/bm_bossbot_status.py pending" in WORKFLOW_PATH.read_text( - encoding="utf-8" - ) - assert "uv run --script scripts/bm_bossbot_status.py finalize" in WORKFLOW_PATH.read_text( - encoding="utf-8" - ) - - -def test_bm_bossbot_rejects_stale_successful_test_runs_before_codex() -> None: - workflow_text = WORKFLOW_PATH.read_text(encoding="utf-8") - workflow = _workflow() - steps = workflow["jobs"]["review"]["steps"] - normalize = next(step for step in steps if step["name"] == "Normalize PR event") - classify = next(step for step in steps if step["name"] == "Classify PR author") - - assert "tested_sha" in normalize["run"] - assert "current_head_sha" in normalize["run"] - assert "actions/workflows/test.yml/runs" in normalize["run"] - assert "-f event=push" in normalize["run"] - assert "-f event=pull_request" not in normalize["run"] - assert "-f head_sha=\"${current_head_sha}\"" in normalize["run"] - assert 'select(.conclusion == "success")' in normalize["run"] - assert "no successful Tests workflow for ${current_head_sha}" in workflow_text - stale_sha_guard = '[ -n "${tested_sha}" ] && [ "${tested_sha}" != "${current_head_sha}" ]' - assert stale_sha_guard in normalize["run"] - assert "should_review=false" in normalize["run"] - assert "Tests passed for ${tested_sha}, but current head is ${current_head_sha}" in workflow_text - assert classify["if"] == "steps.pr.outputs.should_review == 'true'" - - -def test_bm_bossbot_assets_are_non_gating_and_separate_from_review_job() -> None: - workflow = _workflow() - review_steps = workflow["jobs"]["review"]["steps"] - asset_job = workflow["jobs"]["assets"] - asset_steps = asset_job["steps"] - - assert asset_job["needs"] == "review" - assert asset_job["if"] == ( - "needs.review.result == 'success' && needs.review.outputs.should_review == 'true'" - ) - assert not any(step["name"] == "Generate non-gating PR image" for step in review_steps) - assert not any(step["name"] == "Publish non-gating PR image" for step in review_steps) - - generate = next(step for step in asset_steps if step["name"] == "Generate non-gating PR image") - publish = next(step for step in asset_steps if step["name"] == "Publish non-gating PR image") - - assert generate["continue-on-error"] is True - assert publish["continue-on-error"] is True - assert "uv run --script scripts/generate_pr_infographic.py" in WORKFLOW_PATH.read_text( - encoding="utf-8" - ) - assert "--provenance-output" in WORKFLOW_PATH.read_text(encoding="utf-8") - assert "git rm -rf --ignore-unmatch ." in WORKFLOW_PATH.read_text(encoding="utf-8") - assert "" in WORKFLOW_PATH.read_text(encoding="utf-8") - assert "BM Bossbot image for PR" in WORKFLOW_PATH.read_text(encoding="utf-8") - assert "gh pr edit" in WORKFLOW_PATH.read_text(encoding="utf-8") - assert "--body-file" in WORKFLOW_PATH.read_text(encoding="utf-8") - assert "BM_INFOGRAPHIC_PROVENANCE:start" in WORKFLOW_PATH.read_text(encoding="utf-8") - assert "BM_INFOGRAPHIC_PROVENANCE:end" in WORKFLOW_PATH.read_text(encoding="utf-8") - - -def test_bm_bossbot_rejects_oversized_diffs_without_partial_approval() -> None: - workflow_text = WORKFLOW_PATH.read_text(encoding="utf-8") - workflow = _workflow() - steps = workflow["jobs"]["review"]["steps"] - run_codex = next(step for step in steps if step["name"] == "Run BM Bossbot review with Codex") - - assert "max_diff_bytes=120000" in workflow_text - assert "diff_truncated=true" in workflow_text - assert "review_complete: false" in workflow_text - assert 'verdict: "needs_human"' in workflow_text - assert "Diff exceeds BM Bossbot review limit" in workflow_text - assert ( - run_codex["if"] - == "steps.pr.outputs.should_review == 'true' && " - "steps.trust.outputs.trusted_author == 'true' && " - "steps.context.outputs.diff_truncated != 'true'" - ) - assert "head -c 120000" not in workflow_text - - -def test_bm_bossbot_does_not_run_codex_for_outside_contributors() -> None: - workflow_text = WORKFLOW_PATH.read_text(encoding="utf-8") - workflow = _workflow() - steps = workflow["jobs"]["review"]["steps"] - - classify = next(step for step in steps if step["name"] == "Classify PR author") - outside = next(step for step in steps if step["name"] == "Decline outside contributor PRs") - collect = next(step for step in steps if step["name"] == "Collect sanitized PR context") - run_codex = next(step for step in steps if step["name"] == "Run BM Bossbot review with Codex") - select_review = next(step for step in steps if step["name"] == "Select BM Bossbot review output") - finalize = next(step for step in steps if step["name"] == "Finalize BM Bossbot approval") - - assert "OWNER|MEMBER|COLLABORATOR" in classify["run"] - assert ( - outside["if"] - == "steps.pr.outputs.should_review == 'true' && steps.trust.outputs.trusted_author != 'true'" - ) - assert ( - collect["if"] - == "steps.pr.outputs.should_review == 'true' && steps.trust.outputs.trusted_author == 'true'" - ) - assert ( - run_codex["if"] - == "steps.pr.outputs.should_review == 'true' && " - "steps.trust.outputs.trusted_author == 'true' && " - "steps.context.outputs.diff_truncated != 'true'" - ) - assert select_review["if"] == "always() && steps.pr.outputs.should_review == 'true'" - assert finalize["if"] == "always() && steps.pr.outputs.should_review == 'true'" - assert "BM Bossbot does not run for outside contributors" in workflow_text - assert "missing-bm-bossbot-review.json" in workflow_text - assert '--review "${{ steps.review_output.outputs.review_file }}"' in finalize["run"] - - -def test_bm_bossbot_prompt_references_engineering_style_and_json_bullets() -> None: - prompt = PROMPT_PATH.read_text(encoding="utf-8") - - assert "docs/ENGINEERING_STYLE.md" in prompt - assert "- Set `reviewed_head_sha`" in prompt - assert "- Do not include Markdown outside the JSON." in prompt - - -def test_claude_code_review_is_manual_advisory_only() -> None: - workflow = yaml.safe_load( - Path(".github/workflows/claude-code-review.yml").read_text(encoding="utf-8") - ) - - assert "pull_request" not in workflow["on"] - assert "workflow_dispatch" in workflow["on"] - assert workflow["on"]["workflow_dispatch"]["inputs"]["pr_number"]["required"] is True diff --git a/tests/ci/test_project_updates.py b/tests/ci/test_project_updates.py deleted file mode 100644 index d14f3c2a..00000000 --- a/tests/ci/test_project_updates.py +++ /dev/null @@ -1,718 +0,0 @@ -import json -from pathlib import Path - -import pytest -import yaml -from pydantic import ValidationError - -from basic_memory.ci.project_updates import ( - AgentSynthesis, - ProjectUpdateConfig, - ProjectUpdateContext, - build_project_update_note, - collect_project_update_context, - detect_github_repo, - load_project_update_config, - parse_github_remote, - render_agent_synthesis_schema, - render_capture_prompt, - render_soul_template, - render_workflow, - schema_seed_specs, -) -from basic_memory.ci import project_updates - - -def _write_json(path: Path, payload: dict) -> Path: - path.write_text(json.dumps(payload), encoding="utf-8") - return path - - -def _pr_payload(*, merged: bool = True) -> dict: - return { - "action": "closed", - "repository": { - "full_name": "basicmachines-co/basic-memory", - "html_url": "https://github.com/basicmachines-co/basic-memory", - }, - "pull_request": { - "number": 123, - "title": "Remember project updates", - "body": "Adds Auto BM capture.\n\nCloses #77", - "html_url": "https://github.com/basicmachines-co/basic-memory/pull/123", - "merged": merged, - "merged_at": "2026-06-04T18:42:00Z" if merged else None, - "merge_commit_sha": "abc123", - "changed_files": 4, - "labels": [{"name": "feature"}, {"name": "ci"}], - "user": {"login": "octocat"}, - }, - } - - -def _synthesis_payload(**overrides: object) -> dict[str, object]: - payload: dict[str, object] = { - "summary": "Auto BM now records project updates.", - "story": ( - "GitHub delivery events were losing their useful narrative after merge. " - "Auto BM collects source facts, lets the agent explain the change, and " - "publishes the result as durable project memory." - ), - "problem_addressed": "Project delivery context was not preserved after GitHub events.", - "solution": "Collect GitHub facts and publish an idempotent Basic Memory note.", - "system_impact": "Future humans and agents can recover the delivery narrative.", - "why_it_matters": "Future agents can recover project context.", - "components_changed": ["basic_memory.ci.project_updates"], - "complexity_introduced": [], - "refactors_or_removals": [], - "user_facing_changes": [], - "internal_changes": [], - "verification": [], - "follow_ups": [], - "decision_candidates": [], - "task_candidates": [], - } - payload.update(overrides) - return payload - - -def test_collect_merged_pull_request_context(tmp_path: Path) -> None: - event_path = _write_json(tmp_path / "event.json", _pr_payload()) - - context = collect_project_update_context( - event_name="pull_request", - event_path=event_path, - config=ProjectUpdateConfig(project="team-memory"), - ) - - assert context.eligible is True - assert context.source_event == "pull_request_merged" - assert context.repo == "basicmachines-co/basic-memory" - assert context.idempotency_key == "github:basicmachines-co/basic-memory:pull_request_merged:123" - assert context.pr_number == 123 - assert context.sha == "abc123" - assert context.labels == ["feature", "ci"] - assert context.linked_issues == ["#77"] - assert context.source_url == "https://github.com/basicmachines-co/basic-memory/pull/123" - - -def test_collect_enriches_pull_request_context_from_github_api( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - def fake_github_api_get(path: str, token: str) -> list[dict] | dict: - assert token == "github-token" - if path.startswith("/repos/basicmachines-co/basic-memory/pulls/123/files"): - return [ - { - "filename": "src/basic_memory/ci/project_updates.py", - "status": "modified", - "additions": 42, - "deletions": 7, - "changes": 49, - } - ] - if path.startswith("/repos/basicmachines-co/basic-memory/pulls/123/commits"): - return [ - { - "sha": "abc123def456", - "commit": { - "message": "fix ci synthesis schema\n\nRequire all fields.", - "author": {"name": "Pat"}, - }, - } - ] - if path == "/repos/basicmachines-co/basic-memory/issues/77": - return { - "number": 77, - "title": "Codex structured output rejects optional schema fields", - "body": "Auto BM failed before publish when optional fields were omitted.", - "html_url": "https://github.com/basicmachines-co/basic-memory/issues/77", - "state": "closed", - } - raise AssertionError(f"unexpected GitHub API path: {path}") - - monkeypatch.setenv("GITHUB_TOKEN", "github-token") - monkeypatch.setattr(project_updates, "_github_api_get", fake_github_api_get, raising=False) - event_path = _write_json(tmp_path / "event.json", _pr_payload()) - - context = collect_project_update_context( - event_name="pull_request", - event_path=event_path, - config=ProjectUpdateConfig(project="team-memory"), - ) - - assert context.changed_files[0].filename == "src/basic_memory/ci/project_updates.py" - assert context.changed_files[0].status == "modified" - assert context.commits[0].message == "fix ci synthesis schema\n\nRequire all fields." - assert context.linked_issue_details[0].number == 77 - assert ( - context.linked_issue_details[0].title - == "Codex structured output rejects optional schema fields" - ) - - -def test_github_api_get_list_fetches_multiple_pages(monkeypatch: pytest.MonkeyPatch) -> None: - calls: list[str] = [] - - def fake_github_api_get(path: str, token: str) -> list[dict]: - assert token == "github-token" - calls.append(path) - if path.endswith("page=1"): - return [{"filename": f"file-{index}.py"} for index in range(100)] - if path.endswith("page=2"): - return [{"filename": "file-100.py"}] - raise AssertionError(f"unexpected GitHub API path: {path}") - - monkeypatch.setattr(project_updates, "_github_api_get", fake_github_api_get, raising=False) - - files = project_updates._github_api_get_list( - "/repos/basicmachines-co/basic-memory/pulls/123/files", - "github-token", - ) - - assert len(files) == 101 - assert calls == [ - "/repos/basicmachines-co/basic-memory/pulls/123/files?per_page=100&page=1", - "/repos/basicmachines-co/basic-memory/pulls/123/files?per_page=100&page=2", - ] - - -def test_collect_handles_sparse_pull_request_payload(tmp_path: Path) -> None: - payload = { - "action": "closed", - "repository": {}, - "pull_request": { - "number": 123, - "merged": True, - "labels": "not-a-list", - }, - } - event_path = _write_json(tmp_path / "event.json", payload) - - context = collect_project_update_context( - event_name="pull_request", - event_path=event_path, - config=ProjectUpdateConfig(project="team-memory"), - ) - - assert context.eligible is True - assert context.repo is None - assert context.repo_url is None - assert context.labels == [] - assert context.linked_issues == [] - - -def test_collect_handles_missing_repository_payload(tmp_path: Path) -> None: - payload = { - "action": "closed", - "pull_request": { - "number": 123, - "merged": True, - }, - } - event_path = _write_json(tmp_path / "event.json", payload) - - context = collect_project_update_context( - event_name="pull_request", - event_path=event_path, - config=ProjectUpdateConfig(project="team-memory"), - ) - - assert context.eligible is True - assert context.repo is None - assert context.repo_url is None - - -def test_collect_rejects_missing_payload_shapes(tmp_path: Path) -> None: - pr_context = collect_project_update_context( - event_name="pull_request", - event_path=_write_json(tmp_path / "pr.json", {"action": "closed"}), - config=ProjectUpdateConfig(project="team-memory"), - ) - workflow_context = collect_project_update_context( - event_name="workflow_run", - event_path=_write_json(tmp_path / "workflow.json", {"action": "completed"}), - config=ProjectUpdateConfig(project="team-memory"), - ) - - assert pr_context.eligible is False - assert pr_context.skip_reason == "pull request payload missing" - assert workflow_context.eligible is False - assert workflow_context.skip_reason == "workflow run payload missing" - - -def test_collect_ignores_non_closed_pull_request_action(tmp_path: Path) -> None: - payload = _pr_payload() - payload["action"] = "opened" - event_path = _write_json(tmp_path / "event.json", payload) - - context = collect_project_update_context( - event_name="pull_request", - event_path=event_path, - config=ProjectUpdateConfig(project="team-memory"), - ) - - assert context.eligible is False - assert context.skip_reason == "pull request action was not closed" - - -def test_collect_ignores_closed_unmerged_pull_request(tmp_path: Path) -> None: - event_path = _write_json(tmp_path / "event.json", _pr_payload(merged=False)) - - context = collect_project_update_context( - event_name="pull_request", - event_path=event_path, - config=ProjectUpdateConfig(project="team-memory"), - ) - - assert context.eligible is False - assert context.skip_reason == "pull request was closed without merging" - - -def test_collect_successful_configured_production_deploy(tmp_path: Path) -> None: - payload = { - "action": "completed", - "repository": { - "full_name": "basicmachines-co/basic-memory-cloud", - "html_url": "https://github.com/basicmachines-co/basic-memory-cloud", - }, - "workflow_run": { - "id": 98765, - "name": "Deploy Production", - "conclusion": "success", - "html_url": "https://github.com/basicmachines-co/basic-memory-cloud/actions/runs/98765", - "head_sha": "def456", - "updated_at": "2026-06-04T19:10:00Z", - }, - } - event_path = _write_json(tmp_path / "event.json", payload) - - context = collect_project_update_context( - event_name="workflow_run", - event_path=event_path, - config=ProjectUpdateConfig( - project="cloud-memory", - deploy_workflows=["Deploy Production"], - production_environments=["production"], - ), - ) - - assert context.eligible is True - assert context.source_event == "production_deploy_succeeded" - assert context.workflow_run_id == "98765" - assert context.environment == "production" - assert context.idempotency_key == ( - "github:basicmachines-co/basic-memory-cloud:production_deploy_succeeded:production:98765" - ) - - -def test_collect_ignores_failed_or_unconfigured_deploy(tmp_path: Path) -> None: - payload = { - "action": "completed", - "repository": {"full_name": "basicmachines-co/basic-memory"}, - "workflow_run": {"id": 1, "name": "Tests", "conclusion": "failure"}, - } - event_path = _write_json(tmp_path / "event.json", payload) - - context = collect_project_update_context( - event_name="workflow_run", - event_path=event_path, - config=ProjectUpdateConfig(project="team-memory"), - ) - - assert context.eligible is False - assert context.skip_reason == "workflow conclusion was failure" - - -def test_collect_ignores_successful_unconfigured_deploy(tmp_path: Path) -> None: - payload = { - "action": "completed", - "repository": {"full_name": "basicmachines-co/basic-memory"}, - "workflow_run": {"id": 1, "name": "Tests", "conclusion": "success"}, - } - event_path = _write_json(tmp_path / "event.json", payload) - - context = collect_project_update_context( - event_name="workflow_run", - event_path=event_path, - config=ProjectUpdateConfig(project="team-memory"), - ) - - assert context.eligible is False - assert context.skip_reason == "workflow 'Tests' is not configured for project updates" - - -def test_collect_ignores_unsupported_event(tmp_path: Path) -> None: - event_path = _write_json(tmp_path / "event.json", {}) - - context = collect_project_update_context( - event_name="push", - event_path=event_path, - config=ProjectUpdateConfig(project="team-memory"), - ) - - assert context.eligible is False - assert context.skip_reason == "unsupported GitHub event: push" - - -def test_collect_rejects_missing_or_invalid_event_payload(tmp_path: Path) -> None: - with pytest.raises(ValueError, match="not found"): - collect_project_update_context( - event_name="pull_request", - event_path=tmp_path / "missing.json", - config=ProjectUpdateConfig(project="team-memory"), - ) - - invalid_json = tmp_path / "invalid.json" - invalid_json.write_text("{", encoding="utf-8") - with pytest.raises(ValueError, match="not valid JSON"): - collect_project_update_context( - event_name="pull_request", - event_path=invalid_json, - config=ProjectUpdateConfig(project="team-memory"), - ) - - list_json = tmp_path / "list.json" - list_json.write_text("[]", encoding="utf-8") - with pytest.raises(ValueError, match="JSON object"): - collect_project_update_context( - event_name="pull_request", - event_path=list_json, - config=ProjectUpdateConfig(project="team-memory"), - ) - - -def test_build_project_update_note_uses_deterministic_identity_fields(tmp_path: Path) -> None: - event_path = _write_json(tmp_path / "event.json", _pr_payload()) - context = collect_project_update_context( - event_name="pull_request", - event_path=event_path, - config=ProjectUpdateConfig(project="team-memory"), - ) - synthesis = AgentSynthesis.model_validate( - _synthesis_payload( - why_it_matters="Future agents can recover the delivery narrative.", - repo="evil/repo", - source_event="production_deploy_succeeded", - verification=["Unit tests cover event normalization."], - ) - ) - - note = build_project_update_note(context=context, synthesis=synthesis) - - assert note.title == "PR #123: Remember project updates" - assert note.directory == "project-updates/github/basicmachines-co/basic-memory" - assert note.metadata["repo"] == "basicmachines-co/basic-memory" - assert note.metadata["source_event"] == "pull_request_merged" - assert note.metadata["idempotency_key"] == context.idempotency_key - assert "evil/repo" not in note.content - - -def test_build_project_update_note_renders_story_sections(tmp_path: Path) -> None: - event_path = _write_json(tmp_path / "event.json", _pr_payload()) - context = collect_project_update_context( - event_name="pull_request", - event_path=event_path, - config=ProjectUpdateConfig(project="team-memory"), - ) - synthesis = AgentSynthesis.model_validate( - { - "summary": "Auto BM now publishes durable project updates.", - "story": ( - "Auto BM needed to preserve the delivery narrative, not just the mechanics. " - "The change adds a CI handoff where Codex synthesizes context and bm publishes it." - ), - "problem_addressed": "Project context was lost after meaningful GitHub delivery events.", - "solution": "Collect GitHub facts, let Codex synthesize intent, then publish idempotently.", - "system_impact": "Merges now leave durable memory for future humans and agents.", - "why_it_matters": "Future work can recover why the delivery happened.", - "components_changed": [ - "basic_memory.ci.project_updates", - "basic_memory.cli.commands.ci", - ], - "complexity_introduced": ["Adds a CI-only agent synthesis boundary."], - "refactors_or_removals": ["Keeps Basic Memory auth out of the agent step."], - "verification": ["Unit tests cover collect and publish behavior."], - } - ) - - note = build_project_update_note(context=context, synthesis=synthesis) - - assert "## Story" in note.content - assert "## Problem Addressed" in note.content - assert "## How The Change Solves It" in note.content - assert "## Impact On The System" in note.content - assert "## Project Memory" in note.content - assert "## Why It Matters" not in note.content - assert "## Components Changed" in note.content - assert "basic_memory.ci.project_updates" in note.content - assert "## Complexity Introduced" in note.content - assert "## Refactors Or Removals" in note.content - - -def test_build_project_update_note_renders_linked_issue_details_as_links() -> None: - context = ProjectUpdateContext( - eligible=True, - source_event="pull_request_merged", - repo="basicmachines-co/basic-memory", - repo_url="https://github.com/basicmachines-co/basic-memory", - source_url="https://github.com/basicmachines-co/basic-memory/pull/123", - idempotency_key="github:basicmachines-co/basic-memory:pull_request_merged:123", - pr_number=123, - title="Remember project updates", - linked_issues=["#77", "#88"], - linked_issue_details=[ - project_updates.LinkedIssueDetail( - number=77, - title="Codex structured output rejects optional schema fields", - state="closed", - url="https://github.com/basicmachines-co/basic-memory/issues/77", - ) - ], - ) - synthesis = AgentSynthesis.model_validate(_synthesis_payload()) - - note = build_project_update_note(context=context, synthesis=synthesis) - - assert ( - "- Linked issue: [#77 Codex structured output rejects optional schema fields " - "(closed)](https://github.com/basicmachines-co/basic-memory/issues/77)" in note.content - ) - assert ( - "- Linked issue: [#88](https://github.com/basicmachines-co/basic-memory/issues/88)" - in note.content - ) - assert "- Linked issues: #77, #88" not in note.content - - -def test_build_project_update_note_for_production_deploy(tmp_path: Path) -> None: - payload = { - "action": "completed", - "repository": { - "full_name": "basicmachines-co/basic-memory-cloud", - "html_url": "https://github.com/basicmachines-co/basic-memory-cloud", - }, - "workflow_run": { - "id": 98765, - "name": "Deploy Production", - "conclusion": "success", - "html_url": "https://github.com/basicmachines-co/basic-memory-cloud/actions/runs/98765", - "head_sha": "def456", - "updated_at": "2026-06-04T19:10:00Z", - }, - } - context = collect_project_update_context( - event_name="workflow_run", - event_path=_write_json(tmp_path / "event.json", payload), - config=ProjectUpdateConfig( - project="cloud-memory", - deploy_workflows=["Deploy Production"], - production_environments=["production"], - ), - ) - synthesis = AgentSynthesis.model_validate( - _synthesis_payload( - summary="Production deploy completed.", - story=( - "A configured production workflow completed successfully. " - "The deploy SHA is now recorded as durable project memory." - ), - problem_addressed="Production delivery needed a durable deployment record.", - solution="Publish a project update for the successful workflow run.", - system_impact="The production deploy is connected to its workflow run and SHA.", - why_it_matters="The latest project update reached users.", - ) - ) - - note = build_project_update_note(context=context, synthesis=synthesis) - - assert note.title == "Production deploy: 2026-06-04" - assert note.metadata["workflow_run_id"] == "98765" - assert note.metadata["environment"] == "production" - assert "https://github.com/basicmachines-co/basic-memory-cloud/actions/runs/98765" in ( - note.content - ) - - -def test_build_project_update_note_rejects_invalid_context() -> None: - synthesis = AgentSynthesis.model_validate( - _synthesis_payload( - summary="Auto BM records project updates.", - why_it_matters="Future agents can recover context.", - ) - ) - with pytest.raises(ValueError, match="ineligible"): - build_project_update_note( - context=ProjectUpdateContext(eligible=False, skip_reason="not useful"), - synthesis=synthesis, - ) - - with pytest.raises(ValueError, match="deterministic identity"): - build_project_update_note( - context=ProjectUpdateContext( - eligible=True, - source_event="pull_request_merged", - repo="basicmachines-co/basic-memory", - ), - synthesis=synthesis, - ) - - -def test_agent_synthesis_requires_summary_and_why_it_matters() -> None: - missing_why = _synthesis_payload() - missing_why.pop("why_it_matters") - with pytest.raises(ValidationError): - AgentSynthesis.model_validate(missing_why) - - with pytest.raises(ValidationError): - AgentSynthesis.model_validate(_synthesis_payload(summary=" ")) - - -def test_agent_synthesis_requires_delivery_narrative_fields() -> None: - with pytest.raises(ValidationError): - AgentSynthesis.model_validate( - { - "summary": "Auto BM records project updates.", - "why_it_matters": "Future agents can recover context.", - } - ) - - -def test_project_update_config_requires_non_empty_lists() -> None: - with pytest.raises(ValueError, match="at least one"): - ProjectUpdateConfig(deploy_workflows=[" "]) - - -def test_render_workflow_invokes_codex_read_only_without_basic_memory_secret() -> None: - workflow = render_workflow( - ProjectUpdateConfig( - project="team-memory", - deploy_workflows=["Deploy Production"], - production_environments=["production"], - ) - ) - - assert "openai/codex-action@v1" in workflow - assert "sandbox: read-only" in workflow - assert "output-schema-file: ${{ runner.temp }}/agent-synthesis.schema.json" in workflow - assert "BASIC_MEMORY_CLOUD_API_KEY: ${{ secrets.BASIC_MEMORY_API_KEY }}" in workflow - assert "BASIC_MEMORY_CLOUD_HOST: ${{ vars.BASIC_MEMORY_CLOUD_HOST || '' }}" not in workflow - assert "BASIC_MEMORY_CI_CLOUD_HOST: ${{ vars.BASIC_MEMORY_CLOUD_HOST }}" in workflow - assert 'if [ -n "$BASIC_MEMORY_CI_CLOUD_HOST" ]' in workflow - assert "--context .github/basic-memory/project-update-context.json" in workflow - assert "GITHUB_TOKEN: ${{ github.token }}" in workflow - assert "--cloud \\" in workflow - codex_step = workflow.split("- name: Synthesize project update with Codex", 1)[1].split( - "- name: Publish project update", 1 - )[0] - assert "BASIC_MEMORY_API_KEY" not in codex_step - - -def test_render_workflow_outputs_valid_github_actions_yaml() -> None: - workflow = render_workflow(ProjectUpdateConfig(project="team-memory")) - - parsed = yaml.safe_load(workflow) - - assert isinstance(parsed, dict) - assert parsed["on"]["pull_request"]["types"] == ["closed"] - assert parsed["on"]["workflow_run"]["types"] == ["completed"] - - -def test_render_capture_prompt_uses_workspace_context_path() -> None: - prompt = render_capture_prompt() - - assert ".github/basic-memory/project-update-context.json" in prompt - assert ".github/basic-memory/SOUL.md" in prompt - assert "${{ runner.temp }}" not in prompt - assert "Do not write a fill-in-the-blanks note" in prompt - assert "Read the PR diff before writing" in prompt - assert "problem -> solution -> impact" in prompt - assert "It is okay to say when the code is messy" in prompt - assert "Ground all judgments" in prompt - - -def test_render_soul_template_guides_personality_without_overriding_facts() -> None: - soul = render_soul_template() - - assert soul.startswith("# Auto BM Soul") - assert "It is okay to say when code is messy" in soul - assert "Notice good simplifications" in soul - assert "Do not invent intent, impact, tests, or drama" in soul - assert "Keep personality in service of memory" in soul - - -def test_render_agent_synthesis_schema_is_ci_guardrail_not_domain_schema() -> None: - schema = json.loads(render_agent_synthesis_schema()) - - assert schema["title"] == "AgentSynthesis" - assert "summary" in schema["required"] - assert "story" in schema["required"] - assert "problem_addressed" in schema["required"] - assert "solution" in schema["required"] - assert "system_impact" in schema["required"] - assert "components_changed" in schema["required"] - assert "why_it_matters" in schema["required"] - assert set(schema["required"]) == set(schema["properties"]) - assert "project_update" not in json.dumps(schema) - - -def test_schema_seed_specs_are_basic_memory_schema_notes() -> None: - specs = schema_seed_specs() - - assert {spec.entity for spec in specs} == { - "ProjectUpdate", - "GitHubPullRequestUpdate", - "GitHubProductionDeployUpdate", - } - assert all(spec.metadata["type"] == "schema" for spec in specs) - assert all(spec.metadata["settings"]["validation"] == "warn" for spec in specs) - project_update = next(spec for spec in specs if spec.entity == "ProjectUpdate") - assert "story" in project_update.metadata["schema"] - assert "problem_addressed" in project_update.metadata["schema"] - - -def test_parse_github_remote_accepts_https_and_ssh() -> None: - assert parse_github_remote("https://github.com/basicmachines-co/basic-memory.git") == ( - "basicmachines-co", - "basic-memory", - ) - assert parse_github_remote("git@github.com:basicmachines-co/basic-memory.git") == ( - "basicmachines-co", - "basic-memory", - ) - - -def test_parse_github_remote_rejects_non_github_remote() -> None: - with pytest.raises(ValueError, match="GitHub remote"): - parse_github_remote("https://example.com/basicmachines-co/basic-memory.git") - - -def test_detect_github_repo_requires_origin_remote(tmp_path: Path) -> None: - with pytest.raises(ValueError, match="No remote.origin.url"): - detect_github_repo(tmp_path) - - -def test_load_project_update_config_handles_missing_and_invalid_yaml(tmp_path: Path) -> None: - assert load_project_update_config(tmp_path / "missing.yml") == ProjectUpdateConfig() - - invalid = tmp_path / "invalid.yml" - invalid.write_text("- not\n- an\n- object\n", encoding="utf-8") - with pytest.raises(ValueError, match="YAML object"): - load_project_update_config(invalid) - - -def test_private_note_helpers_reject_invalid_repo_shape() -> None: - context = ProjectUpdateContext(eligible=True, repo="not-owner-repo") - with pytest.raises(ValueError, match="owner/repo"): - project_updates._note_directory(context, ProjectUpdateConfig(project="team-memory")) - - missing_repo = ProjectUpdateContext(eligible=True) - with pytest.raises(ValueError, match="missing repo"): - project_updates._note_directory(missing_repo, ProjectUpdateConfig(project="team-memory")) - - -def test_private_note_title_uses_generic_fallback_for_unknown_event() -> None: - context = ProjectUpdateContext(eligible=True, source_event="unknown") - - assert project_updates._note_title(context) == "Project update" diff --git a/tests/ci/test_testmon_cache.py b/tests/ci/test_testmon_cache.py deleted file mode 100644 index 92fb8518..00000000 --- a/tests/ci/test_testmon_cache.py +++ /dev/null @@ -1,114 +0,0 @@ -from pathlib import Path - -import pytest - -from scripts import testmon_cache - - -def _write_testmon_file(directory: Path, filename: str, content: str) -> Path: - directory.mkdir(parents=True, exist_ok=True) - path = directory / filename - path.write_text(content, encoding="utf-8") - return path - - -def test_seed_testmon_data_reports_missing_shared_cache(tmp_path: Path) -> None: - repo_root = tmp_path / "repo" - cache_dir = tmp_path / "cache" - repo_root.mkdir() - - result = testmon_cache.seed_testmon_data(repo_root=repo_root, cache_dir=cache_dir) - - assert result.status == "missing" - assert result.copied == () - assert not (repo_root / ".testmondata").exists() - - -def test_seed_testmon_data_keeps_existing_local_data(tmp_path: Path) -> None: - repo_root = tmp_path / "repo" - cache_dir = tmp_path / "cache" - local_datafile = _write_testmon_file(repo_root, ".testmondata", "local") - _write_testmon_file(cache_dir, ".testmondata", "shared") - - result = testmon_cache.seed_testmon_data(repo_root=repo_root, cache_dir=cache_dir) - - assert result.status == "exists" - assert result.copied == () - assert local_datafile.read_text(encoding="utf-8") == "local" - - -def test_seed_testmon_data_replaces_stale_sidecars(tmp_path: Path) -> None: - repo_root = tmp_path / "repo" - cache_dir = tmp_path / "cache" - _write_testmon_file(repo_root, ".testmondata-shm", "stale sidecar") - _write_testmon_file(cache_dir, ".testmondata", "shared main") - _write_testmon_file(cache_dir, ".testmondata-wal", "shared wal") - - result = testmon_cache.seed_testmon_data(repo_root=repo_root, cache_dir=cache_dir) - - assert result.status == "seeded" - assert {path.name for path in result.copied} == {".testmondata", ".testmondata-wal"} - assert (repo_root / ".testmondata").read_text(encoding="utf-8") == "shared main" - assert (repo_root / ".testmondata-wal").read_text(encoding="utf-8") == "shared wal" - assert not (repo_root / ".testmondata-shm").exists() - - -def test_refresh_testmon_data_requires_local_data(tmp_path: Path) -> None: - repo_root = tmp_path / "repo" - cache_dir = tmp_path / "cache" - repo_root.mkdir() - - with pytest.raises(FileNotFoundError): - testmon_cache.refresh_testmon_data(repo_root=repo_root, cache_dir=cache_dir) - - -def test_refresh_testmon_data_replaces_shared_cache(tmp_path: Path) -> None: - repo_root = tmp_path / "repo" - cache_dir = tmp_path / "cache" - _write_testmon_file(repo_root, ".testmondata", "local main") - _write_testmon_file(repo_root, ".testmondata-shm", "local shm") - _write_testmon_file(cache_dir, ".testmondata", "old main") - _write_testmon_file(cache_dir, ".testmondata-wal", "old wal") - - result = testmon_cache.refresh_testmon_data(repo_root=repo_root, cache_dir=cache_dir) - - assert result.status == "refreshed" - assert {path.name for path in result.copied} == {".testmondata", ".testmondata-shm"} - assert (cache_dir / ".testmondata").read_text(encoding="utf-8") == "local main" - assert (cache_dir / ".testmondata-shm").read_text(encoding="utf-8") == "local shm" - assert not (cache_dir / ".testmondata-wal").exists() - - -def test_resolve_cache_dir_prefers_explicit_path_over_env( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - repo_root = tmp_path / "repo" - env_cache_dir = tmp_path / "env-cache" - explicit_cache_dir = tmp_path / "explicit-cache" - repo_root.mkdir() - monkeypatch.setenv(testmon_cache.TESTMON_CACHE_ENV, str(env_cache_dir)) - - assert testmon_cache.resolve_cache_dir(repo_root) == env_cache_dir.resolve() - assert ( - testmon_cache.resolve_cache_dir(repo_root, explicit_cache_dir) - == explicit_cache_dir.resolve() - ) - - -def test_status_command_prints_local_and_shared_paths( - tmp_path: Path, capsys: pytest.CaptureFixture[str] -) -> None: - repo_root = tmp_path / "repo" - cache_dir = tmp_path / "cache" - repo_root.mkdir() - _write_testmon_file(repo_root, ".testmondata", "local main") - - exit_code = testmon_cache.main( - ["--repo-root", str(repo_root), "--cache-dir", str(cache_dir), "status"] - ) - - assert exit_code == 0 - output = capsys.readouterr().out - assert f"Repo root: {repo_root.resolve()}" in output - assert "Worktree ready: True" in output - assert "Cache ready: False" in output diff --git a/tests/ci/test_validate_skills.py b/tests/ci/test_validate_skills.py deleted file mode 100644 index 251048cc..00000000 --- a/tests/ci/test_validate_skills.py +++ /dev/null @@ -1,92 +0,0 @@ -from pathlib import Path - -import pytest - -from scripts.validate_skills import parse_frontmatter - - -def test_parse_frontmatter_rejects_unquoted_mapping_colon(tmp_path: Path) -> None: - skill = tmp_path / "SKILL.md" - skill.write_text( - "\n".join( - [ - "---", - "name: bm-qa", - "description: Use when validating fixes. Drives the full loop: map issue to commit.", - "---", - "# Skill", - "", - ] - ), - encoding="utf-8", - ) - - with pytest.raises(SystemExit, match="invalid YAML"): - parse_frontmatter(skill) - - -def test_parse_frontmatter_allows_url_colons_in_plain_values(tmp_path: Path) -> None: - skill = tmp_path / "SKILL.md" - skill.write_text( - "\n".join( - [ - "---", - "name: memory-notes", - "description: See https://docs.basicmemory.com for usage.", - "---", - "# Skill", - "", - ] - ), - encoding="utf-8", - ) - - frontmatter = parse_frontmatter(skill) - - assert frontmatter["description"] == "See https://docs.basicmemory.com for usage." - - -def test_parse_frontmatter_strips_matching_single_quotes(tmp_path: Path) -> None: - skill = tmp_path / "SKILL.md" - skill.write_text( - "\n".join( - [ - "---", - "name: memory-notes", - "description: 'Use when values contain mapping-like text: safely.'", - "---", - "# Skill", - "", - ] - ), - encoding="utf-8", - ) - - frontmatter = parse_frontmatter(skill) - - assert frontmatter["description"] == "Use when values contain mapping-like text: safely." - - -def test_parse_frontmatter_keeps_nested_fields_nested(tmp_path: Path) -> None: - schema = tmp_path / "schema.md" - schema.write_text( - "\n".join( - [ - "---", - "type: schema", - "entity: Task", - "schema:", - " type: object", - "---", - "# Task", - "", - ] - ), - encoding="utf-8", - ) - - frontmatter = parse_frontmatter(schema) - - assert frontmatter["type"] == "schema" - assert frontmatter["entity"] == "Task" - assert frontmatter["schema"] == "" diff --git a/tests/scripts/test_bm_bossbot_status.py b/tests/scripts/test_bm_bossbot_status.py deleted file mode 100644 index c9f327b9..00000000 --- a/tests/scripts/test_bm_bossbot_status.py +++ /dev/null @@ -1,204 +0,0 @@ -import json -from pathlib import Path -from typing import Mapping - -import pytest -from typer.testing import CliRunner - -from scripts import bm_bossbot_status - - -def _event_payload(body: str = "Event snapshot body") -> dict[str, object]: - return { - "repository": {"full_name": "basicmachines-co/basic-memory"}, - "pull_request": { - "number": 925, - "body": body, - "head": {"sha": "abc123"}, - }, - } - - -def test_status_script_is_uv_typer_entrypoint() -> None: - source = bm_bossbot_status.__file__ - assert source is not None - text = open(source, encoding="utf-8").read() - - assert text.startswith("#!/usr/bin/env -S uv run --script\n") - assert "# /// script" in text - assert "typer" in text - assert hasattr(bm_bossbot_status, "app") - - -def _review_payload(**overrides: object) -> dict[str, object]: - payload: dict[str, object] = { - "reviewed_head_sha": "abc123", - "review_complete": True, - "verdict": "approve", - "blocking_findings": [], - "nonblocking_findings": [], - "summary": "The change is ready.", - } - payload.update(overrides) - return payload - - -def test_validate_review_accepts_matching_approved_head_sha() -> None: - result = bm_bossbot_status.validate_review(_review_payload(), expected_head_sha="abc123") - - assert result.approved is True - assert result.state == "success" - assert result.description == "BM Bossbot approved this head SHA" - - -def test_validate_review_rejects_stale_head_sha() -> None: - result = bm_bossbot_status.validate_review(_review_payload(), expected_head_sha="def456") - - assert result.approved is False - assert result.state == "failure" - assert result.description == "BM Bossbot reviewed a stale head SHA" - - -def test_validate_review_rejects_blocking_findings() -> None: - result = bm_bossbot_status.validate_review( - _review_payload(blocking_findings=[{"title": "Missing test", "body": "Add coverage."}]), - expected_head_sha="abc123", - ) - - assert result.approved is False - assert result.state == "failure" - assert result.description == "BM Bossbot requested changes" - - -def test_status_payload_uses_required_context() -> None: - payload = bm_bossbot_status.build_status_payload( - state="pending", - description="BM Bossbot is reviewing this head SHA", - target_url="https://github.com/basicmachines-co/basic-memory/actions/runs/1", - ) - - assert payload == { - "state": "pending", - "context": "BM Bossbot Approval", - "description": "BM Bossbot is reviewing this head SHA", - "target_url": "https://github.com/basicmachines-co/basic-memory/actions/runs/1", - } - - -def test_upsert_summary_block_replaces_existing_block() -> None: - body = "\n".join( - [ - "Intro", - "", - "Old summary", - "", - "Footer", - ] - ) - - updated = bm_bossbot_status.upsert_summary_block(body, "New summary") - - assert "Old summary" not in updated - assert "New summary" in updated - assert updated.startswith("Intro") - assert updated.endswith("Footer") - - -def test_finalize_review_fetches_current_pr_body_before_upserting( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - event_path = tmp_path / "event.json" - review_path = tmp_path / "review.json" - event_path.write_text(json.dumps(_event_payload()), encoding="utf-8") - review_path.write_text(json.dumps(_review_payload()), encoding="utf-8") - monkeypatch.setenv("GITHUB_TOKEN", "token") - - updated_bodies: list[str] = [] - statuses: list[Mapping[str, str]] = [] - - def fake_get_pull_request_body(*, token: str, repo: str, number: int) -> str: - assert token == "token" - assert repo == "basicmachines-co/basic-memory" - assert number == 925 - return "Current body edited while the workflow was running" - - def fake_update_pull_request_body(*, token: str, repo: str, number: int, body: str) -> None: - updated_bodies.append(body) - - def fake_set_commit_status( - *, - token: str, - repo: str, - sha: str, - payload: Mapping[str, str], - ) -> None: - statuses.append(payload) - - monkeypatch.setattr(bm_bossbot_status, "get_pull_request_body", fake_get_pull_request_body) - monkeypatch.setattr(bm_bossbot_status, "update_pull_request_body", fake_update_pull_request_body) - monkeypatch.setattr(bm_bossbot_status, "set_commit_status", fake_set_commit_status) - - result = bm_bossbot_status.finalize_review( - event_path=event_path, - review_path=review_path, - repo=None, - run_url="https://github.com/basicmachines-co/basic-memory/actions/runs/1", - token_env="GITHUB_TOKEN", - ) - - assert result.approved is True - assert "Current body edited while the workflow was running" in updated_bodies[0] - assert "Event snapshot body" not in updated_bodies[0] - assert statuses[0]["state"] == "success" - - -def test_finalize_cli_marks_failure_when_review_file_is_missing( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - event_path = tmp_path / "event.json" - missing_review_path = tmp_path / "missing-review.json" - event_path.write_text(json.dumps(_event_payload(body="Current body")), encoding="utf-8") - monkeypatch.setenv("GITHUB_TOKEN", "token") - - updated_bodies: list[str] = [] - statuses: list[Mapping[str, str]] = [] - - def fake_get_pull_request_body(*, token: str, repo: str, number: int) -> str: - return "Current body" - - def fake_update_pull_request_body(*, token: str, repo: str, number: int, body: str) -> None: - updated_bodies.append(body) - - def fake_set_commit_status( - *, - token: str, - repo: str, - sha: str, - payload: Mapping[str, str], - ) -> None: - statuses.append(payload) - - monkeypatch.setattr(bm_bossbot_status, "get_pull_request_body", fake_get_pull_request_body) - monkeypatch.setattr(bm_bossbot_status, "update_pull_request_body", fake_update_pull_request_body) - monkeypatch.setattr(bm_bossbot_status, "set_commit_status", fake_set_commit_status) - - result = CliRunner().invoke( - bm_bossbot_status.app, - [ - "finalize", - "--event", - str(event_path), - "--review", - str(missing_review_path), - "--repo", - "basicmachines-co/basic-memory", - "--run-url", - "https://github.com/basicmachines-co/basic-memory/actions/runs/1", - ], - ) - - assert result.exit_code == 1 - assert "BM Bossbot review output was invalid" in updated_bodies[0] - assert statuses[0]["state"] == "failure" diff --git a/tests/scripts/test_generate_pr_infographic.py b/tests/scripts/test_generate_pr_infographic.py deleted file mode 100644 index b50ada16..00000000 --- a/tests/scripts/test_generate_pr_infographic.py +++ /dev/null @@ -1,360 +0,0 @@ -from pathlib import Path - -import pytest -from click import unstyle -from typer.testing import CliRunner - -from scripts import generate_infographic, generate_pr_infographic - - -def test_infographic_scripts_are_uv_typer_entrypoints() -> None: - for module in (generate_infographic, generate_pr_infographic): - source = module.__file__ - assert source is not None - text = Path(source).read_text(encoding="utf-8") - - assert text.startswith("#!/usr/bin/env -S uv run --script\n") - assert "# /// script" in text - assert "typer" in text - assert hasattr(module, "app") - - -def test_generate_pr_infographic_cli_help_exposes_useful_options() -> None: - result = CliRunner().invoke(generate_pr_infographic.app, ["--help"]) - help_text = unstyle(result.output) - - assert result.exit_code == 0 - assert "--pr-number" in help_text - assert "--pr-body-file" in help_text - assert "--output" in help_text - assert "--theme" in help_text - assert "--provenance-output" in help_text - assert "--print-prompt" in help_text - assert "--dry-run" in help_text - - -def test_extract_bossbot_summary_from_pr_body() -> None: - body = "\n".join( - [ - "Before", - "", - "Reviewed SHA: abc123", - "Verdict: approve", - "", - "After", - ] - ) - - summary = generate_pr_infographic.extract_bossbot_summary(body) - - assert summary == "Reviewed SHA: abc123\nVerdict: approve" - - -def test_extract_bossbot_summary_requires_managed_block() -> None: - with pytest.raises(ValueError, match="BM Bossbot summary block"): - generate_pr_infographic.extract_bossbot_summary("No managed summary") - - -def test_extract_infographic_theme_from_pr_body() -> None: - body = "\n".join( - [ - "Before", - "", - "Italian movie poster with a release-route map", - "", - "After", - ] - ) - - theme = generate_pr_infographic.extract_infographic_theme(body) - - assert theme == "Italian movie poster with a release-route map" - - -def test_extract_infographic_theme_is_optional() -> None: - assert generate_pr_infographic.extract_infographic_theme("No theme") is None - - -def test_select_image_theme_reports_source() -> None: - body = "\n".join( - [ - "", - "paintings: Rembrandt-inspired merge gate", - "", - ] - ) - - from_body = generate_pr_infographic.select_image_theme( - pr_number=42, - summary="Summary: Adds a merge gate.", - pr_body=body, - theme_override=None, - ) - from_cli = generate_pr_infographic.select_image_theme( - pr_number=42, - summary="Summary: Adds a merge gate.", - pr_body=body, - theme_override="80's action movies", - ) - from_auto = generate_pr_infographic.select_image_theme( - pr_number=42, - summary="Summary: Adds a merge gate.", - pr_body="No theme", - theme_override=None, - ) - - assert from_body.theme == "paintings: Rembrandt-inspired merge gate" - assert from_body.source == generate_pr_infographic.ThemeSource.PR_BODY - assert from_cli.theme == "80's action movies" - assert from_cli.source == generate_pr_infographic.ThemeSource.CLI - assert from_auto.theme in generate_pr_infographic.BM_IMAGE_THEME_POOL - assert from_auto.source == generate_pr_infographic.ThemeSource.AUTO - - -def test_build_infographic_prompt_uses_summary_without_making_gate_claims() -> None: - prompt = generate_pr_infographic.build_infographic_prompt( - pr_number=42, - summary="Verdict: approve\nSummary: Adds a merge gate.", - theme="WWII propaganda posters with home-front logistics routes", - theme_source=generate_pr_infographic.ThemeSource.CLI, - ) - - assert "PR #42" in prompt - assert "Adds a merge gate" in prompt - assert "WWII propaganda posters" in prompt - assert "User-supplied visual direction" in prompt - assert "style inspiration only" in prompt - assert "polished landscape WebP editorial image" in prompt - assert "image-first composition" in prompt - assert "scene" in prompt - assert "poster" in prompt - assert "painting" in prompt - assert "classic photograph" in prompt - assert "symbolic tableau" in prompt - assert "before/after value story" in prompt - assert "Do not render an infographic" in prompt - assert "dashboard" in prompt - assert "flowchart" in prompt - assert "copyrighted characters" in prompt - assert "restrained" not in prompt - assert "non-gating" in prompt - assert "BM Bossbot Approval" in prompt - - -def test_build_infographic_provenance_block_includes_image_choices_without_prompt() -> None: - block = generate_pr_infographic.build_infographic_provenance_block( - pr_number=42, - output_path=Path("docs/assets/infographics/pr-42.webp"), - model="gpt-image-2", - size="1536x1024", - quality="high", - theme="classic black-and-white photography", - theme_source=generate_pr_infographic.ThemeSource.CLI, - ) - - assert generate_pr_infographic.PROVENANCE_START in block - assert generate_pr_infographic.PROVENANCE_END in block - assert "BM Bossbot image choices" in block - assert "Generated asset: `docs/assets/infographics/pr-42.webp`" in block - assert "Image model: `gpt-image-2`" in block - assert "Size: `1536x1024`" in block - assert "Quality: `high`" in block - assert "Image mode: `editorial-image`" in block - assert "Theme source: `cli`" in block - assert "classic black-and-white photography" in block - assert "Image prompt sent to" not in block - assert "Images API revised prompt" not in block - - -def test_upsert_managed_block_appends_and_replaces() -> None: - first = "\n".join( - [ - generate_pr_infographic.PROVENANCE_START, - "first", - generate_pr_infographic.PROVENANCE_END, - ] - ) - second = "\n".join( - [ - generate_pr_infographic.PROVENANCE_START, - "second", - generate_pr_infographic.PROVENANCE_END, - ] - ) - - appended = generate_pr_infographic.upsert_managed_block( - "Existing body", - block=first, - start=generate_pr_infographic.PROVENANCE_START, - end=generate_pr_infographic.PROVENANCE_END, - ) - replaced = generate_pr_infographic.upsert_managed_block( - appended, - block=second, - start=generate_pr_infographic.PROVENANCE_START, - end=generate_pr_infographic.PROVENANCE_END, - ) - - assert appended == f"Existing body\n\n{first}\n" - assert "first" not in replaced - assert "second" in replaced - assert replaced.count(generate_pr_infographic.PROVENANCE_START) == 1 - - -def test_build_infographic_prompt_uses_auto_theme_as_visual_direction() -> None: - theme = generate_pr_infographic.select_image_theme( - pr_number=42, - summary="Verdict: approve\nSummary: Adds a merge gate.", - pr_body="No theme", - theme_override=None, - ) - prompt = generate_pr_infographic.build_infographic_prompt( - pr_number=42, - summary="Verdict: approve\nSummary: Adds a merge gate.", - theme=theme.theme, - theme_source=theme.source, - ) - - assert "Selected BM visual direction" in prompt - assert theme.theme in prompt - assert "Use image-first composition" in prompt - assert "movie poster" in prompt - assert "painting" in prompt - assert "classic photograph" in prompt - assert "scene" in prompt - assert "poster" in prompt - assert "cover image" in prompt - assert "symbolic tableau" in prompt - assert "Use at most a short title" in prompt - assert "Do not render an infographic" in prompt - assert "dashboard" in prompt - assert "flowchart" in prompt - assert "bullet-list panel" in prompt - - -@pytest.mark.parametrize("flag", ["--print-prompt", "--dry-run"]) -def test_generate_pr_infographic_can_print_prompt_without_image_call( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - flag: str, -) -> None: - body_file = tmp_path / "pr-body.md" - body_file.write_text( - "\n".join( - [ - "", - "Verdict: approve", - "Summary: Adds a merge gate.", - "", - "", - "space exploration and astronomy", - "", - ] - ), - encoding="utf-8", - ) - - def fail_generate_image_result(**_: object) -> generate_infographic.GeneratedImage: - raise AssertionError("print-prompt mode must not call image generation") - - monkeypatch.setattr( - generate_pr_infographic, "generate_image_result", fail_generate_image_result - ) - output = tmp_path / "docs/assets/infographics/pr-42.webp" - - result = CliRunner().invoke( - generate_pr_infographic.app, - [ - "--pr-number", - "42", - "--pr-body-file", - str(body_file), - "--output", - str(output), - flag, - ], - ) - - assert result.exit_code == 0, result.output - assert ( - "Create a polished landscape WebP editorial image for Basic Memory PR #42" - in result.output - ) - assert "Adds a merge gate" in result.output - assert "space exploration and astronomy" in result.output - assert "image-first composition" in result.output - assert "Do not render an infographic" in result.output - assert "BM Bossbot Approval" in result.output - assert not output.exists() - - -def test_generate_pr_infographic_writes_provenance_after_image_generation( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - body_file = tmp_path / "pr-body.md" - body_file.write_text( - "\n".join( - [ - "", - "Verdict: approve", - "Summary: Adds a merge gate.", - "", - "", - "paintings: Rembrandt-inspired merge gate", - "", - ] - ), - encoding="utf-8", - ) - - def fake_generate_image_result(**kwargs: object) -> generate_infographic.GeneratedImage: - output_path = kwargs["output_path"] - assert isinstance(output_path, Path) - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_bytes(b"fake-webp") - return generate_infographic.GeneratedImage( - path=output_path, - revised_prompt="A Rembrandt-inspired painting of a robot guarding a merge gate.", - ) - - monkeypatch.setattr( - generate_pr_infographic, "generate_image_result", fake_generate_image_result - ) - output = tmp_path / "docs/assets/infographics/pr-42.webp" - provenance = tmp_path / "provenance.md" - - result = CliRunner().invoke( - generate_pr_infographic.app, - [ - "--pr-number", - "42", - "--pr-body-file", - str(body_file), - "--output", - str(output), - "--provenance-output", - str(provenance), - ], - ) - - assert result.exit_code == 0, result.output - assert output.exists() - text = provenance.read_text(encoding="utf-8") - assert "Generated asset:" in text - assert "Image mode: `editorial-image`" in text - assert "Theme source: `pr-body`" in text - assert "paintings: Rembrandt-inspired merge gate" in text - assert "Image prompt sent to" not in text - assert "Images API revised prompt" not in text - assert "robot guarding a merge gate" not in text - assert "Adds a merge gate" not in text - - -def test_validate_output_path_must_stay_under_docs_assets_infographics(tmp_path: Path) -> None: - good = tmp_path / "docs/assets/infographics/pr-42.webp" - bad = tmp_path / "docs/assets/pr-42.webp" - - assert generate_infographic.validate_output_path(good, repo_root=tmp_path) == good - with pytest.raises(ValueError, match="docs/assets/infographics"): - generate_infographic.validate_output_path(bad, repo_root=tmp_path)