mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
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 <paul@basicmachines.co>
This commit is contained in:
@@ -54,10 +54,6 @@ jobs:
|
||||
run: |
|
||||
just typecheck
|
||||
|
||||
- name: Run CI tooling tests
|
||||
run: |
|
||||
just test-ci-tooling
|
||||
|
||||
- name: Run linting
|
||||
run: |
|
||||
just lint
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "<!-- pr-infographic:start -->" 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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"] == ""
|
||||
@@ -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",
|
||||
"<!-- BM_BOSSBOT_SUMMARY:start -->",
|
||||
"Old summary",
|
||||
"<!-- BM_BOSSBOT_SUMMARY:end -->",
|
||||
"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"
|
||||
@@ -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",
|
||||
"<!-- BM_BOSSBOT_SUMMARY:start -->",
|
||||
"Reviewed SHA: abc123",
|
||||
"Verdict: approve",
|
||||
"<!-- BM_BOSSBOT_SUMMARY:end -->",
|
||||
"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",
|
||||
"<!-- BM_INFOGRAPHIC_THEME:start -->",
|
||||
"Italian movie poster with a release-route map",
|
||||
"<!-- BM_INFOGRAPHIC_THEME:end -->",
|
||||
"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(
|
||||
[
|
||||
"<!-- BM_INFOGRAPHIC_THEME:start -->",
|
||||
"paintings: Rembrandt-inspired merge gate",
|
||||
"<!-- BM_INFOGRAPHIC_THEME:end -->",
|
||||
]
|
||||
)
|
||||
|
||||
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(
|
||||
[
|
||||
"<!-- BM_BOSSBOT_SUMMARY:start -->",
|
||||
"Verdict: approve",
|
||||
"Summary: Adds a merge gate.",
|
||||
"<!-- BM_BOSSBOT_SUMMARY:end -->",
|
||||
"<!-- BM_INFOGRAPHIC_THEME:start -->",
|
||||
"space exploration and astronomy",
|
||||
"<!-- BM_INFOGRAPHIC_THEME:end -->",
|
||||
]
|
||||
),
|
||||
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(
|
||||
[
|
||||
"<!-- BM_BOSSBOT_SUMMARY:start -->",
|
||||
"Verdict: approve",
|
||||
"Summary: Adds a merge gate.",
|
||||
"<!-- BM_BOSSBOT_SUMMARY:end -->",
|
||||
"<!-- BM_INFOGRAPHIC_THEME:start -->",
|
||||
"paintings: Rembrandt-inspired merge gate",
|
||||
"<!-- BM_INFOGRAPHIC_THEME:end -->",
|
||||
]
|
||||
),
|
||||
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)
|
||||
Reference in New Issue
Block a user