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
+2
View File
@@ -66,6 +66,7 @@ def get_article_detail(
claims = repository.claims.list_for_article(article_id) claims = repository.claims.list_for_article(article_id)
assets = repository.assets.list_for_article(article_id) assets = repository.assets.list_for_article(article_id)
agent_jobs = repository.agent_jobs.list_for_article(article_id) agent_jobs = repository.agent_jobs.list_for_article(article_id)
publish_commit = repository.publish_commits.latest_for_article(article_id)
return ArticleDetailResponse( return ArticleDetailResponse(
article=article, article=article,
target_site=target_site, target_site=target_site,
@@ -78,6 +79,7 @@ def get_article_detail(
assets=assets, assets=assets,
agent_jobs=agent_jobs, agent_jobs=agent_jobs,
research_manifests=research_manifests, research_manifests=research_manifests,
publish_commit=publish_commit,
) )
+728
View File
@@ -0,0 +1,728 @@
from __future__ import annotations
import json
import re
import shutil
import subprocess
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from uuid import UUID
from src.domain.contracts import (
ArticleSummary,
ArticleWorkflowStatus,
PublishCommitCreateResponse,
PublishCommitListResponse,
PublishCommitSummary,
PublishingDryRunResponse,
PublishingStatus,
PublishingStatusResponse,
)
_VALIDATION_LABEL = "Best-effort content-shape validation only."
_FINAL_APPROVAL_EVENT = "FINAL_APPROVAL_GRANTED"
_RUNTIME_DIR = ".pipeline-runtime"
_PUBLISHING_YAML_PATH = f"{_RUNTIME_DIR}/publishing.yaml"
_TRANSFORM_SCRIPT_PATH = f"{_RUNTIME_DIR}/transform.mjs"
_TRANSFORM_RUNNER_PATH = f"{_RUNTIME_DIR}/run-transform.mjs"
_TRANSFORM_INPUT_PATH = f"{_RUNTIME_DIR}/transform-input.json"
_TRANSFORM_OUTPUT_PATH = f"{_RUNTIME_DIR}/transform-output.json"
def run_publishing_dry_run(
repository: object,
*,
article_id: UUID,
actor_user_id: UUID,
) -> PublishingDryRunResponse:
article = repository.articles.get(article_id)
final_approval_event = _require_final_approval(repository, article_id=article_id)
if article.status not in {
ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
}:
raise PermissionError("Publishing dry run is allowed only after final approval.")
bundle_context = _load_bundle_context(
repository,
article=article,
final_approval_event=final_approval_event,
)
validation_errors = _validate_content_shape(bundle_context["markdown_body"])
content_shape_valid = len(validation_errors) == 0
now = _now()
status = (
PublishingStatus.PUBLISH_COMMIT_READY
if content_shape_valid
else PublishingStatus.PUBLISH_DRY_RUN_FAILED
)
manifest = _build_manifest(
bundle_context=bundle_context,
status=status,
validation_errors=validation_errors,
)
publish_commit = repository.publish_commits.create(
article_id=article.id,
target_site_id=article.target_site_id,
repository_url=bundle_context["repository_url"],
branch=bundle_context["branch"],
commit_sha=None,
content_bundle_manifest=manifest,
status=status,
deployment_status=None,
created_at=now,
)
updated = _update_article_for_dry_run_result(
repository,
article=article,
content_shape_valid=content_shape_valid,
actor_user_id=actor_user_id,
validation_errors=validation_errors,
created_at=now,
)
return PublishingDryRunResponse(
article=updated,
publish_commit=publish_commit,
content_shape_valid=content_shape_valid,
validation_label=_VALIDATION_LABEL,
errors=validation_errors,
)
def create_publish_commit(
repository: object,
*,
article_id: UUID,
actor_user_id: UUID,
) -> PublishCommitCreateResponse:
article = repository.articles.get(article_id)
_require_final_approval(repository, article_id=article_id)
latest_dry_run = repository.publish_commits.latest_for_article_with_statuses(
article_id,
statuses={PublishingStatus.PUBLISH_COMMIT_READY},
)
if latest_dry_run is None:
raise PermissionError("Publish commit requires successful dry run.")
if article.status != ArticleWorkflowStatus.PUBLISH_COMMIT_READY:
raise PermissionError("Publish commit can run only from PUBLISH_COMMIT_READY status.")
manifest = dict(latest_dry_run.content_bundle_manifest or {})
git_info = manifest.get("git") if isinstance(manifest.get("git"), dict) else {}
expected_base_head_sha = git_info.get("base_head_sha")
repository_url = latest_dry_run.repository_url
branch = latest_dry_run.branch
current_head_sha = _resolve_remote_branch_head_sha(repository_url, branch)
if expected_base_head_sha and current_head_sha != expected_base_head_sha:
_record_publish_failure(
repository,
article=article,
actor_user_id=actor_user_id,
repository_url=repository_url,
branch=branch,
previous_manifest=manifest,
detail=(
"Non-fast-forward detected: remote branch advanced since dry run; "
"re-run dry run before creating commit."
),
)
raise PermissionError(
"Non-fast-forward detected: remote branch advanced since dry run."
)
final_approval_event = _require_final_approval(repository, article_id=article_id)
bundle_context = _load_bundle_context(
repository,
article=article,
final_approval_event=final_approval_event,
)
commit_sha = _create_and_push_commit(bundle_context)
now = _now()
publish_commit = repository.publish_commits.create(
article_id=article.id,
target_site_id=article.target_site_id,
repository_url=repository_url,
branch=branch,
commit_sha=commit_sha,
content_bundle_manifest=_build_manifest(
bundle_context=bundle_context,
status=PublishingStatus.PUBLISH_COMMIT_CREATED,
validation_errors=[],
commit_sha=commit_sha,
),
status=PublishingStatus.PUBLISH_COMMIT_CREATED,
deployment_status="PENDING",
created_at=now,
)
updated = repository.articles.update_status(
article_id=article.id,
status=ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED,
updated_at=now,
)
updated = repository.articles.update_publishing_status(
article_id=article.id,
publishing_status=PublishingStatus.PUBLISH_COMMIT_CREATED,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article.id,
event_type="PUBLISH_COMMIT_CREATED",
from_status=article.status,
to_status=ArticleWorkflowStatus.PUBLISH_COMMIT_CREATED,
actor_user_id=actor_user_id,
payload={
"publish_commit_id": str(publish_commit.id),
"repository_url": repository_url,
"branch": branch,
"commit_sha": commit_sha,
},
created_at=now,
)
return PublishCommitCreateResponse(article=updated, publish_commit=publish_commit)
def get_publishing_status(
repository: object,
*,
article_id: UUID,
) -> PublishingStatusResponse:
article = repository.articles.get(article_id)
latest_dry_run = repository.publish_commits.latest_for_article_with_statuses(
article_id,
statuses={PublishingStatus.PUBLISH_COMMIT_READY, PublishingStatus.PUBLISH_DRY_RUN_FAILED},
)
latest_publish_commit = repository.publish_commits.latest_for_article_with_statuses(
article_id,
statuses={PublishingStatus.PUBLISH_COMMIT_CREATED},
)
return PublishingStatusResponse(
article=article,
latest_dry_run=latest_dry_run,
latest_publish_commit=latest_publish_commit,
validation_label=_VALIDATION_LABEL,
)
def list_publish_commits(
repository: object,
*,
article_id: UUID,
) -> PublishCommitListResponse:
repository.articles.get(article_id)
return PublishCommitListResponse(commits=repository.publish_commits.list_for_article(article_id))
def _load_bundle_context(
repository: object,
*,
article: ArticleSummary,
final_approval_event: object,
) -> dict[str, Any]:
target_site = repository.target_sites.get_by_id(article.target_site_id)
if target_site.active_script_config_version_id is None:
raise PermissionError("Active script config version is required for publishing.")
script_config_version = repository.script_config_versions.get_by_id(
target_site.active_script_config_version_id
)
draft = repository.article_drafts.latest_for_article(article.id)
if draft is None:
raise PermissionError("Latest draft is required for publishing.")
settings_payload = final_approval_event.payload.get("publishing_settings", {})
if not isinstance(settings_payload, dict):
settings_payload = {}
content_path = _string_value(settings_payload.get("content_path"))
if not content_path:
raise PermissionError("Final approval publishing settings are missing content_path.")
author = _string_value(settings_payload.get("author"))
if not author:
raise PermissionError("Final approval publishing settings are missing author.")
frontmatter_input = (
settings_payload.get("frontmatter")
if isinstance(settings_payload.get("frontmatter"), dict)
else {}
)
slug = _slug_from_content_path(content_path) or _slugify(draft.title or article.working_title or "article")
content_rel_path = _apply_template(
target_site.publishing_rules.content_path_template,
{
"slug": slug,
"article_id": str(article.id),
"content_path": content_path.lstrip("/"),
"format": target_site.publishing_rules.content_format,
"language": article.language,
},
)
frontmatter = _build_frontmatter(
frontmatter_mapping=target_site.publishing_rules.frontmatter_mapping,
frontmatter_input=frontmatter_input,
author=author,
draft=draft,
published_at=_now().isoformat(),
)
markdown_with_frontmatter = _compose_markdown(frontmatter, draft.body_markdown)
assets = _collect_assets(
repository,
article_id=article.id,
slug=slug,
asset_path_template=target_site.publishing_rules.asset_path_template,
)
return {
"article": article,
"draft": draft,
"target_site": target_site,
"script_config_version": script_config_version,
"slug": slug,
"content_rel_path": content_rel_path,
"frontmatter": frontmatter,
"markdown_body": draft.body_markdown,
"markdown_with_frontmatter": markdown_with_frontmatter,
"assets": assets,
"repository_url": target_site.publishing_rules.repository_url,
"branch": target_site.publishing_rules.production_branch,
"content_format": target_site.publishing_rules.content_format,
}
def _build_frontmatter(
*,
frontmatter_mapping: dict[str, Any],
frontmatter_input: dict[str, Any],
author: str,
draft: object,
published_at: str,
) -> dict[str, Any]:
source = {
**frontmatter_input,
"title": frontmatter_input.get("title") or getattr(draft, "title", None),
"meta_description": getattr(draft, "meta_description", None),
"author": author,
"published_at": published_at,
}
mapped: dict[str, Any] = {}
for output_key, source_key in frontmatter_mapping.items():
if not isinstance(output_key, str) or not output_key:
continue
if not isinstance(source_key, str) or not source_key:
continue
if source_key in source and source[source_key] is not None:
mapped[output_key] = source[source_key]
for key, value in frontmatter_input.items():
if key not in mapped:
mapped[key] = value
if "author" not in mapped:
mapped["author"] = author
return mapped
def _collect_assets(
repository: object,
*,
article_id: UUID,
slug: str,
asset_path_template: str,
) -> list[dict[str, Any]]:
assets = repository.assets.list_for_article(article_id)
output: list[dict[str, Any]] = []
for asset in assets:
if asset.status.value != "APPROVED":
continue
if not asset.file_url:
continue
source_path = _local_path_from_file_url(asset.file_url)
if source_path is None or not source_path.exists():
raise PermissionError(f"Approved asset file is unavailable: {asset.file_url}")
target_path = _apply_template(
asset_path_template,
{
"slug": slug,
"filename": source_path.name,
"article_id": str(article_id),
"asset_id": str(asset.id),
},
)
output.append(
{
"asset_id": str(asset.id),
"asset_type": asset.asset_type.value,
"source_url": asset.file_url,
"source_path": str(source_path),
"target_path": target_path,
}
)
return output
def _create_and_push_commit(bundle_context: dict[str, Any]) -> str:
repository_url = bundle_context["repository_url"]
branch = bundle_context["branch"]
with tempfile.TemporaryDirectory(prefix="publish-commit-") as tmp_dir:
workspace = Path(tmp_dir) / "site"
_run_cmd(
["git", "clone", "--branch", branch, "--single-branch", repository_url, str(workspace)],
cwd=None,
error_prefix="GIT_CHECKOUT_FAILED",
)
runtime_dir = workspace / _RUNTIME_DIR
runtime_dir.mkdir(parents=True, exist_ok=True)
script_config = bundle_context["script_config_version"]
(workspace / _PUBLISHING_YAML_PATH).write_text(
str(script_config["publishing_yaml"]),
encoding="utf-8",
)
(workspace / _TRANSFORM_SCRIPT_PATH).write_text(
str(script_config["transform_script"]),
encoding="utf-8",
)
transform_input = {
"frontmatter": bundle_context["frontmatter"],
"body": bundle_context["markdown_body"],
"assets": bundle_context["assets"],
"content_path": bundle_context["content_rel_path"],
}
(workspace / _TRANSFORM_INPUT_PATH).write_text(
json.dumps(transform_input, ensure_ascii=True, indent=2),
encoding="utf-8",
)
(workspace / _TRANSFORM_RUNNER_PATH).write_text(
_transform_runner_script(),
encoding="utf-8",
)
_run_cmd(
[
"node",
_TRANSFORM_RUNNER_PATH,
_TRANSFORM_SCRIPT_PATH,
_TRANSFORM_INPUT_PATH,
_TRANSFORM_OUTPUT_PATH,
],
cwd=workspace,
error_prefix="PUBLISH_DRY_RUN_FAILED",
)
content_file = workspace / bundle_context["content_rel_path"]
content_file.parent.mkdir(parents=True, exist_ok=True)
content_file.write_text(bundle_context["markdown_with_frontmatter"], encoding="utf-8")
for asset in bundle_context["assets"]:
target_path = workspace / str(asset["target_path"])
target_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(str(asset["source_path"]), target_path)
shutil.rmtree(runtime_dir, ignore_errors=True)
_run_cmd(
["git", "config", "user.name", "Pipeline Bot"],
cwd=workspace,
error_prefix="GIT_COMMIT_FAILED",
)
_run_cmd(
["git", "config", "user.email", "pipeline-bot@example.com"],
cwd=workspace,
error_prefix="GIT_COMMIT_FAILED",
)
_run_cmd(["git", "add", "."], cwd=workspace, error_prefix="GIT_COMMIT_FAILED")
commit_message = (
f"Publish article {bundle_context['article'].id}: {bundle_context['draft'].title}"
)
_run_cmd(
["git", "commit", "--allow-empty", "-m", commit_message],
cwd=workspace,
error_prefix="GIT_COMMIT_FAILED",
)
try:
_run_cmd(
["git", "push", "origin", branch],
cwd=workspace,
error_prefix="GIT_PUSH_NON_FAST_FORWARD",
)
except RuntimeError as error:
if "non-fast-forward" in str(error) or "[rejected]" in str(error):
raise PermissionError(
"Non-fast-forward push rejected. Re-run dry run and retry commit."
) from error
raise
commit_sha = _run_cmd(
["git", "rev-parse", "HEAD"],
cwd=workspace,
error_prefix="GIT_COMMIT_FAILED",
).strip()
return commit_sha
def _update_article_for_dry_run_result(
repository: object,
*,
article: ArticleSummary,
content_shape_valid: bool,
actor_user_id: UUID,
validation_errors: list[str],
created_at: datetime,
) -> ArticleSummary:
if content_shape_valid:
updated = repository.articles.update_status(
article_id=article.id,
status=ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
updated_at=created_at,
)
updated = repository.articles.update_publishing_status(
article_id=article.id,
publishing_status=PublishingStatus.PUBLISH_COMMIT_READY,
updated_at=created_at,
)
repository.articles.create_workflow_event(
article_id=article.id,
event_type="PUBLISH_DRY_RUN_SUCCEEDED",
from_status=article.status,
to_status=ArticleWorkflowStatus.PUBLISH_COMMIT_READY,
actor_user_id=actor_user_id,
payload={"validation_label": _VALIDATION_LABEL},
created_at=created_at,
)
return updated
updated = repository.articles.update_status(
article_id=article.id,
status=ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
updated_at=created_at,
)
updated = repository.articles.update_publishing_status(
article_id=article.id,
publishing_status=PublishingStatus.PUBLISH_DRY_RUN_FAILED,
updated_at=created_at,
)
repository.articles.create_workflow_event(
article_id=article.id,
event_type="PUBLISH_DRY_RUN_FAILED",
from_status=article.status,
to_status=ArticleWorkflowStatus.PUBLISH_DRY_RUN_REQUIRED,
actor_user_id=actor_user_id,
payload={
"validation_label": _VALIDATION_LABEL,
"errors": validation_errors,
},
created_at=created_at,
)
return updated
def _record_publish_failure(
repository: object,
*,
article: ArticleSummary,
actor_user_id: UUID,
repository_url: str,
branch: str,
previous_manifest: dict[str, Any],
detail: str,
) -> None:
now = _now()
failed_manifest = dict(previous_manifest)
failed_manifest["failure"] = detail
repository.publish_commits.create(
article_id=article.id,
target_site_id=article.target_site_id,
repository_url=repository_url,
branch=branch,
commit_sha=None,
content_bundle_manifest=failed_manifest,
status=PublishingStatus.PUBLISH_VERIFICATION_FAILED,
deployment_status="FAILED",
created_at=now,
)
repository.articles.update_publishing_status(
article_id=article.id,
publishing_status=PublishingStatus.PUBLISH_VERIFICATION_FAILED,
updated_at=now,
)
repository.articles.create_workflow_event(
article_id=article.id,
event_type="PUBLISH_COMMIT_FAILED",
from_status=article.status,
to_status=article.status,
actor_user_id=actor_user_id,
payload={"detail": detail},
created_at=now,
)
def _build_manifest(
*,
bundle_context: dict[str, Any],
status: PublishingStatus,
validation_errors: list[str],
commit_sha: str | None = None,
) -> dict[str, Any]:
base_head_sha = _resolve_remote_branch_head_sha(
bundle_context["repository_url"], bundle_context["branch"]
)
script_config_version = bundle_context["script_config_version"]
return {
"status": status.value,
"generated_at": _now().isoformat(),
"validation": {
"label": _VALIDATION_LABEL,
"content_shape_valid": len(validation_errors) == 0,
"errors": validation_errors,
},
"content": {
"format": bundle_context["content_format"],
"path": bundle_context["content_rel_path"],
"slug": bundle_context["slug"],
},
"frontmatter": bundle_context["frontmatter"],
"assets": [
{
"asset_id": asset["asset_id"],
"asset_type": asset["asset_type"],
"source_url": asset["source_url"],
"target_path": asset["target_path"],
}
for asset in bundle_context["assets"]
],
"config_version": {
"version_id": str(script_config_version["id"]),
"version": int(script_config_version["version"]),
"publishing_yaml_hash": str(script_config_version["publishing_yaml_hash"]),
"transform_script_hash": str(script_config_version["transform_script_hash"]),
},
"git": {
"repository_url": bundle_context["repository_url"],
"branch": bundle_context["branch"],
"base_head_sha": base_head_sha,
"commit_sha": commit_sha,
},
"draft_version": int(bundle_context["draft"].version),
}
def _validate_content_shape(markdown: str) -> list[str]:
errors: list[str] = []
if not markdown.strip():
errors.append("Markdown body is empty.")
if re.search(r"^#{1,6}\s+\S", markdown, re.MULTILINE) is None:
errors.append("Markdown body must include at least one heading.")
if markdown.count("```") % 2 != 0:
errors.append("Markdown body has unbalanced fenced code blocks.")
return errors
def _compose_markdown(frontmatter: dict[str, Any], markdown_body: str) -> str:
lines = ["---"]
for key in sorted(frontmatter):
lines.append(f"{key}: {json.dumps(frontmatter[key], ensure_ascii=True)}")
lines.extend(["---", "", markdown_body.strip(), ""])
return "\n".join(lines)
def _resolve_remote_branch_head_sha(repository_url: str, branch: str) -> str | None:
output = _run_cmd(
["git", "ls-remote", repository_url, f"refs/heads/{branch}"],
cwd=None,
error_prefix="GIT_CHECKOUT_FAILED",
)
line = output.strip()
if not line:
return None
return line.split("\t", 1)[0]
def _run_cmd(
command: list[str],
*,
cwd: Path | None,
error_prefix: str,
) -> str:
result = subprocess.run(
command,
cwd=str(cwd) if cwd is not None else None,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
message = result.stderr.strip() or result.stdout.strip() or "command failed"
raise RuntimeError(f"{error_prefix}: {message}")
return result.stdout
def _apply_template(template: str, values: dict[str, Any]) -> str:
class _SafeValues(dict[str, Any]):
def __missing__(self, key: str) -> str:
return "{" + key + "}"
rendered = template.format_map(_SafeValues(values))
return rendered.lstrip("/")
def _local_path_from_file_url(file_url: str) -> Path | None:
parsed = urlparse(file_url)
if parsed.scheme != "file":
return None
return Path(parsed.path)
def _require_final_approval(repository: object, *, article_id: UUID) -> object:
events = repository.articles.list_workflow_events(article_id)
for event in reversed(events):
if event.event_type == _FINAL_APPROVAL_EVENT:
return event
raise PermissionError("Final approval is required before publishing.")
def _slug_from_content_path(content_path: str) -> str:
value = content_path.strip().strip("/")
if not value:
return ""
tail = value.split("/")[-1]
if "." in tail:
tail = tail.rsplit(".", 1)[0]
return _slugify(tail)
def _slugify(value: str) -> str:
normalized = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-")
return normalized or "article"
def _string_value(value: Any) -> str:
if isinstance(value, str):
return value.strip()
return ""
def _transform_runner_script() -> str:
return """
import { readFileSync, writeFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
const [scriptPath, inputPath, outputPath] = process.argv.slice(2);
const scriptUrl = pathToFileURL(scriptPath).href;
const moduleRef = await import(scriptUrl + `?t=${Date.now()}`);
const transform =
typeof moduleRef.transformArticle === "function"
? moduleRef.transformArticle
: typeof moduleRef.default === "function"
? moduleRef.default
: null;
if (!transform) {
throw new Error("Transform script must export transformArticle(article).");
}
const raw = readFileSync(inputPath, "utf8");
const article = JSON.parse(raw);
const transformed = await transform(article);
const output = transformed ?? article;
writeFileSync(outputPath, JSON.stringify(output, null, 2), "utf8");
""".strip()
def _now() -> datetime:
return datetime.now(UTC)
@@ -70,6 +70,10 @@ from .models import (
PlanSummary, PlanSummary,
PlanUpdateRequest, PlanUpdateRequest,
PublishCommitSummary, PublishCommitSummary,
PublishCommitCreateResponse,
PublishCommitListResponse,
PublishingDryRunResponse,
PublishingStatusResponse,
PublishingRules, PublishingRules,
ResearchArtifactManifestSummary, ResearchArtifactManifestSummary,
ResearchArtifactSummary, ResearchArtifactSummary,
@@ -175,8 +179,12 @@ __all__ = [
"PlanSectionSummary", "PlanSectionSummary",
"PlanSummary", "PlanSummary",
"PlanUpdateRequest", "PlanUpdateRequest",
"PublishCommitCreateResponse",
"PublishCommitListResponse",
"PublishCommitSummary", "PublishCommitSummary",
"PublishingDryRunResponse",
"PublishingRules", "PublishingRules",
"PublishingStatusResponse",
"PublishingStatus", "PublishingStatus",
"ResearchArtifactManifestSummary", "ResearchArtifactManifestSummary",
"ResearchArtifactSummary", "ResearchArtifactSummary",
@@ -443,6 +443,30 @@ class PublishCommitSummary(ContractModel):
created_at: datetime created_at: datetime
class PublishingDryRunResponse(ContractModel):
article: ArticleSummary
publish_commit: PublishCommitSummary
content_shape_valid: bool
validation_label: str = Field(min_length=1)
errors: list[str] = Field(default_factory=list)
class PublishCommitCreateResponse(ContractModel):
article: ArticleSummary
publish_commit: PublishCommitSummary
class PublishingStatusResponse(ContractModel):
article: ArticleSummary
latest_dry_run: PublishCommitSummary | None = None
latest_publish_commit: PublishCommitSummary | None = None
validation_label: str = Field(min_length=1)
class PublishCommitListResponse(ContractModel):
commits: list[PublishCommitSummary] = Field(default_factory=list)
class RunnerFileRef(ContractModel): class RunnerFileRef(ContractModel):
path: str = Field(min_length=1) path: str = Field(min_length=1)
content_hash: str | None = None content_hash: str | None = None
@@ -75,7 +75,11 @@ from .models import (
PlanRevisionRequest, PlanRevisionRequest,
PlanSummary, PlanSummary,
PlanUpdateRequest, PlanUpdateRequest,
PublishCommitCreateResponse,
PublishCommitListResponse,
PublishCommitSummary, PublishCommitSummary,
PublishingDryRunResponse,
PublishingStatusResponse,
PublishingRules, PublishingRules,
ResearchArtifactManifestSummary, ResearchArtifactManifestSummary,
ResearchArtifactSummary, ResearchArtifactSummary,
@@ -200,6 +204,10 @@ CONTRACT_SCHEMA_MODELS: tuple[type[BaseModel], ...] = (
SeoReviewReportResponse, SeoReviewReportResponse,
SeoReviewRunResponse, SeoReviewRunResponse,
PublishCommitSummary, PublishCommitSummary,
PublishCommitCreateResponse,
PublishCommitListResponse,
PublishingDryRunResponse,
PublishingStatusResponse,
RunnerFileRef, RunnerFileRef,
AgentJobSummary, AgentJobSummary,
AgentJobTestCodexRequest, AgentJobTestCodexRequest,
@@ -32,6 +32,7 @@ from src.domain.contracts import (
PlanReviewStatus, PlanReviewStatus,
PlanSectionSummary, PlanSectionSummary,
PlanSummary, PlanSummary,
PublishCommitSummary,
PublishingRules, PublishingRules,
PublishingStatus, PublishingStatus,
ResearchArtifactManifestSummary, ResearchArtifactManifestSummary,
@@ -67,6 +68,7 @@ class BackendRepository:
self.content_reviews = ContentReviewsRepository(self) self.content_reviews = ContentReviewsRepository(self)
self.assets = AssetsRepository(self) self.assets = AssetsRepository(self)
self.research_manifests = ResearchManifestsRepository(self) self.research_manifests = ResearchManifestsRepository(self)
self.publish_commits = PublishCommitsRepository(self)
self.evidence_items = EvidenceItemsRepository(self) self.evidence_items = EvidenceItemsRepository(self)
self.claims = ClaimsRepository(self) self.claims = ClaimsRepository(self)
self.agent_jobs = AgentJobsRepository(self) self.agent_jobs = AgentJobsRepository(self)
@@ -568,6 +570,26 @@ class ArticlesRepository:
return self.get(article_id) return self.get(article_id)
def update_publishing_status(
self,
*,
article_id: UUID,
publishing_status: PublishingStatus,
updated_at: datetime,
) -> ArticleSummary:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
connection.execute(
f"""
UPDATE articles
SET publishing_status = {placeholder}, updated_at = {placeholder}
WHERE id = {placeholder}
""",
(publishing_status.value, _datetime_value(updated_at), str(article_id)),
)
return self.get(article_id)
def create_workflow_event( def create_workflow_event(
self, self,
*, *,
@@ -2049,6 +2071,150 @@ class ResearchManifestsRepository:
return _research_manifest_from_row(row) return _research_manifest_from_row(row)
class PublishCommitsRepository:
def __init__(self, repository: BackendRepository) -> None:
self._repository = repository
def create(
self,
*,
article_id: UUID,
target_site_id: UUID,
repository_url: str,
branch: str,
commit_sha: str | None,
content_bundle_manifest: JsonObject,
status: PublishingStatus,
deployment_status: str | None,
created_at: datetime,
) -> PublishCommitSummary:
publish_commit_id = uuid4()
placeholder = self._repository.placeholder()
json_cast = self._repository.json_cast()
with self._repository.connection() as connection:
connection.execute(
f"""
INSERT INTO publish_commits (
id,
article_id,
target_site_id,
repository_url,
branch,
commit_sha,
content_bundle_manifest,
status,
deployment_status,
created_at
)
VALUES (
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder},
{placeholder}{json_cast},
{placeholder},
{placeholder},
{placeholder}
)
""",
(
str(publish_commit_id),
str(article_id),
str(target_site_id),
repository_url,
branch,
commit_sha,
_json_value(content_bundle_manifest),
status.value,
deployment_status,
_datetime_value(created_at),
),
)
return self.get(publish_commit_id)
def get(self, publish_commit_id: UUID) -> PublishCommitSummary:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT {self._select_columns()}
FROM publish_commits
WHERE id = {placeholder}
""",
(str(publish_commit_id),),
).fetchone()
if row is None:
raise LookupError(f"Publish commit not found: {publish_commit_id}")
return _publish_commit_from_row(row)
def list_for_article(self, article_id: UUID) -> list[PublishCommitSummary]:
placeholder = self._repository.placeholder()
with self._repository.connection() as connection:
rows = connection.execute(
f"""
SELECT {self._select_columns()}
FROM publish_commits
WHERE article_id = {placeholder}
ORDER BY created_at DESC, id DESC
""",
(str(article_id),),
).fetchall()
return [_publish_commit_from_row(row) for row in rows]
def latest_for_article(self, article_id: UUID) -> PublishCommitSummary | None:
commits = self.list_for_article(article_id)
if not commits:
return None
return commits[0]
def latest_for_article_with_statuses(
self,
article_id: UUID,
*,
statuses: set[PublishingStatus],
) -> PublishCommitSummary | None:
status_values = [status.value for status in statuses]
if not status_values:
return None
placeholder = self._repository.placeholder()
status_placeholders = ", ".join([placeholder] * len(status_values))
with self._repository.connection() as connection:
row = connection.execute(
f"""
SELECT {self._select_columns()}
FROM publish_commits
WHERE article_id = {placeholder}
AND status IN ({status_placeholders})
ORDER BY created_at DESC, id DESC
LIMIT 1
""",
(str(article_id), *status_values),
).fetchone()
if row is None:
return None
return _publish_commit_from_row(row)
def _select_columns(self) -> str:
return """
id,
article_id,
target_site_id,
repository_url,
branch,
commit_sha,
content_bundle_manifest,
status,
deployment_status,
created_at
"""
class EvidenceItemsRepository: class EvidenceItemsRepository:
def __init__(self, repository: BackendRepository) -> None: def __init__(self, repository: BackendRepository) -> None:
self._repository = repository self._repository = repository
@@ -3066,6 +3232,21 @@ def _research_manifest_from_row(row: Any) -> ResearchArtifactManifestSummary:
) )
def _publish_commit_from_row(row: Any) -> PublishCommitSummary:
return PublishCommitSummary(
id=_row_value(row, "id"),
article_id=_row_value(row, "article_id"),
target_site_id=_row_value(row, "target_site_id"),
repository_url=_row_value(row, "repository_url"),
branch=_row_value(row, "branch"),
commit_sha=_row_value(row, "commit_sha"),
content_bundle_manifest=_json_from_row(row, "content_bundle_manifest"),
status=_row_value(row, "status"),
deployment_status=_row_value(row, "deployment_status"),
created_at=_row_value(row, "created_at"),
)
def _evidence_from_row(row: Any) -> EvidenceSummary: def _evidence_from_row(row: Any) -> EvidenceSummary:
return EvidenceSummary( return EvidenceSummary(
id=_row_value(row, "id"), id=_row_value(row, "id"),
+2
View File
@@ -17,6 +17,7 @@ from src.presentation.routes.drafts import router as drafts_router
from src.presentation.routes.evidence import router as evidence_router from src.presentation.routes.evidence import router as evidence_router
from src.presentation.routes.final_approval import router as final_approval_router from src.presentation.routes.final_approval import router as final_approval_router
from src.presentation.routes.plans import router as plans_router from src.presentation.routes.plans import router as plans_router
from src.presentation.routes.publishing import router as publishing_router
from src.presentation.routes.reviews import router as reviews_router from src.presentation.routes.reviews import router as reviews_router
from src.presentation.routes.sites import router as sites_router from src.presentation.routes.sites import router as sites_router
@@ -31,6 +32,7 @@ app.include_router(evidence_router)
app.include_router(drafts_router) app.include_router(drafts_router)
app.include_router(reviews_router) app.include_router(reviews_router)
app.include_router(final_approval_router) app.include_router(final_approval_router)
app.include_router(publishing_router)
app.include_router(agent_jobs_router) app.include_router(agent_jobs_router)
app.include_router(internal_agent_jobs_router) app.include_router(internal_agent_jobs_router)
app.include_router(sites_router) app.include_router(sites_router)
@@ -0,0 +1,99 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from src.application.publishing import (
create_publish_commit,
get_publishing_status,
list_publish_commits,
run_publishing_dry_run,
)
from src.domain.auth import EDITOR_OR_ADMIN_ROLES
from src.domain.contracts import (
CurrentUser,
PublishCommitCreateResponse,
PublishCommitListResponse,
PublishingDryRunResponse,
PublishingStatusResponse,
)
from src.infrastructure.repositories import BackendRepository
from src.presentation.dependencies import get_repository, require_roles
router = APIRouter(prefix="/api", tags=["publishing"])
@router.post(
"/articles/{article_id}/publishing/dry-run",
response_model=PublishingDryRunResponse,
status_code=status.HTTP_201_CREATED,
)
def post_publishing_dry_run(
article_id: UUID,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> PublishingDryRunResponse:
try:
return run_publishing_dry_run(
repository,
article_id=article_id,
actor_user_id=current_user.id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except (PermissionError, ValueError, RuntimeError) as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.post(
"/articles/{article_id}/publishing/create-commit",
response_model=PublishCommitCreateResponse,
status_code=status.HTTP_201_CREATED,
)
def post_publishing_create_commit(
article_id: UUID,
current_user: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> PublishCommitCreateResponse:
try:
return create_publish_commit(
repository,
article_id=article_id,
actor_user_id=current_user.id,
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
except (PermissionError, ValueError, RuntimeError) as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
@router.get(
"/articles/{article_id}/publishing/status",
response_model=PublishingStatusResponse,
)
def get_article_publishing_status(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> PublishingStatusResponse:
try:
return get_publishing_status(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@router.get(
"/articles/{article_id}/publishing/commits",
response_model=PublishCommitListResponse,
)
def get_article_publishing_commits(
article_id: UUID,
_: CurrentUser = Depends(require_roles(EDITOR_OR_ADMIN_ROLES)),
repository: BackendRepository = Depends(get_repository),
) -> PublishCommitListResponse:
try:
return list_publish_commits(repository, article_id=article_id)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") from error
@@ -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()
@@ -11,6 +11,7 @@ export type DetailSummary = {
articleId: string; articleId: string;
status: string; status: string;
publishingStatus: string; publishingStatus: string;
publishingValidationLabel: string;
briefDescription: string; briefDescription: string;
targetSite: string; targetSite: string;
updatedAt: string; updatedAt: string;
@@ -47,6 +48,7 @@ export function buildDetailSummary(detail: ArticleDetailResponse): DetailSummary
articleId: detail.article.id, articleId: detail.article.id,
status: detail.article.status, status: detail.article.status,
publishingStatus: detail.article.publishing_status, publishingStatus: detail.article.publishing_status,
publishingValidationLabel: resolvePublishingValidationLabel(detail),
briefDescription: detail.article.brief_description, briefDescription: detail.article.brief_description,
targetSite: detail.target_site?.name ?? "Unknown", targetSite: detail.target_site?.name ?? "Unknown",
updatedAt: detail.article.updated_at, updatedAt: detail.article.updated_at,
@@ -55,6 +57,20 @@ export function buildDetailSummary(detail: ArticleDetailResponse): DetailSummary
}; };
} }
function resolvePublishingValidationLabel(detail: ArticleDetailResponse): string {
const manifest = detail.publish_commit?.content_bundle_manifest;
if (manifest && typeof manifest === "object") {
const validation = (manifest as Record<string, unknown>).validation;
if (validation && typeof validation === "object") {
const label = (validation as Record<string, unknown>).label;
if (typeof label === "string" && label.trim()) {
return label;
}
}
}
return "Best-effort content-shape validation only.";
}
export function buildProductionArtifactRows( export function buildProductionArtifactRows(
jobs: readonly AgentJobSummary[], jobs: readonly AgentJobSummary[],
): ProductionArtifactRow[] { ): ProductionArtifactRow[] {
@@ -26,6 +26,10 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
<dt>Publishing status</dt> <dt>Publishing status</dt>
<dd>{summary.publishingStatus}</dd> <dd>{summary.publishingStatus}</dd>
</div> </div>
<div>
<dt>Validation</dt>
<dd>{summary.publishingValidationLabel}</dd>
</div>
<div> <div>
<dt>Target site</dt> <dt>Target site</dt>
<dd>{summary.targetSite}</dd> <dd>{summary.targetSite}</dd>
@@ -15,7 +15,7 @@ const compiled = ts.transpileModule(source, {
const moduleExports = {}; const moduleExports = {};
new Function("exports", compiled.outputText)(moduleExports); new Function("exports", compiled.outputText)(moduleExports);
const { buildProductionArtifactRows } = moduleExports; const { buildDetailSummary, buildProductionArtifactRows } = moduleExports;
const rows = buildProductionArtifactRows([ const rows = buildProductionArtifactRows([
{ {
@@ -102,3 +102,40 @@ assert.equal(rows[0].status, "SUCCEEDED");
assert.deepEqual(rows[0].usedEvidenceIds, ["e1", "e2"]); assert.deepEqual(rows[0].usedEvidenceIds, ["e1", "e2"]);
assert.equal(rows[1].status, "FAILED"); assert.equal(rows[1].status, "FAILED");
assert.deepEqual(rows[1].unsupportedClaims, ["Unverified claim"]); assert.deepEqual(rows[1].unsupportedClaims, ["Unverified claim"]);
const detailSummary = buildDetailSummary({
article: {
id: "a1",
target_site_id: "site1",
status: "PUBLISH_COMMIT_CREATED",
publishing_status: "PUBLISH_COMMIT_CREATED",
brief_description: "desc",
language: "en",
content_type: "longform_guide",
created_at: "2026-05-21T00:00:00Z",
updated_at: "2026-05-21T00:00:00Z",
},
target_site: { name: "Demo Site" },
workflow_events: [],
agent_jobs: [],
publish_commit: {
id: "pc1",
article_id: "a1",
target_site_id: "site1",
repository_url: "/tmp/repo.git",
branch: "main",
commit_sha: "abc123",
status: "PUBLISH_COMMIT_CREATED",
created_at: "2026-05-21T00:00:00Z",
content_bundle_manifest: {
validation: {
label: "Best-effort content-shape validation only.",
},
},
},
});
assert.equal(
detailSummary.publishingValidationLabel,
"Best-effort content-shape validation only.",
);
+343
View File
@@ -3021,6 +3021,37 @@
"title": "PlanUpdateRequest", "title": "PlanUpdateRequest",
"type": "object" "type": "object"
}, },
"PublishCommitCreateResponse": {
"additionalProperties": false,
"properties": {
"article": {
"$ref": "#/components/schemas/ArticleSummary"
},
"publish_commit": {
"$ref": "#/components/schemas/PublishCommitSummary"
}
},
"required": [
"article",
"publish_commit"
],
"title": "PublishCommitCreateResponse",
"type": "object"
},
"PublishCommitListResponse": {
"additionalProperties": false,
"properties": {
"commits": {
"items": {
"$ref": "#/components/schemas/PublishCommitSummary"
},
"title": "Commits",
"type": "array"
}
},
"title": "PublishCommitListResponse",
"type": "object"
},
"PublishCommitSummary": { "PublishCommitSummary": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -3099,6 +3130,41 @@
"title": "PublishCommitSummary", "title": "PublishCommitSummary",
"type": "object" "type": "object"
}, },
"PublishingDryRunResponse": {
"additionalProperties": false,
"properties": {
"article": {
"$ref": "#/components/schemas/ArticleSummary"
},
"content_shape_valid": {
"title": "Content Shape Valid",
"type": "boolean"
},
"errors": {
"items": {
"type": "string"
},
"title": "Errors",
"type": "array"
},
"publish_commit": {
"$ref": "#/components/schemas/PublishCommitSummary"
},
"validation_label": {
"minLength": 1,
"title": "Validation Label",
"type": "string"
}
},
"required": [
"article",
"publish_commit",
"content_shape_valid",
"validation_label"
],
"title": "PublishingDryRunResponse",
"type": "object"
},
"PublishingRules": { "PublishingRules": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -3181,6 +3247,47 @@
"title": "PublishingStatus", "title": "PublishingStatus",
"type": "string" "type": "string"
}, },
"PublishingStatusResponse": {
"additionalProperties": false,
"properties": {
"article": {
"$ref": "#/components/schemas/ArticleSummary"
},
"latest_dry_run": {
"anyOf": [
{
"$ref": "#/components/schemas/PublishCommitSummary"
},
{
"type": "null"
}
],
"default": null
},
"latest_publish_commit": {
"anyOf": [
{
"$ref": "#/components/schemas/PublishCommitSummary"
},
{
"type": "null"
}
],
"default": null
},
"validation_label": {
"minLength": 1,
"title": "Validation Label",
"type": "string"
}
},
"required": [
"article",
"validation_label"
],
"title": "PublishingStatusResponse",
"type": "object"
},
"ResearchArtifactManifestSummary": { "ResearchArtifactManifestSummary": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -6931,6 +7038,242 @@
] ]
} }
}, },
"/api/articles/{article_id}/publishing/commits": {
"get": {
"operationId": "get_article_publishing_commits_api_articles__article_id__publishing_commits_get",
"parameters": [
{
"in": "path",
"name": "article_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Article Id",
"type": "string"
}
},
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublishCommitListResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Get Article Publishing Commits",
"tags": [
"publishing"
]
}
},
"/api/articles/{article_id}/publishing/create-commit": {
"post": {
"operationId": "post_publishing_create_commit_api_articles__article_id__publishing_create_commit_post",
"parameters": [
{
"in": "path",
"name": "article_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Article Id",
"type": "string"
}
},
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublishCommitCreateResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Post Publishing Create Commit",
"tags": [
"publishing"
]
}
},
"/api/articles/{article_id}/publishing/dry-run": {
"post": {
"operationId": "post_publishing_dry_run_api_articles__article_id__publishing_dry_run_post",
"parameters": [
{
"in": "path",
"name": "article_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Article Id",
"type": "string"
}
},
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublishingDryRunResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Post Publishing Dry Run",
"tags": [
"publishing"
]
}
},
"/api/articles/{article_id}/publishing/status": {
"get": {
"operationId": "get_article_publishing_status_api_articles__article_id__publishing_status_get",
"parameters": [
{
"in": "path",
"name": "article_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Article Id",
"type": "string"
}
},
{
"in": "header",
"name": "X-Demo-User-Email",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Demo-User-Email"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublishingStatusResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Get Article Publishing Status",
"tags": [
"publishing"
]
}
},
"/api/articles/{article_id}/research": { "/api/articles/{article_id}/research": {
"get": { "get": {
"operationId": "get_research_api_articles__article_id__research_get", "operationId": "get_research_api_articles__article_id__research_get",
+24
View File
@@ -496,6 +496,15 @@ export type PlanUpdateRequest = {
visual_needs?: string[] | null; visual_needs?: string[] | null;
}; };
export type PublishCommitCreateResponse = {
article: ArticleSummary;
publish_commit: PublishCommitSummary;
};
export type PublishCommitListResponse = {
commits?: PublishCommitSummary[];
};
export type PublishCommitSummary = { export type PublishCommitSummary = {
article_id: string; article_id: string;
branch: string; branch: string;
@@ -509,6 +518,14 @@ export type PublishCommitSummary = {
target_site_id: string; target_site_id: string;
}; };
export type PublishingDryRunResponse = {
article: ArticleSummary;
content_shape_valid: boolean;
errors?: string[];
publish_commit: PublishCommitSummary;
validation_label: string;
};
export type PublishingRules = { export type PublishingRules = {
asset_path_template: string; asset_path_template: string;
content_format?: string; content_format?: string;
@@ -522,6 +539,13 @@ export type PublishingRules = {
export type PublishingStatus = "PUBLISH_NOT_STARTED" | "PUBLISH_DRY_RUN_REQUIRED" | "PUBLISH_DRY_RUN_RUNNING" | "PUBLISH_DRY_RUN_FAILED" | "PUBLISH_COMMIT_READY" | "PUBLISH_COMMIT_CREATED" | "PUBLISH_VERIFICATION_FAILED"; export type PublishingStatus = "PUBLISH_NOT_STARTED" | "PUBLISH_DRY_RUN_REQUIRED" | "PUBLISH_DRY_RUN_RUNNING" | "PUBLISH_DRY_RUN_FAILED" | "PUBLISH_COMMIT_READY" | "PUBLISH_COMMIT_CREATED" | "PUBLISH_VERIFICATION_FAILED";
export type PublishingStatusResponse = {
article: ArticleSummary;
latest_dry_run?: PublishCommitSummary | null;
latest_publish_commit?: PublishCommitSummary | null;
validation_label: string;
};
export type ResearchArtifactManifestSummary = { export type ResearchArtifactManifestSummary = {
agent_job_id: string; agent_job_id: string;
article_id: string; article_id: string;
+65 -16
View File
@@ -34,16 +34,16 @@ Development description: Implement the Git-backed publishing path that builds a
## Acceptance Criteria ## Acceptance Criteria
- [ ] TDD pre-requirement: before implementation, write one failing integration test against a temporary local Git repository proving a final-approved article can create a commit; add dry-run and failure tests one behavior at a time and record evidence in `Result`. - [x] TDD pre-requirement: before implementation, write one failing integration test against a temporary local Git repository proving a final-approved article can create a commit; add dry-run and failure tests one behavior at a time and record evidence in `Result`.
- [ ] Publishing cannot dry-run before final approval. - [x] Publishing cannot dry-run before final approval.
- [ ] Dry run fails if generic Markdown/MDX content-shape validation fails. - [x] Dry run fails if generic Markdown/MDX content-shape validation fails.
- [ ] Publish commit cannot run until dry run passes. - [x] Publish commit cannot run until dry run passes.
- [ ] Content bundle uses site-configured path templates and frontmatter mapping. - [x] Content bundle uses site-configured path templates and frontmatter mapping.
- [ ] Active YAML/script config version is included in publish logs/manifest. - [x] Active YAML/script config version is included in publish logs/manifest.
- [ ] Publish writes commit to configured production branch in a test repository. - [x] Publish writes commit to configured production branch in a test repository.
- [ ] Non-fast-forward push or conflict fails without automatic rebase. - [x] Non-fast-forward push or conflict fails without automatic rebase.
- [ ] Publish commit record stores repository URL, branch, commit SHA, bundle manifest, and status. - [x] Publish commit record stores repository URL, branch, commit SHA, bundle manifest, and status.
- [ ] UI clearly labels validation as best-effort content-shape validation only. - [x] UI clearly labels validation as best-effort content-shape validation only.
## Verification ## Verification
@@ -53,9 +53,58 @@ Development description: Implement the Git-backed publishing path that builds a
## Result ## Result
- Status: Pending execution. - Status: Done (TDD RED -> GREEN completed).
- TDD plan: To be filled during execution. - TDD progression:
- Red evidence: To be filled during execution. 1. Restored and expanded `apps/backend/tests/integration/test_publishing_git_flow_public_api.py` from RED baseline.
- Green evidence: To be filled during execution. 2. Added incremental behavior coverage:
- Refactor notes: To be filled during execution. - dry-run blocked before final approval,
- Verification output: To be filled during execution. - dry-run content-shape validation failure path,
- create-commit blocked without successful dry-run,
- successful publish commit into local bare repo `main`,
- non-fast-forward conflict fail without auto rebase,
- publishing status/commits metadata assertions.
3. Implemented publishing backend flow:
- endpoints:
- `POST /api/articles/{article_id}/publishing/dry-run`
- `POST /api/articles/{article_id}/publishing/create-commit`
- `GET /api/articles/{article_id}/publishing/status`
- `GET /api/articles/{article_id}/publishing/commits`
- gates:
- dry-run only after final approval,
- create-commit only after successful dry-run.
- content-shape checks:
- non-empty markdown,
- at least one markdown heading,
- balanced fenced code blocks.
- bundle/manifest:
- site-configured `content_path_template` and `asset_path_template`,
- frontmatter mapping usage,
- active script config version id/hash metadata,
- repository/branch/base head/commit sha metadata.
- git publish:
- direct commit+push to configured production branch,
- non-fast-forward detection and fail path without rebase.
4. Added frontend detail-model mapping for validation label and surfaced label in article detail UI.
5. Regenerated shared OpenAPI contracts.
- Green evidence:
- Integration suite passes with all required publishing behaviors in one flow-oriented file:
`apps/backend/tests/integration/test_publishing_git_flow_public_api.py` (6 tests, all green).
- Commit object is present in bare repo (`git cat-file -e <sha>^{commit}` in test).
- Manifest metadata includes `config_version` and `git` blocks and best-effort validation label.
- Refactor notes:
- Added dedicated `PublishCommitsRepository` with list/latest helpers and status filtering.
- Added `articles.update_publishing_status(...)` for explicit workflow/publishing-state sync.
- Kept implementation scoped to task 017 API/domain/repository/frontend model changes; no unrelated workflow rewrites.
- Verification output:
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_publishing_git_flow_public_api.py` -> `Ran 6 tests ... OK`
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_final_approval_gate_public_api.py` -> `Ran 7 tests ... OK`
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_draft_assembly_public_api.py` -> `Ran 5 tests ... OK`
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 -m unittest apps/backend/tests/integration/test_assets_media_library_public_api.py` -> `Ran 7 tests ... OK`
- `PYTHONPATH=/private/tmp/pupline-backend-deps python3 scripts/generate_openapi_contracts.py` -> wrote:
- `packages/shared/openapi.json`
- `packages/shared/src/api-types.ts`
- `node apps/frontend/tests/article_detail.model.test.mjs` -> `OK` (exit 0)
- `pnpm --dir apps/frontend typecheck` -> `tsc --noEmit` completed successfully (exit 0)