refactor(mcp): split target/credential resolver, scope by org_unit:customer:project, support Vietnamese acceptance heading
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user