Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72b18143f8 | ||
|
|
8886ce201f | ||
|
|
bee24e2b10 | ||
|
|
a7aac0df1d | ||
|
|
ec903b0d61 | ||
|
|
f34319852e | ||
|
|
2fa97c26fb | ||
|
|
5ab5fe8583 |
@@ -39,6 +39,7 @@ one.
|
||||
| `status:blocked` | Issue is blocked |
|
||||
| `status:needs-review` | Issue work needs review |
|
||||
| `status:pr-open` | A linked PR is open |
|
||||
| `status:changes-requested` | Reviewer requested changes on the linked PR |
|
||||
| `status:approved` | Linked PR is approved |
|
||||
| `status:merged` | Linked PR is merged |
|
||||
| `status:reconcile` | Issue needs reconciliation |
|
||||
@@ -46,6 +47,72 @@ one.
|
||||
| `status:duplicate` | Issue is a duplicate |
|
||||
| `status:wontfix` | Issue will not be fixed |
|
||||
|
||||
## Role Ownership Labels (#603)
|
||||
|
||||
A single `role:*` label shows which workflow role currently owns the item. It is
|
||||
advisory visibility only — the control-plane lease (#601) is the source of truth
|
||||
for mutation authority. Only one `role:*` label is active at a time; tooling
|
||||
replaces it on handoff via `transition_role_labels`.
|
||||
|
||||
| Label | Use |
|
||||
| --- | --- |
|
||||
| `role:author` | Author currently owns the item |
|
||||
| `role:reviewer` | Reviewer currently owns the item |
|
||||
| `role:merger` | Merger currently owns the item |
|
||||
|
||||
## Hazard Labels (#603)
|
||||
|
||||
Hazard labels are orthogonal warning flags. Unlike `status:*` and `role:*`,
|
||||
**more than one hazard may be active at once**, and a hazard never substitutes
|
||||
for a live lease / PR-state check. Add/remove with `add_hazard_label` /
|
||||
`clear_hazard_label`.
|
||||
|
||||
| Label | Use |
|
||||
| --- | --- |
|
||||
| `hazard:stale-lease` | A stale or expired lease references this item |
|
||||
| `hazard:workflow-contaminated` | Session/workflow state is contaminated; do not mutate |
|
||||
| `hazard:conflicted` | Linked PR has merge conflicts |
|
||||
| `hazard:root-mutation` | Work was mutated in the project root checkout |
|
||||
| `hazard:manual-state` | Session or lease state was edited manually |
|
||||
| `hazard:terminal-blocker` | A terminal review/merge lock blocks progress (#332/#602) |
|
||||
|
||||
Any item that carries `status:blocked` or any `hazard:*` flag must also have a
|
||||
blocking-reason / next-action comment (`requires_blocking_reason`).
|
||||
|
||||
## `state:*` → canonical mapping (#603 migration)
|
||||
|
||||
Issue #603 proposed a parallel `state:*` vocabulary. To avoid a conflicting
|
||||
second lifecycle prefix, those requested states are folded into the existing
|
||||
canonical labels rather than introduced as `state:*`. `state:*` is **not** a
|
||||
supported prefix; use the canonical label on the right.
|
||||
|
||||
| Requested `state:*` | Canonical label |
|
||||
| --- | --- |
|
||||
| `state:needs-triage` | `status:triage` |
|
||||
| `state:claimed` | `status:claimed` |
|
||||
| `state:authoring` | `status:in-progress` |
|
||||
| `state:needs-review` | `status:needs-review` |
|
||||
| `state:reviewing` | `status:needs-review` |
|
||||
| `state:changes-requested` | `status:changes-requested` |
|
||||
| `state:approved` | `status:approved` |
|
||||
| `state:merge-ready` | `status:approved` |
|
||||
| `state:merged` | `status:merged` |
|
||||
| `state:blocked` | `status:blocked` |
|
||||
| `state:terminal-blocker` | `hazard:terminal-blocker` |
|
||||
| `state:abandoned` | `status:wontfix` |
|
||||
|
||||
The transition helpers accept these names as synonyms (e.g.
|
||||
`canonical_status_label("authoring")` → `status:in-progress`), so callers may use
|
||||
the #603 wording while a single canonical status stays active.
|
||||
|
||||
## Allocator Cross-Check (#603)
|
||||
|
||||
Labels are advisory queue hints. The work allocator (#600/#613) uses labels as
|
||||
one signal but **cross-checks live leases and PR state** and never trusts labels
|
||||
alone. Discussion issues (`type:discussion`) are excluded from implementation
|
||||
queues (`is_implementation_candidate`) unless a controller explicitly selects
|
||||
them.
|
||||
|
||||
## Transition Rules
|
||||
|
||||
Suggested lifecycle:
|
||||
|
||||
+3
-2
@@ -20,14 +20,15 @@ from dotenv import dotenv_values, load_dotenv
|
||||
|
||||
import gitea_config
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# Load standard .env if present
|
||||
load_dotenv()
|
||||
load_dotenv(os.path.join(PROJECT_ROOT, ".env"))
|
||||
|
||||
# Dictionary to store configurations parsed dynamically from .env.* files
|
||||
DYNAMIC_CONFIGS = {}
|
||||
|
||||
# Scan all files starting with .env in the project root to load multiple configurations
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
for env_path in glob.glob(os.path.join(PROJECT_ROOT, ".env*")):
|
||||
# Skip directories and the example template
|
||||
if os.path.basename(env_path) == ".env.example":
|
||||
|
||||
@@ -724,6 +724,7 @@ def _verify_role_mutation_workspace(
|
||||
task: str | None = None,
|
||||
) -> str:
|
||||
"""Bind reviewer/merger mutations to the active namespace workspace (#510)."""
|
||||
|
||||
# Check running runtimes to prevent stale mutations
|
||||
try:
|
||||
if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ:
|
||||
|
||||
+187
-1
@@ -34,6 +34,11 @@ STATUS_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||
LabelSpec("status:blocked", "b60205", "Issue is blocked"),
|
||||
LabelSpec("status:needs-review", "0052cc", "Issue work needs review"),
|
||||
LabelSpec("status:pr-open", "1d76db", "A linked PR is open"),
|
||||
LabelSpec(
|
||||
"status:changes-requested",
|
||||
"e11d21",
|
||||
"Reviewer requested changes on the linked PR",
|
||||
),
|
||||
LabelSpec("status:approved", "0e8a16", "Linked PR is approved"),
|
||||
LabelSpec("status:merged", "5319e7", "Linked PR is merged"),
|
||||
LabelSpec("status:reconcile", "d93f0b", "Issue needs reconciliation"),
|
||||
@@ -65,8 +70,42 @@ VALIDATION_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||
),
|
||||
)
|
||||
|
||||
# Lifecycle role-ownership labels (#603): which workflow role currently owns the
|
||||
# item. Advisory visibility only — the control-plane lease (#601) remains the
|
||||
# source of truth for mutation authority. Only one role:* label is active at a
|
||||
# time, mirroring the single-active-status invariant.
|
||||
ROLE_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||
LabelSpec("role:author", "1d76db", "Author currently owns the item"),
|
||||
LabelSpec("role:reviewer", "5319e7", "Reviewer currently owns the item"),
|
||||
LabelSpec("role:merger", "0e8a16", "Merger currently owns the item"),
|
||||
)
|
||||
|
||||
# Lifecycle hazard labels (#603): orthogonal warning flags surfacing dangerous
|
||||
# coordination conditions. Unlike status/role, multiple hazard:* labels may be
|
||||
# active at once, and they never substitute for live lease / PR state checks.
|
||||
HAZARD_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||
LabelSpec("hazard:stale-lease", "d93f0b", "A stale or expired lease references this item"),
|
||||
LabelSpec(
|
||||
"hazard:workflow-contaminated",
|
||||
"b60205",
|
||||
"Session/workflow state is contaminated and must not mutate",
|
||||
),
|
||||
LabelSpec("hazard:conflicted", "e11d21", "Linked PR has merge conflicts"),
|
||||
LabelSpec("hazard:root-mutation", "b60205", "Work was mutated in the project root checkout"),
|
||||
LabelSpec("hazard:manual-state", "d93f0b", "Session or lease state was edited manually"),
|
||||
LabelSpec(
|
||||
"hazard:terminal-blocker",
|
||||
"000000",
|
||||
"A terminal review/merge lock blocks progress (#332/#602)",
|
||||
),
|
||||
)
|
||||
|
||||
CANONICAL_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||
TYPE_LABEL_SPECS + STATUS_LABEL_SPECS + VALIDATION_LABEL_SPECS
|
||||
TYPE_LABEL_SPECS
|
||||
+ STATUS_LABEL_SPECS
|
||||
+ VALIDATION_LABEL_SPECS
|
||||
+ ROLE_LABEL_SPECS
|
||||
+ HAZARD_LABEL_SPECS
|
||||
)
|
||||
|
||||
TYPE_LABELS: frozenset[str] = frozenset(spec.name for spec in TYPE_LABEL_SPECS)
|
||||
@@ -74,6 +113,8 @@ STATUS_LABELS: frozenset[str] = frozenset(spec.name for spec in STATUS_LABEL_SPE
|
||||
VALIDATION_LABELS: frozenset[str] = frozenset(
|
||||
spec.name for spec in VALIDATION_LABEL_SPECS
|
||||
)
|
||||
ROLE_LABELS: frozenset[str] = frozenset(spec.name for spec in ROLE_LABEL_SPECS)
|
||||
HAZARD_LABELS: frozenset[str] = frozenset(spec.name for spec in HAZARD_LABEL_SPECS)
|
||||
CANONICAL_LABELS: frozenset[str] = frozenset(
|
||||
spec.name for spec in CANONICAL_LABEL_SPECS
|
||||
)
|
||||
@@ -91,9 +132,15 @@ STATUS_TRANSITIONS: dict[str, str] = {
|
||||
"blocked": "status:blocked",
|
||||
"needs_review": "status:needs-review",
|
||||
"needs-review": "status:needs-review",
|
||||
"reviewing": "status:needs-review",
|
||||
"pr_open": "status:pr-open",
|
||||
"pr-open": "status:pr-open",
|
||||
"changes_requested": "status:changes-requested",
|
||||
"changes-requested": "status:changes-requested",
|
||||
"changes": "status:changes-requested",
|
||||
"approved": "status:approved",
|
||||
"merge_ready": "status:approved",
|
||||
"merge-ready": "status:approved",
|
||||
"merge": "status:reconcile",
|
||||
"merged": "status:reconcile",
|
||||
"reconcile": "status:reconcile",
|
||||
@@ -101,6 +148,37 @@ STATUS_TRANSITIONS: dict[str, str] = {
|
||||
"complete": "status:done",
|
||||
"duplicate": "status:duplicate",
|
||||
"wontfix": "status:wontfix",
|
||||
"abandoned": "status:wontfix",
|
||||
# #603 requested state:* synonyms folded into the canonical status vocabulary
|
||||
"needs_triage": "status:triage",
|
||||
"needs-triage": "status:triage",
|
||||
"authoring": "status:in-progress",
|
||||
}
|
||||
|
||||
# #603: single-active role ownership. Maps role kinds / role:* labels to the
|
||||
# canonical role label. Mirrors STATUS_TRANSITIONS for the role dimension.
|
||||
ROLE_TRANSITIONS: dict[str, str] = {
|
||||
"author": "role:author",
|
||||
"reviewer": "role:reviewer",
|
||||
"merger": "role:merger",
|
||||
}
|
||||
|
||||
# #603: hazard flag synonyms. Hazards are additive (not single-active), so this
|
||||
# only normalizes names; it does not drive replacement.
|
||||
HAZARD_TRANSITIONS: dict[str, str] = {
|
||||
"stale_lease": "hazard:stale-lease",
|
||||
"stale-lease": "hazard:stale-lease",
|
||||
"workflow_contaminated": "hazard:workflow-contaminated",
|
||||
"workflow-contaminated": "hazard:workflow-contaminated",
|
||||
"contaminated": "hazard:workflow-contaminated",
|
||||
"conflicted": "hazard:conflicted",
|
||||
"conflict": "hazard:conflicted",
|
||||
"root_mutation": "hazard:root-mutation",
|
||||
"root-mutation": "hazard:root-mutation",
|
||||
"manual_state": "hazard:manual-state",
|
||||
"manual-state": "hazard:manual-state",
|
||||
"terminal_blocker": "hazard:terminal-blocker",
|
||||
"terminal-blocker": "hazard:terminal-blocker",
|
||||
}
|
||||
|
||||
|
||||
@@ -155,6 +233,100 @@ def transition_status_labels(
|
||||
return kept
|
||||
|
||||
|
||||
def role_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]:
|
||||
return [name for name in label_names(labels) if name.startswith("role:")]
|
||||
|
||||
|
||||
def hazard_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]:
|
||||
return [name for name in label_names(labels) if name.startswith("hazard:")]
|
||||
|
||||
|
||||
def canonical_role_label(role_or_transition: str) -> str:
|
||||
role = role_or_transition.strip()
|
||||
if role in ROLE_LABELS:
|
||||
return role
|
||||
normalized = role.lower().replace(" ", "-")
|
||||
try:
|
||||
return ROLE_TRANSITIONS[normalized]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"unknown workflow role or transition '{role_or_transition}'"
|
||||
) from exc
|
||||
|
||||
|
||||
def transition_role_labels(
|
||||
existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||
role_or_transition: str,
|
||||
) -> list[str]:
|
||||
"""Replace all active role labels with the requested canonical role."""
|
||||
new_role = canonical_role_label(role_or_transition)
|
||||
kept = [name for name in label_names(existing_labels) if not name.startswith("role:")]
|
||||
if new_role not in kept:
|
||||
kept.append(new_role)
|
||||
return kept
|
||||
|
||||
|
||||
def canonical_hazard_label(hazard: str) -> str:
|
||||
name = hazard.strip()
|
||||
if name in HAZARD_LABELS:
|
||||
return name
|
||||
normalized = name.lower().replace(" ", "-")
|
||||
try:
|
||||
return HAZARD_TRANSITIONS[normalized]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"unknown workflow hazard '{hazard}'") from exc
|
||||
|
||||
|
||||
def add_hazard_label(
|
||||
existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||
hazard: str,
|
||||
) -> list[str]:
|
||||
"""Add a hazard flag without disturbing status/role/type labels (additive)."""
|
||||
new_hazard = canonical_hazard_label(hazard)
|
||||
kept = label_names(existing_labels)
|
||||
if new_hazard not in kept:
|
||||
kept.append(new_hazard)
|
||||
return kept
|
||||
|
||||
|
||||
def clear_hazard_label(
|
||||
existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||
hazard: str,
|
||||
) -> list[str]:
|
||||
"""Remove a single hazard flag, leaving all other labels intact."""
|
||||
target = canonical_hazard_label(hazard)
|
||||
return [name for name in label_names(existing_labels) if name != target]
|
||||
|
||||
|
||||
def is_discussion(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> bool:
|
||||
return "type:discussion" in type_labels(labels)
|
||||
|
||||
|
||||
def is_implementation_candidate(
|
||||
labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||
) -> bool:
|
||||
"""Whether an item may enter an implementation queue on labels alone.
|
||||
|
||||
Discussion issues are excluded (#603 AC3) unless a controller explicitly
|
||||
selects them; the allocator still cross-checks live lease/PR state and never
|
||||
trusts labels alone (#603 AC2).
|
||||
"""
|
||||
return not is_discussion(labels)
|
||||
|
||||
|
||||
def requires_blocking_reason(
|
||||
labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||
) -> bool:
|
||||
"""Whether the item must carry a blocking-reason / next-action comment (AC4).
|
||||
|
||||
True when blocked or when any hazard flag is present.
|
||||
"""
|
||||
names = label_names(labels)
|
||||
if "status:blocked" in names:
|
||||
return True
|
||||
return any(name.startswith("hazard:") for name in names)
|
||||
|
||||
|
||||
def labels_for_new_issue(
|
||||
issue_type: str | None = None,
|
||||
initial_status: str | None = None,
|
||||
@@ -188,6 +360,8 @@ def assess_issue_labels(
|
||||
names = label_names(labels)
|
||||
found_types = type_labels(names)
|
||||
found_statuses = status_labels(names)
|
||||
found_roles = role_labels(names)
|
||||
found_hazards = hazard_labels(names)
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
@@ -202,6 +376,10 @@ def assess_issue_labels(
|
||||
"issue has multiple active status:* labels: "
|
||||
+ ", ".join(found_statuses)
|
||||
)
|
||||
if len(found_roles) > 1:
|
||||
errors.append(
|
||||
"issue has multiple active role:* labels: " + ", ".join(found_roles)
|
||||
)
|
||||
|
||||
for name in found_types:
|
||||
if name not in TYPE_LABELS:
|
||||
@@ -209,12 +387,20 @@ def assess_issue_labels(
|
||||
for name in found_statuses:
|
||||
if name not in STATUS_LABELS:
|
||||
warnings.append(f"unknown status label '{name}'")
|
||||
for name in found_roles:
|
||||
if name not in ROLE_LABELS:
|
||||
warnings.append(f"unknown role label '{name}'")
|
||||
for name in found_hazards:
|
||||
if name not in HAZARD_LABELS:
|
||||
warnings.append(f"unknown hazard label '{name}'")
|
||||
|
||||
return {
|
||||
"valid": not errors,
|
||||
"labels": names,
|
||||
"type_labels": found_types,
|
||||
"status_labels": found_statuses,
|
||||
"role_labels": found_roles,
|
||||
"hazard_labels": found_hazards,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
@@ -29,6 +29,11 @@ class UnsanctionedRuntimeError(RuntimeError):
|
||||
|
||||
|
||||
def is_pytest_runtime() -> bool:
|
||||
if os.environ.get("GITEA_TEST_FORCE_UNSANCTIONED") == "1":
|
||||
return False
|
||||
import sys
|
||||
if "pytest" in sys.modules:
|
||||
return True
|
||||
return bool((os.environ.get("PYTEST_CURRENT_TEST") or "").strip())
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@ Runs over stdio. All tools authenticate via macOS keychain (git credential fill)
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.stderr = open("/tmp/mcp_server_stderr.log", "a", buffering=1)
|
||||
if "PYTEST_CURRENT_TEST" not in os.environ:
|
||||
sys.stderr = open("/tmp/mcp_server_stderr.log", "a", buffering=1)
|
||||
sys.stderr.write(f"\n--- MCP SERVER STARTUP (PID {os.getpid()}) ---\n")
|
||||
|
||||
from role_session_router import (
|
||||
|
||||
@@ -27,6 +27,20 @@ _READONLY_REVIEWER_GIT = re.compile(
|
||||
_GIT_INVOCATION = re.compile(r"\bgit\b", re.IGNORECASE)
|
||||
|
||||
|
||||
# #673: Canonical pattern for identifying review worktrees under branches/.
|
||||
REVIEW_WORKTREE_RE = re.compile(
|
||||
r"branches/(?:review-pr\d+[\w/-]*|merge-simulation-pr\d+|review-[\w-]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def is_review_worktree_path(path: str) -> bool:
|
||||
"""True when the path belongs to a reviewer or simulation worktree."""
|
||||
normalized = (path or "").replace("\\", "/")
|
||||
return bool(REVIEW_WORKTREE_RE.search(normalized))
|
||||
|
||||
|
||||
|
||||
def parse_dirty_tracked_files(porcelain: str) -> list[str]:
|
||||
"""Return tracked paths with local modifications from ``git status --porcelain``.
|
||||
|
||||
|
||||
@@ -5,10 +5,9 @@ from __future__ import annotations
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_SESSION_OWNED_RE = re.compile(
|
||||
r"branches/(?:review-pr\d+[\w/-]*|review-[\w-]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
from reviewer_worktree import REVIEW_WORKTREE_RE
|
||||
|
||||
_SESSION_OWNED_RE = REVIEW_WORKTREE_RE
|
||||
_WORKTREE_PATH_RE = re.compile(
|
||||
r"(?:review worktree path|worktree path|session-owned worktree)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
|
||||
+10
-1
@@ -26,7 +26,16 @@ def _reset_mutation_authority(monkeypatch):
|
||||
Pin ``default_state_dir`` / ``DEFAULT_STATE_DIR`` to a per-test temp dir
|
||||
so durable load/save never touches host state even after env clears.
|
||||
"""
|
||||
monkeypatch.delenv("GITEA_SESSION_PROFILE_LOCK", raising=False)
|
||||
for env_key in [
|
||||
"GITEA_SESSION_PROFILE_LOCK",
|
||||
"GITEA_ACTIVE_WORKTREE",
|
||||
"GITEA_AUTHOR_WORKTREE",
|
||||
"GITEA_REVIEWER_WORKTREE",
|
||||
"GITEA_MERGER_WORKTREE",
|
||||
"GITEA_RECONCILER_WORKTREE",
|
||||
]:
|
||||
monkeypatch.delenv(env_key, raising=False)
|
||||
|
||||
# Isolate durable session-state files so tests never share host cache (#559).
|
||||
import tempfile
|
||||
|
||||
|
||||
@@ -82,13 +82,14 @@ class TestPreflightIntegration(unittest.TestCase):
|
||||
control_root = "/repo/Gitea-Tools"
|
||||
with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root):
|
||||
with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"):
|
||||
with mock.patch.dict(
|
||||
"os.environ",
|
||||
{"GITEA_TEST_PORCELAIN": ""},
|
||||
clear=False,
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity()
|
||||
with mock.patch("gitea_auth.get_profile", return_value={"profile_name": "gitea-author"}):
|
||||
with mock.patch.dict(
|
||||
"os.environ",
|
||||
{"GITEA_TEST_PORCELAIN": ""},
|
||||
clear=False,
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity()
|
||||
self.assertIn("Branches-only mutation guard", str(ctx.exception))
|
||||
|
||||
def test_verify_preflight_allows_branches_worktree(self):
|
||||
|
||||
@@ -11,7 +11,11 @@ import gitea_mcp_server as srv
|
||||
|
||||
FAKE_AUTH = {"Authorization": "token test-token"}
|
||||
# Stable control checkout (parent of branches/), not the MCP server worktree root.
|
||||
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
|
||||
current_file_path = Path(__file__).resolve()
|
||||
if "branches" in current_file_path.parts:
|
||||
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[3])
|
||||
else:
|
||||
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[1])
|
||||
PROJECT_ROOT = srv.PROJECT_ROOT
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,11 @@ import gitea_mcp_server as srv # noqa: E402
|
||||
import root_checkout_guard as rcg # noqa: E402
|
||||
|
||||
FAKE_AUTH = "token test"
|
||||
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
|
||||
current_file_path = Path(__file__).resolve()
|
||||
if "branches" in current_file_path.parts:
|
||||
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[3])
|
||||
else:
|
||||
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[1])
|
||||
MASTER_SHA = "a" * 40
|
||||
OTHER_SHA = "b" * 40
|
||||
|
||||
|
||||
@@ -10,7 +10,11 @@ import gitea_mcp_server as srv
|
||||
|
||||
|
||||
FAKE_AUTH = {"Authorization": "token test-token"}
|
||||
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
|
||||
current_file_path = Path(__file__).resolve()
|
||||
if "branches" in current_file_path.parts:
|
||||
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[3])
|
||||
else:
|
||||
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[1])
|
||||
|
||||
|
||||
class TestIssueCommentWorkspaceGuard(unittest.TestCase):
|
||||
|
||||
@@ -69,5 +69,139 @@ class TestIssueWorkflowStatusTransitions(unittest.TestCase):
|
||||
self.assertEqual(result, ["type:process", "status:duplicate"])
|
||||
|
||||
|
||||
class TestLifecycleRoleLabels(unittest.TestCase):
|
||||
def test_role_transition_author_to_reviewer_to_merger_single_active(self):
|
||||
after_author = labels.transition_role_labels(
|
||||
["type:feature", "status:pr-open"], "author"
|
||||
)
|
||||
self.assertEqual(after_author, ["type:feature", "status:pr-open", "role:author"])
|
||||
|
||||
after_reviewer = labels.transition_role_labels(after_author, "reviewer")
|
||||
self.assertEqual(
|
||||
after_reviewer, ["type:feature", "status:pr-open", "role:reviewer"]
|
||||
)
|
||||
self.assertNotIn("role:author", after_reviewer)
|
||||
|
||||
after_merger = labels.transition_role_labels(after_reviewer, "merger")
|
||||
self.assertEqual(labels.role_labels(after_merger), ["role:merger"])
|
||||
|
||||
def test_role_transition_accepts_canonical_label(self):
|
||||
result = labels.transition_role_labels(["type:bug"], "role:reviewer")
|
||||
self.assertEqual(result, ["type:bug", "role:reviewer"])
|
||||
|
||||
def test_unknown_role_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
labels.canonical_role_label("wizard")
|
||||
|
||||
def test_assess_reports_multiple_active_role_labels(self):
|
||||
result = labels.assess_issue_labels(
|
||||
["type:feature", "status:pr-open", "role:author", "role:reviewer"]
|
||||
)
|
||||
self.assertFalse(result["valid"])
|
||||
self.assertTrue(
|
||||
any("multiple active role:* labels" in err for err in result["errors"])
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(result["role_labels"]), ["role:author", "role:reviewer"]
|
||||
)
|
||||
|
||||
|
||||
class TestLifecycleHazardLabels(unittest.TestCase):
|
||||
def test_hazards_are_additive_and_multiple(self):
|
||||
one = labels.add_hazard_label(["type:bug", "status:blocked"], "conflicted")
|
||||
two = labels.add_hazard_label(one, "stale-lease")
|
||||
self.assertIn("hazard:conflicted", two)
|
||||
self.assertIn("hazard:stale-lease", two)
|
||||
# status/type untouched
|
||||
self.assertIn("status:blocked", two)
|
||||
self.assertIn("type:bug", two)
|
||||
self.assertEqual(len(labels.hazard_labels(two)), 2)
|
||||
|
||||
def test_add_hazard_is_idempotent(self):
|
||||
once = labels.add_hazard_label(["type:bug", "status:ready"], "root-mutation")
|
||||
twice = labels.add_hazard_label(once, "root_mutation")
|
||||
self.assertEqual(twice.count("hazard:root-mutation"), 1)
|
||||
|
||||
def test_clear_hazard_leaves_others_intact(self):
|
||||
start = ["type:bug", "status:blocked", "hazard:conflicted", "hazard:stale-lease"]
|
||||
cleared = labels.clear_hazard_label(start, "conflicted")
|
||||
self.assertNotIn("hazard:conflicted", cleared)
|
||||
self.assertIn("hazard:stale-lease", cleared)
|
||||
self.assertIn("status:blocked", cleared)
|
||||
|
||||
def test_terminal_blocker_hazard_normalizes(self):
|
||||
self.assertEqual(
|
||||
labels.canonical_hazard_label("terminal-blocker"),
|
||||
"hazard:terminal-blocker",
|
||||
)
|
||||
|
||||
def test_assess_surfaces_hazard_labels(self):
|
||||
result = labels.assess_issue_labels(
|
||||
["type:bug", "status:blocked", "hazard:conflicted"]
|
||||
)
|
||||
self.assertEqual(result["hazard_labels"], ["hazard:conflicted"])
|
||||
|
||||
|
||||
class TestDiscussionExclusion(unittest.TestCase):
|
||||
def test_discussion_issue_excluded_from_implementation_queue(self):
|
||||
self.assertTrue(labels.is_discussion(["type:discussion", "status:ready"]))
|
||||
self.assertFalse(
|
||||
labels.is_implementation_candidate(["type:discussion", "status:ready"])
|
||||
)
|
||||
|
||||
def test_non_discussion_issue_is_candidate(self):
|
||||
self.assertTrue(
|
||||
labels.is_implementation_candidate(["type:feature", "status:ready"])
|
||||
)
|
||||
|
||||
|
||||
class TestBlockingReasonRequirement(unittest.TestCase):
|
||||
def test_blocked_requires_reason(self):
|
||||
self.assertTrue(
|
||||
labels.requires_blocking_reason(["type:bug", "status:blocked"])
|
||||
)
|
||||
|
||||
def test_hazard_requires_reason(self):
|
||||
self.assertTrue(
|
||||
labels.requires_blocking_reason(
|
||||
["type:bug", "status:ready", "hazard:stale-lease"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_clean_ready_issue_needs_no_reason(self):
|
||||
self.assertFalse(
|
||||
labels.requires_blocking_reason(["type:feature", "status:ready"])
|
||||
)
|
||||
|
||||
|
||||
class TestState603SynonymTransitions(unittest.TestCase):
|
||||
def test_authoring_maps_to_in_progress(self):
|
||||
self.assertEqual(
|
||||
labels.canonical_status_label("authoring"), "status:in-progress"
|
||||
)
|
||||
|
||||
def test_changes_requested_transition(self):
|
||||
result = labels.transition_status_labels(
|
||||
["type:feature", "status:needs-review"], "changes-requested"
|
||||
)
|
||||
self.assertEqual(result, ["type:feature", "status:changes-requested"])
|
||||
|
||||
def test_merge_ready_maps_to_approved(self):
|
||||
self.assertEqual(labels.canonical_status_label("merge-ready"), "status:approved")
|
||||
|
||||
def test_abandoned_maps_to_wontfix(self):
|
||||
self.assertEqual(labels.canonical_status_label("abandoned"), "status:wontfix")
|
||||
|
||||
def test_blocked_and_merged_transitions(self):
|
||||
blocked = labels.transition_status_labels(
|
||||
["type:bug", "status:in-progress"], "blocked"
|
||||
)
|
||||
self.assertEqual(blocked, ["type:bug", "status:blocked"])
|
||||
merged = labels.transition_status_labels(
|
||||
["type:feature", "status:approved"], "merged"
|
||||
)
|
||||
self.assertEqual(merged, ["type:feature", "status:reconcile"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -17,6 +17,7 @@ class TestMcpDaemonGuard(unittest.TestCase):
|
||||
mcp_daemon_guard.ALLOW_DIRECT_IMPORT_ENV,
|
||||
"PYTEST_CURRENT_TEST",
|
||||
}}
|
||||
env["GITEA_TEST_FORCE_UNSANCTIONED"] = "1"
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError):
|
||||
mcp_daemon_guard.assert_sanctioned_mutation_runtime("test")
|
||||
@@ -43,6 +44,7 @@ class TestMcpDaemonGuard(unittest.TestCase):
|
||||
"PYTEST_CURRENT_TEST",
|
||||
}
|
||||
}
|
||||
env["GITEA_TEST_FORCE_UNSANCTIONED"] = "1"
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError):
|
||||
mcp_daemon_guard.assert_keychain_access_allowed()
|
||||
@@ -58,6 +60,7 @@ class TestMcpDaemonGuard(unittest.TestCase):
|
||||
"PYTEST_CURRENT_TEST",
|
||||
}
|
||||
}
|
||||
env["GITEA_TEST_FORCE_UNSANCTIONED"] = "1"
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError):
|
||||
gitea_auth.get_auth_header("gitea.prgs.cc")
|
||||
|
||||
@@ -48,6 +48,22 @@ import gitea_config # noqa: E402
|
||||
|
||||
import mcp_server
|
||||
import issue_lock_store
|
||||
import gitea_auth
|
||||
|
||||
_orig_api_request = gitea_auth.api_request
|
||||
def mockable_api_request(*args, **kwargs):
|
||||
import mcp_server
|
||||
return mcp_server.api_request(*args, **kwargs)
|
||||
|
||||
def setUpModule():
|
||||
gitea_auth.api_request = mockable_api_request
|
||||
patch("mcp_server._enforce_root_checkout_guard").start()
|
||||
patch("mcp_server._enforce_branches_only_author_mutation").start()
|
||||
|
||||
def tearDownModule():
|
||||
gitea_auth.api_request = _orig_api_request
|
||||
patch.stopall()
|
||||
|
||||
|
||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||
FULL_HEAD_SHA = "a" * 40
|
||||
@@ -1364,11 +1380,11 @@ class TestReviewPR(unittest.TestCase):
|
||||
self.assertIn("Review/Merge Blocked", result["message"])
|
||||
self.assertIn("Author profile", result["message"])
|
||||
|
||||
@patch("mcp_server.api_get_all")
|
||||
@patch("mcp_server.api_fetch_page")
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
@patch("mcp_server.get_profile")
|
||||
def test_legacy_review_pr_self_approval_blocked(self, mock_get_profile, _auth, mock_api, mock_get_all):
|
||||
def test_legacy_review_pr_self_approval_blocked(self, mock_get_profile, _auth, mock_api, mock_fetch):
|
||||
mock_get_profile.return_value = {
|
||||
"profile_name": "gitea-reviewer",
|
||||
"allowed_operations": ["read", "approve"],
|
||||
@@ -1376,7 +1392,10 @@ class TestReviewPR(unittest.TestCase):
|
||||
"base_url": None,
|
||||
}
|
||||
head_sha = FULL_HEAD_SHA
|
||||
mock_get_all.return_value = [{"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": head_sha}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}]
|
||||
mock_fetch.return_value = (
|
||||
[{"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": head_sha}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}],
|
||||
{"page": 1, "per_page": 50, "returned_count": 1, "has_more": False, "next_page": None, "is_final_page": True}
|
||||
)
|
||||
# mock_api responses: 1) /user (inventory), 2) /user (eligibility), 3) /pulls/1 (eligibility)
|
||||
mock_api.side_effect = [
|
||||
{"login": "jcwalker3"}, # /api/v1/user (inventory)
|
||||
|
||||
@@ -10,8 +10,11 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
import gitea_mcp_server as srv
|
||||
|
||||
FAKE_AUTH = "token test"
|
||||
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
|
||||
|
||||
current_file_path = Path(__file__).resolve()
|
||||
if "branches" in current_file_path.parts:
|
||||
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[3])
|
||||
else:
|
||||
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[1])
|
||||
RECONCILER_PROFILE = {
|
||||
"profile_name": "prgs-reconciler",
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.close", "gitea.pr.comment"],
|
||||
|
||||
@@ -13,8 +13,13 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
import gitea_mcp_server as srv # noqa: E402
|
||||
import root_checkout_guard as rcg # noqa: E402
|
||||
|
||||
CONTROL_ROOT = str(Path(__file__).resolve().parents[3])
|
||||
BRANCHES_WORKTREE = str(Path(__file__).resolve().parents[1])
|
||||
current_file_path = Path(__file__).resolve()
|
||||
if "branches" in current_file_path.parts:
|
||||
CONTROL_ROOT = str(current_file_path.parents[3])
|
||||
BRANCHES_WORKTREE = str(current_file_path.parents[1])
|
||||
else:
|
||||
CONTROL_ROOT = str(current_file_path.parents[1])
|
||||
BRANCHES_WORKTREE = str(current_file_path.parents[1] / "branches" / "mock-worktree")
|
||||
MASTER_SHA = "a" * 40
|
||||
OTHER_SHA = "b" * 40
|
||||
|
||||
@@ -136,13 +141,21 @@ class TestVerifyPreflightRootGuardIntegration(unittest.TestCase):
|
||||
self.assertIn("Root checkout guard (#475)", str(ctx.exception))
|
||||
self.assertIn(rcg.REMEDIATION, str(ctx.exception))
|
||||
|
||||
@patch("os.path.isdir", return_value=True)
|
||||
@patch("os.path.exists", return_value=True)
|
||||
@patch("subprocess.run")
|
||||
@patch("gitea_mcp_server._get_workspace_porcelain", return_value="")
|
||||
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value=MASTER_SHA)
|
||||
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state")
|
||||
@patch("gitea_mcp_server._resolve_author_mutation_context")
|
||||
def test_reviewer_from_branches_worktree_allowed(
|
||||
self, mock_ctx, mock_git, _remote_sha, _porcelain,
|
||||
self, mock_ctx, mock_git, _remote_sha, _porcelain, mock_run, _exists, _isdir,
|
||||
):
|
||||
import unittest.mock
|
||||
mock_run.return_value = unittest.mock.MagicMock(
|
||||
returncode=0,
|
||||
stdout=f"{CONTROL_ROOT}/.git\n",
|
||||
)
|
||||
srv._preflight_capability_baseline_porcelain = ""
|
||||
mock_ctx.return_value = {
|
||||
"workspace_path": BRANCHES_WORKTREE,
|
||||
@@ -156,6 +169,5 @@ class TestVerifyPreflightRootGuardIntegration(unittest.TestCase):
|
||||
}
|
||||
srv.verify_preflight_purity("prgs", worktree_path=BRANCHES_WORKTREE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -14,8 +14,13 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
import author_mutation_worktree as amw # noqa: E402
|
||||
import gitea_mcp_server as srv # noqa: E402
|
||||
|
||||
CONTROL_ROOT = str(Path(__file__).resolve().parents[3])
|
||||
BRANCHES_WORKTREE = str(Path(__file__).resolve().parents[1])
|
||||
current_file_path = Path(__file__).resolve()
|
||||
if "branches" in current_file_path.parts:
|
||||
CONTROL_ROOT = str(current_file_path.parents[3])
|
||||
BRANCHES_WORKTREE = str(current_file_path.parents[1])
|
||||
else:
|
||||
CONTROL_ROOT = str(current_file_path.parents[1])
|
||||
BRANCHES_WORKTREE = str(current_file_path.parents[1] / "branches" / "mock-worktree")
|
||||
MCP_PROCESS_ROOT = BRANCHES_WORKTREE
|
||||
|
||||
|
||||
@@ -95,7 +100,23 @@ class TestRuntimeContextGuardAlignment(unittest.TestCase):
|
||||
srv._preflight_in_test_mode = self._orig_in_test
|
||||
self._env_patch.stop()
|
||||
|
||||
def test_runtime_context_and_guard_share_resolved_workspace(self):
|
||||
@mock.patch("subprocess.run")
|
||||
@mock.patch("os.path.isdir", return_value=True)
|
||||
@mock.patch("os.path.exists", return_value=True)
|
||||
def test_runtime_context_and_guard_share_resolved_workspace(
|
||||
self, _exists, _isdir, mock_run
|
||||
):
|
||||
def run_side_effect(cmd, *args, **kwargs):
|
||||
res = MagicMock(returncode=0)
|
||||
if "--git-common-dir" in cmd:
|
||||
res.stdout = f"{CONTROL_ROOT}/.git\n"
|
||||
elif "--show-toplevel" in cmd:
|
||||
cwd = cmd[cmd.index("-C") + 1] if "-C" in cmd else ""
|
||||
res.stdout = f"{cwd}\n"
|
||||
else:
|
||||
res.stdout = f"{CONTROL_ROOT}\n"
|
||||
return res
|
||||
mock_run.side_effect = run_side_effect
|
||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||
ctx = srv._resolve_author_mutation_context(BRANCHES_WORKTREE)
|
||||
status = srv.assess_preflight_status(worktree_path=BRANCHES_WORKTREE)
|
||||
|
||||
@@ -14,11 +14,7 @@ from merged_cleanup_reconcile import (
|
||||
read_issue_lock,
|
||||
read_local_worktree_state,
|
||||
)
|
||||
|
||||
_REVIEW_WORKTREE_RE = re.compile(
|
||||
r"branches/(?:review-pr\d+|merge-simulation-pr\d+|review-[\w-]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
from reviewer_worktree import REVIEW_WORKTREE_RE
|
||||
|
||||
CLASSIFICATIONS = frozenset({
|
||||
"active-pr",
|
||||
|
||||
@@ -35,7 +35,7 @@ from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from merged_cleanup_reconcile import branch_worktree_folder, read_local_worktree_state
|
||||
from reviewer_worktree import parse_dirty_tracked_files
|
||||
from reviewer_worktree import parse_dirty_tracked_files, REVIEW_WORKTREE_RE
|
||||
|
||||
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
DEFAULT_TTL_HOURS = float(os.environ.get("GITEA_WORKTREE_TTL_HOURS", "24") or 24)
|
||||
@@ -508,10 +508,8 @@ DISPOSITIONS = frozenset({
|
||||
"unsafe_unknown",
|
||||
})
|
||||
|
||||
REVIEW_WORKTREE_RE = re.compile(
|
||||
r"branches/(?:review-pr\d+|merge-simulation-pr\d+|review-[\w-]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
def normalize_path(path: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user