Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d67b2f54eb | ||
|
|
a2cabb64b1 | ||
|
|
69e9e25fcf |
@@ -308,6 +308,28 @@ metadata. Final-report validation blocks handoffs that hide lock read/write/dele
|
|||||||
under `External-state mutations: none` or mix author PR creation with reviewer
|
under `External-state mutations: none` or mix author PR creation with reviewer
|
||||||
approval in one run. See also #438 (global lock redesign).
|
approval in one run. See also #438 (global lock redesign).
|
||||||
|
|
||||||
|
### Non-destructive lock recovery after push (#440)
|
||||||
|
|
||||||
|
When work is pushed but the in-memory MCP session lock is lost (server restart,
|
||||||
|
crashed process, or stale session pointer):
|
||||||
|
|
||||||
|
1. **Do not delete the remote branch.** Branch deletion is not a normal recovery
|
||||||
|
step and can destroy the only copy of unmerged work.
|
||||||
|
2. **Re-run `gitea_lock_issue`** with the same `issue_number`, exact
|
||||||
|
`branch_name`, and active `branches/` worktree. Own-branch adoption
|
||||||
|
reacquires the lease when the remote branch is the caller's exact branch and
|
||||||
|
no open PR or competing same-issue branch exists.
|
||||||
|
3. **Call `gitea_create_pr`** with the same `head` branch. The server resolves
|
||||||
|
the durable keyed lock file even when the session pointer was cleared at
|
||||||
|
restart.
|
||||||
|
4. **Stop fail-closed** when another actor owns a competing branch, an open PR
|
||||||
|
already exists, or a live foreign lease blocks takeover — never adopt their
|
||||||
|
branch.
|
||||||
|
|
||||||
|
Branch ownership uses structured evidence
|
||||||
|
`(fix|feat|docs|chore)/issue-<n>-<desc>` — not broad substring matches on
|
||||||
|
`issue-<n>`.
|
||||||
|
|
||||||
Remote branches matching the issue number are also treated as active work unless
|
Remote branches matching the issue number are also treated as active work unless
|
||||||
the recovery review proves the branch is abandoned or superseded. Never delete
|
the recovery review proves the branch is abandoned or superseded. Never delete
|
||||||
or clean up a branch when it has an active lease, dirty worktree, open PR, or is
|
or clean up a branch when it has an active lease, dirty worktree, open PR, or is
|
||||||
|
|||||||
+5
-4
@@ -541,6 +541,7 @@ import issue_lock_worktree # noqa: E402
|
|||||||
import issue_lock_provenance # noqa: E402
|
import issue_lock_provenance # noqa: E402
|
||||||
import issue_lock_store # noqa: E402
|
import issue_lock_store # noqa: E402
|
||||||
import issue_lock_adoption # noqa: E402
|
import issue_lock_adoption # noqa: E402
|
||||||
|
import issue_branch_ownership # noqa: E402
|
||||||
import already_landed_reconcile # noqa: E402
|
import already_landed_reconcile # noqa: E402
|
||||||
import author_mutation_worktree # noqa: E402
|
import author_mutation_worktree # noqa: E402
|
||||||
import issue_claim_heartbeat # noqa: E402
|
import issue_claim_heartbeat # noqa: E402
|
||||||
@@ -1286,11 +1287,11 @@ def gitea_lock_issue(
|
|||||||
worktree_path: Author scratch-clone path to validate (defaults to
|
worktree_path: Author scratch-clone path to validate (defaults to
|
||||||
GITEA_AUTHOR_WORKTREE or the MCP server project root).
|
GITEA_AUTHOR_WORKTREE or the MCP server project root).
|
||||||
"""
|
"""
|
||||||
# 1. Enforce branch name includes issue number
|
# 1. Enforce canonical issue branch ownership (#440)
|
||||||
expected_pattern = f"issue-{issue_number}"
|
if not issue_branch_ownership.branch_belongs_to_issue(branch_name, issue_number):
|
||||||
if expected_pattern not in branch_name:
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Branch name '{branch_name}' must contain locked issue pattern '{expected_pattern}' (fail closed)"
|
f"Branch name '{branch_name}' must match "
|
||||||
|
f"(fix|feat|docs|chore)/issue-{issue_number}-<desc> (fail closed)"
|
||||||
)
|
)
|
||||||
|
|
||||||
blocked = _profile_permission_block(
|
blocked = _profile_permission_block(
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Structured issue/branch ownership evidence (#440).
|
||||||
|
|
||||||
|
Author branches must follow ``(fix|feat|docs|chore)/issue-<n>-<desc>``. Duplicate-work
|
||||||
|
and lock-recovery gates use this parser instead of broad substring matching on
|
||||||
|
``issue-<n>`` so unrelated branch names cannot false-positive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
ISSUE_BRANCH_RE = re.compile(
|
||||||
|
r"^(?P<prefix>fix|feat|docs|chore)/issue-(?P<num>\d+)(?:-|$)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_issue_branch(branch_name: str) -> int | None:
|
||||||
|
"""Return the issue number encoded in a canonical author branch, else None."""
|
||||||
|
match = ISSUE_BRANCH_RE.match((branch_name or "").strip())
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return int(match.group("num"))
|
||||||
|
|
||||||
|
|
||||||
|
def branch_belongs_to_issue(branch_name: str, issue_number: int) -> bool:
|
||||||
|
"""True when ``branch_name`` is a canonical branch for ``issue_number``."""
|
||||||
|
parsed = parse_issue_branch(branch_name)
|
||||||
|
return parsed is not None and parsed == issue_number
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Own-branch lock adoption / recovery for ``gitea_lock_issue`` (#442 / #443).
|
"""Own-branch lock adoption / recovery for ``gitea_lock_issue`` (#440 / #442 / #443).
|
||||||
|
|
||||||
When an issue's own already-pushed branch exists, lock reacquisition must be
|
When an issue's own already-pushed branch exists, lock reacquisition must be
|
||||||
allowed (adoption) instead of being treated as #400 duplicate competing work.
|
allowed (adoption) instead of being treated as #400 duplicate competing work.
|
||||||
@@ -14,6 +14,8 @@ this module additionally records whether they passed for proof purposes.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import issue_branch_ownership
|
||||||
|
|
||||||
ADOPT = "adopt_existing_branch"
|
ADOPT = "adopt_existing_branch"
|
||||||
BLOCK_COMPETING = "block_competing_branch"
|
BLOCK_COMPETING = "block_competing_branch"
|
||||||
NO_MATCH = "no_matching_branch"
|
NO_MATCH = "no_matching_branch"
|
||||||
@@ -40,13 +42,12 @@ def assess_own_branch_adoption(
|
|||||||
existing_branches,
|
existing_branches,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Decide whether an existing matching branch is adoptable."""
|
"""Decide whether an existing matching branch is adoptable."""
|
||||||
marker = f"issue-{issue_number}"
|
|
||||||
requested = (requested_branch or "").strip()
|
requested = (requested_branch or "").strip()
|
||||||
|
|
||||||
matches: list[tuple[str, str | None]] = []
|
matches: list[tuple[str, str | None]] = []
|
||||||
for entry in existing_branches or []:
|
for entry in existing_branches or []:
|
||||||
name = _branch_name(entry).strip()
|
name = _branch_name(entry).strip()
|
||||||
if marker in name:
|
if issue_branch_ownership.branch_belongs_to_issue(name, issue_number):
|
||||||
matches.append((name, _branch_sha(entry)))
|
matches.append((name, _branch_sha(entry)))
|
||||||
|
|
||||||
competing = sorted({name for name, _ in matches if name != requested})
|
competing = sorted({name for name, _ in matches if name != requested})
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ class TestIssueLockArtifactWarning(unittest.TestCase):
|
|||||||
"mcp_server.issue_duplicate_context_fetcher",
|
"mcp_server.issue_duplicate_context_fetcher",
|
||||||
return_value=([], [], {"status": "not_claimed"}),
|
return_value=([], [], {"status": "not_claimed"}),
|
||||||
)
|
)
|
||||||
@patch("mcp_server.api_get_all", return_value=[])
|
|
||||||
@patch("mcp_server._auth", return_value="token x")
|
@patch("mcp_server._auth", return_value="token x")
|
||||||
@patch("mcp_server._resolve", return_value=("h", "o", "r"))
|
@patch("mcp_server._resolve", return_value=("h", "o", "r"))
|
||||||
@patch("issue_lock_worktree.read_worktree_git_state")
|
@patch("issue_lock_worktree.read_worktree_git_state")
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Structured issue branch ownership (#440)."""
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import issue_branch_ownership as ibo # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssueBranchOwnership(unittest.TestCase):
|
||||||
|
def test_canonical_branch_parses_issue_number(self):
|
||||||
|
self.assertEqual(
|
||||||
|
ibo.parse_issue_branch("feat/issue-420-server-code-parity"),
|
||||||
|
420,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_prefix_variants_supported(self):
|
||||||
|
for prefix in ("fix", "feat", "docs", "chore"):
|
||||||
|
branch = f"{prefix}/issue-440-lock-recovery"
|
||||||
|
self.assertTrue(ibo.branch_belongs_to_issue(branch, 440))
|
||||||
|
|
||||||
|
def test_substring_false_positive_rejected(self):
|
||||||
|
self.assertFalse(
|
||||||
|
ibo.branch_belongs_to_issue("feat/my-issue-420-backport", 420)
|
||||||
|
)
|
||||||
|
self.assertFalse(ibo.branch_belongs_to_issue("release/issue-420-hotfix", 420))
|
||||||
|
|
||||||
|
def test_different_issue_number_rejected(self):
|
||||||
|
self.assertFalse(
|
||||||
|
ibo.branch_belongs_to_issue("feat/issue-421-server-code-parity", 420)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -43,6 +43,15 @@ class TestAssessOwnBranchAdoption(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(result["outcome"], NO_MATCH)
|
self.assertEqual(result["outcome"], NO_MATCH)
|
||||||
|
|
||||||
|
def test_substring_only_branch_name_is_ignored(self):
|
||||||
|
result = assess_own_branch_adoption(
|
||||||
|
issue_number=420,
|
||||||
|
requested_branch=REQ,
|
||||||
|
existing_branches=[{"name": "feat/my-issue-420-backport"}],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["outcome"], NO_MATCH)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
|
||||||
|
|
||||||
class TestBuildAdoptionProof(unittest.TestCase):
|
class TestBuildAdoptionProof(unittest.TestCase):
|
||||||
def test_proof_has_required_fields(self):
|
def test_proof_has_required_fields(self):
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""End-to-end lock recovery scenarios for issue #440."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import issue_lock_adoption # noqa: E402
|
||||||
|
import issue_lock_store as ils # noqa: E402
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
from mcp_server import gitea_create_pr, gitea_lock_issue # noqa: E402
|
||||||
|
|
||||||
|
PRGS_REPO = mcp_server.REMOTES["prgs"]["repo"]
|
||||||
|
|
||||||
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||||
|
BRANCH = "feat/issue-420-server-code-parity"
|
||||||
|
ISSUE_WRITE_ENV = {
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": (
|
||||||
|
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
CREATE_PR_ENV = {
|
||||||
|
"GITEA_PROFILE_NAME": "author-test",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.create,gitea.branch.push",
|
||||||
|
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.approve,gitea.pr.merge,gitea.pr.review",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_master_git_state_for_lock():
|
||||||
|
return {
|
||||||
|
"current_branch": "master",
|
||||||
|
"porcelain_status": "",
|
||||||
|
"base_equivalent": True,
|
||||||
|
"inspected_git_root": "/tmp/repo",
|
||||||
|
"base_branch": "master",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_record(**overrides):
|
||||||
|
import issue_lock_provenance
|
||||||
|
|
||||||
|
work_lease = {
|
||||||
|
"operation_type": "author_issue_work",
|
||||||
|
"expires_at": "2999-01-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
record = {
|
||||||
|
"issue_number": 420,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": PRGS_REPO,
|
||||||
|
"worktree_path": os.path.realpath(os.getcwd()),
|
||||||
|
"work_lease": work_lease,
|
||||||
|
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||||||
|
tool="gitea_lock_issue",
|
||||||
|
claimant=work_lease.get("claimant"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
record.update(overrides)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssueLockRecovery(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmpdir = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self._tmpdir.cleanup)
|
||||||
|
self.lock_dir = self._tmpdir.name
|
||||||
|
|
||||||
|
def test_substring_branch_does_not_trigger_adoption(self):
|
||||||
|
result = issue_lock_adoption.assess_own_branch_adoption(
|
||||||
|
issue_number=420,
|
||||||
|
requested_branch=BRANCH,
|
||||||
|
existing_branches=[{"name": "feat/my-issue-420-backport"}],
|
||||||
|
)
|
||||||
|
self.assertEqual(result["outcome"], issue_lock_adoption.NO_MATCH)
|
||||||
|
|
||||||
|
def test_competing_actor_branch_blocks_adoption(self):
|
||||||
|
result = issue_lock_adoption.assess_own_branch_adoption(
|
||||||
|
issue_number=420,
|
||||||
|
requested_branch=BRANCH,
|
||||||
|
existing_branches=[{"name": "feat/issue-420-other-work"}],
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||||
|
return_value=_clean_master_git_state_for_lock(),
|
||||||
|
)
|
||||||
|
@patch("mcp_server.api_get_all")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
@patch(
|
||||||
|
"mcp_server.issue_duplicate_context_fetcher",
|
||||||
|
return_value=([], [], {"status": "not_claimed"}),
|
||||||
|
)
|
||||||
|
def test_lock_recovery_after_restart_adopts_own_branch(self, _dup_fetcher, _auth, mock_api, _git):
|
||||||
|
mock_api.return_value = [{"name": BRANCH, "commit": {"id": "934688a"}}]
|
||||||
|
env = {**ISSUE_WRITE_ENV, "GITEA_ISSUE_LOCK_DIR": self.lock_dir}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
with patch("os.getpid", return_value=9999):
|
||||||
|
res = gitea_lock_issue(
|
||||||
|
issue_number=420,
|
||||||
|
branch_name=BRANCH,
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertTrue(res["success"])
|
||||||
|
self.assertIn("adoption", res)
|
||||||
|
found = ils.find_lock_for_branch(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo=PRGS_REPO,
|
||||||
|
branch_name=BRANCH,
|
||||||
|
lock_dir=self.lock_dir,
|
||||||
|
)
|
||||||
|
self.assertEqual(found["branch_name"], BRANCH)
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_create_pr_resolves_durable_lock_after_session_loss(self, _auth, _role, mock_api):
|
||||||
|
mock_api.return_value = {"number": 421, "html_url": "https://example/pr/421"}
|
||||||
|
worktree = os.path.realpath(os.getcwd())
|
||||||
|
path = ils.lock_file_path(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo=PRGS_REPO,
|
||||||
|
issue_number=420,
|
||||||
|
lock_dir=self.lock_dir,
|
||||||
|
)
|
||||||
|
ils.save_lock_file(path, _lock_record(worktree_path=worktree))
|
||||||
|
|
||||||
|
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": self.lock_dir}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
with patch("os.getpid", return_value=8888):
|
||||||
|
self.assertIsNone(ils.read_session_issue_lock(lock_dir=self.lock_dir))
|
||||||
|
res = gitea_create_pr(
|
||||||
|
title=f"feat: server parity Closes #420",
|
||||||
|
head=BRANCH,
|
||||||
|
remote="prgs",
|
||||||
|
worktree_path=worktree,
|
||||||
|
)
|
||||||
|
self.assertEqual(res["number"], 421)
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||||
|
return_value=_clean_master_git_state_for_lock(),
|
||||||
|
)
|
||||||
|
@patch("mcp_server.api_get_all")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
@patch(
|
||||||
|
"mcp_server.issue_duplicate_context_fetcher",
|
||||||
|
return_value=([{
|
||||||
|
"number": 99,
|
||||||
|
"head": {"ref": BRANCH},
|
||||||
|
"title": "WIP",
|
||||||
|
"body": "",
|
||||||
|
}], [], {"status": "not_claimed"}),
|
||||||
|
)
|
||||||
|
def test_open_pr_blocks_recovery_lock(self, _dup_fetcher, _auth, mock_api, _git):
|
||||||
|
env = {**ISSUE_WRITE_ENV, "GITEA_ISSUE_LOCK_DIR": self.lock_dir}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
gitea_lock_issue(issue_number=420, branch_name=BRANCH, remote="prgs")
|
||||||
|
self.assertIn("open PR #99 already covers issue", str(ctx.exception))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Tests for early duplicate-work detection (#400)."""
|
"""Tests for early duplicate-work detection (#400)."""
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -8,8 +9,6 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
import issue_lock_provenance
|
|
||||||
import issue_lock_store
|
|
||||||
import issue_work_duplicate_gate as dup_gate
|
import issue_work_duplicate_gate as dup_gate
|
||||||
import mcp_server
|
import mcp_server
|
||||||
from issue_work_duplicate_gate import (
|
from issue_work_duplicate_gate import (
|
||||||
@@ -123,9 +122,8 @@ class TestDuplicateReportOutcome(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestInjectableDuplicateFetcher(unittest.TestCase):
|
class TestInjectableDuplicateFetcher(unittest.TestCase):
|
||||||
@patch("mcp_server.api_get_all", return_value=[])
|
|
||||||
@patch("mcp_server.get_auth_header", return_value="token x")
|
@patch("mcp_server.get_auth_header", return_value="token x")
|
||||||
def test_lock_issue_uses_injected_fetcher(self, _auth, _api):
|
def test_lock_issue_uses_injected_fetcher(self, _auth):
|
||||||
seen = {}
|
seen = {}
|
||||||
|
|
||||||
def fetcher(h, o, r, auth, issue_number):
|
def fetcher(h, o, r, auth, issue_number):
|
||||||
@@ -142,29 +140,26 @@ class TestInjectableDuplicateFetcher(unittest.TestCase):
|
|||||||
"porcelain_status": "",
|
"porcelain_status": "",
|
||||||
"base_equivalent": True,
|
"base_equivalent": True,
|
||||||
},
|
},
|
||||||
):
|
), patch.dict(os.environ, {
|
||||||
with tempfile.TemporaryDirectory() as lock_dir:
|
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment",
|
||||||
with patch.dict(os.environ, {
|
}, clear=True):
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment",
|
with patch.object(mcp_server, "ISSUE_LOCK_FILE", tempfile.mktemp()):
|
||||||
"GITEA_ISSUE_LOCK_DIR": lock_dir,
|
mcp_server.gitea_lock_issue(
|
||||||
}, clear=True):
|
issue_number=400,
|
||||||
mcp_server.gitea_lock_issue(
|
branch_name="feat/issue-400-duplicate-work-preflight",
|
||||||
issue_number=400,
|
remote="prgs",
|
||||||
branch_name="feat/issue-400-duplicate-work-preflight",
|
)
|
||||||
remote="prgs",
|
|
||||||
)
|
|
||||||
self.assertEqual(seen["issue_number"], 400)
|
self.assertEqual(seen["issue_number"], 400)
|
||||||
|
|
||||||
|
|
||||||
class TestMcpDuplicateRecheck(unittest.TestCase):
|
class TestMcpDuplicateRecheck(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._dir = tempfile.TemporaryDirectory()
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
self._env_patch = patch.dict(
|
self.lock_path = os.path.join(self._dir.name, "gitea_issue_lock.json")
|
||||||
os.environ,
|
self._lock_patch = patch.object(
|
||||||
{"GITEA_ISSUE_LOCK_DIR": self._dir.name},
|
mcp_server, "ISSUE_LOCK_FILE", self.lock_path
|
||||||
clear=False,
|
|
||||||
)
|
)
|
||||||
self._env_patch.start()
|
self._lock_patch.start()
|
||||||
self._remotes = patch.dict(mcp_server.REMOTES, {
|
self._remotes = patch.dict(mcp_server.REMOTES, {
|
||||||
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
|
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
|
||||||
"repo": "Example-Repo"},
|
"repo": "Example-Repo"},
|
||||||
@@ -177,27 +172,12 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
|
|||||||
self._dir.cleanup()
|
self._dir.cleanup()
|
||||||
|
|
||||||
def _write_lock(self, issue_number=400, branch="feat/issue-400-x"):
|
def _write_lock(self, issue_number=400, branch="feat/issue-400-x"):
|
||||||
worktree_path = os.path.realpath(os.getcwd())
|
with open(self.lock_path, "w", encoding="utf-8") as fh:
|
||||||
work_lease = {
|
json.dump({
|
||||||
"operation_type": "author_issue_work",
|
"issue_number": issue_number,
|
||||||
"issue_number": issue_number,
|
"branch_name": branch,
|
||||||
"branch": branch,
|
"remote": "prgs",
|
||||||
"claimant": {"username": "test-user", "profile": "test-author"},
|
}, fh)
|
||||||
"expires_at": "2999-01-01T00:00:00Z",
|
|
||||||
}
|
|
||||||
issue_lock_store.bind_session_lock({
|
|
||||||
"issue_number": issue_number,
|
|
||||||
"branch_name": branch,
|
|
||||||
"remote": "prgs",
|
|
||||||
"org": "Example-Org",
|
|
||||||
"repo": "Example-Repo",
|
|
||||||
"worktree_path": worktree_path,
|
|
||||||
"work_lease": work_lease,
|
|
||||||
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
|
||||||
tool="gitea_lock_issue",
|
|
||||||
claimant=work_lease.get("claimant"),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
@patch("mcp_server._assess_issue_duplicate_gate")
|
@patch("mcp_server._assess_issue_duplicate_gate")
|
||||||
@patch("mcp_server.get_profile", return_value={
|
@patch("mcp_server.get_profile", return_value={
|
||||||
@@ -261,7 +241,6 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
|
|||||||
base="master",
|
base="master",
|
||||||
body="Closes #400",
|
body="Closes #400",
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
worktree_path=os.path.realpath(os.getcwd()),
|
|
||||||
)
|
)
|
||||||
self.assertFalse(result["success"])
|
self.assertFalse(result["success"])
|
||||||
self.assertIsNone(result.get("number"))
|
self.assertIsNone(result.get("number"))
|
||||||
|
|||||||
+18
-20
@@ -3146,7 +3146,7 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
def test_lock_issue_mismatch_branch_fails(self):
|
def test_lock_issue_mismatch_branch_fails(self):
|
||||||
with self.assertRaises(ValueError) as ctx:
|
with self.assertRaises(ValueError) as ctx:
|
||||||
gitea_lock_issue(issue_number=196, branch_name="feat/issue-195-mutations", remote="prgs")
|
gitea_lock_issue(issue_number=196, branch_name="feat/issue-195-mutations", remote="prgs")
|
||||||
self.assertIn("must contain locked issue pattern", str(ctx.exception))
|
self.assertIn("must match", str(ctx.exception))
|
||||||
|
|
||||||
@patch(
|
@patch(
|
||||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||||
@@ -3203,7 +3203,6 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_lock_issue_adopts_exact_own_branch(self, _auth, mock_api, _git_state):
|
def test_lock_issue_adopts_exact_own_branch(self, _auth, mock_api, _git_state):
|
||||||
branch = "feat/issue-196-mutations"
|
branch = "feat/issue-196-mutations"
|
||||||
self.mock_dup_fetcher.return_value = ([], [branch], {"status": "not_claimed"})
|
|
||||||
mock_api.return_value = [{"name": branch, "commit": {"id": "abc123"}}]
|
mock_api.return_value = [{"name": branch, "commit": {"id": "abc123"}}]
|
||||||
res = gitea_lock_issue(issue_number=196, branch_name=branch, remote="prgs")
|
res = gitea_lock_issue(issue_number=196, branch_name=branch, remote="prgs")
|
||||||
self.assertTrue(res["success"])
|
self.assertTrue(res["success"])
|
||||||
@@ -3431,17 +3430,16 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_create_pr_manual_lock_seed_blocked(self, _auth, _role):
|
def test_create_pr_manual_lock_seed_blocked(self, _auth, _role):
|
||||||
worktree = os.path.realpath(os.getcwd())
|
worktree = os.path.realpath(os.getcwd())
|
||||||
with tempfile.TemporaryDirectory() as lock_dir:
|
env = self._create_pr_env()
|
||||||
env = {**self._create_pr_env(), "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
with patch.dict(os.environ, env, clear=True):
|
||||||
with patch.dict(os.environ, env, clear=True):
|
issue_lock_store.save_lock_file(
|
||||||
issue_lock_store.save_lock_file(
|
issue_lock_store.lock_file_path(
|
||||||
issue_lock_store.lock_file_path(
|
remote="prgs",
|
||||||
remote="prgs",
|
org="Scaled-Tech-Consulting",
|
||||||
org="Scaled-Tech-Consulting",
|
repo=mcp_server.REMOTES["prgs"]["repo"],
|
||||||
repo=mcp_server.REMOTES["prgs"]["repo"],
|
issue_number=447,
|
||||||
issue_number=447,
|
lock_dir=self._lock_dir.name,
|
||||||
lock_dir=lock_dir,
|
),
|
||||||
),
|
|
||||||
_sample_issue_lock(
|
_sample_issue_lock(
|
||||||
issue_number=447,
|
issue_number=447,
|
||||||
branch_name="feat/issue-447-lock-provenance",
|
branch_name="feat/issue-447-lock-provenance",
|
||||||
@@ -3451,14 +3449,14 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
worktree_path=worktree,
|
worktree_path=worktree,
|
||||||
lock_provenance=None,
|
lock_provenance=None,
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
gitea_create_pr(
|
||||||
|
title="feat: lock provenance Closes #447",
|
||||||
|
head="feat/issue-447-lock-provenance",
|
||||||
|
remote="prgs",
|
||||||
|
worktree_path=worktree,
|
||||||
)
|
)
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
|
||||||
gitea_create_pr(
|
|
||||||
title="feat: lock provenance Closes #447",
|
|
||||||
head="feat/issue-447-lock-provenance",
|
|
||||||
remote="prgs",
|
|
||||||
worktree_path=worktree,
|
|
||||||
)
|
|
||||||
self.assertIn("lock provenance", str(ctx.exception).lower())
|
self.assertIn("lock provenance", str(ctx.exception).lower())
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
|
|||||||
Reference in New Issue
Block a user