Skip to content
Open
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
46 changes: 46 additions & 0 deletions src/mergai/agent_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import git

from .agents.base import Agent
from .utils import git_utils


class AgentExecutionError(Exception):
Expand Down Expand Up @@ -312,6 +313,51 @@ def validate_solution_files(self, solution: dict) -> str | None:

return None

def validate_resolved_files_have_no_markers(self, solution: dict) -> str | None:
"""Validate that files reported as 'resolved' have no conflict markers left.

The agent is allowed to leave conflict markers in files listed under
`response.unresolved`, but any file listed under `response.resolved`
must be free of markers.

Args:
solution: The solution dict from the agent, expected to have
structure: {"response": {"resolved": {path: ...}}}.

Returns:
None if all resolved files are free of conflict markers, or an
error message listing the offending files.
"""
offending: list[str] = []
for path in solution["response"].get("resolved", {}):
if git_utils.file_has_conflict_markers_in_workdir(path):
offending.append(path)

if offending:
return (
"The following files were marked as resolved but still contain "
"conflict markers: "
+ ", ".join(offending)
+ ". Remove the conflict markers from these files, or move them "
"to the 'unresolved' section of your response."
)
return None

def validate_solution(self, solution: dict) -> str | None:
"""Combined validator for agent solutions.

Runs `validate_solution_files` (files listed as resolved/modified were
actually changed on disk) followed by
`validate_resolved_files_have_no_markers` (no conflict markers remain
in files the agent claimed to have resolved).

Returns the first error encountered, or None if both checks pass.
"""
error = self.validate_solution_files(solution)
if error:
return error
return self.validate_resolved_files_have_no_markers(solution)

def validate_describe_response(self, response: dict) -> str | None:
"""Validate that describe response has the correct format.

Expand Down
7 changes: 6 additions & 1 deletion src/mergai/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ def resolve(self, force: bool, yolo: bool, agent_desc: str | None = None):
try:
solution = executor.run_with_retry(
prompt=prompt,
validator=executor.validate_solution_files,
validator=executor.validate_solution,
)
except AgentExecutionError as e:
raise Exception(str(e)) from e
Expand Down Expand Up @@ -392,6 +392,11 @@ def add_selective_note(self, commit: str, fields: list[str]):
selective_note["user_comment"] = self.note.user_comment
elif field == "merge_description" and self.note.has_merge_description:
selective_note["merge_description"] = self.note.merge_description
elif field == "ci_fix_history" and self.note.has_ci_fix_history:
# Attach only the most recent attempt; older ones already
# live on earlier commits' notes.
assert self.note.ci_fix_history is not None # for type-checkers
selective_note["ci_fix_history"] = [self.note.ci_fix_history[-1]]

# Write selective note to temp file and attach as git note
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
Expand Down
15 changes: 15 additions & 0 deletions src/mergai/ci/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""CI workflow handling for mergai.

This package implements the automated fix loop for CI workflow failures
on mergai PRs (PSMDB-1972). Top-level pieces:

- :mod:`mergai.ci.context_builders` — turn workflow failure artifacts
(git diffs, SARIF files, logs) into a structured
:class:`~mergai.ci.context_builders.base.WorkflowContext`.
- :mod:`mergai.ci.handlers` — execute a fix given a
``WorkflowContext``: either a shell command (``command``) or an AI
agent run (``resolve``).

The ``mergai ci handle`` click command in :mod:`mergai.commands.ci`
wires these together and is the public entry point.
"""
47 changes: 47 additions & 0 deletions src/mergai/ci/context_builders/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Context builders for CI workflow failures.

Looks up a :class:`~.base.WorkflowContextBuilder` by ``type`` string
from :class:`mergai.config.WorkflowContextConfig`. New types can be
registered by appending to ``_BUILDERS``.
"""

from ...app import AppContext
from .base import WorkflowContext, WorkflowContextBuilder
from .diff import DiffContextBuilder
from .sarif import SARIFContextBuilder

_BUILDERS: dict[str, type[WorkflowContextBuilder]] = {
"diff": DiffContextBuilder,
"sarif": SARIFContextBuilder,
}


def get_context_builder(app: AppContext, type_: str) -> WorkflowContextBuilder:
"""Return a context-builder instance for the given context type.

Args:
app: The active :class:`~mergai.app.AppContext`. Builders receive
it so they can fall back to GitHub-API sources (e.g. job
logs) when the expected artifact is missing.
type_: ``WorkflowContextConfig.type`` value (e.g. ``"diff"``,
``"sarif"``).

Raises:
ValueError: If no builder is registered for ``type_``.
"""
builder_cls = _BUILDERS.get(type_)
if builder_cls is None:
known = ", ".join(sorted(_BUILDERS)) or "(none)"
raise ValueError(
f"No context builder registered for type '{type_}'. Known: {known}"
)
return builder_cls(app)


__all__ = [
"WorkflowContext",
"WorkflowContextBuilder",
"DiffContextBuilder",
"SARIFContextBuilder",
"get_context_builder",
]
84 changes: 84 additions & 0 deletions src/mergai/ci/context_builders/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Base types for workflow context builders."""

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any

from ...app import AppContext
from ...config import WorkflowContextConfig


@dataclass
class WorkflowContext:
"""Structured context extracted from a failed CI workflow run.

Context builders populate this from workflow artifacts, the GitHub API,
or logs. Handlers consume it: ``CommandHandler`` mostly uses it for
reporting, while ``ResolveHandler`` feeds ``summary`` + ``details`` +
``files_affected`` into the AI prompt.

Attributes:
workflow_name: The failing workflow's name (e.g. ``"format"``).
run_id: GitHub workflow run ID that produced this failure.
pr_number: Pull request number the run is associated with.
summary: One-line human-readable summary of the failure.
files_affected: Paths (repo-relative) implicated by the failure.
details: Full text content for the AI prompt (the diff, SARIF
findings, log excerpt — whatever the builder extracts).
raw_data: Original parsed data, kept for storage/debugging.
"""

workflow_name: str
run_id: str
pr_number: int
summary: str
files_affected: list[str] = field(default_factory=list)
details: str = ""
raw_data: dict[str, Any] = field(default_factory=dict)


class WorkflowContextBuilder(ABC):
"""Abstract base class for context builders.

A builder maps a ``WorkflowContextConfig`` (with a ``type`` and
``source``) to a concrete :class:`WorkflowContext`. Subclasses are
registered by type via
:func:`mergai.ci.context_builders.get_context_builder`.

Builders receive the active :class:`~mergai.app.AppContext` so they
can fall back to GitHub-API sources (e.g. job logs) when the expected
artifact is missing — the SARIF builder uses this when the workflow
failed before producing its SARIF report.
"""

def __init__(self, app: AppContext):
self.app = app

@abstractmethod
def build_context(
self,
config: WorkflowContextConfig,
workflow_name: str,
run_id: str,
pr_number: int,
artifacts_dir: str | None,
) -> WorkflowContext:
"""Build a :class:`WorkflowContext` for a given failed run.

Args:
config: The per-workflow context config (``type``, ``source``,
``artifact_name``, ``extract_pattern``).
workflow_name: Name of the failing workflow.
run_id: GitHub workflow run ID.
pr_number: PR number.
artifacts_dir: Directory with downloaded workflow artifacts.
Each artifact is extracted into a subdirectory named after
the artifact.

Returns:
Populated WorkflowContext.

Raises:
FileNotFoundError: If the expected artifact is missing.
ValueError: If the artifact content can't be parsed.
"""
62 changes: 62 additions & 0 deletions src/mergai/ci/context_builders/diff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Context builder for workflows that upload a git diff artifact.

Used by the ``format`` workflow: format.yml writes ``diff.patch``
(unified diff) and ``files.txt`` (one path per line) into the
``format-results`` artifact.
"""

from pathlib import Path

from ...config import WorkflowContextConfig
from .base import WorkflowContext, WorkflowContextBuilder


class DiffContextBuilder(WorkflowContextBuilder):
"""Reads ``diff.patch`` + ``files.txt`` from a workflow artifact."""

def build_context(
self,
config: WorkflowContextConfig,
workflow_name: str,
run_id: str,
pr_number: int,
artifacts_dir: str | None,
) -> WorkflowContext:
if artifacts_dir is None:
raise FileNotFoundError(
f"Workflow '{workflow_name}' needs artifacts_dir (diff context)"
)
if not config.artifact_name:
raise ValueError(
f"Workflow '{workflow_name}' diff context requires "
f"'context.artifact_name' to be set"
)

artifact_path = Path(artifacts_dir) / config.artifact_name
diff_file = artifact_path / "diff.patch"
files_file = artifact_path / "files.txt"

diff_content = diff_file.read_text() if diff_file.exists() else ""
files_affected: list[str] = []
if files_file.exists():
files_affected = [
line.strip()
for line in files_file.read_text().splitlines()
if line.strip()
]

count = len(files_affected)
summary = (
f"{workflow_name} failed: {count} file{'s' if count != 1 else ''} "
f"need changes"
)

return WorkflowContext(
workflow_name=workflow_name,
run_id=run_id,
pr_number=pr_number,
summary=summary,
files_affected=files_affected,
details=diff_content,
raw_data={"diff": diff_content, "files": files_affected},
)
Loading
Loading