Add Gitea client and git operations (B2) — step 6a, part 1
The standalone half of the Gitea layer; orchestrator wiring follows once the executor loop lands, to avoid colliding with it in control.py. - gitea.py: GiteaClient over httpx — open PRs, list and post issue comments, close PRs. find_pull_request is the A3 external-truth query that makes opening a PR idempotent across a crash, and it checks closed PRs too, since a crash after opening must not be answered with "nothing is open, so open another" - pull_request_reconciler: an unreachable Gitea returns None rather than a guess, so the agent gates to the human instead of risking a duplicate side effect - git.py: branch, commit, push for the workspace. The token is passed per-invocation rather than stored as a remote, so it never lands in .git/config, and anything raised is scrubbed of it - commit_all returns None when there is nothing to commit: an empty milestone is a fact to report, not an error - Live test does the real round trip against the sandbox repo and cleans up after itself Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
47b2bdfdbd
commit
2071e6ab6d
7 files changed
+614
No files matched your search
@@ -0,0 +1,175 @@
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nisse_dev.gitea import (
|
||||
Comment,
|
||||
GiteaClient,
|
||||
GiteaError,
|
||||
pull_request_reconciler,
|
||||
)
|
||||
from nisse_dev.state import Intent
|
||||
|
||||
INTENT = Intent(
|
||||
id=1,
|
||||
kind="open_pr",
|
||||
payload={"branch": "milestone-1"},
|
||||
status="pending",
|
||||
created_at="t",
|
||||
resolved_at=None,
|
||||
)
|
||||
|
||||
|
||||
def client(handler) -> GiteaClient:
|
||||
return GiteaClient(
|
||||
"https://gitea.test/", "tok", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
|
||||
|
||||
def pr_json(number: int, head: str, state: str = "open") -> dict:
|
||||
return {
|
||||
"number": number,
|
||||
"state": state,
|
||||
"head": {"ref": head},
|
||||
"html_url": f"https://gitea.test/o/r/pulls/{number}",
|
||||
}
|
||||
|
||||
|
||||
def test_open_pull_request_posts_expected_body() -> None:
|
||||
seen: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(request.url)
|
||||
seen["auth"] = request.headers.get("authorization")
|
||||
seen["body"] = json.loads(request.content)
|
||||
return httpx.Response(201, json=pr_json(7, "milestone-1"))
|
||||
|
||||
pr = client(handler).open_pull_request(
|
||||
"o/r", head="milestone-1", base="main", title="T", body="B"
|
||||
)
|
||||
assert seen["url"] == "https://gitea.test/api/v1/repos/o/r/pulls"
|
||||
assert seen["auth"] == "token tok"
|
||||
assert seen["body"] == {
|
||||
"head": "milestone-1",
|
||||
"base": "main",
|
||||
"title": "T",
|
||||
"body": "B",
|
||||
}
|
||||
assert pr.number == 7
|
||||
assert pr.head == "milestone-1"
|
||||
|
||||
|
||||
def test_find_pull_request_matches_head_branch() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.params.get("state") == "open":
|
||||
return httpx.Response(200, json=[pr_json(3, "other")])
|
||||
return httpx.Response(200, json=[])
|
||||
|
||||
assert client(handler).find_pull_request("o/r", "milestone-1") is None
|
||||
|
||||
|
||||
def test_find_pull_request_also_checks_closed() -> None:
|
||||
# A crash after opening must not be answered with "nothing open, open
|
||||
# another" — the effect already happened.
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.params.get("state") == "open":
|
||||
return httpx.Response(200, json=[])
|
||||
return httpx.Response(200, json=[pr_json(4, "milestone-1", "closed")])
|
||||
|
||||
found = client(handler).find_pull_request("o/r", "milestone-1")
|
||||
assert found is not None
|
||||
assert found.number == 4
|
||||
|
||||
|
||||
def test_error_status_raises_gitea_error() -> None:
|
||||
handler = lambda request: httpx.Response(403, text="forbidden")
|
||||
with pytest.raises(GiteaError, match="403"):
|
||||
client(handler).whoami()
|
||||
|
||||
|
||||
def test_list_and_post_comments() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.method == "POST":
|
||||
return httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"id": 9,
|
||||
"user": {"login": "don"},
|
||||
"body": "hi",
|
||||
"created_at": "t",
|
||||
},
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"id": 8,
|
||||
"user": {"login": "don"},
|
||||
"body": "approve rev-1",
|
||||
"created_at": "t",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
c = client(handler)
|
||||
assert c.list_comments("o/r", 1) == [
|
||||
Comment(id=8, author="don", body="approve rev-1", created_at="t")
|
||||
]
|
||||
assert c.post_comment("o/r", 1, "hi").id == 9
|
||||
|
||||
|
||||
def test_list_comments_passes_since() -> None:
|
||||
seen: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["since"] = request.url.params.get("since")
|
||||
return httpx.Response(200, json=[])
|
||||
|
||||
client(handler).list_comments("o/r", 1, since="2026-09-07T00:00:00Z")
|
||||
assert seen["since"] == "2026-09-07T00:00:00Z"
|
||||
|
||||
|
||||
def test_from_env_requires_config() -> None:
|
||||
with pytest.raises(GiteaError, match="NISSE_GITEA_URL"):
|
||||
GiteaClient.from_env({})
|
||||
|
||||
|
||||
# --- reconciler (A3 external truth) --------------------------------------
|
||||
|
||||
|
||||
def test_reconciler_reports_existing_pr_as_done() -> None:
|
||||
handler = lambda request: httpx.Response(
|
||||
200,
|
||||
json=[pr_json(5, "milestone-1")]
|
||||
if request.url.params.get("state") == "open"
|
||||
else [],
|
||||
)
|
||||
assert pull_request_reconciler(client(handler), "o/r")(INTENT) is True
|
||||
|
||||
|
||||
def test_reconciler_reports_missing_pr_as_not_done() -> None:
|
||||
handler = lambda request: httpx.Response(200, json=[])
|
||||
assert pull_request_reconciler(client(handler), "o/r")(INTENT) is False
|
||||
|
||||
|
||||
def test_unreachable_gitea_is_unverifiable_not_a_guess() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("refused")
|
||||
|
||||
# None gates to the human: a delayed decision is cheap, a duplicate PR
|
||||
# is not.
|
||||
assert pull_request_reconciler(client(handler), "o/r")(INTENT) is None
|
||||
|
||||
|
||||
def test_intent_without_a_branch_is_unverifiable() -> None:
|
||||
handler = lambda request: httpx.Response(200, json=[])
|
||||
blank = Intent(
|
||||
id=2,
|
||||
kind="open_pr",
|
||||
payload={},
|
||||
status="pending",
|
||||
created_at="t",
|
||||
resolved_at=None,
|
||||
)
|
||||
assert pull_request_reconciler(client(handler), "o/r")(blank) is None
|
||||
Reference in new issue
Block a user