feat(task-017): implement git publishing dry-run and commit flow
This commit is contained in:
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user