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
2 changes: 1 addition & 1 deletion .github/workflows/agents-shipgate.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
name: Agents Shipgate

on:
Expand All @@ -20,6 +20,6 @@
config: shipgate.yaml
ci_mode: advisory
diff_base: target
fail_on_decisions: block
fail_on_merge_verdicts: blocked
upload_artifact: "true"
pr_comment: "false"
5 changes: 3 additions & 2 deletions src/agents_shipgate/cli/verify/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ def verify(
help=(
"Local base ref/SHA for PR diff. Verify never fetches it. When "
"omitted, verify auto-detects the default branch (origin/HEAD, "
"origin/main, origin/master, main, master) if it points at a "
"different commit than the head; --no-base disables that."
"origin/main, origin/master) if it points at a different commit "
"than the head. Local main/master are used only when passed "
"explicitly; --no-base disables auto-detection."
),
),
no_base: bool = typer.Option(
Expand Down
88 changes: 78 additions & 10 deletions src/agents_shipgate/cli/verify/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import io
import subprocess
import tarfile
from dataclasses import dataclass
from pathlib import Path

from agents_shipgate.core.errors import ConfigError
Expand All @@ -25,7 +26,14 @@ def ensure_git_workspace(workspace: Path) -> Path:
return Path(root).resolve()


DEFAULT_BASE_CANDIDATES = ("origin/main", "origin/master", "main", "master")
REMOTE_BASE_CANDIDATES = ("origin/main", "origin/master")
LOCAL_BASE_CANDIDATES = ("main", "master")


@dataclass(frozen=True)
class DefaultBaseDetection:
base: str | None
notes: list[str]


def commit_sha(workspace: Path, ref: str) -> str | None:
Expand All @@ -42,16 +50,27 @@ def commit_sha(workspace: Path, ref: str) -> str | None:
def detect_default_base(workspace: Path, head: str = "HEAD") -> str | None:
"""Best-effort default base ref for PR-style diff enrichment.

Tries the remote default branch (``origin/HEAD``) first, then the
conventional candidates. A candidate qualifies only when it exists
locally and points at a different commit than ``head`` — diffing a
branch against itself adds scan cost without diff signal. Never
fetches; this only reads refs that already exist in the checkout.
Tries the remote default branch (``origin/HEAD``) first, then remote
conventional candidates (``origin/main``, ``origin/master``). A
candidate qualifies only when it exists locally and points at a
different commit than ``head`` — diffing a branch against itself adds
scan cost without diff signal. Local ``main``/``master`` are never
selected implicitly because they are often stale in CI and worktrees;
pass ``--base main`` explicitly when that is intended. Never fetches;
this only reads refs that already exist in the checkout.
"""

return detect_default_base_with_notes(workspace, head).base


def detect_default_base_with_notes(
workspace: Path, head: str = "HEAD"
) -> DefaultBaseDetection:
"""Return the implicit base plus warnings for skipped local defaults."""

head_sha = commit_sha(workspace, head)
if head_sha is None:
return None
return DefaultBaseDetection(base=None, notes=[])
candidates: list[str] = []
origin_head = _run_git(
workspace, ["rev-parse", "--abbrev-ref", "origin/HEAD"], check=False
Expand All @@ -60,12 +79,59 @@ def detect_default_base(workspace: Path, head: str = "HEAD") -> str | None:
name = origin_head.stdout.strip()
if name and name != "origin/HEAD":
candidates.append(name)
candidates.extend(c for c in DEFAULT_BASE_CANDIDATES if c not in candidates)
candidates.extend(c for c in REMOTE_BASE_CANDIDATES if c not in candidates)
selected_base: str | None = None
selected_base_sha: str | None = None
for candidate in candidates:
sha = commit_sha(workspace, candidate)
if sha is not None and sha != head_sha:
return candidate
return None
selected_base = candidate
selected_base_sha = sha
break
notes = _skipped_local_base_notes(
workspace,
head_sha,
selected_base_sha=selected_base_sha,
)
return DefaultBaseDetection(base=selected_base, notes=notes)


def _skipped_local_base_notes(
workspace: Path,
head_sha: str,
*,
selected_base_sha: str | None,
) -> list[str]:
notes: list[str] = []
for local in LOCAL_BASE_CANDIDATES:
local_sha = commit_sha(workspace, local)
if local_sha is None or local_sha == head_sha:
continue
if selected_base_sha is not None and local_sha == selected_base_sha:
continue
remote = f"origin/{local}"
remote_sha = commit_sha(workspace, remote)
if remote_sha is not None and remote_sha == local_sha:
continue
if remote_sha is not None and remote_sha != local_sha:
notes.append(
f"Skipped local base {local!r} for implicit auto-base because "
"only remote refs are auto-detected; "
f"{local!r} points at {_short_sha(local_sha)} while {remote!r} "
f"points at {_short_sha(remote_sha)}. Pass --base {local} "
"explicitly if that local branch is intended."
)
continue
notes.append(
f"Skipped local base {local!r} for implicit auto-base because only "
"remote refs are auto-detected. Pass --base "
f"{local} explicitly if that local branch is intended."
)
return notes


def _short_sha(sha: str) -> str:
return sha[:12]


def ref_exists(workspace: Path, ref: str) -> bool:
Expand Down Expand Up @@ -196,7 +262,9 @@ def _safe_extract(tar: tarfile.TarFile, destination: Path) -> None:
__all__ = [
"archive_tree",
"commit_sha",
"DefaultBaseDetection",
"detect_default_base",
"detect_default_base_with_notes",
"diff_context",
"ensure_git_workspace",
"git_path",
Expand Down
11 changes: 6 additions & 5 deletions src/agents_shipgate/cli/verify/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
from .fix_task import build_fix_task
from .git import (
archive_tree,
detect_default_base,
detect_default_base_with_notes,
diff_context,
ensure_git_workspace,
git_path,
Expand Down Expand Up @@ -185,11 +185,12 @@ def run_verify(
raise ConfigError(f"Head ref does not exist locally: {head}")

if base is None and auto_base:
detected = detect_default_base(git_root, head)
if detected is not None:
base = detected
detection = detect_default_base_with_notes(git_root, head)
base_notes.extend(detection.notes)
if detection.base is not None:
base = detection.base
base_notes.append(
f"Auto-detected base {detected!r} for diff context; "
f"Auto-detected base {detection.base!r} for diff context; "
"pass --base to override or --no-base to disable."
)

Expand Down
57 changes: 57 additions & 0 deletions tests/test_action_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,35 @@

import yaml

WORKFLOW_DIR = Path(".github/workflows")


def _workflow_paths() -> list[Path]:
return sorted([*WORKFLOW_DIR.glob("*.yml"), *WORKFLOW_DIR.glob("*.yaml")])


def _workflow_steps(workflow: dict) -> list[dict]:
steps: list[dict] = []
jobs = workflow.get("jobs") or {}
for job in jobs.values():
if not isinstance(job, dict):
continue
for step in job.get("steps") or []:
if isinstance(step, dict):
steps.append(step)
return steps


def _local_action_metadata_path(uses: str) -> Path | None:
if not uses.startswith("./"):
return None
action_path = Path(uses.split("@", 1)[0])
candidates = [action_path / "action.yml", action_path / "action.yaml"]
for candidate in candidates:
if candidate.exists():
return candidate
raise AssertionError(f"Local action {uses!r} has no action.yml or action.yaml")


def test_github_script_reads_output_dir_from_env():
text = Path("action.yml").read_text(encoding="utf-8")
Expand Down Expand Up @@ -166,6 +195,34 @@ def test_action_preserves_reports_before_applying_exit_code():
assert "args+=(--no-plugins)" in text


def test_repo_workflows_use_declared_local_action_inputs():
for workflow_path in _workflow_paths():
workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
assert isinstance(workflow, dict), f"{workflow_path} must parse as a mapping"
for step in _workflow_steps(workflow):
uses = step.get("uses")
if not isinstance(uses, str):
continue
metadata_path = _local_action_metadata_path(uses)
if metadata_path is None:
continue
metadata = yaml.safe_load(metadata_path.read_text(encoding="utf-8"))
declared = set((metadata.get("inputs") or {}).keys())
supplied = set((step.get("with") or {}).keys())
unknown = supplied - declared
assert not unknown, (
f"{workflow_path}: step uses {uses!r} with undeclared local "
f"action inputs {sorted(unknown)}; update {metadata_path}"
)


def test_agents_shipgate_workflow_uses_merge_verdict_policy_input():
text = (WORKFLOW_DIR / "agents-shipgate.yml").read_text(encoding="utf-8")

assert "fail_on_decisions" not in text
assert "fail_on_merge_verdicts: blocked" in text


def test_action_step_summary_leads_with_verifier_merge_state():
text = Path("action.yml").read_text(encoding="utf-8")
script = Path("scripts/github_action_outputs.py").read_text(encoding="utf-8")
Expand Down
2 changes: 1 addition & 1 deletion tests/test_adapter_static_only.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ class AllowedException:
AllowedException(
relative_path="cli/verify/git.py",
surface="attr_call:subprocess.run",
line=152,
line=218,
snippet="subprocess.run(cmd, capture_output=True, check=check, text=text, timeout=60)",
rationale=(
"_run_git helper for verify: executes fixed git argv assembled "
Expand Down
Loading