feat(task-017): implement git publishing dry-run and commit flow

This commit is contained in:
2026-05-22 00:56:53 +03:00
parent d3990b4019
commit 318ae790ef
15 changed files with 1971 additions and 17 deletions
@@ -0,0 +1,429 @@
from __future__ import annotations
import base64
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi.testclient import TestClient
BACKEND_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(BACKEND_ROOT))
from src.application.seed_data import seed_reference_data # noqa: E402
from src.infrastructure.repositories import open_backend_repository # noqa: E402
from src.presentation.dependencies import get_repository # noqa: E402
from src.presentation.main import app # noqa: E402
DEMO_EDITOR_EMAIL = "editor@example.com"
DEMO_ADMIN_EMAIL = "admin@example.com"
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
class PublishingGitFlowPublicApiTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
self.objects_root = Path(self.tmp_dir.name) / "objects"
self.bare_repo_path = Path(self.tmp_dir.name) / "demo-site.git"
self.seed_repo_path = Path(self.tmp_dir.name) / "demo-site-seed"
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(self.objects_root)
self._prepare_local_bare_git_repo()
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'publishing-git-flow.db'}"
self.repository = open_backend_repository(dsn)
self.repository.setup()
seed_reference_data(self.repository)
app.dependency_overrides[get_repository] = lambda: self.repository
self.client = TestClient(app)
def tearDown(self) -> None:
app.dependency_overrides.clear()
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
self.tmp_dir.cleanup()
def test_publishing_dry_run_blocked_before_final_approval(self) -> None:
article_id, _ = self._prepare_article_ready_for_final_approval()
response = self.client.post(
f"/api/articles/{article_id}/publishing/dry-run",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(409, response.status_code, response.text)
self.assertIn("final approval", response.text.lower())
def test_publishing_dry_run_validation_failure_sets_failed_status(self) -> None:
article_id, draft = self._prepare_final_approved_article()
patch_response = self.client.patch(
f"/api/articles/{article_id}/drafts/{draft['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"body_markdown": "Only plain text, no markdown heading present."},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
response = self.client.post(
f"/api/articles/{article_id}/publishing/dry-run",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, response.status_code, response.text)
body = response.json()
self.assertFalse(body["content_shape_valid"])
self.assertIn("best-effort", body["validation_label"].lower())
self.assertIn("heading", " ".join(body["errors"]).lower())
self.assertEqual("PUBLISH_DRY_RUN_FAILED", body["article"]["publishing_status"])
self.assertEqual("PUBLISH_DRY_RUN_REQUIRED", body["article"]["status"])
def test_publish_commit_blocked_before_successful_dry_run(self) -> None:
article_id, _ = self._prepare_final_approved_article()
response = self.client.post(
f"/api/articles/{article_id}/publishing/create-commit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(409, response.status_code, response.text)
self.assertIn("dry run", response.text.lower())
def test_final_approved_article_can_create_commit_in_local_git_repository(self) -> None:
article_id, _ = self._prepare_final_approved_article()
dry_run_response = self.client.post(
f"/api/articles/{article_id}/publishing/dry-run",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, dry_run_response.status_code, dry_run_response.text)
self.assertTrue(dry_run_response.json()["content_shape_valid"])
self.assertEqual("PUBLISH_COMMIT_READY", dry_run_response.json()["article"]["status"])
commit_response = self.client.post(
f"/api/articles/{article_id}/publishing/create-commit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, commit_response.status_code, commit_response.text)
body = commit_response.json()
commit = body["publish_commit"]
commit_sha = commit["commit_sha"]
self.assertTrue(commit_sha)
self.assertEqual(str(self.bare_repo_path), commit["repository_url"])
self.assertEqual("main", commit["branch"])
self.assertEqual("PUBLISH_COMMIT_CREATED", commit["status"])
check = subprocess.run(
["git", "--git-dir", str(self.bare_repo_path), "cat-file", "-e", f"{commit_sha}^{{commit}}"],
capture_output=True,
text=True,
)
self.assertEqual(0, check.returncode, check.stderr)
def test_non_fast_forward_conflict_fails_without_rebase(self) -> None:
article_id, _ = self._prepare_final_approved_article()
dry_run_response = self.client.post(
f"/api/articles/{article_id}/publishing/dry-run",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, dry_run_response.status_code, dry_run_response.text)
self._push_remote_commit("competing remote update")
commit_response = self.client.post(
f"/api/articles/{article_id}/publishing/create-commit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(409, commit_response.status_code, commit_response.text)
self.assertIn("non-fast-forward", commit_response.text.lower())
def test_status_and_commits_endpoints_expose_required_metadata(self) -> None:
article_id, _ = self._prepare_final_approved_article()
dry_run_response = self.client.post(
f"/api/articles/{article_id}/publishing/dry-run",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, dry_run_response.status_code, dry_run_response.text)
commit_response = self.client.post(
f"/api/articles/{article_id}/publishing/create-commit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, commit_response.status_code, commit_response.text)
status_response = self.client.get(
f"/api/articles/{article_id}/publishing/status",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, status_response.status_code, status_response.text)
status_body = status_response.json()
self.assertIn("best-effort", status_body["validation_label"].lower())
self.assertEqual("PUBLISH_COMMIT_CREATED", status_body["article"]["status"])
self.assertEqual("PUBLISH_COMMIT_CREATED", status_body["article"]["publishing_status"])
self.assertIsNotNone(status_body["latest_dry_run"])
self.assertIsNotNone(status_body["latest_publish_commit"])
commits_response = self.client.get(
f"/api/articles/{article_id}/publishing/commits",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, commits_response.status_code, commits_response.text)
commits = commits_response.json()["commits"]
self.assertGreaterEqual(len(commits), 2)
commit_rows = {row["status"]: row for row in commits}
self.assertIn("PUBLISH_COMMIT_READY", commit_rows)
self.assertIn("PUBLISH_COMMIT_CREATED", commit_rows)
created = commit_rows["PUBLISH_COMMIT_CREATED"]
self.assertEqual(str(self.bare_repo_path), created["repository_url"])
self.assertEqual("main", created["branch"])
self.assertTrue(created["commit_sha"])
manifest = created["content_bundle_manifest"]
self.assertIn("content", manifest)
self.assertIn("frontmatter", manifest)
self.assertIn("assets", manifest)
self.assertIn("config_version", manifest)
self.assertIn("git", manifest)
self.assertEqual("main", manifest["git"]["branch"])
self.assertEqual(str(self.bare_repo_path), manifest["git"]["repository_url"])
self.assertIn("best-effort", manifest["validation"]["label"].lower())
self.assertEqual("content/articles/git-publishing-flow.mdx", manifest["content"]["path"])
def _prepare_local_bare_git_repo(self) -> None:
self._run_git(["init", "--bare", str(self.bare_repo_path)])
self.seed_repo_path.mkdir(parents=True, exist_ok=True)
self._run_git(["init"], cwd=self.seed_repo_path)
self._run_git(["config", "user.name", "Pipeline Bot"], cwd=self.seed_repo_path)
self._run_git(["config", "user.email", "pipeline-bot@example.com"], cwd=self.seed_repo_path)
(self.seed_repo_path / "README.md").write_text("# Demo Site\n", encoding="utf-8")
self._run_git(["add", "README.md"], cwd=self.seed_repo_path)
self._run_git(["commit", "-m", "seed"], cwd=self.seed_repo_path)
self._run_git(["branch", "-M", "main"], cwd=self.seed_repo_path)
self._run_git(["remote", "add", "origin", str(self.bare_repo_path)], cwd=self.seed_repo_path)
self._run_git(["push", "origin", "main"], cwd=self.seed_repo_path)
def _run_git(self, args: list[str], *, cwd: Path | None = None) -> None:
subprocess.run(
["git", *args],
cwd=str(cwd) if cwd is not None else None,
check=True,
capture_output=True,
text=True,
)
def _push_remote_commit(self, message: str) -> None:
readme_path = self.seed_repo_path / "README.md"
readme_path.write_text(
readme_path.read_text(encoding="utf-8") + f"\n{message}\n",
encoding="utf-8",
)
self._run_git(["add", "README.md"], cwd=self.seed_repo_path)
self._run_git(["commit", "-m", message], cwd=self.seed_repo_path)
self._run_git(["push", "origin", "main"], cwd=self.seed_repo_path)
def _prepare_final_approved_article(self) -> tuple[str, dict[str, Any]]:
article_id, draft = self._prepare_article_ready_for_final_approval()
self._submit_final_approval(article_id, draft=draft)
return article_id, draft
def _prepare_article_ready_for_final_approval(self) -> tuple[str, dict[str, Any]]:
site_id = self._configure_local_git_repository_for_site()
article_id = self._create_article_with_approved_plan(site_id)
self._ensure_evidence_ready(article_id)
self._approve_all_evidence(article_id)
section_jobs = self._start_parallel_production(article_id)
self.assertGreaterEqual(len(section_jobs), 1)
for index, job in enumerate(section_jobs, start=1):
completed = self._complete_job(
job["id"],
output={
"status": "SUCCEEDED",
"output_files": [{"path": f"outputs/section-{index}.md"}],
"payload": {
"used_evidence_ids": job["payload"]["used_evidence_ids"],
"unsupported_claims": [],
"draft_markdown": (
f"## {job['payload']['heading']}\n\n"
"Publishing-ready section with [internal link](/guides/internal)."
),
},
},
)
self.assertEqual("SUCCEEDED", completed["status"])
assemble_response = self.client.post(
f"/api/articles/{article_id}/draft/assemble",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, assemble_response.status_code, assemble_response.text)
draft = assemble_response.json()["draft"]
assets_response = self.client.post(
f"/api/articles/{article_id}/assets/generate-specs",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, assets_response.status_code, assets_response.text)
assets = assets_response.json()["assets"]
self.assertTrue(assets)
for asset in assets:
upload_response = self.client.post(
f"/api/articles/{article_id}/assets/{asset['id']}/upload",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"filename": f"{asset['id']}.png",
"content_base64": base64.b64encode(b"asset-binary").decode("utf-8"),
"content_type": "image/png",
},
)
self.assertEqual(200, upload_response.status_code, upload_response.text)
approve_response = self.client.post(
f"/api/articles/{article_id}/assets/{asset['id']}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, approve_response.status_code, approve_response.text)
return article_id, draft
def _submit_final_approval(self, article_id: str, *, draft: dict[str, Any]) -> None:
approval_response = self.client.post(
f"/api/articles/{article_id}/final-approval",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"draft_version": draft["version"],
"publishing_settings": {
"content_path": "/guides/git-publishing-flow",
"author": "Editorial Team",
"publishing_mode": "MANUAL",
"frontmatter": {"title": draft["title"], "category": "Guides"},
},
},
)
self.assertEqual(200, approval_response.status_code, approval_response.text)
self.assertEqual("PUBLISH_DRY_RUN_REQUIRED", approval_response.json()["article"]["status"])
def _configure_local_git_repository_for_site(self) -> UUID:
sites_response = self.client.get(
"/api/sites",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, sites_response.status_code, sites_response.text)
site = sites_response.json()[0]["site"]
publishing_rules = dict(site["publishing_rules"])
publishing_rules["repository_url"] = str(self.bare_repo_path)
publishing_rules["production_branch"] = "main"
patch_response = self.client.patch(
f"/api/sites/{site['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
json={"publishing_rules": publishing_rules},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
return UUID(site["id"])
def _create_article_with_approved_plan(self, site_id: UUID) -> str:
article_response = self.client.post(
"/api/articles",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={
"target_site_id": str(site_id),
"brief_description": "Publish final-approved content into local git repository.",
"working_title": "Git Publishing Dry Run And Commit",
"content_type": "longform_guide",
"primary_keyword": "git publishing dry run",
},
)
self.assertEqual(201, article_response.status_code, article_response.text)
article_id = article_response.json()["article"]["id"]
questions_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, questions_response.status_code, questions_response.text)
questions = questions_response.json()["questions"]
for question in questions:
if question["is_required"]:
patch_response = self.client.patch(
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"answer": f"Answer for {question['category']}"},
)
self.assertEqual(200, patch_response.status_code, patch_response.text)
submit_response = self.client.post(
f"/api/articles/{article_id}/boundary-questions/submit",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, submit_response.status_code, submit_response.text)
plan_response = self.client.post(
f"/api/articles/{article_id}/plan/generate",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, plan_response.status_code, plan_response.text)
plan = plan_response.json()["plan"]
self.assertGreaterEqual(len(plan["sections"]), 1)
approve_response = self.client.post(
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, approve_response.status_code, approve_response.text)
return article_id
def _ensure_evidence_ready(self, article_id: str) -> None:
research_response = self.client.post(
f"/api/articles/{article_id}/research/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(201, research_response.status_code, research_response.text)
evidence_response = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
def _approve_all_evidence(self, article_id: str) -> None:
response = self.client.get(
f"/api/articles/{article_id}/evidence",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(200, response.status_code, response.text)
for item in response.json()["evidence"]:
patch = self.client.patch(
f"/api/articles/{article_id}/evidence/{item['id']}",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
json={"review_status": "APPROVED"},
)
self.assertEqual(200, patch.status_code, patch.text)
def _start_parallel_production(self, article_id: str) -> list[dict[str, Any]]:
response = self.client.post(
f"/api/articles/{article_id}/draft/start",
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
)
self.assertEqual(202, response.status_code, response.text)
return [job for job in response.json()["jobs"] if job["job_type"] == "SECTION_SCAFFOLD"]
def _complete_job(self, job_id: str, *, output: dict[str, Any]) -> dict[str, Any]:
response = self.client.post(
f"/internal/agent-jobs/{job_id}/complete",
json={
"workspace_path": f"/tmp/{job_id}",
"stdout": "fake section scaffolding runner\n",
"stderr": "",
"exit_code": 0,
"duration_ms": 1,
"output": output,
},
)
self.assertEqual(200, response.status_code, response.text)
return response.json()["job"]
if __name__ == "__main__":
unittest.main()