refactor(mcp): split target/credential resolver, scope by org_unit:customer:project, support Vietnamese acceptance heading

This commit is contained in:
2026-08-31 14:46:32 +09:00
parent 98f4a1ed77
commit c0c7222ad1
2 changed files with 240 additions and 64 deletions
+107 -44
View File
@@ -9,19 +9,30 @@ read-only Gitea client instead of :class:`UnconfiguredIssueProvider`.
Design notes (documented here so a reviewer does not have to guess):
* Credentials (``GITEA_TOKEN``) and the base URL are read INSIDE
``build_provider``/``_resolve_target`` only — never at import time — because
``build_provider`` is only ever invoked by
* Target resolution and credential resolution are two SEPARATE, independently
replaceable seams — :class:`GiteaTargetResolver` (identity -> which
repository) and :class:`GiteaTokenResolver` (identity -> which credential).
Today both are simple: one shared read-only service-account token for
every caller, which is an accepted, documented pilot shortcut. The split
exists so that switching to an on-behalf-of-user credential model later
means replacing ONLY :class:`GiteaTokenResolver` — the tool, the target
routing logic, and :func:`build_provider`'s signature never change.
``GITEA_TOKEN``/``GITEA_BASE_URL`` are read INSIDE these resolvers only —
never at import time — because ``build_provider`` is only ever invoked by
``ProjectProviderResolver.resolve`` (see ``runtime.py``), which
``server.py::dispatch`` calls strictly AFTER ``policy.decide`` returns
``True``. Reading env vars at module scope would read them before the
policy check ever runs.
* The provider is bound to exactly one project/repository at construction
time (``identity.project`` -> ``PROJECT_CONTEXT_REPO_MAP``). The
``project_id`` argument received per call is only used to echo it back in
the response and as a defensive equality check — it is never used to pick
which repository to query. This means a caller can never redirect the
provider to an arbitrary repository by tampering with ``project_id``.
* The provider is bound to exactly one repository at construction time,
resolved from the FULL identity scope — ``org_unit:customer:project``, not
``project`` alone — via ``PROJECT_CONTEXT_REPO_MAP`` (see
:func:`_scope_key`). Scoping by project alone would let two different
customers/org_units that happen to reuse the same project label collide
onto the same repository mapping. The ``project_id`` argument received per
call is only used to echo it back in the response and as a defensive
equality check — it is never used to pick which repository to query. This
means a caller can never redirect the provider to an arbitrary repository
by tampering with ``project_id``.
* ``revision`` pins to the issue's own ``updated_at`` timestamp. Gitea issues
(unlike commits/PRs) have no natural commit SHA of their own, so the most
meaningful, verifiable "version marker" Gitea offers for an issue is its
@@ -97,7 +108,16 @@ _URL_PATTERN = re.compile(r"https?://\S+")
_MARKDOWN_LINK_PATTERN = re.compile(r"\[[^\]]*\]\([^)]*\)")
# ATX heading line, e.g. "# Acceptance Criteria" / "## Acceptance Criteria".
_HEADING_PATTERN = re.compile(r"^(#{1,6})[ \t]+(.+?)\s*$", re.MULTILINE)
_ACCEPTANCE_HEADING_NAMES = ("acceptance criteria",)
# English + the Vietnamese phrasing the Core Team's own task template uses
# (see PROJECT_CONTEXT_MCP_TEAM_GUIDE.md-authored issues) — matched
# case-insensitively. Add more synonyms here rather than loosening the
# fallback-to-whole-body behavior, which exists only for issues with no
# fixed template at all.
_ACCEPTANCE_HEADING_NAMES = (
"acceptance criteria",
"tiêu chí chấp nhận",
"tiêu chí hoàn thành",
)
def _extract_heading_section(text: str, heading_names: tuple[str, ...]) -> str | None:
@@ -135,13 +155,23 @@ class UnconfiguredIssueProvider:
@dataclass(frozen=True)
class _GiteaRepoTarget:
"""WHICH repository to query. Deliberately carries no credential — see
the module docstring's note on the Target/Credential Resolver split."""
base_url: str
owner: str
repo: str
token: str
project_id: str
def _scope_key(identity: IdentityContext) -> str:
"""The full tenancy scope a repository mapping is keyed by. Using
``org_unit:customer:project`` (rather than ``project`` alone) means two
different customers/org_units that happen to reuse the same project
label can never collide onto the same Gitea repository."""
return f"{identity.org_unit}:{identity.customer}:{identity.project}"
def _load_repo_map() -> dict[str, str]:
raw = os.environ.get("PROJECT_CONTEXT_REPO_MAP", "").strip()
if not raw:
@@ -159,53 +189,86 @@ def _load_repo_map() -> dict[str, str]:
):
raise ProviderError(
"UNAVAILABLE",
"PROJECT_CONTEXT_REPO_MAP must be a JSON object of project_id -> 'owner/repo'.",
"PROJECT_CONTEXT_REPO_MAP must be a JSON object of "
"'org_unit:customer:project' -> 'owner/repo'.",
retryable=False,
)
return parsed
def _resolve_target(identity: IdentityContext) -> _GiteaRepoTarget:
"""Read-only credential/target resolution. Only ever called AFTER the
runtime's policy has already granted ALLOW for this identity/tool."""
base_url = os.environ.get("GITEA_BASE_URL", "").strip().rstrip("/")
token = os.environ.get("GITEA_TOKEN", "").strip()
if not base_url or not token:
raise ProviderError(
"UNAVAILABLE",
"GITEA_BASE_URL/GITEA_TOKEN are not configured for this environment.",
retryable=False,
class GiteaTargetResolver:
"""Resolves WHICH Gitea repository an identity's full scope
(``org_unit``/``customer``/``project``) is approved to read. Pure
routing logic — knows nothing about credentials, so this can evolve
(e.g. a richer tenancy model) without touching how credentials are
obtained. Only ever called AFTER the runtime's policy has already
granted ALLOW for this identity/tool."""
def resolve(self, identity: IdentityContext) -> _GiteaRepoTarget:
base_url = os.environ.get("GITEA_BASE_URL", "").strip().rstrip("/")
if not base_url:
raise ProviderError(
"UNAVAILABLE",
"GITEA_BASE_URL is not configured for this environment.",
retryable=False,
)
repo_map = _load_repo_map()
slug = repo_map.get(_scope_key(identity), "")
if not slug or "/" not in slug:
raise ProviderError(
"UNAVAILABLE",
"This org_unit/customer/project scope is not mapped to an "
"approved Gitea repository.",
retryable=False,
)
owner, _, repo = slug.partition("/")
if not owner or not repo:
raise ProviderError(
"UNAVAILABLE",
"This project's Gitea repository mapping is malformed.",
retryable=False,
)
return _GiteaRepoTarget(
base_url=base_url, owner=owner, repo=repo, project_id=identity.project,
)
repo_map = _load_repo_map()
slug = repo_map.get(identity.project, "")
if not slug or "/" not in slug:
raise ProviderError(
"UNAVAILABLE",
"This project is not mapped to an approved Gitea repository.",
retryable=False,
)
owner, _, repo = slug.partition("/")
if not owner or not repo:
raise ProviderError(
"UNAVAILABLE",
"This project's Gitea repository mapping is malformed.",
retryable=False,
)
return _GiteaRepoTarget(
base_url=base_url, owner=owner, repo=repo, token=token, project_id=identity.project,
)
class GiteaTokenResolver:
"""Resolves WHICH credential to use for a given identity. Today: a
single shared read-only service-account token (``GITEA_TOKEN``) for
every caller — an accepted shortcut for a read-only pilot. Isolated
behind this seam so a future on-behalf-of-user credential model only
means replacing THIS class; :class:`GiteaTargetResolver`,
:class:`GiteaIssueProvider`, and :func:`build_provider`'s signature
never change."""
def resolve(self, identity: IdentityContext) -> str:
token = os.environ.get("GITEA_TOKEN", "").strip()
if not token:
raise ProviderError(
"UNAVAILABLE",
"GITEA_TOKEN is not configured for this environment.",
retryable=False,
)
return token
def build_provider(identity: IdentityContext) -> IssueProvider:
"""Replace only this factory when wiring the approved read-only issue adapter."""
return GiteaIssueProvider(_resolve_target(identity))
"""Replace only this factory when wiring the approved read-only issue
adapter (or swap in a different :class:`GiteaTokenResolver` alone, to
move to an on-behalf-of-user credential model without touching this
function's signature or callers)."""
target = GiteaTargetResolver().resolve(identity)
token = GiteaTokenResolver().resolve(identity)
return GiteaIssueProvider(target, token)
class GiteaIssueProvider:
"""Read-only adapter mapping one Gitea issue/PR onto the neutral schema."""
def __init__(self, target: _GiteaRepoTarget) -> None:
def __init__(self, target: _GiteaRepoTarget, token: str) -> None:
self._target = target
self._token = token
def get_issue_context(
self,
@@ -345,7 +408,7 @@ class GiteaIssueProvider:
f"{self._target.base_url}/api/v1/repos/{self._target.owner}/"
f"{self._target.repo}/issues/{issue_key}"
)
headers = {"Authorization": f"token {self._target.token}"}
headers = {"Authorization": f"token {self._token}"}
try:
response = requests.get(url, headers=headers, timeout=_REQUEST_TIMEOUT_SECONDS)
except requests.exceptions.Timeout as exc:
+133 -20
View File
@@ -20,6 +20,8 @@ from cowork_local.mcp_servers.project_context.foundation import (
)
from cowork_local.mcp_servers.project_context.providers.issue import (
GiteaIssueProvider,
GiteaTargetResolver,
GiteaTokenResolver,
UnconfiguredIssueProvider,
_GiteaRepoTarget,
)
@@ -95,7 +97,6 @@ def _target(**overrides: Any) -> _GiteaRepoTarget:
base_url="http://example.test",
owner="gitea-admin",
repo="cowork-local",
token=FAKE_TOKEN,
project_id="cowork-local",
)
base.update(overrides)
@@ -134,7 +135,7 @@ def test_happy_path_returns_full_schema_with_openable_source(
) -> None:
transport = _FakeTransport([_FakeResponse(200, _issue_payload())])
monkeypatch.setattr(requests, "get", transport)
provider = GiteaIssueProvider(_target())
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
app, policy, resolver = _runtime(identity, provider)
result = dispatch(
@@ -167,7 +168,7 @@ def test_source_fields_are_all_present_and_well_formed(
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, _issue_payload())]))
provider = GiteaIssueProvider(_target())
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
app, _, _ = _runtime(identity, provider)
result = dispatch(
@@ -215,6 +216,10 @@ def test_denied_project_never_resolves_credentials_or_calls_gitea(
assert result.payload["error"]["code"] == "DENIED"
assert policy.calls == 1
assert resolver.calls == 0
# A DENIED response must still carry a correlation_id (traceable/auditable
# by itself) and never a credential of any kind.
assert result.payload["error"]["correlation_id"]
assert FAKE_TOKEN not in str(result.payload)
def test_permission_decision_lives_outside_the_tool(
@@ -224,7 +229,7 @@ def test_permission_decision_lives_outside_the_tool(
outcome, proving `tools/issue_context.py` contains no permission logic
of its own."""
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, _issue_payload())]))
provider = GiteaIssueProvider(_target())
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
arguments = {"project_id": "cowork-local", "issue_key": "1"}
allowed_app, _, _ = _runtime(identity, provider, allowed=True)
@@ -245,7 +250,7 @@ def test_not_found_issue_maps_to_not_found(
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(404)]))
provider = GiteaIssueProvider(_target())
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
app, _, _ = _runtime(identity, provider)
result = dispatch(
@@ -264,7 +269,7 @@ def test_provider_raises_provider_error_directly_for_not_found(
dispatch): the raised exception must carry the right `.code`/`.retryable`
for the runtime to map correctly."""
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(404)]))
provider = GiteaIssueProvider(_target())
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
with pytest.raises(ProviderError) as exc_info:
provider.get_issue_context(
@@ -279,7 +284,7 @@ def test_upstream_timeout_maps_to_upstream_timeout(
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(requests, "get", _FakeTransport([requests.exceptions.Timeout("slow")]))
provider = GiteaIssueProvider(_target())
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
app, _, _ = _runtime(identity, provider)
result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app)
@@ -298,7 +303,7 @@ def test_upstream_status_codes_map_to_distinct_error_codes(
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch, status_code: int, expected_code: str,
) -> None:
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(status_code)]))
provider = GiteaIssueProvider(_target())
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
app, _, _ = _runtime(identity, provider)
result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app)
@@ -311,7 +316,7 @@ def test_malformed_gitea_response_maps_to_upstream_error(
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, json_body="__missing__")]))
provider = GiteaIssueProvider(_target())
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
app, _, _ = _runtime(identity, provider)
result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app)
@@ -341,7 +346,7 @@ def test_invalid_issue_key_format_rejected_before_network_call(
) -> None:
transport = _FakeTransport([]) # empty queue: a real call would raise IndexError
monkeypatch.setattr(requests, "get", transport)
provider = GiteaIssueProvider(_target())
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
app, _, _ = _runtime(identity, provider)
result = dispatch(
@@ -358,7 +363,7 @@ def test_invalid_cursor_rejected_before_network_call(
) -> None:
transport = _FakeTransport([])
monkeypatch.setattr(requests, "get", transport)
provider = GiteaIssueProvider(_target())
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
app, _, _ = _runtime(identity, provider)
result = dispatch(
@@ -372,6 +377,91 @@ def test_invalid_cursor_rejected_before_network_call(
assert transport.calls == []
# ---------------------------------------------------------------------------
# Target Resolver / Credential Resolver split (owner review item #1)
# ---------------------------------------------------------------------------
def test_target_resolver_and_token_resolver_are_independent_seams(
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Architecture requirement: target routing (which repo) and credential
lookup (which token) must be two independently callable/replaceable
components, not one merged function — so a future on-behalf-of-user
credential model can replace ONLY the token side."""
monkeypatch.setenv("GITEA_BASE_URL", "http://example.test")
monkeypatch.setenv("GITEA_TOKEN", FAKE_TOKEN)
monkeypatch.setenv(
"PROJECT_CONTEXT_REPO_MAP", '{"fsg:internal:cowork-local": "gitea-admin/cowork-local"}',
)
target = GiteaTargetResolver().resolve(identity)
assert target.owner == "gitea-admin"
assert target.repo == "cowork-local"
assert not hasattr(target, "token") # the target itself carries no credential
token = GiteaTokenResolver().resolve(identity)
assert token == FAKE_TOKEN
def test_repo_mapping_is_scoped_by_full_identity_not_project_alone(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Two identities that happen to reuse the same `project` label but
belong to different org_unit/customer must resolve to DIFFERENT
repositories, not collide onto the same mapping entry."""
monkeypatch.setenv("GITEA_BASE_URL", "http://example.test")
monkeypatch.setenv("GITEA_TOKEN", FAKE_TOKEN)
monkeypatch.setenv(
"PROJECT_CONTEXT_REPO_MAP",
'{"fsg:internal:cowork-local": "gitea-admin/cowork-local", '
'"other-org:other-customer:cowork-local": "gitea-admin/other-repo"}',
)
identity_a = IdentityContext(
actor_id="a", org_unit="fsg", customer="internal",
project="cowork-local", granted_scopes=frozenset({"read"}),
)
identity_b = IdentityContext(
actor_id="b", org_unit="other-org", customer="other-customer",
project="cowork-local", granted_scopes=frozenset({"read"}),
)
target_a = GiteaTargetResolver().resolve(identity_a)
target_b = GiteaTargetResolver().resolve(identity_b)
assert target_a.repo == "cowork-local"
assert target_b.repo == "other-repo"
def test_build_provider_wiring_end_to_end_happy_path(
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Owner review item #2: at least one happy-path test must go through
the REAL `ProjectProviderResolver` -> `PROVIDER_FACTORIES` ->
`build_provider` -> `GiteaTargetResolver`/`GiteaTokenResolver` chain,
instead of injecting a pre-built `GiteaIssueProvider` directly, so the
actual production wiring is proven to work end-to-end."""
monkeypatch.setenv("GITEA_BASE_URL", "http://example.test")
monkeypatch.setenv("GITEA_TOKEN", FAKE_TOKEN)
monkeypatch.setenv(
"PROJECT_CONTEXT_REPO_MAP", '{"fsg:internal:cowork-local": "gitea-admin/cowork-local"}',
)
transport = _FakeTransport([_FakeResponse(200, _issue_payload())])
monkeypatch.setattr(requests, "get", transport)
policy = RecordingPolicy(allowed=True)
app = ProjectContextRuntime(
identity=identity, policy=policy, credential_resolver=ProjectProviderResolver(),
)
result = dispatch(
"get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app,
)
assert result.ok is True
assert result.payload["title"] == "MCP pilot"
assert result.payload["correlation_id"]
assert transport.calls[0]["headers"] == {"Authorization": f"token {FAKE_TOKEN}"}
assert transport.calls[0]["url"].endswith("/api/v1/repos/gitea-admin/cowork-local/issues/1")
# ---------------------------------------------------------------------------
# Fail-closed configuration (build_provider itself, via the real resolver)
# ---------------------------------------------------------------------------
@@ -448,7 +538,7 @@ def test_truncation_and_cursor_paginate_related_items(
payload = _issue_payload(body=f"See also {mentions}.")
transport = _FakeTransport([_FakeResponse(200, payload), _FakeResponse(200, payload)])
monkeypatch.setattr(requests, "get", transport)
provider = GiteaIssueProvider(_target())
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
app, _, _ = _runtime(identity, provider)
first = dispatch(
@@ -482,7 +572,7 @@ def test_full_detail_uses_a_larger_related_page_than_standard(
mentions = " ".join(f"#{n}" for n in range(2, 32)) # 30 distinct related items
payload = _issue_payload(body=f"See also {mentions}.")
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, payload)]))
provider = GiteaIssueProvider(_target())
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
app, _, _ = _runtime(identity, provider)
result = dispatch(
@@ -507,7 +597,7 @@ def test_url_fragment_is_not_mistaken_for_a_related_issue(
body = "See http://example.test/gitea-admin/cowork-local/wiki/guide#42 and also #7 directly."
payload = _issue_payload(body=body)
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, payload)]))
provider = GiteaIssueProvider(_target())
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
app, _, _ = _runtime(identity, provider)
result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app)
@@ -531,7 +621,7 @@ def test_related_excludes_number_that_is_only_a_markdown_link_label(
)
payload = _issue_payload(body=body)
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, payload)]))
provider = GiteaIssueProvider(_target())
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
app, _, _ = _runtime(identity, provider)
result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app)
@@ -556,7 +646,7 @@ def test_acceptance_criteria_is_scoped_to_its_own_heading_not_definition_of_done
)
payload = _issue_payload(body=body)
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, payload)]))
provider = GiteaIssueProvider(_target())
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
app, _, _ = _runtime(identity, provider)
result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app)
@@ -567,6 +657,29 @@ def test_acceptance_criteria_is_scoped_to_its_own_heading_not_definition_of_done
]
def test_acceptance_criteria_supports_vietnamese_heading(
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Owner review item #4: Core Team task tickets are increasingly
authored in Vietnamese ('Tiêu chí chấp nhận' / 'Tiêu chí hoàn thành')
— these must be recognized the same way as the English heading,
without falling back and grabbing an unrelated section's checklist."""
body = (
"# Tiêu chí chấp nhận\n\n"
"- [ ] Điều kiện thật sự cần đạt.\n\n"
"# Definition of Done\n\n"
"- [ ] Không liên quan, không được lẫn vào.\n"
)
payload = _issue_payload(body=body)
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, payload)]))
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
app, _, _ = _runtime(identity, provider)
result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app)
assert result.payload["acceptance_criteria"] == ["Điều kiện thật sự cần đạt."]
def test_acceptance_criteria_falls_back_to_whole_body_without_a_heading(
identity: IdentityContext, monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -576,7 +689,7 @@ def test_acceptance_criteria_falls_back_to_whole_body_without_a_heading(
body = "Ad-hoc issue, no headings.\n\n- [ ] Just do the thing.\n"
payload = _issue_payload(body=body)
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, payload)]))
provider = GiteaIssueProvider(_target())
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
app, _, _ = _runtime(identity, provider)
result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app)
@@ -590,7 +703,7 @@ def test_summary_detail_omits_related_and_shortens_description(
long_paragraph = "First paragraph. " * 40 # > 280 chars
payload = _issue_payload(body=f"{long_paragraph}\n\nSecond paragraph mentions #2.")
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(200, payload)]))
provider = GiteaIssueProvider(_target())
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
app, _, _ = _runtime(identity, provider)
result = dispatch(
@@ -618,7 +731,7 @@ def test_unexpected_transport_error_does_not_leak_credential_or_raw_exception(
f"connect failed for token={FAKE_TOKEN} at internal-host:5432"
)
monkeypatch.setattr(requests, "get", _FakeTransport([leaking_exception]))
provider = GiteaIssueProvider(_target())
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
app, _, _ = _runtime(identity, provider)
result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app)
@@ -636,7 +749,7 @@ def test_not_found_message_does_not_distinguish_missing_from_inaccessible(
"""Security requirement: a denial/miss must not reveal whether the
underlying resource exists — the safe_message must stay generic."""
monkeypatch.setattr(requests, "get", _FakeTransport([_FakeResponse(404)]))
provider = GiteaIssueProvider(_target())
provider = GiteaIssueProvider(_target(), FAKE_TOKEN)
app, _, _ = _runtime(identity, provider)
result = dispatch("get_project_issue_context", {"project_id": "cowork-local", "issue_key": "1"}, app)