Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 244 additions & 0 deletions src/automation_workflow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
"""Content-generation + orchestration wiring for bounded automation (Arc D, phase 3b).

This is the last mile of Safe Automation: it binds the gated executor (phase 3a,
``automation_executor.py``) to the existing context-recovery content primitives,
and drives the full lifecycle loop —
``load -> filter-approved -> plan -> execute -> mark-executed -> save``.

Design rules carried from the earlier phases:

* Content is derived FRESH at execution time (recovery's ``_build_context_sections``
+ ``_suggested_catalog_seed``), so nothing goes stale between proposal and run.
* A context-PR plan produces the SAME managed block as the direct-apply recovery
path — it reuses recovery's section builder rather than reimplementing it, so
the two render paths can never diverge.
* The executor owns every git/gh safety rail (approval gate, never-default-branch,
never-force, skip-dirty, dry-run-default, fail-fast). This module only builds
the plan/seed and routes approved proposals to it.
* A context-PR is skipped when the repo has no GitHub slug — we never fabricate
a head/base ref from the local display name.
"""

from __future__ import annotations

import re
from functools import partial
from pathlib import Path

from src.automation_executor import (
AutomationExecutionError,
CommandRunner,
ExecutionPlan,
ExecutionResult,
default_command_runner,
execute_catalog_seed,
execute_context_pr,
)
from src.automation_proposals import (
ACTION_CATALOG_SEED,
ACTION_CONTEXT_PR,
AutomationProposal,
executable_proposals,
load_proposals,
mark_executed,
save_proposals,
)
from src.portfolio_context_recovery import (
_catalog_repo_key,
_suggested_catalog_seed,
write_managed_context_block,
)
from src.portfolio_truth_types import PortfolioTruthProject, PortfolioTruthSnapshot

CONTRACT_VERSION = "automation_workflow_v1"

# Auto-PR branch defaults. ``default_branch`` is the PR base / the branch the
# executor refuses to commit onto; it is a parameter (not a buried literal) so
# callers can override per-repo, defaulting to the portfolio convention.
DEFAULT_BRANCH_PREFIX = "auto/context-"
DEFAULT_DEFAULT_BRANCH = "main"

# Collapse any run of characters git/gh would dislike into a single hyphen.
_BRANCH_UNSAFE = re.compile(r"[^A-Za-z0-9._-]+")


def _branch_slug(project: PortfolioTruthProject) -> str:
"""A safe, lowercased branch fragment from the repo slug (never flag-like)."""
full = project.identity.repo_full_name
raw = full.split("/")[-1] if full else project.identity.display_name
slug = _BRANCH_UNSAFE.sub("-", raw).strip("-_.").lower()
return slug or "repo"


def build_context_pr_plan(
project: PortfolioTruthProject,
*,
workspace_root: Path,
branch_prefix: str = DEFAULT_BRANCH_PREFIX,
default_branch: str = DEFAULT_DEFAULT_BRANCH,
) -> ExecutionPlan:
"""Build the ``ExecutionPlan`` for a context-improvement auto-PR.

The plan's ``apply_change`` regenerates the managed context block from the
project's current repository signals; the executor handles all git/gh
mechanics (and every safety rail) around it.
"""
repo_path = workspace_root / project.identity.path
display = project.identity.display_name
commit_message = f"docs(context): refresh managed context block for {display}"
pr_body = (
f"Automated managed-context refresh for **{display}**.\n\n"
f"Regenerates the managed context block in "
f"`{project.derived.primary_context_file}` from current repository "
"signals. Generated by the GithubRepoAuditor bounded-automation "
"executor (Arc D) — review before merge."
)
return ExecutionPlan(
repo_path=repo_path,
default_branch=default_branch,
branch_name=f"{branch_prefix}{_branch_slug(project)}",
commit_message=commit_message,
pr_title=commit_message,
pr_body=pr_body,
apply_change=partial(write_managed_context_block, project),
has_git=project.identity.has_git,
)


def build_catalog_seeds_for(
project: PortfolioTruthProject,
) -> dict[str, dict[str, str]]:
"""Build the catalog-seed payload (keyed by catalog repo key) for one project."""
seed = _suggested_catalog_seed(project)
if not seed:
return {}
return {_catalog_repo_key(project): seed}


def _index_projects(
snapshot: PortfolioTruthSnapshot,
) -> tuple[dict[str, PortfolioTruthProject], dict[str, PortfolioTruthProject]]:
"""Index projects by GitHub slug and by display name (mirrors risk keying)."""
by_slug: dict[str, PortfolioTruthProject] = {}
by_name: dict[str, PortfolioTruthProject] = {}
for project in snapshot.projects:
if project.identity.repo_full_name:
by_slug[project.identity.repo_full_name] = project
by_name[project.identity.display_name] = project
return by_slug, by_name


def _resolve_project(
proposal: AutomationProposal,
by_slug: dict[str, PortfolioTruthProject],
by_name: dict[str, PortfolioTruthProject],
) -> PortfolioTruthProject | None:
if proposal.repo_full_name and proposal.repo_full_name in by_slug:
return by_slug[proposal.repo_full_name]
return by_name.get(proposal.display_name)


def execute_approved_proposals(
*,
proposals_path: Path,
snapshot: PortfolioTruthSnapshot,
workspace_root: Path,
catalog_path: Path,
executed_at: str,
dry_run: bool = True,
runner: CommandRunner = default_command_runner,
branch_prefix: str = DEFAULT_BRANCH_PREFIX,
default_branch: str = DEFAULT_DEFAULT_BRANCH,
) -> list[ExecutionResult]:
"""Execute every APPROVED proposal in the queue, behind the executor's rails.

Only proposals an operator has approved are acted on. Each one is routed by
``action_type`` to the right executor entry point with freshly-derived
content. A real apply (``outcome == "applied"``) transitions the proposal to
EXECUTED and the queue is persisted; dry runs, skips, and failures leave the
queue untouched so the operator can re-run.
"""
proposals = load_proposals(proposals_path)
by_slug, by_name = _index_projects(snapshot)

working = list(proposals)
results: list[ExecutionResult] = []

for proposal in executable_proposals(proposals):
project = _resolve_project(proposal, by_slug, by_name)
# One proposal's failure must never abandon the rest of the batch: a
# safety-rail violation or a catalog I/O error becomes a `failed` result
# for that proposal, and the loop continues.
try:
result = _dispatch_proposal(
proposal,
project,
workspace_root=workspace_root,
catalog_path=catalog_path,
dry_run=dry_run,
runner=runner,
branch_prefix=branch_prefix,
default_branch=default_branch,
)
except (AutomationExecutionError, OSError) as error:
result = ExecutionResult(proposal.proposal_id, "failed", str(error).strip())

results.append(result)
if result.outcome == "applied":
working = mark_executed(
working,
proposal.proposal_id,
executed_at=executed_at,
execution_ref=result.reference,
)

if any(result.outcome == "applied" for result in results):
save_proposals(proposals_path, working)

return results


def _dispatch_proposal(
proposal: AutomationProposal,
project: PortfolioTruthProject | None,
*,
workspace_root: Path,
catalog_path: Path,
dry_run: bool,
runner: CommandRunner,
branch_prefix: str,
default_branch: str,
) -> ExecutionResult:
"""Route one APPROVED proposal to the right executor entry point.

Returns a ``skipped`` result for the policy cases (no matching project, a
context-PR without a GitHub slug, an unknown action); otherwise it builds
fresh content and calls the executor, whose own result/exception it surfaces.
"""
if project is None:
return ExecutionResult(proposal.proposal_id, "skipped", "project-not-found")

if proposal.action_type == ACTION_CONTEXT_PR:
# A context-PR needs a real GitHub slug for the head/base refs; we never
# fabricate one from the local display name (decided 2026-06-06).
if not proposal.repo_full_name:
return ExecutionResult(proposal.proposal_id, "skipped", "no-repo-full-name")
plan = build_context_pr_plan(
project,
workspace_root=workspace_root,
branch_prefix=branch_prefix,
default_branch=default_branch,
)
return execute_context_pr(proposal, plan, dry_run=dry_run, runner=runner)

if proposal.action_type == ACTION_CATALOG_SEED:
return execute_catalog_seed(
proposal,
catalog_path=catalog_path,
seeds=build_catalog_seeds_for(project),
dry_run=dry_run,
)

return ExecutionResult(
proposal.proposal_id, "skipped", f"unknown-action:{proposal.action_type}"
)
Loading
Loading