Add Gitea client and git operations (B2) — step 6a, part 1 #14
No files matched your search
@@ -20,6 +20,9 @@ import httpx
|
|||||||
from nisse_dev.reconcile import Reconciler
|
from nisse_dev.reconcile import Reconciler
|
||||||
from nisse_dev.state import Intent
|
from nisse_dev.state import Intent
|
||||||
|
|
||||||
|
PR_PAGE_SIZE = 50
|
||||||
|
MAX_PR_PAGES = 40 # 2000 PRs; beyond that, say so rather than guess
|
||||||
|
|
||||||
|
|
||||||
class GiteaError(RuntimeError):
|
class GiteaError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
@@ -92,16 +95,31 @@ class GiteaClient:
|
|||||||
|
|
||||||
def find_pull_request(self, repo: str, head: str) -> PullRequest | None:
|
def find_pull_request(self, repo: str, head: str) -> PullRequest | None:
|
||||||
"""External truth for A3: did the PR for this branch already get
|
"""External truth for A3: did the PR for this branch already get
|
||||||
opened? Checks closed PRs too — a crash after opening must not be
|
opened? Includes closed PRs — a crash after opening must not be
|
||||||
answered with "no PR is open, so open another"."""
|
answered with "no PR is open, so open another".
|
||||||
for state in ("open", "closed"):
|
|
||||||
|
Pages to the end rather than sampling the first page: a "not found"
|
||||||
|
that only means "not in the newest 50" would licence a duplicate PR.
|
||||||
|
If the history is longer than this walk, raise instead of guessing —
|
||||||
|
the reconciler turns that into "unverifiable" and gates to the human.
|
||||||
|
"""
|
||||||
|
for page in range(1, MAX_PR_PAGES + 1):
|
||||||
data = self._request(
|
data = self._request(
|
||||||
"GET", f"/repos/{repo}/pulls", params={"state": state, "limit": 50}
|
"GET",
|
||||||
|
f"/repos/{repo}/pulls",
|
||||||
|
params={"state": "all", "limit": PR_PAGE_SIZE, "page": page},
|
||||||
)
|
)
|
||||||
for item in data or []:
|
items = data or []
|
||||||
|
for item in items:
|
||||||
if (item.get("head") or {}).get("ref") == head:
|
if (item.get("head") or {}).get("ref") == head:
|
||||||
return _pull_request(item)
|
return _pull_request(item)
|
||||||
return None
|
if len(items) < PR_PAGE_SIZE:
|
||||||
|
return None # reached the end: definitively absent
|
||||||
|
raise GiteaError(
|
||||||
|
f"searched {MAX_PR_PAGES * PR_PAGE_SIZE} pull requests in {repo} "
|
||||||
|
f"without reaching the end; cannot confirm whether {head!r} was "
|
||||||
|
"already opened"
|
||||||
|
)
|
||||||
|
|
||||||
def list_comments(
|
def list_comments(
|
||||||
self, repo: str, index: int, since: str | None = None
|
self, repo: str, index: int, since: str | None = None
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import httpx
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nisse_dev.gitea import (
|
from nisse_dev.gitea import (
|
||||||
|
PR_PAGE_SIZE,
|
||||||
Comment,
|
Comment,
|
||||||
GiteaClient,
|
GiteaClient,
|
||||||
GiteaError,
|
GiteaError,
|
||||||
@@ -61,27 +62,58 @@ def test_open_pull_request_posts_expected_body() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_find_pull_request_matches_head_branch() -> None:
|
def test_find_pull_request_matches_head_branch() -> None:
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
handler = lambda request: httpx.Response(200, json=[pr_json(3, "other")])
|
||||||
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
|
assert client(handler).find_pull_request("o/r", "milestone-1") is None
|
||||||
|
|
||||||
|
|
||||||
def test_find_pull_request_also_checks_closed() -> None:
|
def test_find_pull_request_also_finds_closed() -> None:
|
||||||
# A crash after opening must not be answered with "nothing open, open
|
# A crash after opening must not be answered with "nothing open, open
|
||||||
# another" — the effect already happened.
|
# another" — the effect already happened.
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
handler = lambda request: httpx.Response(
|
||||||
if request.url.params.get("state") == "open":
|
200, json=[pr_json(4, "milestone-1", "closed")]
|
||||||
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")
|
found = client(handler).find_pull_request("o/r", "milestone-1")
|
||||||
assert found is not None
|
assert found is not None
|
||||||
assert found.number == 4
|
assert found.number == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_pull_request_pages_past_the_first_page() -> None:
|
||||||
|
# The match sits on page 2; sampling only the newest page would report
|
||||||
|
# "absent" and licence a duplicate PR.
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
page = int(request.url.params.get("page", 1))
|
||||||
|
if page == 1:
|
||||||
|
return httpx.Response(
|
||||||
|
200, json=[pr_json(i, f"other-{i}") for i in range(PR_PAGE_SIZE)]
|
||||||
|
)
|
||||||
|
return httpx.Response(200, json=[pr_json(999, "milestone-1", "closed")])
|
||||||
|
|
||||||
|
found = client(handler).find_pull_request("o/r", "milestone-1")
|
||||||
|
assert found is not None
|
||||||
|
assert found.number == 999
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_page_ends_the_search_definitively() -> None:
|
||||||
|
calls: list[int] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
calls.append(int(request.url.params.get("page", 1)))
|
||||||
|
return httpx.Response(200, json=[pr_json(1, "other")])
|
||||||
|
|
||||||
|
assert client(handler).find_pull_request("o/r", "milestone-1") is None
|
||||||
|
assert calls == [1] # one short page proves absence; no further paging
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbounded_history_raises_rather_than_guessing() -> None:
|
||||||
|
# Never answer "absent" from an incomplete search: the reconciler turns
|
||||||
|
# this error into "unverifiable" and gates to the human.
|
||||||
|
handler = lambda request: httpx.Response(
|
||||||
|
200, json=[pr_json(i, f"other-{i}") for i in range(PR_PAGE_SIZE)]
|
||||||
|
)
|
||||||
|
with pytest.raises(GiteaError, match="without reaching the end"):
|
||||||
|
client(handler).find_pull_request("o/r", "milestone-1")
|
||||||
|
|
||||||
|
|
||||||
def test_error_status_raises_gitea_error() -> None:
|
def test_error_status_raises_gitea_error() -> None:
|
||||||
handler = lambda request: httpx.Response(403, text="forbidden")
|
handler = lambda request: httpx.Response(403, text="forbidden")
|
||||||
with pytest.raises(GiteaError, match="403"):
|
with pytest.raises(GiteaError, match="403"):
|
||||||
@@ -139,12 +171,7 @@ def test_from_env_requires_config() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_reconciler_reports_existing_pr_as_done() -> None:
|
def test_reconciler_reports_existing_pr_as_done() -> None:
|
||||||
handler = lambda request: httpx.Response(
|
handler = lambda request: httpx.Response(200, json=[pr_json(5, "milestone-1")])
|
||||||
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
|
assert pull_request_reconciler(client(handler), "o/r")(INTENT) is True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user