diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index df75377..00a793c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -13,6 +13,11 @@ Provide a brief description of the changes in this PR. - [ ] CI/CD changes - [ ] Security fix +## Dependencies + +Depends on: + ## Related Issues Closes #(issue number) Relates to #(issue number) diff --git a/=3.0 b/=3.0 new file mode 100644 index 0000000..e69de29 diff --git a/autopilot/dependency_graph.py b/autopilot/dependency_graph.py new file mode 100644 index 0000000..242003e --- /dev/null +++ b/autopilot/dependency_graph.py @@ -0,0 +1,652 @@ +#!/usr/bin/env python3 +""" +Dependency Graph Resolver — PR Dependency Chain Analyser. + +Scans open PRs in the three active repos for explicit or implicit dependency +references, builds a NetworkX DAG, and applies tier labels via the GitHub API. + +Environment variables: + GITHUB_TOKEN GitHub personal access token + ANTHROPIC_API_KEY Anthropic API key (used for LLM extraction fallback) + ENABLE_LIVE_MODE Set to "true" to apply labels (default: dry-run only) + +Active repos (MVP scope — paused repos are deferred until validated): + labgadget015-dotcom/autonomous-github-agent + labgadget015-dotcom/ai-automation-engine + labgadget015-dotcom/github-multi-agent-system + +Tier definitions: + tier-0-blocker PR that at least one other PR depends on + tier-1-dependent PR that depends on at least one other PR + tier-2-leaf PR with no dependencies and no dependents + +DAG edge direction: + A → B means "A depends on B" + So tier-0-blocker = nodes with in-degree > 0 (others point to them) + tier-1-dependent = nodes with out-degree > 0 (they point to others) + tier-2-leaf = nodes with in-degree == 0 AND out-degree == 0 + A node that is both a dependency of others AND depends on others gets + tier-0-blocker (higher priority). +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from github import Github, GithubException + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +ACTIVE_REPOS: list[dict[str, str]] = [ + {"owner": "labgadget015-dotcom", "name": "autonomous-github-agent"}, + {"owner": "labgadget015-dotcom", "name": "ai-automation-engine"}, + {"owner": "labgadget015-dotcom", "name": "github-multi-agent-system"}, +] + +TIER_LABELS: dict[int, str] = { + 0: "tier-0-blocker", + 1: "tier-1-dependent", + 2: "tier-2-leaf", +} + +LABEL_COLORS: dict[int, str] = { + 0: "b60205", # red + 1: "e4e669", # yellow + 2: "0e8a16", # green +} + +LABEL_DESCRIPTIONS: dict[int, str] = { + 0: "This PR blocks other PRs — merge first", + 1: "This PR depends on at least one other PR", + 2: "This PR has no dependencies and no dependents", +} + +# Sentinel used to distinguish "caller did not pass llm_client" from explicit None +_UNSET = object() + +# Regex pre-pass — matches "Depends on #123", "depends-on #45", "Depends on owner/repo#67" +_DEPENDS_ON_LOCAL_RE = re.compile(r"depends[\s_-]+on\s+#(\d+)", re.IGNORECASE) +_DEPENDS_ON_QUALIFIED_RE = re.compile( + r"depends[\s_-]+on\s+[\w.\-]+/[\w.\-]+#(\d+)", re.IGNORECASE +) + +# LLM model for extraction (cheap, fast) +_HAIKU_MODEL = "claude-3-5-haiku-20241022" + +_EXTRACTION_SYSTEM_PROMPT = ( + "You extract PR dependency information from pull request descriptions. " + "Return ONLY a valid JSON object — no prose, no markdown fences. " + "Schema: " + '{"pr_id": , "repo": "", ' + '"depends_on": [, ...], "confidence_score": }' +) + + +# --------------------------------------------------------------------------- +# Data structures +# --------------------------------------------------------------------------- + + +@dataclass +class PRNode: + """Lightweight descriptor for an open pull request.""" + + pr_id: int + repo: str # "owner/name" + title: str + body: str + url: str + depends_on: list[int] = field(default_factory=list) + confidence: float = 1.0 + + +@dataclass +class ExtractionResult: + """Structured result from LLM dependency extraction.""" + + pr_id: int + repo: str + depends_on: list[int] + confidence_score: float + + +# --------------------------------------------------------------------------- +# Regex helpers +# --------------------------------------------------------------------------- + + +def extract_deps_regex(text: str) -> list[int]: + """Return PR numbers referenced by explicit ``Depends on #X`` patterns. + + Handles both local (``#123``) and qualified (``owner/repo#123``) forms. + Deduplicates and preserves order of first occurrence. + """ + if not text: + return [] + seen: set[int] = set() + result: list[int] = [] + for pattern in (_DEPENDS_ON_LOCAL_RE, _DEPENDS_ON_QUALIFIED_RE): + for match in pattern.finditer(text): + num = int(match.group(1)) + if num not in seen: + seen.add(num) + result.append(num) + return result + + +# --------------------------------------------------------------------------- +# LLM helpers +# --------------------------------------------------------------------------- + + +def _build_extraction_prompt(node: PRNode) -> str: + body_snippet = (node.body or "")[:1500] + return ( + f"PR #{node.pr_id} in repo {node.repo}\n" + f"Title: {node.title}\n" + f"Body:\n{body_snippet}\n\n" + "Identify which PR numbers this PR depends on. " + "A dependency is an explicit statement like 'Depends on #X', " + "'blocked by #X', or 'requires #X to be merged first'. " + "If there are no dependencies, return an empty depends_on list. " + "Return JSON only." + ) + + +def extract_deps_llm( + node: PRNode, + llm_client: Any, +) -> ExtractionResult: + """Call the LLM to extract dependencies and return a structured result. + + Falls back to an empty result on any error so the caller can proceed. + """ + import asyncio + + prompt = _build_extraction_prompt(node) + try: + result = asyncio.run( + llm_client.generate( + prompt, + system_prompt=_EXTRACTION_SYSTEM_PROMPT, + max_tokens=200, + temperature=0.0, + ) + ) + raw = result.get("content", "{}").strip() + # Strip accidental markdown fences + raw = re.sub(r"^```(?:json)?\s*", "", raw, flags=re.MULTILINE) + raw = re.sub(r"\s*```$", "", raw, flags=re.MULTILINE) + data = json.loads(raw) + return ExtractionResult( + pr_id=int(data.get("pr_id", node.pr_id)), + repo=str(data.get("repo", node.repo)), + depends_on=[int(x) for x in data.get("depends_on", [])], + confidence_score=float(data.get("confidence_score", 0.0)), + ) + except Exception as exc: + logger.warning( + "LLM extraction failed for %s#%d (falling back to regex): %s", + node.repo, + node.pr_id, + exc, + ) + return ExtractionResult( + pr_id=node.pr_id, + repo=node.repo, + depends_on=[], + confidence_score=0.0, + ) + + +# --------------------------------------------------------------------------- +# Main engine +# --------------------------------------------------------------------------- + + +class DependencyGraph: + """Builds and labels a PR dependency DAG across the three active repos. + + Args: + github_token: GitHub PAT. Defaults to ``GITHUB_TOKEN`` env var. + anthropic_api_key: Anthropic API key for LLM extraction. Defaults to + ``ANTHROPIC_API_KEY`` env var. + dry_run: When *True* (default) no labels are applied. + Set *False* or ``ENABLE_LIVE_MODE=true`` to write. + max_llm_calls: Hard cap on LLM calls per run (default 30). + """ + + def __init__( + self, + github_token: str | None = None, + anthropic_api_key: str | None = None, + dry_run: bool | None = None, + max_llm_calls: int = 30, + ) -> None: + token = github_token or os.getenv("GITHUB_TOKEN") + if not token: + raise ValueError("GITHUB_TOKEN is required") + self._github = Github(token) + + self._anthropic_api_key = anthropic_api_key or os.getenv("ANTHROPIC_API_KEY") + self.max_llm_calls = max_llm_calls + self._llm_calls_used = 0 + + if dry_run is None: + dry_run = os.getenv("ENABLE_LIVE_MODE", "").lower() != "true" + self.dry_run = dry_run + + # ------------------------------------------------------------------ + # LLM client (lazy) + # ------------------------------------------------------------------ + + def _make_llm_client(self) -> Any | None: + """Return a configured LLMClient or None if no API key is available.""" + if not self._anthropic_api_key: + logger.info("No ANTHROPIC_API_KEY — LLM extraction disabled") + return None + try: + sys.path.insert(0, str(Path(__file__).parent.parent)) + from core.llm_provider import LLMClient + + return LLMClient( + { + "llm_provider": "anthropic", + "anthropic_api_key": self._anthropic_api_key, + "model": _HAIKU_MODEL, + "llm_max_cost_usd": 5.0, + } + ) + except Exception as exc: + logger.warning("Failed to initialise LLMClient: %s", exc) + return None + + # ------------------------------------------------------------------ + # Repo scanning + # ------------------------------------------------------------------ + + def scan_repos(self, repos: list[dict[str, str]] | None = None) -> list[PRNode]: + """Fetch all open PRs from *repos* and return a list of PRNode objects. + + Args: + repos: List of ``{owner, name}`` dicts. Defaults to + :data:`ACTIVE_REPOS`. + + Returns: + List of :class:`PRNode` instances (one per open PR). + """ + if repos is None: + repos = ACTIVE_REPOS + + nodes: list[PRNode] = [] + for repo_cfg in repos: + full_name = f"{repo_cfg['owner']}/{repo_cfg['name']}" + try: + repo = self._github.get_repo(full_name) + for pr in repo.get_pulls(state="open"): + nodes.append( + PRNode( + pr_id=pr.number, + repo=full_name, + title=pr.title or "", + body=pr.body or "", + url=pr.html_url, + ) + ) + logger.info( + "[dep-graph] Fetched %d open PRs from %s", + sum(1 for n in nodes if n.repo == full_name), + full_name, + ) + except Exception as exc: + logger.error( + "[dep-graph] Failed to fetch PRs from %s: %s", full_name, exc + ) + + return nodes + + # ------------------------------------------------------------------ + # Dependency extraction + # ------------------------------------------------------------------ + + def _resolve_deps( + self, node: PRNode, all_ids: set[int], llm_client: Any | None + ) -> list[int]: + """Return a validated dependency list for *node*. + + Steps: + 1. Regex pre-pass (cheap, zero API cost). + 2. If regex finds nothing AND an LLM client is available AND the + LLM call budget is not exhausted, attempt LLM extraction. + 3. Filter to IDs that exist in *all_ids* (avoids phantom edges). + """ + deps = extract_deps_regex(node.body) + + if ( + not deps + and llm_client is not None + and self._llm_calls_used < self.max_llm_calls + ): + result = extract_deps_llm(node, llm_client) + self._llm_calls_used += 1 + if result.confidence_score >= 0.5: + deps = result.depends_on + node.confidence = result.confidence_score + + # Only keep IDs that actually exist as open PRs in the scanned repos + return [d for d in deps if d in all_ids] + + # ------------------------------------------------------------------ + # DAG construction + # ------------------------------------------------------------------ + + def build_dag( + self, + nodes: list[PRNode], + llm_client: Any | None = None, + ): + """Build a directed acyclic graph from *nodes*. + + Edge ``A → B`` means "PR A depends on PR B". + + Args: + nodes: List of PR nodes returned by :meth:`scan_repos`. + llm_client: Optional LLM client for dependency extraction. + + Returns: + A ``networkx.DiGraph`` where each node key is ``"repo#pr_id"`` + and nodes carry ``pr_id``, ``repo``, ``title``, ``url`` attrs. + """ + import networkx as nx + + dag: nx.DiGraph = nx.DiGraph() + + all_ids: set[int] = {n.pr_id for n in nodes} + + # First pass: add all nodes so edges can safely reference them + for node in nodes: + key = f"{node.repo}#{node.pr_id}" + dag.add_node( + key, + pr_id=node.pr_id, + repo=node.repo, + title=node.title, + url=node.url, + ) + + # Second pass: resolve dependencies and add edges + for node in nodes: + key = f"{node.repo}#{node.pr_id}" + deps = self._resolve_deps(node, all_ids, llm_client) + node.depends_on = deps + for dep_id in deps: + # Find the dep's repo (assume same repo if not qualified) + dep_node = next((n for n in nodes if n.pr_id == dep_id), None) + dep_repo = dep_node.repo if dep_node else node.repo + dep_key = f"{dep_repo}#{dep_id}" + dag.add_edge(key, dep_key) + + # Remove cycles (shouldn't happen with PRs, but guard defensively) + if not _is_dag(dag): + logger.warning("[dep-graph] Cycle detected — removing back-edges") + dag = _break_cycles(dag) + + return dag + + # ------------------------------------------------------------------ + # Tier assignment + # ------------------------------------------------------------------ + + def assign_tiers(self, dag: Any) -> dict[str, int]: + """Return a mapping of node key → tier integer. + + Tier rules (in priority order): + 0 — node has in-degree > 0 (other PRs depend on it → blocker) + 1 — node has out-degree > 0 (it depends on others → dependent) + 2 — no in-edges AND no out-edges (leaf) + """ + tiers: dict[str, int] = {} + for node in dag.nodes: + in_deg = dag.in_degree(node) + out_deg = dag.out_degree(node) + if in_deg > 0: + tiers[node] = 0 + elif out_deg > 0: + tiers[node] = 1 + else: + tiers[node] = 2 + return tiers + + # ------------------------------------------------------------------ + # Label management + # ------------------------------------------------------------------ + + def _ensure_label(self, repo_obj: Any, tier: int) -> None: + """Create the tier label in *repo_obj* if it does not already exist.""" + label_name = TIER_LABELS[tier] + try: + repo_obj.get_label(label_name) + except GithubException: + try: + repo_obj.create_label( + name=label_name, + color=LABEL_COLORS[tier], + description=LABEL_DESCRIPTIONS[tier], + ) + logger.info("[dep-graph] Created label '%s'", label_name) + except GithubException as exc: + logger.warning( + "[dep-graph] Could not create label '%s': %s", label_name, exc + ) + + def apply_labels( + self, + nodes: list[PRNode], + tier_map: dict[str, int], + dag: Any, + ) -> dict[str, Any]: + """Apply tier labels to each PR via the GitHub API. + + In dry-run mode, logs what *would* be applied without making API calls. + + Returns: + Summary dict with ``labels_applied``, ``dry_run_previews``, and + ``errors`` counts. + """ + repo_cache: dict[str, Any] = {} + labels_applied = 0 + dry_run_previews = 0 + errors = 0 + + for node in nodes: + key = f"{node.repo}#{node.pr_id}" + tier = tier_map.get(key, 2) + label_name = TIER_LABELS[tier] + + if self.dry_run: + logger.info( + "[dep-graph] DRY-RUN: would apply '%s' to %s", label_name, key + ) + dry_run_previews += 1 + continue + + try: + if node.repo not in repo_cache: + repo_cache[node.repo] = self._github.get_repo(node.repo) + repo_obj = repo_cache[node.repo] + + self._ensure_label(repo_obj, tier) + + pr_obj = repo_obj.get_pull(node.pr_id) + existing_tier_labels = [ + lbl for lbl in pr_obj.labels if lbl.name in TIER_LABELS.values() + ] + # Remove stale tier labels before applying new one + for lbl in existing_tier_labels: + if lbl.name != label_name: + pr_obj.remove_from_labels(lbl) + pr_obj.add_to_labels(label_name) + logger.info("[dep-graph] Applied '%s' to %s", label_name, key) + labels_applied += 1 + except GithubException as exc: + logger.error("[dep-graph] Failed to label %s: %s", key, exc) + errors += 1 + + return { + "labels_applied": labels_applied, + "dry_run_previews": dry_run_previews, + "errors": errors, + } + + # ------------------------------------------------------------------ + # Orchestration + # ------------------------------------------------------------------ + + def run( + self, + repos: list[dict[str, str]] | None = None, + llm_client: Any = _UNSET, + ) -> dict[str, Any]: + """End-to-end scan → build → label pipeline. + + Args: + repos: Repo list (defaults to :data:`ACTIVE_REPOS`). + llm_client: LLM client for extraction. Pass ``None`` to disable. + When omitted, a client is created automatically if + ``ANTHROPIC_API_KEY`` is set. + + Returns: + Summary dict with DAG stats and label counts. + """ + if llm_client is _UNSET: + llm_client = self._make_llm_client() + + mode = "DRY-RUN" if self.dry_run else "LIVE" + logger.info( + "[dep-graph] Starting (%s, max_llm_calls=%d)", mode, self.max_llm_calls + ) + + nodes = self.scan_repos(repos) + logger.info("[dep-graph] Scanned %d open PRs", len(nodes)) + + dag = self.build_dag(nodes, llm_client=llm_client) + logger.info( + "[dep-graph] DAG: %d nodes, %d edges", + dag.number_of_nodes(), + dag.number_of_edges(), + ) + + tier_map = self.assign_tiers(dag) + tier_counts = { + TIER_LABELS[t]: sum(1 for v in tier_map.values() if v == t) + for t in TIER_LABELS + } + logger.info("[dep-graph] Tier distribution: %s", tier_counts) + + label_summary = self.apply_labels(nodes, tier_map, dag) + + summary: dict[str, Any] = { + "prs_scanned": len(nodes), + "dag_nodes": dag.number_of_nodes(), + "dag_edges": dag.number_of_edges(), + "tier_counts": tier_counts, + "llm_calls_used": self._llm_calls_used, + **label_summary, + } + + if self.dry_run: + print( + f"[dep-graph] DRY-RUN complete — {len(nodes)} PRs scanned, " + f"{dag.number_of_edges()} dependency edges, " + f"{label_summary['dry_run_previews']} labels previewed." + ) + else: + print( + f"[dep-graph] LIVE run complete — {label_summary['labels_applied']} labels applied, " + f"{label_summary['errors']} errors." + ) + + return summary + + +# --------------------------------------------------------------------------- +# DAG cycle-breaking utilities +# --------------------------------------------------------------------------- + + +def _is_dag(g: Any) -> bool: + """Return True if *g* is a directed acyclic graph.""" + import networkx as nx + + return nx.is_directed_acyclic_graph(g) + + +def _break_cycles(g: Any) -> Any: + """Remove the minimum set of back-edges needed to eliminate all cycles.""" + import networkx as nx + + g = g.copy() + for cycle in nx.simple_cycles(g): + if len(cycle) >= 2: + g.remove_edge(cycle[-1], cycle[0]) + logger.warning("[dep-graph] Removed back-edge %s → %s", cycle[-1], cycle[0]) + if _is_dag(g): + break + return g + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + + +def main(argv: list[str] | None = None) -> None: + """CLI entry point for the Dependency Graph Resolver.""" + import argparse + + parser = argparse.ArgumentParser( + description="Dependency Graph Resolver — PR dependency chain analyser" + ) + parser.add_argument( + "--live", + action="store_true", + help="Enable live mode (apply labels). Default is dry-run.", + ) + parser.add_argument( + "--max-llm-calls", + type=int, + default=30, + help="Hard cap on LLM calls per run (default: 30)", + ) + parser.add_argument( + "--no-llm", + action="store_true", + help="Disable LLM extraction (regex pre-pass only)", + ) + args = parser.parse_args(argv) + + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + + engine = DependencyGraph( + dry_run=not args.live, + max_llm_calls=args.max_llm_calls, + ) + llm_client = None if args.no_llm else engine._make_llm_client() + summary = engine.run(llm_client=llm_client) + + print(f"LLM calls used: {summary['llm_calls_used']} / {args.max_llm_calls}") + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt index 6806b5a..bd040b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,3 +16,4 @@ tenacity>=8.2.3 numpy>=1.26.0 scikit-learn>=1.4.0 psutil>=5.9.0 +networkx>=3.0 diff --git a/tests/unit/test_dependency_graph.py b/tests/unit/test_dependency_graph.py new file mode 100644 index 0000000..b06e157 --- /dev/null +++ b/tests/unit/test_dependency_graph.py @@ -0,0 +1,592 @@ +"""Unit tests for autopilot/dependency_graph.py""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_engine(dry_run=True, max_llm_calls=30, token="tok", anthropic_key=None): + from autopilot.dependency_graph import DependencyGraph + + mock_gh = MagicMock() + with ( + patch("autopilot.dependency_graph.Github", return_value=mock_gh), + patch.dict( + "os.environ", + { + "GITHUB_TOKEN": token, + **({"ANTHROPIC_API_KEY": anthropic_key} if anthropic_key else {}), + }, + ), + ): + engine = DependencyGraph( + dry_run=dry_run, + max_llm_calls=max_llm_calls, + ) + engine._github = mock_gh + return engine + + +def _make_pr_node(pr_id=1, repo="owner/repo", title="Test", body="", url="http://x/1"): + from autopilot.dependency_graph import PRNode + + return PRNode(pr_id=pr_id, repo=repo, title=title, body=body, url=url) + + +def _make_mock_pr(number, title="Test PR", body="", html_url="http://x/1"): + pr = MagicMock() + pr.number = number + pr.title = title + pr.body = body + pr.html_url = html_url + return pr + + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + + +class TestDependencyGraphInit: + def test_requires_github_token(self): + from autopilot.dependency_graph import DependencyGraph + + with ( + patch.dict("os.environ", {}, clear=True), + pytest.raises(ValueError, match="GITHUB_TOKEN"), + ): + DependencyGraph() + + def test_dry_run_default(self): + engine = _make_engine() + assert engine.dry_run is True + + def test_live_mode_via_env(self): + from autopilot.dependency_graph import DependencyGraph + + mock_gh = MagicMock() + with ( + patch("autopilot.dependency_graph.Github", return_value=mock_gh), + patch.dict( + "os.environ", {"GITHUB_TOKEN": "tok", "ENABLE_LIVE_MODE": "true"} + ), + ): + engine = DependencyGraph() + assert engine.dry_run is False + + def test_dry_run_explicit_override(self): + engine = _make_engine(dry_run=False) + assert engine.dry_run is False + + def test_max_llm_calls_stored(self): + engine = _make_engine(max_llm_calls=5) + assert engine.max_llm_calls == 5 + + +# --------------------------------------------------------------------------- +# Regex extraction +# --------------------------------------------------------------------------- + + +class TestExtractDepsRegex: + def setup_method(self): + from autopilot.dependency_graph import extract_deps_regex + + self.extract = extract_deps_regex + + def test_empty_text_returns_empty(self): + assert self.extract("") == [] + + def test_none_like_empty_returns_empty(self): + # Body may be None — caller guards, but test empty string edge-case + assert self.extract("No dependencies here") == [] + + def test_depends_on_hash_syntax(self): + result = self.extract("Depends on #42") + assert result == [42] + + def test_depends_on_case_insensitive(self): + result = self.extract("DEPENDS ON #10") + assert 10 in result + + def test_depends_on_hyphen_variant(self): + result = self.extract("depends-on #7") + assert 7 in result + + def test_depends_on_underscore_variant(self): + result = self.extract("depends_on #99") + assert 99 in result + + def test_multiple_deps_extracted(self): + text = "Depends on #1\nDepends on #2\nSome text\nDepends on #3" + result = self.extract(text) + assert result == [1, 2, 3] + + def test_deduplication(self): + result = self.extract("Depends on #5\nDepends on #5") + assert result.count(5) == 1 + + def test_qualified_syntax(self): + result = self.extract("Depends on owner/repo#55") + assert 55 in result + + def test_unrelated_hash_not_extracted(self): + result = self.extract("Closes #100\nFixes #200") + assert result == [] + + def test_mixed_content(self): + text = "## Description\nThis fixes a bug.\n\nDepends on #12\n\nCloses #50" + result = self.extract(text) + assert result == [12] + assert 50 not in result + + +# --------------------------------------------------------------------------- +# LLM extraction +# --------------------------------------------------------------------------- + + +class TestExtractDepsLlm: + def _make_llm(self, response_json: dict) -> MagicMock: + llm = MagicMock() + llm.generate = AsyncMock(return_value={"content": json.dumps(response_json)}) + return llm + + def test_happy_path(self): + from autopilot.dependency_graph import extract_deps_llm + + node = _make_pr_node(pr_id=10, repo="o/r") + llm = self._make_llm( + {"pr_id": 10, "repo": "o/r", "depends_on": [5, 7], "confidence_score": 0.9} + ) + result = extract_deps_llm(node, llm) + assert result.depends_on == [5, 7] + assert result.confidence_score == pytest.approx(0.9) + + def test_no_dependencies(self): + from autopilot.dependency_graph import extract_deps_llm + + node = _make_pr_node(pr_id=3, repo="o/r") + llm = self._make_llm( + {"pr_id": 3, "repo": "o/r", "depends_on": [], "confidence_score": 0.8} + ) + result = extract_deps_llm(node, llm) + assert result.depends_on == [] + + def test_strips_markdown_fences(self): + from autopilot.dependency_graph import extract_deps_llm + + node = _make_pr_node(pr_id=1, repo="o/r") + raw = '```json\n{"pr_id": 1, "repo": "o/r", "depends_on": [2], "confidence_score": 0.7}\n```' + llm = MagicMock() + llm.generate = AsyncMock(return_value={"content": raw}) + result = extract_deps_llm(node, llm) + assert result.depends_on == [2] + + def test_llm_exception_returns_empty(self): + from autopilot.dependency_graph import extract_deps_llm + + node = _make_pr_node(pr_id=1, repo="o/r") + llm = MagicMock() + llm.generate = AsyncMock(side_effect=RuntimeError("boom")) + result = extract_deps_llm(node, llm) + assert result.depends_on == [] + assert result.confidence_score == 0.0 + + def test_invalid_json_returns_empty(self): + from autopilot.dependency_graph import extract_deps_llm + + node = _make_pr_node(pr_id=1, repo="o/r") + llm = MagicMock() + llm.generate = AsyncMock(return_value={"content": "not-json"}) + result = extract_deps_llm(node, llm) + assert result.depends_on == [] + + +# --------------------------------------------------------------------------- +# DAG building +# --------------------------------------------------------------------------- + + +class TestBuildDag: + def setup_method(self): + self.engine = _make_engine() + + def _nodes(self, specs): + """Build PRNode list from (pr_id, repo, body) tuples.""" + return [ + _make_pr_node(pr_id=s[0], repo=s[1], body=s[2] if len(s) > 2 else "") + for s in specs + ] + + def test_empty_nodes_returns_empty_dag(self): + dag = self.engine.build_dag([], llm_client=None) + assert dag.number_of_nodes() == 0 + assert dag.number_of_edges() == 0 + + def test_single_node_no_deps(self): + nodes = self._nodes([(1, "o/r")]) + dag = self.engine.build_dag(nodes, llm_client=None) + assert dag.number_of_nodes() == 1 + assert dag.number_of_edges() == 0 + + def test_explicit_dep_creates_edge(self): + nodes = self._nodes( + [ + (1, "o/r", "Depends on #2"), + (2, "o/r", ""), + ] + ) + dag = self.engine.build_dag(nodes, llm_client=None) + assert dag.has_edge("o/r#1", "o/r#2") + + def test_dep_to_unknown_pr_ignored(self): + nodes = self._nodes([(1, "o/r", "Depends on #999")]) + dag = self.engine.build_dag(nodes, llm_client=None) + assert dag.number_of_edges() == 0 + + def test_chain_a_depends_on_b_depends_on_c(self): + nodes = self._nodes( + [ + (1, "o/r", "Depends on #2"), + (2, "o/r", "Depends on #3"), + (3, "o/r", ""), + ] + ) + dag = self.engine.build_dag(nodes, llm_client=None) + assert dag.has_edge("o/r#1", "o/r#2") + assert dag.has_edge("o/r#2", "o/r#3") + + def test_node_attrs_populated(self): + nodes = [_make_pr_node(pr_id=7, repo="a/b", title="My PR", url="http://x/7")] + dag = self.engine.build_dag(nodes, llm_client=None) + attrs = dag.nodes["a/b#7"] + assert attrs["pr_id"] == 7 + assert attrs["repo"] == "a/b" + assert attrs["title"] == "My PR" + assert attrs["url"] == "http://x/7" + + def test_llm_called_when_no_regex_match(self): + from autopilot.dependency_graph import extract_deps_llm + + nodes = self._nodes([(1, "o/r", "No explicit dep"), (2, "o/r", "")]) + llm = MagicMock() + llm.generate = AsyncMock( + return_value={ + "content": json.dumps( + { + "pr_id": 1, + "repo": "o/r", + "depends_on": [2], + "confidence_score": 0.9, + } + ) + } + ) + dag = self.engine.build_dag(nodes, llm_client=llm) + assert dag.has_edge("o/r#1", "o/r#2") + + def test_llm_not_called_when_regex_matches(self): + # Node 1 has an explicit "Depends on #2" — regex resolves this without LLM. + # Build the DAG with an LLM client that fails loudly if called for node 1. + llm = MagicMock() + llm.generate = AsyncMock(return_value={"content": "{}"}) + nodes = self._nodes([(1, "o/r", "Depends on #2"), (2, "o/r", "")]) + dag = self.engine.build_dag(nodes, llm_client=llm) + # The edge must exist even though LLM returned no dependencies for node 2 + assert dag.has_edge("o/r#1", "o/r#2") + # LLM was NOT called for node 1 (the one with the regex match) + for call_args in llm.generate.call_args_list: + assert "PR #1 in repo o/r" not in call_args[0][0] + + def test_llm_budget_respected(self): + self.engine.max_llm_calls = 1 + nodes = self._nodes( + [ + (1, "o/r", "No dep"), + (2, "o/r", "No dep"), + (3, "o/r", "No dep"), + ] + ) + llm = MagicMock() + llm.generate = AsyncMock( + return_value={ + "content": json.dumps( + { + "pr_id": 1, + "repo": "o/r", + "depends_on": [], + "confidence_score": 0.8, + } + ) + } + ) + self.engine.build_dag(nodes, llm_client=llm) + assert llm.generate.call_count == 1 + + +# --------------------------------------------------------------------------- +# Tier assignment +# --------------------------------------------------------------------------- + + +class TestAssignTiers: + def _build_simple_dag(self, edges: list[tuple[str, str]]): + import networkx as nx + + g: nx.DiGraph = nx.DiGraph() + nodes = set() + for a, b in edges: + nodes.add(a) + nodes.add(b) + g.add_nodes_from(nodes) + g.add_edges_from(edges) + return g + + def test_leaf_no_edges(self): + import networkx as nx + + engine = _make_engine() + g: nx.DiGraph = nx.DiGraph() + g.add_node("x") + tiers = engine.assign_tiers(g) + assert tiers["x"] == 2 + + def test_blocker_has_incoming_edge(self): + engine = _make_engine() + g = self._build_simple_dag([("A", "B")]) + tiers = engine.assign_tiers(g) + assert tiers["B"] == 0 + + def test_dependent_has_outgoing_edge_only(self): + engine = _make_engine() + g = self._build_simple_dag([("A", "B")]) + tiers = engine.assign_tiers(g) + assert tiers["A"] == 1 + + def test_middle_node_is_blocker(self): + engine = _make_engine() + # A → B → C: A depends on B, B depends on C + g = self._build_simple_dag([("A", "B"), ("B", "C")]) + tiers = engine.assign_tiers(g) + # B has in-degree=1 (A depends on B) → tier-0-blocker + assert tiers["B"] == 0 + # A depends on B, no one depends on A → tier-1-dependent + assert tiers["A"] == 1 + # C has in-degree=1 (B depends on C) → tier-0-blocker + assert tiers["C"] == 0 + + +# --------------------------------------------------------------------------- +# apply_labels (dry-run) +# --------------------------------------------------------------------------- + + +class TestApplyLabelsDryRun: + def test_dry_run_no_api_calls(self): + from autopilot.dependency_graph import TIER_LABELS + + engine = _make_engine(dry_run=True) + nodes = [_make_pr_node(pr_id=1, repo="o/r")] + tier_map = {"o/r#1": 2} + + import networkx as nx + + dag: nx.DiGraph = nx.DiGraph() + dag.add_node("o/r#1") + + result = engine.apply_labels(nodes, tier_map, dag) + assert result["labels_applied"] == 0 + assert result["dry_run_previews"] == 1 + assert result["errors"] == 0 + engine._github.get_repo.assert_not_called() + + def test_dry_run_multiple_nodes(self): + engine = _make_engine(dry_run=True) + node_ids = [1, 2, 3] + nodes = [_make_pr_node(pr_id=i, repo="o/r") for i in node_ids] + tier_map = {"o/r#1": 0, "o/r#2": 1, "o/r#3": 2} + + import networkx as nx + + dag: nx.DiGraph = nx.DiGraph() + for n in nodes: + dag.add_node(f"o/r#{n.pr_id}") + + result = engine.apply_labels(nodes, tier_map, dag) + assert result["dry_run_previews"] == 3 + + +# --------------------------------------------------------------------------- +# apply_labels (live mode) +# --------------------------------------------------------------------------- + + +class TestApplyLabelsLive: + def _make_live_engine(self): + return _make_engine(dry_run=False) + + def test_label_applied_to_pr(self): + from autopilot.dependency_graph import TIER_LABELS + from github import GithubException + + engine = self._make_live_engine() + mock_repo = MagicMock() + mock_repo.get_label.side_effect = GithubException(404, "not found") + mock_repo.create_label.return_value = MagicMock() + mock_pr = MagicMock() + mock_pr.labels = [] + mock_repo.get_pull.return_value = mock_pr + engine._github.get_repo.return_value = mock_repo + + nodes = [_make_pr_node(pr_id=1, repo="o/r")] + tier_map = {"o/r#1": 0} + + import networkx as nx + + dag: nx.DiGraph = nx.DiGraph() + dag.add_node("o/r#1") + + result = engine.apply_labels(nodes, tier_map, dag) + assert result["labels_applied"] == 1 + assert result["errors"] == 0 + mock_pr.add_to_labels.assert_called_once_with(TIER_LABELS[0]) + + def test_stale_tier_label_removed(self): + from autopilot.dependency_graph import TIER_LABELS + + engine = self._make_live_engine() + mock_repo = MagicMock() + mock_repo.get_label.return_value = MagicMock(name=TIER_LABELS[0]) + + stale_label = MagicMock() + stale_label.name = TIER_LABELS[1] + mock_pr = MagicMock() + mock_pr.labels = [stale_label] + mock_repo.get_pull.return_value = mock_pr + engine._github.get_repo.return_value = mock_repo + + nodes = [_make_pr_node(pr_id=1, repo="o/r")] + tier_map = {"o/r#1": 0} + + import networkx as nx + + dag: nx.DiGraph = nx.DiGraph() + dag.add_node("o/r#1") + + engine.apply_labels(nodes, tier_map, dag) + mock_pr.remove_from_labels.assert_called_once_with(stale_label) + + def test_github_exception_increments_errors(self): + from github import GithubException + + engine = self._make_live_engine() + engine._github.get_repo.side_effect = GithubException(500, "server error") + + nodes = [_make_pr_node(pr_id=1, repo="o/r")] + tier_map = {"o/r#1": 2} + + import networkx as nx + + dag: nx.DiGraph = nx.DiGraph() + dag.add_node("o/r#1") + + result = engine.apply_labels(nodes, tier_map, dag) + assert result["errors"] == 1 + assert result["labels_applied"] == 0 + + +# --------------------------------------------------------------------------- +# run() orchestration +# --------------------------------------------------------------------------- + + +class TestRun: + def test_run_dry_returns_summary_keys(self): + engine = _make_engine(dry_run=True) + mock_repo = MagicMock() + mock_repo.get_pulls.return_value = [] + engine._github.get_repo.return_value = mock_repo + + summary = engine.run(llm_client=None) + assert "prs_scanned" in summary + assert "dag_nodes" in summary + assert "dag_edges" in summary + assert "tier_counts" in summary + assert "llm_calls_used" in summary + + def test_run_counts_match(self): + from autopilot.dependency_graph import ACTIVE_REPOS + + engine = _make_engine(dry_run=True) + prs = [_make_mock_pr(i) for i in range(1, 4)] + mock_repo = MagicMock() + mock_repo.get_pulls.return_value = prs + engine._github.get_repo.return_value = mock_repo + + summary = engine.run(repos=[{"owner": "o", "name": "r"}], llm_client=None) + assert summary["prs_scanned"] == 3 + assert summary["dag_nodes"] == 3 + + def test_run_creates_llm_client_when_anthropic_key_set(self): + engine = _make_engine(anthropic_key="sk-ant-test") + mock_repo = MagicMock() + mock_repo.get_pulls.return_value = [] + engine._github.get_repo.return_value = mock_repo + + with patch.object(engine, "_make_llm_client", return_value=None) as mock_make: + engine.run(repos=[{"owner": "o", "name": "r"}]) + mock_make.assert_called_once() + + def test_run_uses_explicit_none_llm(self): + engine = _make_engine() + mock_repo = MagicMock() + mock_repo.get_pulls.return_value = [_make_mock_pr(1)] + engine._github.get_repo.return_value = mock_repo + + with patch.object(engine, "_make_llm_client") as mock_make: + engine.run(repos=[{"owner": "o", "name": "r"}], llm_client=None) + mock_make.assert_not_called() + + +# --------------------------------------------------------------------------- +# Cycle breaking +# --------------------------------------------------------------------------- + + +class TestCycleBreaking: + def test_is_dag_true_for_acyclic(self): + import networkx as nx + + from autopilot.dependency_graph import _is_dag + + g: nx.DiGraph = nx.DiGraph() + g.add_edges_from([("A", "B"), ("B", "C")]) + assert _is_dag(g) is True + + def test_is_dag_false_for_cycle(self): + import networkx as nx + + from autopilot.dependency_graph import _is_dag + + g: nx.DiGraph = nx.DiGraph() + g.add_edges_from([("A", "B"), ("B", "A")]) + assert _is_dag(g) is False + + def test_break_cycles_removes_back_edge(self): + import networkx as nx + + from autopilot.dependency_graph import _break_cycles, _is_dag + + g: nx.DiGraph = nx.DiGraph() + g.add_edges_from([("A", "B"), ("B", "A")]) + result = _break_cycles(g) + assert _is_dag(result) is True