Files
Gitea-Tools/tests/test_issue_784_dependency_edges.py
T
sysadminandClaude Opus 4.8 0589ec8069 feat(control-plane): persist allocator dependency edges as durable state (Closes #784)
Umbrella #628 scope item 6 requires dependencies to be durable structured
state carrying source, target, type, blocking condition, completion
condition, current state, and evidence. Nothing stored any of that.

Dependency knowledge existed only as a per-run computation:
allocator_dependencies re-parsed the Depends: declaration out of every
issue body on every allocation, _allocator_candidates_from_gitea resolved
each reference against live issue state, and the result collapsed into two
in-memory WorkCandidate fields that classify_skip consumed and discarded.
Three consequences followed. Nothing could answer "what is waiting on #N"
without re-listing every open issue and re-parsing every body, so the
reverse edge automatic resumption needs did not exist in any form. Only
issue-blocked-by-issue was expressible, leaving the other six #628
relationships with nowhere to live. And no observation was recorded, so a
transient lookup failure and a real block were indistinguishable after the
fact.

Add the store:

- dependency_edges table under schema v4. Creating the table is itself the
  v3 to v4 migration: additive, idempotent, and it never touches the
  existing tables. Uniqueness is (scope, source, target, edge_type), so
  re-observation updates one row rather than appending duplicates.
- dependency_graph.py owns the vocabulary: the seven #628 relationship
  types, the three states, and fail-closed normalization for both plus
  endpoint kinds. An unrecognized value writes nothing rather than landing
  as unqueryable free text. Evidence is sanitized before storage, so no
  credential or endpoint URL can be persisted or read back.
- upsert_dependency_edge, list_dependency_edges, and
  record_dependency_edge_observation on ControlPlaneDB. Filtering by target
  makes reverse lookup a single query. State transitions append to the
  existing events table rather than a parallel audit table.
- The allocator persists what it already resolved. States map one-to-one
  from the resolver's met/unmet/unavailable partitions, so nothing is
  re-classified and unavailable evidence is never recorded as met.
- gitea_list_dependency_edges exposes stored edges read-only, gated on
  gitea.read, and is added to the documented inventory the #781 drift guard
  checks.

Selection is deliberately untouched: classify_skip still consumes the
in-memory dependency_unmet field. The write is best-effort and reports
failures through reasons, so a broken or absent store leaves allocation
behaving exactly as it did before — proven by allocating the same candidate
set through a store whose writes all raise and comparing the selection,
skip set, and candidate count.

Automatic blocking and resumption (#628 item 7), non-issue edge creation,
and defect auto-linking are later slices; this one only makes the graph
durable and queryable.

Tests: 31 new cases covering fresh-schema creation, a real v3-to-v4
migration with row retention, idempotent re-migration, enum rejection,
upsert idempotence, forward and reverse lookup, scope isolation, transition
events, redaction at rest, live allocation-run ingestion, write-failure
tolerance, and the tool's permission gate.

Verification: 4126 passed in the branch worktree. The 11 failures in
test_commit_payloads, test_issue_702_review_findings_f1_f6, test_mcp_server,
test_post_merge_moot_lease, and test_reconciler_supersession_close reproduce
identically on a clean detached checkout of master at 300e8acd, so they are
pre-existing and proven by baseline run, not introduced here.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-21 18:07:21 -04:00

723 lines
27 KiB
Python

"""Tests for durable dependency edges (#784, umbrella #628 scope item 6)."""
from __future__ import annotations
import json
import os
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
import allocator_dependencies
import dependency_graph
import gitea_mcp_server as srv
import mcp_tool_inventory
from control_plane_db import SCHEMA_VERSION, ControlPlaneDB, ControlPlaneError
ISSUE = dependency_graph.WORK_KIND_ISSUE
PR = dependency_graph.WORK_KIND_PR
EDGE_BLOCKED = dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE
# Schema as it stood before this change, used to prove a real v3 → v4 migration
# rather than a fresh-database creation dressed up as one.
_V3_SCHEMA = """
CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE sessions (
session_id TEXT PRIMARY KEY,
role TEXT NOT NULL,
profile TEXT,
namespace TEXT,
pid INTEGER,
started_at TEXT NOT NULL,
last_heartbeat_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
);
CREATE TABLE work_items (
work_item_id INTEGER PRIMARY KEY AUTOINCREMENT,
remote TEXT NOT NULL,
org TEXT NOT NULL,
repo TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('issue', 'pr')),
number INTEGER NOT NULL,
state TEXT NOT NULL DEFAULT 'open',
priority INTEGER NOT NULL DEFAULT 0,
current_head_sha TEXT,
updated_at TEXT NOT NULL,
UNIQUE (remote, org, repo, kind, number)
);
CREATE TABLE leases (
lease_id TEXT PRIMARY KEY,
work_item_id INTEGER NOT NULL REFERENCES work_items(work_item_id),
session_id TEXT NOT NULL REFERENCES sessions(session_id),
role TEXT NOT NULL,
phase TEXT NOT NULL DEFAULT 'claimed',
expires_at TEXT NOT NULL,
heartbeat_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
);
CREATE TABLE assignments (
assignment_id TEXT PRIMARY KEY,
work_item_id INTEGER NOT NULL REFERENCES work_items(work_item_id),
session_id TEXT NOT NULL REFERENCES sessions(session_id),
lease_id TEXT NOT NULL REFERENCES leases(lease_id),
allowed_actions TEXT NOT NULL,
forbidden_actions TEXT NOT NULL,
expected_head_sha TEXT,
role TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL
);
CREATE TABLE terminal_locks (
terminal_lock_id INTEGER PRIMARY KEY AUTOINCREMENT,
remote TEXT NOT NULL,
org TEXT NOT NULL,
repo TEXT NOT NULL,
terminal_pr INTEGER NOT NULL,
review_id TEXT,
decision TEXT,
status TEXT NOT NULL DEFAULT 'active',
cleanup_state TEXT,
created_at TEXT NOT NULL,
UNIQUE (remote, org, repo, terminal_pr)
);
CREATE TABLE events (
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
work_item_id INTEGER REFERENCES work_items(work_item_id),
event_type TEXT NOT NULL,
message TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE incident_links (
link_id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
provider_base_url TEXT NOT NULL DEFAULT '',
provider_org TEXT NOT NULL DEFAULT '',
provider_project TEXT NOT NULL DEFAULT '',
provider_issue_id TEXT NOT NULL,
provider_short_id TEXT,
provider_permalink TEXT,
fingerprint TEXT,
gitea_org TEXT NOT NULL,
gitea_repo TEXT NOT NULL,
gitea_issue_number INTEGER NOT NULL,
linked_pr_numbers TEXT,
first_seen TEXT,
last_seen TEXT,
event_count INTEGER,
status TEXT NOT NULL DEFAULT 'open',
release_resolved_at TEXT,
last_sync_at TEXT,
UNIQUE (provider, provider_base_url, provider_org, provider_project,
provider_issue_id)
);
"""
def _edge_kwargs(**overrides):
base = {
"remote": "prgs",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
"source_kind": ISSUE,
"source_number": 784,
"target_kind": ISSUE,
"target_number": 628,
"edge_type": EDGE_BLOCKED,
"state": dependency_graph.STATE_UNMET,
}
base.update(overrides)
return base
class VocabularyTest(unittest.TestCase):
"""AC4, AC5: the edge vocabulary is complete and fails closed."""
def test_all_seven_umbrella_relationship_types_exist(self) -> None:
self.assertEqual(len(dependency_graph.EDGE_TYPES), 7)
for edge_type in (
dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE,
dependency_graph.EDGE_PR_WAITING_FOR_REQUESTED_CHANGES,
dependency_graph.EDGE_MERGE_WAITING_FOR_APPROVAL,
dependency_graph.EDGE_RECONCILIATION_WAITING_FOR_MERGE,
dependency_graph.EDGE_DEPLOYMENT_WAITING_FOR_INFRASTRUCTURE,
dependency_graph.EDGE_ACCEPTANCE_WAITING_FOR_VALIDATION,
dependency_graph.EDGE_TASK_WAITING_FOR_DEFECT_FIX,
):
self.assertIn(edge_type, dependency_graph.EDGE_TYPES)
blocking, completion = dependency_graph.default_conditions(edge_type)
self.assertTrue(blocking and completion)
def test_states_match_the_resolver_partitions(self) -> None:
self.assertEqual(
dependency_graph.EDGE_STATES,
frozenset({"unmet", "met", "unavailable"}),
)
def test_unknown_edge_type_is_rejected(self) -> None:
with self.assertRaises(dependency_graph.InvalidEdgeTypeError):
dependency_graph.normalize_edge_type("waits_for_vibes")
def test_unknown_state_is_rejected(self) -> None:
with self.assertRaises(dependency_graph.InvalidEdgeStateError):
dependency_graph.normalize_edge_state("probably_fine")
def test_non_work_endpoint_kind_is_rejected(self) -> None:
with self.assertRaises(dependency_graph.InvalidEdgeEndpointError):
dependency_graph.normalize_work_kind("incident")
def test_evidence_sanitization_strips_credentials_and_urls(self) -> None:
clean = dependency_graph.sanitize_evidence(
{
"token": "abc123",
"authorization": "Bearer xyz",
"note": "fetched from https://gitea.example.invalid/api/v1/x",
"nested": [{"api_key": "k"}, "plain"],
"observed_state": "closed",
}
)
self.assertEqual(clean["token"], dependency_graph.REDACTED)
self.assertEqual(clean["authorization"], dependency_graph.REDACTED)
self.assertNotIn("https://", clean["note"])
self.assertEqual(clean["nested"][0]["api_key"], dependency_graph.REDACTED)
self.assertEqual(clean["observed_state"], "closed")
class SchemaTest(unittest.TestCase):
"""AC1-AC3: schema creation, migration, and idempotence."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self._tmp.name, "cp.sqlite3")
def tearDown(self) -> None:
self._tmp.cleanup()
def _tables(self) -> set[str]:
conn = sqlite3.connect(self.db_path)
try:
return {
row[0]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
).fetchall()
}
finally:
conn.close()
def _schema_version(self) -> str:
conn = sqlite3.connect(self.db_path)
try:
row = conn.execute(
"SELECT value FROM schema_meta WHERE key = 'schema_version'"
).fetchone()
finally:
conn.close()
return str(row[0]) if row else ""
def test_fresh_database_is_v4_with_the_edge_table(self) -> None:
ControlPlaneDB(self.db_path)
self.assertEqual(SCHEMA_VERSION, 4)
self.assertEqual(self._schema_version(), "4")
self.assertIn("dependency_edges", self._tables())
def _seed_v3(self) -> None:
conn = sqlite3.connect(self.db_path)
try:
conn.executescript(_V3_SCHEMA)
conn.execute(
"INSERT INTO schema_meta(key, value) VALUES ('schema_version', '3')"
)
conn.execute(
"""
INSERT INTO work_items(
remote, org, repo, kind, number, state, priority, updated_at
) VALUES ('prgs', 'O', 'R', 'issue', 601, 'open', 20,
'2026-07-01T00:00:00Z')
"""
)
conn.execute(
"""
INSERT INTO sessions(session_id, role, started_at, last_heartbeat_at)
VALUES ('legacy-session', 'author', '2026-07-01T00:00:00Z',
'2026-07-01T00:00:00Z')
"""
)
conn.execute(
"""
INSERT INTO events(work_item_id, event_type, message, created_at)
VALUES (1, 'legacy', 'kept', '2026-07-01T00:00:00Z')
"""
)
conn.commit()
finally:
conn.close()
def test_v3_database_migrates_in_place_without_losing_rows(self) -> None:
self._seed_v3()
self.assertNotIn("dependency_edges", self._tables())
ControlPlaneDB(self.db_path)
self.assertEqual(self._schema_version(), "4")
self.assertIn("dependency_edges", self._tables())
conn = sqlite3.connect(self.db_path)
try:
self.assertEqual(
conn.execute("SELECT COUNT(*) FROM work_items").fetchone()[0], 1
)
self.assertEqual(
conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0], 1
)
self.assertEqual(
conn.execute(
"SELECT message FROM events WHERE event_type = 'legacy'"
).fetchone()[0],
"kept",
)
for table in ("leases", "assignments", "terminal_locks", "incident_links"):
conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()
finally:
conn.close()
def test_migration_is_idempotent(self) -> None:
self._seed_v3()
ControlPlaneDB(self.db_path)
db = ControlPlaneDB(self.db_path) # second open re-runs the migration
ControlPlaneDB(self.db_path)
self.assertEqual(self._schema_version(), "4")
conn = sqlite3.connect(self.db_path)
try:
tables = conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name = 'dependency_edges'"
).fetchall()
finally:
conn.close()
self.assertEqual(len(tables), 1)
self.assertEqual(db.list_dependency_edges(), [])
class EdgePersistenceTest(unittest.TestCase):
"""AC5-AC10: storage, uniqueness, lookup, scope, audit, redaction."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
def tearDown(self) -> None:
self._tmp.cleanup()
def _events(self) -> list[tuple[str, str]]:
conn = sqlite3.connect(self.db.db_path)
try:
return [
(str(row[0]), str(row[1]))
for row in conn.execute(
"SELECT event_type, message FROM events"
).fetchall()
]
finally:
conn.close()
def test_invalid_values_write_nothing(self) -> None:
with self.assertRaises(dependency_graph.InvalidEdgeTypeError):
self.db.upsert_dependency_edge(**_edge_kwargs(edge_type="nonsense"))
with self.assertRaises(dependency_graph.InvalidEdgeStateError):
self.db.upsert_dependency_edge(**_edge_kwargs(state="maybe"))
with self.assertRaises(dependency_graph.InvalidEdgeEndpointError):
self.db.upsert_dependency_edge(**_edge_kwargs(target_kind="incident"))
self.assertEqual(self.db.list_dependency_edges(), [])
def test_stored_edge_carries_the_full_contract(self) -> None:
edge = self.db.upsert_dependency_edge(
**_edge_kwargs(evidence={"observed_state": "not_closed"})
)
self.assertEqual(edge["source_number"], 784)
self.assertEqual(edge["target_number"], 628)
self.assertEqual(edge["edge_type"], EDGE_BLOCKED)
self.assertEqual(edge["state"], "unmet")
self.assertEqual(edge["blocking_condition"], "target issue is not closed")
self.assertEqual(edge["completion_condition"], "target issue is closed")
self.assertEqual(edge["evidence"], {"observed_state": "not_closed"})
self.assertTrue(edge["created_at"])
self.assertTrue(edge["last_observed_at"])
def test_repeated_upsert_updates_one_row(self) -> None:
first = self.db.upsert_dependency_edge(**_edge_kwargs())
second = self.db.upsert_dependency_edge(
**_edge_kwargs(state="met", evidence={"observed_state": "closed"})
)
self.assertEqual(first["edge_id"], second["edge_id"])
edges = self.db.list_dependency_edges()
self.assertEqual(len(edges), 1)
self.assertEqual(edges[0]["state"], "met")
self.assertEqual(edges[0]["evidence"], {"observed_state": "closed"})
def test_upsert_state_change_is_audited(self) -> None:
self.db.upsert_dependency_edge(**_edge_kwargs())
self.db.upsert_dependency_edge(**_edge_kwargs()) # unchanged: no event
self.assertEqual(self._events(), [])
self.db.upsert_dependency_edge(**_edge_kwargs(state="met"))
events = self._events()
self.assertEqual(len(events), 1)
self.assertEqual(events[0][0], "dependency_edge_state_change")
self.assertIn("unmet -> met", events[0][1])
def test_reverse_lookup_finds_every_waiter(self) -> None:
self.db.upsert_dependency_edge(**_edge_kwargs(source_number=784))
self.db.upsert_dependency_edge(**_edge_kwargs(source_number=790))
self.db.upsert_dependency_edge(
**_edge_kwargs(
source_kind=PR,
source_number=791,
edge_type=dependency_graph.EDGE_TASK_WAITING_FOR_DEFECT_FIX,
)
)
self.db.upsert_dependency_edge(
**_edge_kwargs(source_number=792, target_number=999)
)
waiters = self.db.list_dependency_edges(target_kind=ISSUE, target_number=628)
self.assertEqual(
sorted(edge["source_number"] for edge in waiters), [784, 790, 791]
)
def test_forward_lookup_and_state_filter(self) -> None:
self.db.upsert_dependency_edge(**_edge_kwargs(target_number=628))
self.db.upsert_dependency_edge(**_edge_kwargs(target_number=603, state="met"))
blockers = self.db.list_dependency_edges(source_number=784, state="unmet")
self.assertEqual([edge["target_number"] for edge in blockers], [628])
def test_scope_isolation(self) -> None:
self.db.upsert_dependency_edge(**_edge_kwargs())
self.db.upsert_dependency_edge(**_edge_kwargs(repo="Other-Repo"))
self.assertEqual(
len(self.db.list_dependency_edges(remote="prgs", repo="Gitea-Tools")), 1
)
self.assertEqual(
len(self.db.list_dependency_edges(remote="prgs", repo="Other-Repo")), 1
)
self.assertEqual(len(self.db.list_dependency_edges(remote="dadeschools")), 0)
def test_observation_records_transition_with_prior_state(self) -> None:
edge = self.db.upsert_dependency_edge(**_edge_kwargs())
updated = self.db.record_dependency_edge_observation(
edge["edge_id"],
state="met",
evidence={"observed_state": "closed"},
detail="target closed by merge",
)
self.assertEqual(updated["prior_state"], "unmet")
self.assertEqual(updated["state"], "met")
self.assertTrue(updated["state_changed"])
events = self._events()
self.assertEqual(len(events), 1)
self.assertIn("unmet -> met", events[0][1])
self.assertIn("target closed by merge", events[0][1])
def test_observation_on_unknown_edge_fails_closed(self) -> None:
with self.assertRaises(ControlPlaneError):
self.db.record_dependency_edge_observation("no-such-edge", state="met")
def test_evidence_never_persists_a_credential_or_endpoint(self) -> None:
self.db.upsert_dependency_edge(
**_edge_kwargs(
evidence={
"token": "super-secret",
"source": "GET https://gitea.example.invalid/api/v1/issues/628",
}
)
)
conn = sqlite3.connect(self.db.db_path)
try:
raw = conn.execute("SELECT evidence FROM dependency_edges").fetchone()[0]
finally:
conn.close()
self.assertNotIn("super-secret", raw)
self.assertNotIn("https://", raw)
stored = json.loads(raw)
self.assertEqual(stored["token"], dependency_graph.REDACTED)
self.assertEqual(
self.db.list_dependency_edges()[0]["evidence"]["token"],
dependency_graph.REDACTED,
)
class ResolutionIngestionTest(unittest.TestCase):
"""AC11, AC12: allocation-run ingestion and write-failure tolerance."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
def tearDown(self) -> None:
self._tmp.cleanup()
def _resolution(self):
# Same call the allocator makes: parse the body, resolve live state.
body = "* Parent: #628 · Depends: #601, #603, #999 · Related: #613"
refs = allocator_dependencies.parse_dependency_refs(body)
live = {601: "closed", 603: "open", 999: None}
return allocator_dependencies.resolve_dependency_state(
refs, lambda n: live[n], subject="issue#784"
)
def test_one_edge_per_reference_with_matching_state(self) -> None:
resolution = self._resolution()
reasons = dependency_graph.record_issue_dependency_edges(
self.db,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
source_number=784,
resolution=resolution,
observed_by="prgs-author-1234-abcd",
)
self.assertEqual(reasons, [])
edges = {
edge["target_number"]: edge
for edge in self.db.list_dependency_edges(source_number=784)
}
self.assertEqual(sorted(edges), [601, 603, 999])
self.assertEqual(edges[601]["state"], "met")
self.assertEqual(edges[603]["state"], "unmet")
self.assertEqual(edges[999]["state"], "unavailable")
self.assertEqual(edges[999]["evidence"]["observed_state"], "unavailable")
self.assertEqual(
edges[603]["evidence"]["observed_by_session"], "prgs-author-1234-abcd"
)
self.assertEqual(edges[601]["edge_type"], EDGE_BLOCKED)
def test_unavailable_evidence_is_never_recorded_as_met(self) -> None:
resolution = self._resolution()
dependency_graph.record_issue_dependency_edges(
self.db,
remote="prgs",
org="O",
repo="R",
source_number=784,
resolution=resolution,
)
met = self.db.list_dependency_edges(state="met")
self.assertEqual([edge["target_number"] for edge in met], [601])
def test_store_write_failure_is_reported_not_raised(self) -> None:
class BrokenStore:
def upsert_dependency_edge(self, **_kwargs):
raise RuntimeError("disk is on fire")
reasons = dependency_graph.record_issue_dependency_edges(
BrokenStore(),
remote="prgs",
org="O",
repo="R",
source_number=784,
resolution=self._resolution(),
)
self.assertEqual(len(reasons), 3)
self.assertTrue(all("disk is on fire" in reason for reason in reasons))
def test_no_declared_dependencies_writes_nothing(self) -> None:
resolution = allocator_dependencies.resolve_dependency_state(
(), lambda n: "closed", subject="issue#784"
)
reasons = dependency_graph.record_issue_dependency_edges(
self.db,
remote="prgs",
org="O",
repo="R",
source_number=784,
resolution=resolution,
)
self.assertEqual(reasons, [])
self.assertEqual(self.db.list_dependency_edges(), [])
class AllocationRunIngestionTest(unittest.TestCase):
"""AC11, AC12, AC14: the live allocator path writes edges without changing
what it selects."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
def tearDown(self) -> None:
self._tmp.cleanup()
@staticmethod
def _issue(number: int, *, body: str = "") -> dict:
return {
"number": number,
"title": f"issue {number}",
"body": body,
"labels": [{"name": "status:ready"}],
"state": "open",
}
def _fake_gitea(self, issues, *, closed=()):
closed_set = set(closed)
def api_get_all(url, _auth, **_kw):
if "/pulls" in url:
return []
return list(issues)
def api_request(_method, url, _auth, **_kw):
number = int(url.rsplit("/", 1)[-1])
state = "closed" if number in closed_set else "open"
return {"number": number, "state": state}
return api_get_all, api_request
def _allocate(self, issues, *, closed=(), db, **kwargs):
api_get_all, api_request = self._fake_gitea(issues, closed=closed)
with patch(
"gitea_mcp_server._profile_operation_gate", return_value=None
), patch(
"gitea_mcp_server._resolve", return_value=("h", "O", "R")
), patch(
"gitea_mcp_server._auth", return_value="token REDACTED"
), patch(
"gitea_mcp_server.get_profile",
return_value={"profile_name": "prgs-author", "role": "author"},
), patch(
"gitea_mcp_server._authenticated_username", return_value="jcwalker3"
), patch(
"gitea_mcp_server._control_plane_db_or_error", return_value=(db, [])
), patch(
"gitea_mcp_server.api_get_all", side_effect=api_get_all
), patch(
"gitea_mcp_server.api_request", side_effect=api_request
), patch(
"gitea_mcp_server.sentry_observability.monitor_checkin", return_value=None
):
return srv.gitea_allocate_next_work(
remote="prgs", org="O", repo="R", role="author", **kwargs
)
def test_live_run_persists_one_edge_per_declared_reference(self) -> None:
issues = [
self._issue(600, body="* Parent: #900 · Depends: #601, #500"),
self._issue(601),
self._issue(602),
]
result = self._allocate(issues, closed={500}, db=self.db)
self.assertTrue(result["success"])
edges = self.db.list_dependency_edges(remote="prgs", org="O", repo="R")
by_target = {edge["target_number"]: edge for edge in edges}
self.assertEqual(sorted(by_target), [500, 601])
self.assertEqual(by_target[601]["state"], "unmet")
self.assertEqual(by_target[500]["state"], "met")
self.assertEqual(by_target[601]["source_number"], 600)
self.assertEqual(
by_target[601]["edge_type"], dependency_graph.EDGE_ISSUE_BLOCKED_BY_ISSUE
)
self.assertTrue(by_target[601]["evidence"]["observed_by_session"])
def test_selection_is_unchanged_by_the_store(self) -> None:
issues = [
self._issue(600, body="* Depends: #601"),
self._issue(601),
self._issue(602),
]
class DeadStore:
"""Stands in for a control-plane DB whose edge writes all fail."""
def __init__(self, real):
self._real = real
def __getattr__(self, name):
return getattr(self._real, name)
def upsert_dependency_edge(self, **_kwargs):
raise RuntimeError("edge store unavailable")
healthy = self._allocate(issues, db=self.db)
broken = self._allocate(issues, db=DeadStore(self.db))
self.assertEqual(
healthy["selected"]["number"], broken["selected"]["number"]
)
self.assertEqual(
{s["number"] for s in healthy["skipped"]},
{s["number"] for s in broken["skipped"]},
)
self.assertEqual(healthy["candidate_count"], broken["candidate_count"])
self.assertTrue(broken["success"])
warnings = broken.get("inventory_warnings") or []
self.assertTrue(
any("edge store unavailable" in str(w) for w in warnings),
f"write failure must surface in reasons, got {warnings}",
)
def test_repeated_runs_do_not_duplicate_edges(self) -> None:
issues = [self._issue(600, body="* Depends: #601"), self._issue(601)]
self._allocate(issues, db=self.db)
self._allocate(issues, db=self.db)
self.assertEqual(len(self.db.list_dependency_edges()), 1)
class ListDependencyEdgesToolTest(unittest.TestCase):
"""AC13: the read-only tool is gated and never mutates."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
self.db.upsert_dependency_edge(**_edge_kwargs(org="O", repo="R"))
def tearDown(self) -> None:
self._tmp.cleanup()
def _call(self, *, read_block=None, **kwargs):
with patch(
"gitea_mcp_server._profile_operation_gate", return_value=read_block
), patch(
"gitea_mcp_server._resolve", return_value=("h", "O", "R")
), patch(
"gitea_mcp_server._permission_block_report", return_value={"blocked": True}
), patch(
"gitea_mcp_server._control_plane_db_or_error", return_value=(self.db, [])
):
return srv.gitea_list_dependency_edges(remote="prgs", **kwargs)
def test_returns_stored_edges(self) -> None:
result = self._call()
self.assertTrue(result["success"])
self.assertTrue(result["read_only"])
self.assertEqual(result["count"], 1)
self.assertEqual(result["edges"][0]["target_number"], 628)
self.assertEqual(len(result["edge_types"]), 7)
def test_reverse_lookup_filter(self) -> None:
self.assertEqual(self._call(target_number=628)["count"], 1)
self.assertEqual(self._call(target_number=999)["count"], 0)
def test_without_read_permission_it_fails_closed(self) -> None:
result = self._call(read_block=["gitea.read not allowed"])
self.assertFalse(result["success"])
self.assertEqual(result["edges"], [])
self.assertIn("permission_report", result)
def test_invalid_filter_fails_closed(self) -> None:
result = self._call(edge_type="not_a_real_type")
self.assertFalse(result["success"])
self.assertEqual(result["edges"], [])
self.assertTrue(any("fail closed" in r for r in result["reasons"]))
def test_tool_is_documented_in_the_inventory(self) -> None:
"""The #781 drift guard requires a registered tool to be documented."""
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
doc = os.path.join(repo_root, mcp_tool_inventory.INVENTORY_DOC_PATH)
with open(doc, "r", encoding="utf-8") as handle:
documented = mcp_tool_inventory.parse_documented_inventory(handle.read())
self.assertIn("gitea_list_dependency_edges", documented)
if __name__ == "__main__": # pragma: no cover
unittest.main()