Continuous Development: An Autonomous Gitea-Claude Workflow

I Gave Claude Code a Gitea Account

I wanted to assign a Gitea issue to an AI teammate and get merged code back, without babysitting a terminal. So I gave Claude Code its own account on my self-hosted Gitea and built a small dispatcher that turns issue assignment into pull requests, CI fixes, and merges.

My homeserver already runs Gitea, a Gitea Actions runner, and a few dozen containers across Portainer stacks. Claude Code was already my daily driver for interactive work. The missing piece was letting it work on its own, the way a junior engineer works a ticket queue: pick up the next ticket, open a PR, fix what CI complains about, ship it, repeat.

This post covers the design and the first version of the code. It hasn't survived contact with real repos yet, so a follow-up will cover what broke.

The rules

Before writing any code, I wrote down how the bot should behave:

  1. Work comes from Gitea Issues. Assigning an issue to the claude user is the only trigger.
  2. One issue at a time per repo. Different repos can run in parallel.
  3. Follow the work all the way through: open the PR (or a stack of PRs), fix CI, handle feedback, merge.
  4. Once everything for an issue is merged to main, pick up the next assigned issue.
  5. Questions don't get guessed at. They become new issues assigned to me.

Rules 2 through 4 turned out to drive the whole architecture.

Why not just a Gitea Action

The obvious first move was CI. There's a community port of the Claude Code action for Gitea, and I already run an Actions runner. You mention @claude in an issue or PR, a job spins up, and Claude does the thing.

That's great for one-shot requests. It falls apart on my rules because every Actions job is stateless and knows nothing about the job before it. To enforce one issue per repo, track which PRs belong to which issue, rebase a stack after the bottom PR merges, and know when an issue is truly done, every run would have to rebuild that picture from scratch in workflow YAML.

What I actually needed was a long-running process with a little memory. A dispatcher, not a job.

The architecture

The dispatcher is a small FastAPI service that shares a container with the Claude Code CLI. Gitea is the source of truth for everything: labels, assignees, PR state, and CI status.

Webhooks from Gitea only wake the loop. The real work happens in a reconcile pass that also runs every 60 seconds, because webhooks get dropped and a missed event shouldn't strand an issue. Each pass reads the current state from Gitea and decides the next step for each repo.

SQLite stores only what Gitea can't: for each repo, the active issue, the Claude session ID, a retry count, and a comment watermark. Claude runs headless with claude -p --output-format json. The dispatcher saves the session ID, so a CI fix or a review comment resumes the same session with --resume instead of starting cold. The official gitea-mcp server gives Claude tools for issues and PRs, and a semaphore caps concurrent sessions so parallel test suites don't trigger the OOM killer.

The life of an issue

Every issue follows the same loop, and labels on the issue (claude:working, claude:in-review, claude:blocked) show me where it is without opening a dashboard.

  1. Pick. The oldest open issue assigned to the bot gets picked first, unless one is labeled priority:high. The dispatcher comments, sets the label, and gives Claude the issue text and discussion on a clean checkout of main.
  2. Implement. Claude reads the repo before writing anything, then pushes claude/<issue>-<slug> and opens a PR. A bigger change becomes a stack of PRs, each targeting the branch below it.
  3. Review loop. A red CI run or a new comment from me resumes the same Claude session with the details. It fixes, pushes, and waits for CI again.
  4. Merge. When the bottom PR is green and mergeable, the dispatcher merges it.
  5. Restack. If PRs are stacked on top, the dispatcher rebases the next one onto main with git rebase --onto, then retargets it. Using the old parent tip as the cut point keeps this working with squash merges. Claude only gets involved if the rebase conflicts.
  6. Done. When nothing is left open, the dispatcher closes the issue with a comment that includes the Claude Code cost, deletes the branches, and frees the repo for the next assigned issue.

None of this lives in Claude's head. If the container restarts mid-review, the next reconcile pass reads the PRs and labels and picks up where it left off.

Questions become issues

The part I like most: Claude doesn't ask me questions in a chat window I'm not watching. It files them where I already work. Each question becomes a new issue, assigned to me, labeled claude:question, linked back to the source issue, with Claude's recommended answer and the alternatives.

Questions come in two kinds:

  • Non-blocking. Claude can proceed on a stated assumption, or it spotted a follow-up or tech debt along the way. It files the issue, notes the assumption in the PR, and keeps going.
  • Blocking. Proceeding would probably waste the work. Claude files the issue and stops, and the source issue gets claude:blocked.

To unblock, I reply on the question issue, or just close it, which means "go with your recommendation." The dispatcher sees that, resumes the same Claude session with my answer, and the work continues. A blocked issue keeps its repo's slot. If I'd rather skip it, I unassign the bot.

The dispatcher uses the same channel for its own trouble: CI still red after three fix attempts, a merge conflict a rebase can't resolve, or a repo with no CI at all. Each one becomes a question issue instead of a silent retry loop that burns tokens overnight.

Auto-merge, and what it forced

I went with the aggressive option: when CI is green, the bot merges its own PR with no human review. These are my side projects and homelab repos, and the point is throughput. But it means CI is now the only reviewer, and that changed several design choices.

  • No CI, no merge. With REQUIRE_CI=true, a PR that reports zero commit statuses never auto-merges. After a grace period, the dispatcher files a question telling me the repo needs CI. A green check from an empty pipeline would be worse than no automation.
  • The model knows there's no reviewer. The system prompt tells Claude its PRs merge unreviewed. It's asked to write tests that would catch a wrong implementation, keep PRs small, and say in the PR body what it verified.
  • Hard rules against gaming CI. Claude may never push to main, merge, weaken CI config, or delete tests to get green. Branch protection enforces the first two. The prompt carries the rest.
  • Bounded retries. Three fix attempts per PR, then a question issue. A stuck loop shouldn't be able to run up an API bill while I sleep.
  • Escape hatches. A /merge comment from me on the bottom PR merges it regardless of CI. Unassigning the bot stops work on the issue.

One gotcha: Gitea branch protection has to require zero approvals. The bot can't approve its own PRs, so any required approval deadlocks the whole pipeline.

Containers and security

The original plan had separate dispatcher and worker containers. To run Claude in a different container, though, the dispatcher would need the Docker socket. Mounting /var/run/docker.sock into a container that runs an autonomous agent hands that agent root on the whole homeserver. So both run in one container, and Claude runs as a subprocess under a non-root user.

"Access across containers" ended up meaning a dedicated network instead. The agent sits on an agent-sandbox network with a throwaway Postgres and Redis for running test suites, plus a link to the Gitea network. It can reach what it needs to test, and nothing else on the box.

The less obvious risk is prompt injection. Issue text goes straight into the prompt, so anyone who can file an issue can effectively instruct the bot. In my setup that's just me. If you open this up to other people, treat issue write access like commit access. The bot's Gitea token is also scoped to the repos where it's a collaborator, so a confused session can only damage repos I've opted in.

What's next

The dispatcher is about 500 lines of Python: a webhook endpoint, the reconcile loop, a thin Gitea client, and prompt templates. Next up is pointing it at a few real repos and seeing what breaks. My guesses: the stacked-PR rebase path, and Rails repos that need a full toolchain inside the agent image before Claude can run a single test.

A few takeaways so far:

  • Make the forge the source of truth. Labels, assignees, and PR state already describe the work. The dispatcher only remembers what Gitea can't: a session ID and a retry count.
  • Run the agent like a coworker, not a script. It picks up tickets, asks questions in the issue tracker, and answers review comments. Those are all channels I already check.
  • Autonomy is a CI problem. Once there's no human reviewer, the quality of your test suite is the quality of what ships.

Pull the code, test it out, and let me know if it helps your development workflow!

Comments 0

Leave a Comment

Your email is optional and will only be used to display your name.
Comments are moderated and may take time to appear.

No comments yet. Be the first to share your thoughts!