feat: initial commit — Billo Release Agent (LangGraph)
LangGraph-based release automation agent with: - PR discovery (webhook + polling) - AI code review via Claude Code CLI (subscription-based) - Auto-create Jira tickets for PRs without ticket ID - Jira ticket lifecycle management (code review -> staging -> done) - CI/CD pipeline trigger, polling, and approval gates - Slack interactive messages with approval buttons - Per-repo semantic versioning - PostgreSQL persistence (threads, staging, releases) - FastAPI API (webhooks, approvals, status, manual triggers) - Docker Compose deployment 1069 tests, 95%+ coverage.
This commit is contained in:
332
tests/scripts/test_migrate.py
Normal file
332
tests/scripts/test_migrate.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""Tests for scripts/migrate_json_to_db.py.
|
||||
|
||||
Phase 5 - Step 5: Migration script tests using pure functions and
|
||||
dry-run mode. No real database required.
|
||||
Written FIRST (TDD RED phase).
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.migrate_json_to_db import (
|
||||
collect_json_files,
|
||||
parse_staging_json,
|
||||
parse_archived_json,
|
||||
build_staging_insert_sql,
|
||||
build_archived_insert_sql,
|
||||
is_archived_filename,
|
||||
is_staging_filename,
|
||||
MigrationRecord,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture data (mirrors real JSON structure from release-workflow/releases/)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STAGING_JSON = {
|
||||
"version": "v1.0.0",
|
||||
"repo": "Billo.Platform.Document",
|
||||
"started_at": "2026-03-17",
|
||||
"tickets": [
|
||||
{
|
||||
"id": "ALLPOST-4219",
|
||||
"summary": "Test release bot",
|
||||
"pr_id": "10460",
|
||||
"pr_url": "https://dev.azure.com/billodev/Billo%20App%20Platform/_git/Billo.Platform.Document/pullrequest/10460",
|
||||
"pr_title": "chore: trigger release bot test",
|
||||
"branch": "feature_ALLPOST-4219_test_release_bot",
|
||||
"merged_at": "2026-03-17",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
PAYMENT_STAGING_JSON = {
|
||||
"version": "v1.0.1",
|
||||
"repo": "Billo.Platform.Payment",
|
||||
"started_at": "2026-03-23",
|
||||
"tickets": [
|
||||
{
|
||||
"id": "ALLPOST-4228",
|
||||
"summary": "Invoice upload fails on Hangfire retry - BlobAlreadyExists 409",
|
||||
"pr_id": "10481",
|
||||
"pr_url": "https://dev.azure.com/billodev/Billo%20App%20Platform/_git/Billo.Platform.Payment/pullrequest/10481",
|
||||
"pr_title": "Invoice upload fails on Hangfire retry - BlobAlreadyExists 409",
|
||||
"branch": "bug/ALLPOST-4228_fix-invoice-upload-blob-already-exists",
|
||||
"merged_at": "2026-03-23",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
# Archived JSON has an additional released_at field
|
||||
ARCHIVED_JSON = {
|
||||
"version": "v1.0.0",
|
||||
"repo": "Billo.Platform.Payment",
|
||||
"started_at": "2026-01-01",
|
||||
"tickets": [],
|
||||
"released_at": "2026-01-15",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_staging_filename / is_archived_filename
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFileNameClassification:
|
||||
def test_staging_filename_identified(self) -> None:
|
||||
assert is_staging_filename("Billo.Platform.Document.json") is True
|
||||
|
||||
def test_archived_filename_identified(self) -> None:
|
||||
assert is_archived_filename("Billo.Platform.Payment_v1.0.1_2026-03-23.json") is True
|
||||
|
||||
def test_staging_filename_not_archived(self) -> None:
|
||||
assert is_archived_filename("Billo.Platform.Document.json") is False
|
||||
|
||||
def test_archived_filename_not_staging(self) -> None:
|
||||
assert is_staging_filename("Billo.Platform.Payment_v1.0.1_2026-03-23.json") is False
|
||||
|
||||
def test_non_json_file_is_not_staging(self) -> None:
|
||||
assert is_staging_filename("README.md") is False
|
||||
|
||||
def test_non_json_file_is_not_archived(self) -> None:
|
||||
assert is_archived_filename("README.md") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_staging_json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseStagingJson:
|
||||
def test_parse_returns_migration_record(self) -> None:
|
||||
record = parse_staging_json(STAGING_JSON)
|
||||
assert isinstance(record, MigrationRecord)
|
||||
|
||||
def test_parse_extracts_repo(self) -> None:
|
||||
record = parse_staging_json(STAGING_JSON)
|
||||
assert record.repo == "Billo.Platform.Document"
|
||||
|
||||
def test_parse_extracts_version(self) -> None:
|
||||
record = parse_staging_json(STAGING_JSON)
|
||||
assert record.version == "v1.0.0"
|
||||
|
||||
def test_parse_extracts_started_at(self) -> None:
|
||||
record = parse_staging_json(STAGING_JSON)
|
||||
assert record.started_at == date(2026, 3, 17)
|
||||
|
||||
def test_parse_extracts_tickets(self) -> None:
|
||||
record = parse_staging_json(STAGING_JSON)
|
||||
assert len(record.tickets) == 1
|
||||
assert record.tickets[0]["id"] == "ALLPOST-4219"
|
||||
|
||||
def test_parse_staging_has_no_released_at(self) -> None:
|
||||
record = parse_staging_json(STAGING_JSON)
|
||||
assert record.released_at is None
|
||||
|
||||
def test_parse_staging_with_multiple_tickets(self) -> None:
|
||||
data = {
|
||||
**STAGING_JSON,
|
||||
"tickets": [
|
||||
{**STAGING_JSON["tickets"][0], "id": "ALLPOST-1"},
|
||||
{**STAGING_JSON["tickets"][0], "id": "ALLPOST-2"},
|
||||
],
|
||||
}
|
||||
record = parse_staging_json(data)
|
||||
assert len(record.tickets) == 2
|
||||
|
||||
def test_parse_staging_with_empty_tickets(self) -> None:
|
||||
data = {**STAGING_JSON, "tickets": []}
|
||||
record = parse_staging_json(data)
|
||||
assert record.tickets == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_archived_json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseArchivedJson:
|
||||
def test_parse_returns_migration_record(self) -> None:
|
||||
record = parse_archived_json(ARCHIVED_JSON)
|
||||
assert isinstance(record, MigrationRecord)
|
||||
|
||||
def test_parse_extracts_released_at(self) -> None:
|
||||
record = parse_archived_json(ARCHIVED_JSON)
|
||||
assert record.released_at == date(2026, 1, 15)
|
||||
|
||||
def test_parse_extracts_repo(self) -> None:
|
||||
record = parse_archived_json(ARCHIVED_JSON)
|
||||
assert record.repo == "Billo.Platform.Payment"
|
||||
|
||||
def test_parse_extracts_version(self) -> None:
|
||||
record = parse_archived_json(ARCHIVED_JSON)
|
||||
assert record.version == "v1.0.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_staging_insert_sql
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildStagingInsertSql:
|
||||
def test_returns_tuple_of_sql_and_params(self) -> None:
|
||||
record = parse_staging_json(STAGING_JSON)
|
||||
sql, params = build_staging_insert_sql(record)
|
||||
assert isinstance(sql, str)
|
||||
assert isinstance(params, tuple)
|
||||
|
||||
def test_sql_inserts_into_staging_releases(self) -> None:
|
||||
record = parse_staging_json(STAGING_JSON)
|
||||
sql, _ = build_staging_insert_sql(record)
|
||||
assert "staging_releases" in sql
|
||||
|
||||
def test_sql_is_insert_statement(self) -> None:
|
||||
record = parse_staging_json(STAGING_JSON)
|
||||
sql, _ = build_staging_insert_sql(record)
|
||||
assert "INSERT" in sql.upper()
|
||||
|
||||
def test_params_include_repo(self) -> None:
|
||||
record = parse_staging_json(STAGING_JSON)
|
||||
_, params = build_staging_insert_sql(record)
|
||||
assert "Billo.Platform.Document" in params
|
||||
|
||||
def test_params_include_version(self) -> None:
|
||||
record = parse_staging_json(STAGING_JSON)
|
||||
_, params = build_staging_insert_sql(record)
|
||||
assert "v1.0.0" in params
|
||||
|
||||
def test_params_include_started_at(self) -> None:
|
||||
record = parse_staging_json(STAGING_JSON)
|
||||
_, params = build_staging_insert_sql(record)
|
||||
assert "2026-03-17" in params
|
||||
|
||||
def test_params_include_tickets_json(self) -> None:
|
||||
record = parse_staging_json(STAGING_JSON)
|
||||
_, params = build_staging_insert_sql(record)
|
||||
# tickets should be serialized as JSON string
|
||||
tickets_json = next(p for p in params if isinstance(p, str) and "ALLPOST-4219" in p)
|
||||
parsed = json.loads(tickets_json)
|
||||
assert parsed[0]["id"] == "ALLPOST-4219"
|
||||
|
||||
def test_sql_uses_on_conflict_do_nothing_or_update(self) -> None:
|
||||
record = parse_staging_json(STAGING_JSON)
|
||||
sql, _ = build_staging_insert_sql(record)
|
||||
assert "ON CONFLICT" in sql.upper() or "INSERT" in sql.upper()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_archived_insert_sql
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildArchivedInsertSql:
|
||||
def test_returns_tuple_of_sql_and_params(self) -> None:
|
||||
record = parse_archived_json(ARCHIVED_JSON)
|
||||
sql, params = build_archived_insert_sql(record)
|
||||
assert isinstance(sql, str)
|
||||
assert isinstance(params, tuple)
|
||||
|
||||
def test_sql_inserts_into_archived_releases(self) -> None:
|
||||
record = parse_archived_json(ARCHIVED_JSON)
|
||||
sql, _ = build_archived_insert_sql(record)
|
||||
assert "archived_releases" in sql
|
||||
|
||||
def test_sql_is_insert_statement(self) -> None:
|
||||
record = parse_archived_json(ARCHIVED_JSON)
|
||||
sql, _ = build_archived_insert_sql(record)
|
||||
assert "INSERT" in sql.upper()
|
||||
|
||||
def test_params_include_released_at(self) -> None:
|
||||
record = parse_archived_json(ARCHIVED_JSON)
|
||||
_, params = build_archived_insert_sql(record)
|
||||
assert "2026-01-15" in params
|
||||
|
||||
def test_params_include_repo(self) -> None:
|
||||
record = parse_archived_json(ARCHIVED_JSON)
|
||||
_, params = build_archived_insert_sql(record)
|
||||
assert "Billo.Platform.Payment" in params
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# collect_json_files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCollectJsonFiles:
|
||||
def test_returns_empty_list_for_empty_directory(self, tmp_path: Path) -> None:
|
||||
result = collect_json_files(tmp_path)
|
||||
assert result == []
|
||||
|
||||
def test_finds_staging_json_files(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "my-repo.json").write_text(json.dumps(STAGING_JSON))
|
||||
result = collect_json_files(tmp_path)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_finds_archived_json_files(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "my-repo_v1.0.0_2025-06-01.json").write_text(
|
||||
json.dumps(ARCHIVED_JSON)
|
||||
)
|
||||
result = collect_json_files(tmp_path)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_ignores_non_json_files(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("readme")
|
||||
(tmp_path / "my-repo.json").write_text(json.dumps(STAGING_JSON))
|
||||
result = collect_json_files(tmp_path)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_collects_from_nested_directories(self, tmp_path: Path) -> None:
|
||||
repo_dir = tmp_path / "Billo.Platform.Document"
|
||||
repo_dir.mkdir()
|
||||
(repo_dir / "v1.0.0.json").write_text(json.dumps(STAGING_JSON))
|
||||
result = collect_json_files(tmp_path)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_returns_path_objects(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "my-repo.json").write_text(json.dumps(STAGING_JSON))
|
||||
result = collect_json_files(tmp_path)
|
||||
assert all(isinstance(p, Path) for p in result)
|
||||
|
||||
def test_collects_multiple_files(self, tmp_path: Path) -> None:
|
||||
for i in range(3):
|
||||
(tmp_path / f"repo-{i}.json").write_text(json.dumps(STAGING_JSON))
|
||||
result = collect_json_files(tmp_path)
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dry-run mode (integration of pure functions)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDryRunMode:
|
||||
def test_dry_run_collects_records_without_db_access(self, tmp_path: Path) -> None:
|
||||
"""Dry run processes files and returns SQL/params without executing."""
|
||||
repo_dir = tmp_path / "Billo.Platform.Document"
|
||||
repo_dir.mkdir()
|
||||
(repo_dir / "v1.0.0.json").write_text(json.dumps(STAGING_JSON))
|
||||
|
||||
files = collect_json_files(tmp_path)
|
||||
assert len(files) == 1
|
||||
|
||||
# Parse and build SQL — no DB connection needed
|
||||
record = parse_staging_json(json.loads(files[0].read_text()))
|
||||
sql, params = build_staging_insert_sql(record)
|
||||
assert "INSERT" in sql.upper()
|
||||
assert "Billo.Platform.Document" in params
|
||||
|
||||
def test_payment_staging_file_parses_correctly(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "Billo.Platform.Payment.json").write_text(
|
||||
json.dumps(PAYMENT_STAGING_JSON)
|
||||
)
|
||||
files = collect_json_files(tmp_path)
|
||||
record = parse_staging_json(json.loads(files[0].read_text()))
|
||||
assert record.repo == "Billo.Platform.Payment"
|
||||
assert record.version == "v1.0.1"
|
||||
assert len(record.tickets) == 1
|
||||
assert record.tickets[0]["id"] == "ALLPOST-4228"
|
||||
|
||||
def test_archived_file_parses_correctly(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "my-repo_v1.0.0_2026-01-15.json").write_text(
|
||||
json.dumps(ARCHIVED_JSON)
|
||||
)
|
||||
files = collect_json_files(tmp_path)
|
||||
record = parse_archived_json(json.loads(files[0].read_text()))
|
||||
assert record.released_at == date(2026, 1, 15)
|
||||
Reference in New Issue
Block a user