Initial release — OpenMontage: the first open-source agentic video production system
11 production pipelines, 47 tools, 124 agent skills. Supports cloud APIs (fal.ai, OpenAI, ElevenLabs, Suno, HeyGen, Runway) and free local providers (diffusers, Piper TTS, WAN 2.1, Hunyuan, CogVideo). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
"""Checkpoint writer/reader for pipeline state persistence.
|
||||
|
||||
Each stage writes a checkpoint after completion. The orchestrator uses
|
||||
checkpoints to resume pipelines and to present state at human checkpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import jsonschema
|
||||
|
||||
from schemas.artifacts import ARTIFACT_NAMES, validate_artifact
|
||||
|
||||
STAGES = ["research", "proposal", "idea", "script", "scene_plan", "assets", "edit", "compose", "publish"]
|
||||
|
||||
CANONICAL_STAGE_ARTIFACTS = {
|
||||
"research": "research_brief",
|
||||
"proposal": "proposal_packet",
|
||||
"idea": "brief",
|
||||
"script": "script",
|
||||
"scene_plan": "scene_plan",
|
||||
"assets": "asset_manifest",
|
||||
"edit": "edit_decisions",
|
||||
"compose": "render_report",
|
||||
"publish": "publish_log",
|
||||
}
|
||||
|
||||
CHECKPOINT_SCHEMA_PATH = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "schemas"
|
||||
/ "checkpoints"
|
||||
/ "checkpoint.schema.json"
|
||||
)
|
||||
|
||||
|
||||
class CheckpointValidationError(ValueError):
|
||||
"""Raised when a checkpoint or its canonical artifacts are invalid."""
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_checkpoint_schema() -> dict[str, Any]:
|
||||
with open(CHECKPOINT_SCHEMA_PATH) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _validate_artifacts_for_stage(
|
||||
stage: str,
|
||||
status: str,
|
||||
artifacts: dict[str, Any],
|
||||
) -> None:
|
||||
required_artifact = CANONICAL_STAGE_ARTIFACTS[stage]
|
||||
if status in {"completed", "awaiting_human"} and required_artifact not in artifacts:
|
||||
raise CheckpointValidationError(
|
||||
f"Stage {stage!r} with status {status!r} must include "
|
||||
f"canonical artifact {required_artifact!r}"
|
||||
)
|
||||
|
||||
for artifact_name, artifact_data in artifacts.items():
|
||||
if artifact_name not in ARTIFACT_NAMES:
|
||||
continue
|
||||
if not isinstance(artifact_data, dict):
|
||||
raise CheckpointValidationError(
|
||||
f"Artifact {artifact_name!r} must be a JSON object matching its schema"
|
||||
)
|
||||
try:
|
||||
validate_artifact(artifact_name, artifact_data)
|
||||
except Exception as exc:
|
||||
raise CheckpointValidationError(
|
||||
f"Artifact {artifact_name!r} failed schema validation: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def validate_checkpoint(checkpoint: dict[str, Any]) -> None:
|
||||
"""Validate checkpoint structure and canonical artifact payloads."""
|
||||
stage = checkpoint.get("stage")
|
||||
status = checkpoint.get("status")
|
||||
artifacts = checkpoint.get("artifacts")
|
||||
|
||||
if not isinstance(stage, str) or stage not in STAGES:
|
||||
raise CheckpointValidationError(f"Invalid stage: {stage!r}")
|
||||
if not isinstance(status, str):
|
||||
raise CheckpointValidationError(f"Invalid status: {status!r}")
|
||||
if not isinstance(artifacts, dict):
|
||||
raise CheckpointValidationError("Checkpoint artifacts must be a dictionary")
|
||||
|
||||
_validate_artifacts_for_stage(stage, status, artifacts)
|
||||
|
||||
try:
|
||||
jsonschema.validate(instance=checkpoint, schema=_load_checkpoint_schema())
|
||||
except jsonschema.ValidationError as exc:
|
||||
raise CheckpointValidationError(f"Checkpoint failed schema validation: {exc.message}") from exc
|
||||
|
||||
|
||||
def _checkpoint_path(pipeline_dir: Path, project_id: str, stage: str) -> Path:
|
||||
return pipeline_dir / project_id / f"checkpoint_{stage}.json"
|
||||
|
||||
|
||||
def write_checkpoint(
|
||||
pipeline_dir: Path,
|
||||
project_id: str,
|
||||
stage: str,
|
||||
status: str,
|
||||
artifacts: dict[str, Any],
|
||||
*,
|
||||
pipeline_type: Optional[str] = None,
|
||||
style_playbook: Optional[str] = None,
|
||||
checkpoint_policy: str = "guided",
|
||||
human_approval_required: bool = False,
|
||||
human_approved: bool = False,
|
||||
review: Optional[dict] = None,
|
||||
cost_snapshot: Optional[dict] = None,
|
||||
error: Optional[str] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> Path:
|
||||
"""Write a checkpoint file for a pipeline stage."""
|
||||
if stage not in STAGES:
|
||||
raise ValueError(f"Invalid stage: {stage!r}. Must be one of {STAGES}")
|
||||
|
||||
checkpoint = {
|
||||
"version": "1.0",
|
||||
"project_id": project_id,
|
||||
"stage": stage,
|
||||
"status": status,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"checkpoint_policy": checkpoint_policy,
|
||||
"human_approval_required": human_approval_required,
|
||||
"human_approved": human_approved,
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
|
||||
if pipeline_type is not None:
|
||||
checkpoint["pipeline_type"] = pipeline_type
|
||||
if style_playbook is not None:
|
||||
checkpoint["style_playbook"] = style_playbook
|
||||
if review is not None:
|
||||
checkpoint["review"] = review
|
||||
if cost_snapshot is not None:
|
||||
checkpoint["cost_snapshot"] = cost_snapshot
|
||||
if error is not None:
|
||||
checkpoint["error"] = error
|
||||
if metadata is not None:
|
||||
checkpoint["metadata"] = metadata
|
||||
|
||||
validate_checkpoint(checkpoint)
|
||||
|
||||
path = _checkpoint_path(pipeline_dir, project_id, stage)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
json.dump(checkpoint, f, indent=2)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def read_checkpoint(
|
||||
pipeline_dir: Path, project_id: str, stage: str
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Read a checkpoint file. Returns None if not found."""
|
||||
path = _checkpoint_path(pipeline_dir, project_id, stage)
|
||||
if not path.exists():
|
||||
return None
|
||||
with open(path) as f:
|
||||
checkpoint = json.load(f)
|
||||
validate_checkpoint(checkpoint)
|
||||
return checkpoint
|
||||
|
||||
|
||||
def get_latest_checkpoint(
|
||||
pipeline_dir: Path, project_id: str
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Find the most recent checkpoint for a project (by file mtime)."""
|
||||
project_dir = pipeline_dir / project_id
|
||||
if not project_dir.exists():
|
||||
return None
|
||||
|
||||
checkpoints = sorted(
|
||||
project_dir.glob("checkpoint_*.json"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
if not checkpoints:
|
||||
return None
|
||||
|
||||
with open(checkpoints[0]) as f:
|
||||
checkpoint = json.load(f)
|
||||
validate_checkpoint(checkpoint)
|
||||
return checkpoint
|
||||
|
||||
|
||||
def get_completed_stages(pipeline_dir: Path, project_id: str) -> list[str]:
|
||||
"""Return list of stages that have a completed checkpoint."""
|
||||
completed = []
|
||||
for stage in STAGES:
|
||||
cp = read_checkpoint(pipeline_dir, project_id, stage)
|
||||
if cp and cp.get("status") == "completed":
|
||||
completed.append(stage)
|
||||
return completed
|
||||
|
||||
|
||||
def get_next_stage(pipeline_dir: Path, project_id: str) -> Optional[str]:
|
||||
"""Determine the next stage to run based on completed checkpoints."""
|
||||
completed = set(get_completed_stages(pipeline_dir, project_id))
|
||||
for stage in STAGES:
|
||||
if stage not in completed:
|
||||
return stage
|
||||
return None
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Runtime configuration model for OpenMontage.
|
||||
|
||||
Loads config.yaml, merges with env overrides, and provides typed access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BudgetMode(str, Enum):
|
||||
OBSERVE = "observe"
|
||||
WARN = "warn"
|
||||
CAP = "cap"
|
||||
|
||||
|
||||
class CheckpointPolicy(str, Enum):
|
||||
GUIDED = "guided"
|
||||
MANUAL_ALL = "manual_all"
|
||||
AUTO_NONCREATIVE = "auto_noncreative"
|
||||
|
||||
|
||||
class LLMConfig(BaseModel):
|
||||
provider: str = "anthropic"
|
||||
model: Optional[str] = None
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = 4096
|
||||
|
||||
|
||||
class BudgetConfig(BaseModel):
|
||||
mode: BudgetMode = BudgetMode.WARN
|
||||
total_usd: float = 10.0
|
||||
reserve_pct: float = 0.10
|
||||
single_action_approval_usd: float = 0.50
|
||||
require_approval_for_new_paid_tool: bool = True
|
||||
|
||||
|
||||
class CheckpointConfig(BaseModel):
|
||||
policy: CheckpointPolicy = CheckpointPolicy.GUIDED
|
||||
storage_dir: str = "pipeline"
|
||||
|
||||
|
||||
class OutputConfig(BaseModel):
|
||||
default_format: str = "mp4"
|
||||
default_codec: str = "libx264"
|
||||
default_audio_codec: str = "aac"
|
||||
default_resolution: str = "1920x1080"
|
||||
default_fps: int = 30
|
||||
default_crf: int = 23
|
||||
|
||||
|
||||
class PathsConfig(BaseModel):
|
||||
pipeline_dir: str = "pipeline"
|
||||
library_dir: str = "library"
|
||||
styles_dir: str = "styles"
|
||||
skills_dir: str = "skills"
|
||||
output_dir: str = "output"
|
||||
|
||||
|
||||
class OpenMontageConfig(BaseModel):
|
||||
"""Top-level runtime configuration."""
|
||||
|
||||
llm: LLMConfig = Field(default_factory=LLMConfig)
|
||||
budget: BudgetConfig = Field(default_factory=BudgetConfig)
|
||||
checkpoint: CheckpointConfig = Field(default_factory=CheckpointConfig)
|
||||
output: OutputConfig = Field(default_factory=OutputConfig)
|
||||
paths: PathsConfig = Field(default_factory=PathsConfig)
|
||||
|
||||
@classmethod
|
||||
def load(cls, config_path: Optional[Path] = None) -> "OpenMontageConfig":
|
||||
"""Load config from YAML file. Falls back to defaults if file missing."""
|
||||
if config_path is None:
|
||||
config_path = Path(__file__).resolve().parent.parent / "config.yaml"
|
||||
|
||||
if config_path.exists():
|
||||
with open(config_path) as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
return cls.model_validate(raw)
|
||||
|
||||
return cls()
|
||||
|
||||
def resolve_path(self, key: str, project_root: Optional[Path] = None) -> Path:
|
||||
"""Resolve a relative path from PathsConfig against project root."""
|
||||
if project_root is None:
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
value = getattr(self.paths, key)
|
||||
return (project_root / value).resolve()
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Environment variable loader for OpenMontage.
|
||||
|
||||
Loads .env file and provides typed access to environment configuration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
def load_env(project_root: Optional[Path] = None) -> None:
|
||||
"""Load .env file from project root."""
|
||||
if project_root is None:
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
env_path = project_root / ".env"
|
||||
if env_path.exists():
|
||||
load_dotenv(env_path)
|
||||
|
||||
|
||||
def get_env(key: str, default: Optional[str] = None) -> Optional[str]:
|
||||
"""Get an environment variable with optional default."""
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
def require_env(key: str) -> str:
|
||||
"""Get a required environment variable. Raises if missing."""
|
||||
value = os.environ.get(key)
|
||||
if value is None:
|
||||
raise EnvironmentError(f"Required environment variable {key!r} is not set")
|
||||
return value
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Internal media profile constants and render-profile helpers.
|
||||
|
||||
Defines platform-specific media profiles (resolution, aspect ratio, codec, etc.)
|
||||
so the composer and publisher agents can format output correctly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class AspectRatio(str, Enum):
|
||||
LANDSCAPE_16_9 = "16:9"
|
||||
PORTRAIT_9_16 = "9:16"
|
||||
SQUARE_1_1 = "1:1"
|
||||
CINEMATIC_21_9 = "21:9"
|
||||
STANDARD_4_3 = "4:3"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MediaProfile:
|
||||
"""A named render profile for a target platform/format."""
|
||||
name: str
|
||||
width: int
|
||||
height: int
|
||||
aspect_ratio: AspectRatio
|
||||
fps: int
|
||||
codec: str
|
||||
audio_codec: str
|
||||
crf: int
|
||||
pixel_format: str = "yuv420p"
|
||||
max_file_size_mb: Optional[float] = None
|
||||
max_duration_seconds: Optional[float] = None
|
||||
caption_format: str = "srt"
|
||||
notes: str = ""
|
||||
|
||||
|
||||
# ---- Platform profiles ----
|
||||
|
||||
YOUTUBE_LANDSCAPE = MediaProfile(
|
||||
name="youtube_landscape",
|
||||
width=1920, height=1080,
|
||||
aspect_ratio=AspectRatio.LANDSCAPE_16_9,
|
||||
fps=30, codec="libx264", audio_codec="aac", crf=18,
|
||||
caption_format="srt",
|
||||
notes="YouTube standard HD upload",
|
||||
)
|
||||
|
||||
YOUTUBE_4K = MediaProfile(
|
||||
name="youtube_4k",
|
||||
width=3840, height=2160,
|
||||
aspect_ratio=AspectRatio.LANDSCAPE_16_9,
|
||||
fps=30, codec="libx264", audio_codec="aac", crf=18,
|
||||
caption_format="srt",
|
||||
notes="YouTube 4K upload",
|
||||
)
|
||||
|
||||
YOUTUBE_SHORTS = MediaProfile(
|
||||
name="youtube_shorts",
|
||||
width=1080, height=1920,
|
||||
aspect_ratio=AspectRatio.PORTRAIT_9_16,
|
||||
fps=30, codec="libx264", audio_codec="aac", crf=20,
|
||||
max_duration_seconds=60,
|
||||
caption_format="srt",
|
||||
notes="YouTube Shorts (max 60s, vertical)",
|
||||
)
|
||||
|
||||
INSTAGRAM_REELS = MediaProfile(
|
||||
name="instagram_reels",
|
||||
width=1080, height=1920,
|
||||
aspect_ratio=AspectRatio.PORTRAIT_9_16,
|
||||
fps=30, codec="libx264", audio_codec="aac", crf=20,
|
||||
max_file_size_mb=250,
|
||||
max_duration_seconds=90,
|
||||
caption_format="srt",
|
||||
notes="Instagram Reels (max 90s, vertical)",
|
||||
)
|
||||
|
||||
INSTAGRAM_FEED = MediaProfile(
|
||||
name="instagram_feed",
|
||||
width=1080, height=1080,
|
||||
aspect_ratio=AspectRatio.SQUARE_1_1,
|
||||
fps=30, codec="libx264", audio_codec="aac", crf=20,
|
||||
max_file_size_mb=250,
|
||||
max_duration_seconds=60,
|
||||
notes="Instagram feed video (square)",
|
||||
)
|
||||
|
||||
TIKTOK = MediaProfile(
|
||||
name="tiktok",
|
||||
width=1080, height=1920,
|
||||
aspect_ratio=AspectRatio.PORTRAIT_9_16,
|
||||
fps=30, codec="libx264", audio_codec="aac", crf=20,
|
||||
max_file_size_mb=287,
|
||||
max_duration_seconds=600,
|
||||
caption_format="srt",
|
||||
notes="TikTok (max 10min, vertical preferred)",
|
||||
)
|
||||
|
||||
LINKEDIN = MediaProfile(
|
||||
name="linkedin",
|
||||
width=1920, height=1080,
|
||||
aspect_ratio=AspectRatio.LANDSCAPE_16_9,
|
||||
fps=30, codec="libx264", audio_codec="aac", crf=20,
|
||||
max_file_size_mb=5120,
|
||||
max_duration_seconds=600,
|
||||
caption_format="srt",
|
||||
notes="LinkedIn video (landscape preferred, max 10min)",
|
||||
)
|
||||
|
||||
CINEMATIC = MediaProfile(
|
||||
name="cinematic",
|
||||
width=2560, height=1080,
|
||||
aspect_ratio=AspectRatio.CINEMATIC_21_9,
|
||||
fps=24, codec="libx264", audio_codec="aac", crf=16,
|
||||
notes="Cinematic ultra-wide format",
|
||||
)
|
||||
|
||||
GENERIC_HD = MediaProfile(
|
||||
name="generic_hd",
|
||||
width=1920, height=1080,
|
||||
aspect_ratio=AspectRatio.LANDSCAPE_16_9,
|
||||
fps=30, codec="libx264", audio_codec="aac", crf=23,
|
||||
caption_format="srt",
|
||||
notes="Generic HD output (no platform-specific constraints)",
|
||||
)
|
||||
|
||||
|
||||
# ---- Profile registry ----
|
||||
|
||||
ALL_PROFILES: dict[str, MediaProfile] = {
|
||||
p.name: p for p in [
|
||||
YOUTUBE_LANDSCAPE, YOUTUBE_4K, YOUTUBE_SHORTS,
|
||||
INSTAGRAM_REELS, INSTAGRAM_FEED,
|
||||
TIKTOK, LINKEDIN, CINEMATIC, GENERIC_HD,
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def get_profile(name: str) -> MediaProfile:
|
||||
"""Get a media profile by name."""
|
||||
if name not in ALL_PROFILES:
|
||||
available = ", ".join(ALL_PROFILES.keys())
|
||||
raise ValueError(f"Unknown profile {name!r}. Available: {available}")
|
||||
return ALL_PROFILES[name]
|
||||
|
||||
|
||||
def get_profiles_for_platform(platform: str) -> list[MediaProfile]:
|
||||
"""Get all profiles matching a platform prefix."""
|
||||
return [p for name, p in ALL_PROFILES.items() if name.startswith(platform)]
|
||||
|
||||
|
||||
def ffmpeg_output_args(profile: MediaProfile) -> list[str]:
|
||||
"""Generate FFmpeg output arguments for a media profile."""
|
||||
args = [
|
||||
"-c:v", profile.codec,
|
||||
"-c:a", profile.audio_codec,
|
||||
"-crf", str(profile.crf),
|
||||
"-pix_fmt", profile.pixel_format,
|
||||
"-r", str(profile.fps),
|
||||
"-vf", f"scale={profile.width}:{profile.height}",
|
||||
]
|
||||
return args
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Pipeline manifest loader.
|
||||
|
||||
Loads and validates pipeline YAML manifests from pipeline_defs/.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
import jsonschema
|
||||
|
||||
PIPELINE_DEFS_DIR = Path(__file__).resolve().parent.parent / "pipeline_defs"
|
||||
SCHEMA_PATH = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "schemas"
|
||||
/ "pipelines"
|
||||
/ "pipeline_manifest.schema.json"
|
||||
)
|
||||
|
||||
|
||||
def _load_manifest_schema() -> dict:
|
||||
with open(SCHEMA_PATH) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_pipeline(name: str, defs_dir: Optional[Path] = None) -> dict[str, Any]:
|
||||
"""Load and validate a pipeline manifest by name.
|
||||
|
||||
Args:
|
||||
name: Pipeline name (without .yaml extension).
|
||||
defs_dir: Override directory for pipeline definitions.
|
||||
|
||||
Returns:
|
||||
Validated pipeline manifest dict.
|
||||
"""
|
||||
defs_dir = defs_dir or PIPELINE_DEFS_DIR
|
||||
path = defs_dir / f"{name}.yaml"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Pipeline manifest not found: {path}")
|
||||
|
||||
with open(path) as f:
|
||||
manifest = yaml.safe_load(f)
|
||||
|
||||
schema = _load_manifest_schema()
|
||||
jsonschema.validate(instance=manifest, schema=schema)
|
||||
|
||||
return manifest
|
||||
|
||||
|
||||
def list_pipelines(defs_dir: Optional[Path] = None) -> list[str]:
|
||||
"""List all available pipeline manifest names."""
|
||||
defs_dir = defs_dir or PIPELINE_DEFS_DIR
|
||||
return [p.stem for p in defs_dir.glob("*.yaml")]
|
||||
|
||||
|
||||
def get_stage_order(manifest: dict) -> list[str]:
|
||||
"""Extract the ordered list of stage names from a manifest."""
|
||||
return [stage["name"] for stage in manifest["stages"]]
|
||||
|
||||
|
||||
def get_required_tools(manifest: dict) -> set[str]:
|
||||
"""Collect all preferred + fallback + available tools across all stages."""
|
||||
tools: set[str] = set()
|
||||
for stage in manifest["stages"]:
|
||||
tools.update(stage.get("preferred_tools", []))
|
||||
tools.update(stage.get("fallback_tools", []))
|
||||
tools.update(stage.get("tools_available", []))
|
||||
return tools
|
||||
|
||||
|
||||
def get_stage_skill(manifest: dict, stage_name: str) -> Optional[str]:
|
||||
"""Get the skill path for an instruction-driven stage."""
|
||||
for stage in manifest["stages"]:
|
||||
if stage["name"] == stage_name:
|
||||
return stage.get("skill")
|
||||
return None
|
||||
|
||||
|
||||
def get_stage_review_focus(manifest: dict, stage_name: str) -> list[str]:
|
||||
"""Get the review focus items for a stage."""
|
||||
for stage in manifest["stages"]:
|
||||
if stage["name"] == stage_name:
|
||||
return stage.get("review_focus", [])
|
||||
return []
|
||||
Reference in New Issue
Block a user