The standalone half of the Gitea layer. I split it this way because the orchestrator wiring (PR_OPEN, opening a PR after a milestone) touches control.py, which #12 also changes — this part conflicts with nothing and can land independently.
gitea.py — GiteaClient over httpx: open PRs, list and post issue comments, close PRs. Polling rather than webhooks per B2, so the VM stays outbound-only, and intake is structured JSON.
find_pull_request is the A3 payoff. It's the external-truth query that makes opening a PR idempotent across a crash — and it deliberately checks closed PRs too, since a crash after opening must not be answered with "nothing is open, so open another".
pull_request_reconciler returns None when Gitea is unreachable rather than guessing, so the agent gates to the human. A delayed decision costs nothing; a duplicate PR costs cleanup.
git.py — branch, commit, push. 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's nothing to commit, because an empty milestone is a fact to report, not an error.
CI secrets wired and .env.example documented.
Verified against the real sandbox before writing any of it: push to main, push a branch, open PR, comment, read comments, close, delete branch. The live test now does that round trip on every run and cleans up after itself. I also seeded the sandbox's initial commit, since it was empty and a PR needs a base.
19 new unit tests (mocked HTTP), 109 unit + 9 integration green locally.
One setup question for you — see my message: the sandbox repo is owned by nisse-dev-testing itself, which gives the agent account admin on it. That's more privilege than production should have, and it means a self-merge test wouldn't be meaningful. Cheap to change, not blocking this PR.
The standalone half of the Gitea layer. I split it this way because the orchestrator wiring (`PR_OPEN`, opening a PR after a milestone) touches `control.py`, which #12 also changes — this part conflicts with nothing and can land independently.
- **`gitea.py`** — `GiteaClient` over `httpx`: open PRs, list and post issue comments, close PRs. Polling rather than webhooks per B2, so the VM stays outbound-only, and intake is structured JSON.
- **`find_pull_request` is the A3 payoff.** It's the external-truth query that makes opening a PR idempotent across a crash — and it deliberately checks **closed** PRs too, since a crash after opening must not be answered with "nothing is open, so open another".
- **`pull_request_reconciler`** returns `None` when Gitea is unreachable rather than guessing, so the agent gates to the human. A delayed decision costs nothing; a duplicate PR costs cleanup.
- **`git.py`** — branch, commit, push. 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's nothing to commit, because an empty milestone is a fact to report, not an error.
- CI secrets wired and `.env.example` documented.
**Verified against the real sandbox** before writing any of it: push to main, push a branch, open PR, comment, read comments, close, delete branch. The live test now does that round trip on every run and cleans up after itself. I also seeded the sandbox's initial commit, since it was empty and a PR needs a base.
19 new unit tests (mocked HTTP), 109 unit + 9 integration green locally.
**One setup question for you** — see my message: the sandbox repo is owned by `nisse-dev-testing` itself, which gives the agent account admin on it. That's more privilege than production should have, and it means a self-merge test wouldn't be meaningful. Cheap to change, not blocking this PR.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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>
find_pull_request read only the newest 50 per state, so once a repo
passed that many pull requests a "not found" would mean "not in the
newest page" — and the A3 reconciler would read that as "the PR was
never opened" and open a duplicate. Exactly the side effect the
intent log exists to prevent.
It now pages until a short page proves the end, and raises rather
than answering "absent" from an incomplete search; the reconciler
turns that into unverifiable and gates to the human.
Found while wiring CI: every CI run opens a PR in the sandbox, so the
threshold would have been crossed in weeks rather than never.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
The standalone half of the Gitea layer. I split it this way because the orchestrator wiring (
PR_OPEN, opening a PR after a milestone) touchescontrol.py, which #12 also changes — this part conflicts with nothing and can land independently.gitea.py—GiteaClientoverhttpx: open PRs, list and post issue comments, close PRs. Polling rather than webhooks per B2, so the VM stays outbound-only, and intake is structured JSON.find_pull_requestis the A3 payoff. It's the external-truth query that makes opening a PR idempotent across a crash — and it deliberately checks closed PRs too, since a crash after opening must not be answered with "nothing is open, so open another".pull_request_reconcilerreturnsNonewhen Gitea is unreachable rather than guessing, so the agent gates to the human. A delayed decision costs nothing; a duplicate PR costs cleanup.git.py— branch, commit, push. 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_allreturnsNonewhen there's nothing to commit, because an empty milestone is a fact to report, not an error..env.exampledocumented.Verified against the real sandbox before writing any of it: push to main, push a branch, open PR, comment, read comments, close, delete branch. The live test now does that round trip on every run and cleans up after itself. I also seeded the sandbox's initial commit, since it was empty and a PR needs a base.
19 new unit tests (mocked HTTP), 109 unit + 9 integration green locally.
One setup question for you — see my message: the sandbox repo is owned by
nisse-dev-testingitself, which gives the agent account admin on it. That's more privilege than production should have, and it means a self-merge test wouldn't be meaningful. Cheap to change, not blocking this PR.🤖 Generated with Claude Code