Add teabot daemon implementation
teabot watches Gitea repos and dispatches one-shot Claude Code sessions in Docker containers to work issues and review PRs, acting as configurable bot personalities. Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# built binary (repo root only)
|
||||
/teabot
|
||||
# cross-compiled release artifacts (e.g. teabot-linux-amd64)
|
||||
/teabot-*
|
||||
# build output: binaries, completions, RPM
|
||||
dist/
|
||||
# local checksums manifest produced by the release pipeline
|
||||
sha256sums.txt
|
||||
@@ -0,0 +1,17 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-merge-conflict
|
||||
- id: mixed-line-ending
|
||||
args: [--fix=lf]
|
||||
|
||||
- repo: https://github.com/dnephin/pre-commit-golang
|
||||
rev: v0.5.1
|
||||
hooks:
|
||||
- id: go-fmt
|
||||
- id: go-vet
|
||||
- id: go-unit-tests
|
||||
@@ -0,0 +1,18 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- make build
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
@@ -0,0 +1,18 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
steps:
|
||||
- name: pre-commit
|
||||
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
|
||||
commands:
|
||||
- uvx pre-commit run --all-files
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
@@ -0,0 +1,144 @@
|
||||
when:
|
||||
- event: tag
|
||||
|
||||
steps:
|
||||
- name: test
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- go test -race ./...
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
|
||||
# Build the dist/ binary (consumed by the RPM step) plus the cross-platform
|
||||
# binaries attached to the Gitea release.
|
||||
- name: build
|
||||
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
|
||||
commands:
|
||||
- make build VERSION=${CI_COMMIT_TAG}
|
||||
# Shell vars/expansions are escaped as $$ so Woodpecker leaves them for
|
||||
# the shell; ${CI_COMMIT_TAG} is a real Woodpecker var and stays single-$.
|
||||
- |
|
||||
for osarch in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
|
||||
os="$${osarch%/*}"; arch="$${osarch#*/}"
|
||||
GOOS="$$os" GOARCH="$$arch" \
|
||||
go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" \
|
||||
-o "teabot-$${os}-$${arch}" .
|
||||
done
|
||||
depends_on: [test]
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
|
||||
# Package the built binary + completions + systemd unit + example config into
|
||||
# an RPM.
|
||||
- name: package
|
||||
image: git.unkin.net/unkin/almalinux9-rpmbuilder:latest
|
||||
commands:
|
||||
- ./scripts/build-rpm.sh ${CI_COMMIT_TAG}
|
||||
depends_on: [build]
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
|
||||
# Publish the RPM to the artifactapi local rpm repo (a real yum repo;
|
||||
# repodata regenerates automatically).
|
||||
- name: upload-rpm
|
||||
image: git.unkin.net/unkin/almalinux9-base:20260606
|
||||
commands:
|
||||
- |
|
||||
HOST="https://artifactapi.k8s.syd1.au.unkin.net"
|
||||
REPO="rpm-internal"
|
||||
for rpm in dist/*.rpm; do
|
||||
FILE=$$(basename "$$rpm")
|
||||
# artifactapi has no HEAD route (returns 405); probe with GET against
|
||||
# the served path (RPMs are stored under Packages/) to avoid re-upload.
|
||||
code=$$(curl -s -o /dev/null -w '%{http_code}' "$$HOST/api/v2/remotes/$$REPO/files/Packages/$$FILE" || true)
|
||||
if [ "$$code" = "200" ]; then
|
||||
echo "$$FILE already exists in $$REPO (HTTP $$code); skipping upload"
|
||||
continue
|
||||
fi
|
||||
echo "Uploading $$FILE to $$REPO (existence probe returned $$code)"
|
||||
curl -f -X PUT \
|
||||
"$$HOST/api/v2/remotes/$$REPO/files/$$FILE" \
|
||||
-H "Content-Type: application/x-rpm" \
|
||||
--data-binary @"$$rpm"
|
||||
done
|
||||
depends_on: [package]
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 128Mi
|
||||
cpu: 100m
|
||||
limits:
|
||||
memory: 512Mi
|
||||
cpu: 500m
|
||||
|
||||
# Cut a Gitea release with the cross-platform binaries + RPM attached.
|
||||
- name: release
|
||||
image: git.unkin.net/unkin/almalinux9-base:20260606
|
||||
environment:
|
||||
RELEASER_TOKEN:
|
||||
from_secret: RELEASER_TOKEN
|
||||
commands:
|
||||
- |
|
||||
curl --output /usr/local/bin/tea https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/gitea-dl/tea/0.12.0/tea-0.12.0-linux-amd64 && chmod +x /usr/local/bin/tea
|
||||
tea logins add --name gitea --url https://git.unkin.net --token "$${RELEASER_TOKEN}" --no-version-check
|
||||
# $$ escapes shell vars so Woodpecker doesn't blank them; ${CI_COMMIT_TAG}
|
||||
# and ${CI_REPO} are real Woodpecker vars. Find the previous release tag
|
||||
# for the changelog range, skipping tags on the current commit.
|
||||
CUR_SHA=$$(git rev-list -n1 "${CI_COMMIT_TAG}")
|
||||
PREV_TAG=""
|
||||
for t in $$(git tag --sort=-v:refname); do
|
||||
[ "$$t" = "${CI_COMMIT_TAG}" ] && continue
|
||||
[ "$$(git rev-list -n1 "$$t")" = "$$CUR_SHA" ] && continue
|
||||
if git merge-base --is-ancestor "$$t" "${CI_COMMIT_TAG}" 2>/dev/null; then
|
||||
PREV_TAG="$$t"; break
|
||||
fi
|
||||
done
|
||||
if [ -n "$$PREV_TAG" ]; then
|
||||
NOTES=$$(git log "$${PREV_TAG}..${CI_COMMIT_TAG}" --pretty=format:"- %s")
|
||||
else
|
||||
NOTES=$$(git log --pretty=format:"- %s")
|
||||
fi
|
||||
tea releases create --tag "${CI_COMMIT_TAG}" --title "${CI_COMMIT_TAG}" --note "$${NOTES}" --login gitea --repo "${CI_REPO}"
|
||||
RPM=$$(ls dist/*.rpm 2>/dev/null | head -1)
|
||||
ASSETS="teabot-linux-amd64 teabot-linux-arm64 teabot-darwin-amd64 teabot-darwin-arm64"
|
||||
[ -n "$$RPM" ] && ASSETS="$$ASSETS $$RPM"
|
||||
sha256sum $$ASSETS > sha256sums.txt
|
||||
tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS sha256sums.txt \
|
||||
--login gitea --repo "${CI_REPO}"
|
||||
depends_on: [upload-rpm]
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 128Mi
|
||||
cpu: 100m
|
||||
limits:
|
||||
memory: 512Mi
|
||||
cpu: 500m
|
||||
@@ -0,0 +1,33 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
steps:
|
||||
- name: lint
|
||||
image: golangci/golangci-lint:latest
|
||||
commands:
|
||||
- golangci-lint run ./...
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
|
||||
- name: test
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- go test -race ./...
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
@@ -0,0 +1,76 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project Overview
|
||||
|
||||
teabot is a Go daemon (systemd **user** service) that watches Gitea repositories
|
||||
and dispatches **one-shot Claude Code sessions in Docker containers** to work
|
||||
issues and review pull requests. It polls the Gitea API for new issues, pull
|
||||
requests, and comments; for each event it runs a throwaway container that clones
|
||||
the repo and runs `claude --print` with a task-specific prompt, acting as a
|
||||
configurable bot **personality**.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
main.go # package main; wires version into internal/cli
|
||||
internal/cli/ # cobra command tree: run, config init/show
|
||||
internal/config/ # config.yaml + tea-config parsing, defaults, validation
|
||||
internal/gitea/ # read-only Gitea REST client (issues/pulls/comments/diff)
|
||||
internal/state/ # processed-event persistence (JSON, atomic writes)
|
||||
internal/prompt/ # per-event prompt construction (issue/pull/follow-up)
|
||||
internal/dispatch/ # poll loop, event filtering, loop prevention, job orchestration
|
||||
internal/docker/ # containerised Claude execution behind a Runner interface
|
||||
docs/ # architecture + configuration + per-subcommand docs
|
||||
packaging/nfpm.yaml # RPM spec (binary + completions + systemd unit + example config)
|
||||
scripts/build-rpm.sh # generates completions + packages the RPM with nfpm
|
||||
systemd/teabot.service # systemd user unit
|
||||
config.example.yaml # example config (also embedded for `config init`)
|
||||
.woodpecker/ # CI: build, test, pre-commit (PR) + release (tag)
|
||||
```
|
||||
|
||||
## Build / test
|
||||
|
||||
```bash
|
||||
make build # -> dist/teabot (CGO disabled, static)
|
||||
make test # go test -race ./...
|
||||
make rpm # build + package RPM (needs nfpm)
|
||||
```
|
||||
|
||||
Requires Go 1.25+. Deps: `github.com/spf13/cobra`, `gopkg.in/yaml.v3`.
|
||||
|
||||
## Design notes for contributors
|
||||
|
||||
- **Loop prevention is load-bearing.** Any event authored by a personality's
|
||||
Gitea username is skipped (but recorded). Never remove this — it is what stops
|
||||
the bot reacting to its own PRs/comments forever.
|
||||
- **Dedup before dispatch.** Issues/PRs/comments are marked processed in the
|
||||
state store *before* their job starts, so a re-poll or mid-job restart cannot
|
||||
double-launch. State lives at `~/.local/state/teabot/state.json`.
|
||||
- **First-contact seeding.** The first poll of a repo records existing open
|
||||
items as processed without dispatching, so a fresh install doesn't stampede.
|
||||
- **Follow-ups only on acted threads.** Comment follow-ups fire only when teabot
|
||||
previously opened a PR for / reviewed the parent issue/PR.
|
||||
- **Docker is behind `docker.Runner`.** All dispatch logic is tested with a fake
|
||||
runner; no Docker daemon is needed for `go test`. `DockerRunner.buildArgs` is
|
||||
pure given a job dir so the `docker run` argument list is unit-tested directly.
|
||||
- **Personalities use tea configs.** tea persists to `$XDG_CONFIG_HOME/tea` and
|
||||
has no `--config` flag, so each personality's config is mounted into the
|
||||
container and `XDG_CONFIG_HOME` is set there.
|
||||
- **Image reuse.** The default `job_image` is `agent-dev`, which already ships
|
||||
the claude CLI + dev toolchain; teabot does not build its own image.
|
||||
|
||||
## Testing conventions
|
||||
|
||||
Every package has meaningful unit tests (no rubber-stamps): config precedence &
|
||||
validation & tea parsing, state persistence/dedup roundtrips, gitea client via
|
||||
`httptest`, prompt content assertions, docker arg/mount/env construction, and
|
||||
dispatch filtering/loop-prevention/seeding/follow-up routing via a fake client +
|
||||
fake runner. `go build ./...` and `go test -race ./...` must pass.
|
||||
|
||||
## Conventions (house rules)
|
||||
|
||||
- Branches `benvin/<name>`. PR bodies: short "why" paragraph + present-tense
|
||||
"how" bullets. HTTPS remotes for git.unkin.net (SSH blocked). Use `tea`, not
|
||||
`gh`. Do not merge PRs yourself.
|
||||
- Releases are Woodpecker pipelines triggered on `v*` tags; bump with
|
||||
`make patch|minor|major`. Every CI step sets k8s resource requests+limits.
|
||||
@@ -0,0 +1,70 @@
|
||||
BINARY := teabot
|
||||
DIST := dist
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
|
||||
OS ?= $(shell go env GOOS)
|
||||
ARCH ?= $(shell go env GOARCH)
|
||||
|
||||
.PHONY: all build test lint fmt vet clean install completions rpm rpm-package patch minor major _tag
|
||||
|
||||
all: build
|
||||
|
||||
# Build the teabot binary into dist/ (consumed by the RPM packaging step).
|
||||
build:
|
||||
@echo "building $(BINARY) $(VERSION)"
|
||||
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build $(GOFLAGS) -o $(DIST)/$(BINARY) .
|
||||
|
||||
test:
|
||||
go test -race ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
fmt:
|
||||
gofmt -w .
|
||||
|
||||
clean:
|
||||
rm -rf $(DIST) $(BINARY)
|
||||
|
||||
install:
|
||||
go install $(GOFLAGS) .
|
||||
|
||||
# Generate bash/zsh/fish completions into dist/completions.
|
||||
completions: build
|
||||
@mkdir -p $(DIST)/completions
|
||||
$(DIST)/$(BINARY) completion bash > $(DIST)/completions/$(BINARY).bash
|
||||
$(DIST)/$(BINARY) completion zsh > $(DIST)/completions/_$(BINARY)
|
||||
$(DIST)/$(BINARY) completion fish > $(DIST)/completions/$(BINARY).fish
|
||||
|
||||
# Build the binary then package it (with completions + systemd unit) into an RPM.
|
||||
rpm: build rpm-package
|
||||
|
||||
# Package an already-built binary into an RPM (used by CI after the build step).
|
||||
rpm-package:
|
||||
./scripts/build-rpm.sh $(VERSION)
|
||||
|
||||
# Bump helpers — read the latest semver tag and create the next one, then push
|
||||
# the tag to trigger the release pipeline.
|
||||
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
|
||||
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
|
||||
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
|
||||
_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
|
||||
_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
|
||||
|
||||
patch:
|
||||
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
|
||||
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
|
||||
|
||||
minor:
|
||||
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
|
||||
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
|
||||
|
||||
major:
|
||||
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
|
||||
git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW
|
||||
|
||||
_tag:
|
||||
git push origin $(TAG)
|
||||
@@ -1,3 +1,66 @@
|
||||
# teabot
|
||||
|
||||
A Go daemon that watches Gitea repos and dispatches one-shot Claude Code sessions in Docker to work issues and review PRs.
|
||||
teabot is a Go daemon (shipped as a systemd **user** service) that watches Gitea
|
||||
repositories and dispatches **one-shot Claude Code sessions in Docker containers**
|
||||
to work issues and review pull requests.
|
||||
|
||||
On each poll it looks for new issues, new pull requests, and new comments across
|
||||
the repos it watches. For each event it launches a throwaway container that
|
||||
clones the repo fresh and runs `claude --print` with a task-specific prompt:
|
||||
|
||||
- **New issue** → an *implementer* personality reviews it and, if it warrants a
|
||||
change, opens a PR that closes the issue (following the house PR conventions).
|
||||
- **New pull request** → a *reviewer* personality critiques the diff and posts an
|
||||
approving or change-requesting review.
|
||||
- **New comment** on a thread teabot already engaged with → a follow-up session
|
||||
responds or makes the requested change.
|
||||
|
||||
Each personality is a distinct Gitea bot account backed by its own `tea` config
|
||||
file, so an implementer bot can open PRs that a separate reviewer bot critiques.
|
||||
teabot never reacts to events authored by its own personalities (loop
|
||||
prevention) and persists processed state so restarts don't re-trigger work.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# 1. Create the config and edit it.
|
||||
teabot config init
|
||||
$EDITOR ~/.config/teabot/config.yaml
|
||||
|
||||
# 2. Create a tea login file per personality (a normal tea config.yml).
|
||||
tea logins add --name teabot-impl --url https://git.unkin.net --token <impl-token>
|
||||
tea logins add --name teabot-review --url https://git.unkin.net --token <review-token>
|
||||
# point each personality's tea_config: at the resulting config.yml
|
||||
|
||||
# 3. Validate, then run a single cycle to test.
|
||||
teabot config show
|
||||
teabot run --once
|
||||
|
||||
# 4. Enable the daemon.
|
||||
systemctl --user enable --now teabot
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Architecture](docs/architecture.md) — how polling, dispatch, and the Docker
|
||||
execution model fit together.
|
||||
- [Configuration](docs/configuration.md) — every config key, personalities, and
|
||||
Claude/tea credential handling.
|
||||
- [`teabot run`](docs/run.md) — running the daemon, `--once`, logging, systemd.
|
||||
- [`teabot config`](docs/config.md) — the `init` and `show` subcommands.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
make build # -> dist/teabot (static, CGO disabled)
|
||||
make test # go test -race ./...
|
||||
make rpm # build + package an RPM (needs nfpm)
|
||||
```
|
||||
|
||||
Requires Go 1.25+. A `v*` tag triggers the release pipeline: a Gitea release
|
||||
with cross-platform binaries plus an RPM published to the artifactapi
|
||||
`rpm-internal` repo.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# teabot configuration
|
||||
# Location: $XDG_CONFIG_HOME/teabot/config.yaml (default ~/.config/teabot/config.yaml)
|
||||
|
||||
# Base URL of the Gitea instance to watch.
|
||||
gitea_url: https://git.unkin.net
|
||||
|
||||
# How often to poll each repo.
|
||||
poll_interval: 60s
|
||||
|
||||
# Repositories to watch, in owner/name form.
|
||||
repos:
|
||||
- unkin/teabot
|
||||
|
||||
# Maximum number of Claude job containers running at once.
|
||||
max_concurrent: 2
|
||||
|
||||
# Per-session wall-clock timeout.
|
||||
job_timeout: 30m
|
||||
|
||||
# Container image each session runs in. The default already ships the Claude
|
||||
# CLI plus a Go/Node/Python/tea developer toolchain.
|
||||
job_image: git.unkin.net/unkin/agent-dev:latest
|
||||
|
||||
# Home directory inside job_image (mount target for tea/claude config).
|
||||
container_home: /home/agent
|
||||
|
||||
# Host directory holding Claude Code credentials (subscription auth). A private
|
||||
# copy is mounted into each container so token refreshes never touch this dir.
|
||||
claude_config_dir: ~/.claude
|
||||
|
||||
# Optional: use an Anthropic API key / gateway instead of subscription auth.
|
||||
# When set these are injected as ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL.
|
||||
# anthropic_api_key: ""
|
||||
# anthropic_base_url: ""
|
||||
|
||||
# Optional: override the state directory (default ~/.local/state/teabot).
|
||||
# state_dir: ~/.local/state/teabot
|
||||
|
||||
# Bot personalities. Each is a distinct Gitea account backed by its own tea
|
||||
# config file (create it with: tea logins add --name <bot> ...). teabot reads
|
||||
# the token + username from that file and mounts it into the container so tea
|
||||
# acts as this identity. Roles: implementer, reviewer, both.
|
||||
personalities:
|
||||
- name: implementer
|
||||
tea_config: ~/.config/teabot/tea-implementer.yml
|
||||
role: implementer
|
||||
git_name: Teabot Implementer
|
||||
git_email: teabot-implementer@unkin.net
|
||||
- name: reviewer
|
||||
tea_config: ~/.config/teabot/tea-reviewer.yml
|
||||
role: reviewer
|
||||
git_name: Teabot Reviewer
|
||||
git_email: teabot-reviewer@unkin.net
|
||||
@@ -0,0 +1,78 @@
|
||||
# Architecture
|
||||
|
||||
teabot is a single binary with a small set of internal packages. The daemon
|
||||
polls Gitea, decides which events warrant work, and runs each unit of work as a
|
||||
disposable Docker container.
|
||||
|
||||
```
|
||||
main.go
|
||||
└── internal/cli cobra command tree (run, config)
|
||||
└── internal/dispatch poll loop, event filtering, job orchestration
|
||||
├── internal/gitea read-only Gitea REST client
|
||||
├── internal/state processed-event persistence (JSON)
|
||||
├── internal/prompt per-event prompt construction
|
||||
├── internal/config config + tea-config parsing
|
||||
└── internal/docker containerised Claude execution (Runner iface)
|
||||
```
|
||||
|
||||
## Poll cycle
|
||||
|
||||
`dispatch.Dispatcher` runs one `pollRepo` per watched repo each interval:
|
||||
|
||||
1. **List** open issues, open pull requests, and recent comments via the Gitea
|
||||
API (one client authenticated as the first personality — reads only).
|
||||
2. **Seed on first contact.** The very first poll of a repo records everything
|
||||
currently open/recent as *processed* without dispatching anything, so a fresh
|
||||
install does not stampede every existing item. The `seeded` flag is persisted
|
||||
per repo.
|
||||
3. **Classify & dispatch** subsequent events:
|
||||
- a new issue → implementer session,
|
||||
- a new pull request → reviewer session,
|
||||
- a new comment on a thread teabot **acted on** → follow-up session.
|
||||
|
||||
## Filtering rules (loop prevention + dedup)
|
||||
|
||||
Two independent guards decide whether an event becomes a job:
|
||||
|
||||
- **Loop prevention** — any event authored by one of teabot's own personality
|
||||
logins is skipped. This is what stops the bot reacting to its own PRs and
|
||||
comments in an infinite loop. Bot-authored items are still *recorded* as
|
||||
processed so they are never reconsidered.
|
||||
- **Dedup** — every dispatched issue/PR index and every seen comment ID is
|
||||
recorded in the state store (`~/.local/state/teabot/state.json`). An item is
|
||||
marked processed *before* its job starts, so a subsequent poll (or a restart
|
||||
mid-job) cannot double-launch it.
|
||||
|
||||
Comment follow-ups additionally require the parent issue/PR to be in the
|
||||
*acted-on* set — teabot only continues threads it started, never arbitrary
|
||||
comment threads. See [configuration](configuration.md) for the implication.
|
||||
|
||||
## Job execution
|
||||
|
||||
Each session is a `docker.Job` handed to a `docker.Runner`. The production
|
||||
`DockerRunner`:
|
||||
|
||||
1. Materialises a per-job scratch dir containing the prompt, a static
|
||||
`job.sh` entrypoint, a **private copy** of the Claude config dir (so
|
||||
subscription-token refreshes never mutate the host's `~/.claude`), and a copy
|
||||
of the personality's tea config.
|
||||
2. Runs `docker run --rm --entrypoint /bin/bash <image> /teabot/job.sh` with:
|
||||
- the scratch files bind-mounted (`:ro,z` under SELinux),
|
||||
- the tea config mounted at `$HOME/.config/tea/config.yml` and
|
||||
`XDG_CONFIG_HOME` set, so `tea` acts as the bot identity,
|
||||
- git identity + a credential-store token so clones and pushes authenticate,
|
||||
- `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` injected only when configured
|
||||
(otherwise the mounted subscription credentials are used).
|
||||
3. `job.sh` configures git, clones the repo, and runs
|
||||
`claude --print --dangerously-skip-permissions < /teabot/prompt.txt`.
|
||||
|
||||
The `Runner` interface means the whole dispatch layer is unit-testable with a
|
||||
fake runner — no Docker daemon required. Concurrency is bounded by a semaphore
|
||||
sized to `max_concurrent`, and every job has a `job_timeout`.
|
||||
|
||||
## Container image
|
||||
|
||||
teabot reuses the existing **`git.unkin.net/unkin/agent-dev:latest`** image,
|
||||
which already bundles the `claude` CLI plus a Go/Node/Python/`tea` developer
|
||||
toolchain and language servers. The image is configurable via `job_image`, so a
|
||||
purpose-built image can be substituted without code changes.
|
||||
@@ -0,0 +1,57 @@
|
||||
# `teabot config`
|
||||
|
||||
Inspect and scaffold teabot configuration.
|
||||
|
||||
## `teabot config init`
|
||||
|
||||
Writes a fully-commented example config to the `--config` path (default
|
||||
`~/.config/teabot/config.yaml`).
|
||||
|
||||
```bash
|
||||
teabot config init # writes ~/.config/teabot/config.yaml
|
||||
teabot config init --force # overwrite an existing file
|
||||
teabot config init -c ./my.yaml
|
||||
```
|
||||
|
||||
It refuses to overwrite an existing file unless `--force` is given. After
|
||||
writing, edit the file and create the tea config files each personality
|
||||
references (see [configuration](configuration.md)).
|
||||
|
||||
## `teabot config show`
|
||||
|
||||
Loads, validates, and prints the effective configuration — defaults applied and
|
||||
each personality resolved from its tea config (username shown, token never
|
||||
printed). A validation error here means `teabot run` would fail too, so this is
|
||||
the quickest pre-flight check.
|
||||
|
||||
```bash
|
||||
teabot config show
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
config: /home/ben/.config/teabot/config.yaml
|
||||
gitea_url: https://git.unkin.net
|
||||
state_dir: /home/ben/.local/state/teabot
|
||||
poll_interval: 1m0s
|
||||
job_timeout: 30m0s
|
||||
max_concurrent: 2
|
||||
job_image: git.unkin.net/unkin/agent-dev:latest
|
||||
claude_config: /home/ben/.claude
|
||||
repos:
|
||||
- unkin/teabot
|
||||
personalities:
|
||||
- implementer (role=implementer, login=teabot-implementer)
|
||||
- reviewer (role=reviewer, login=teabot-reviewer)
|
||||
```
|
||||
|
||||
## Shell completions
|
||||
|
||||
teabot uses cobra, which provides a `completion` subcommand. The RPM installs
|
||||
bash/zsh/fish completions to the standard system paths automatically. To load
|
||||
ad-hoc:
|
||||
|
||||
```bash
|
||||
source <(teabot completion bash) # or zsh / fish
|
||||
```
|
||||
@@ -0,0 +1,99 @@
|
||||
# Configuration
|
||||
|
||||
teabot reads a single YAML file, by default
|
||||
`$XDG_CONFIG_HOME/teabot/config.yaml` (i.e. `~/.config/teabot/config.yaml`).
|
||||
Override the path with `--config/-c`. Generate a starting point with
|
||||
[`teabot config init`](config.md), and validate the effective settings with
|
||||
`teabot config show`.
|
||||
|
||||
## Top-level keys
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `gitea_url` | `https://git.unkin.net` | Base URL of the Gitea instance to poll. |
|
||||
| `repos` | *(required)* | List of `owner/name` repositories to watch. |
|
||||
| `poll_interval` | `60s` | Delay between poll cycles (Go duration). |
|
||||
| `max_concurrent` | `2` | Maximum job containers running at once. |
|
||||
| `job_timeout` | `30m` | Per-session wall-clock timeout. |
|
||||
| `job_image` | `git.unkin.net/unkin/agent-dev:latest` | Container image each session runs in. |
|
||||
| `container_home` | `/home/agent` | Home dir inside `job_image` (mount target). |
|
||||
| `claude_config_dir` | `~/.claude` | Host dir with Claude Code credentials. |
|
||||
| `anthropic_api_key` | *(unset)* | If set, injected as `ANTHROPIC_API_KEY`. |
|
||||
| `anthropic_base_url` | *(unset)* | If set, injected as `ANTHROPIC_BASE_URL`. |
|
||||
| `state_dir` | `~/.local/state/teabot` | Where processed-event state is persisted. |
|
||||
| `personalities` | *(required)* | Bot identities (see below). |
|
||||
|
||||
`~` and `~/` are expanded in path-valued keys.
|
||||
|
||||
## Personalities
|
||||
|
||||
A personality is a distinct Gitea bot account. teabot must be able to act as
|
||||
different identities — e.g. an implementer that opens PRs and a separate reviewer
|
||||
that critiques them — so each personality points at its **own tea config file**
|
||||
rather than sharing your personal `~/.config/tea/config.yml`.
|
||||
|
||||
```yaml
|
||||
personalities:
|
||||
- name: implementer
|
||||
tea_config: ~/.config/teabot/tea-implementer.yml
|
||||
role: implementer # implementer | reviewer | both
|
||||
git_name: Teabot Implementer
|
||||
git_email: teabot-implementer@unkin.net
|
||||
- name: reviewer
|
||||
tea_config: ~/.config/teabot/tea-reviewer.yml
|
||||
role: reviewer
|
||||
git_name: Teabot Reviewer
|
||||
git_email: teabot-reviewer@unkin.net
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `name` | Label used in logs and prompts. |
|
||||
| `tea_config` | Path to a `tea` config.yml holding this bot's login. teabot reads the API token + username from it and mounts it into the job container. |
|
||||
| `role` | `implementer` (issues), `reviewer` (pull requests), or `both`. Defaults to `both`. |
|
||||
| `git_name` / `git_email` | Commit identity set inside the container. |
|
||||
|
||||
Create each tea config with the normal tea workflow, pointing `HOME`/
|
||||
`XDG_CONFIG_HOME` at a scratch dir so it lands somewhere dedicated, or copy an
|
||||
existing `config.yml` and edit the token. The file format is exactly tea's own:
|
||||
|
||||
```yaml
|
||||
logins:
|
||||
- name: teabot-implementer
|
||||
url: https://git.unkin.net
|
||||
token: <bot-api-token>
|
||||
default: true
|
||||
user: teabot-implementer
|
||||
```
|
||||
|
||||
teabot picks the login whose `url` matches `gitea_url`, else the `default`, else
|
||||
the first. The `user` field is the bot's username — teabot uses it for **loop
|
||||
prevention** (it never reacts to events authored by any personality's username).
|
||||
|
||||
At least one personality must be able to implement and at least one to review,
|
||||
or config validation fails.
|
||||
|
||||
## Claude credentials
|
||||
|
||||
By default teabot uses your Claude **subscription** auth: it copies
|
||||
`claude_config_dir` (default `~/.claude`) into a per-job scratch dir and mounts
|
||||
that copy read-write into the container, so the session can refresh tokens
|
||||
without ever mutating your real config.
|
||||
|
||||
Alternatively set `anthropic_api_key` (and optionally `anthropic_base_url` for a
|
||||
gateway such as LiteLLM). When present these are injected as environment
|
||||
variables and take precedence over the mounted subscription credentials.
|
||||
|
||||
## SELinux
|
||||
|
||||
This host is Fedora, so Docker bind mounts are relabelled with `:z`. teabot does
|
||||
this automatically. If you run under a different security model, the relabel
|
||||
suffix is a field on `DockerRunner` (`SELinuxLabel`).
|
||||
|
||||
## A note on comment follow-ups
|
||||
|
||||
teabot only continues comment threads on issues/PRs it **acted on** itself
|
||||
(opened a PR for, or reviewed). A comment on an unrelated thread is recorded but
|
||||
ignored. Combined with loop prevention (bot-authored comments are skipped), this
|
||||
means teabot will not, for example, keep answering an issue it decided not to
|
||||
implement — it engages a thread only after it has taken an action there.
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# `teabot run`
|
||||
|
||||
Runs the daemon in the foreground. It polls the configured repos on `poll_interval`
|
||||
and dispatches Claude sessions. This is the command the systemd user unit executes.
|
||||
|
||||
```bash
|
||||
teabot run [flags]
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--once` | `false` | Run a single poll cycle then exit. Blocks until every job dispatched during the cycle finishes — ideal for testing. |
|
||||
| `--log-json` | `false` | Emit structured JSON logs instead of text. |
|
||||
| `--log-level` | `info` | `debug`, `info`, `warn`, or `error`. |
|
||||
| `-c`, `--config` | `~/.config/teabot/config.yaml` | Config file path (inherited from the root command). |
|
||||
|
||||
Logs go to stderr (captured by the systemd journal). teabot handles `SIGINT`/
|
||||
`SIGTERM` by stopping the poll loop and waiting for in-flight jobs to finish
|
||||
before exiting.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# One cycle, verbose, to see what would be dispatched.
|
||||
teabot run --once --log-level debug
|
||||
|
||||
# Foreground daemon with JSON logs.
|
||||
teabot run --log-json
|
||||
```
|
||||
|
||||
## systemd (user service)
|
||||
|
||||
The RPM installs a user unit at `/usr/lib/systemd/user/teabot.service`.
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now teabot
|
||||
journalctl --user -u teabot -f
|
||||
```
|
||||
|
||||
Because it is a **user** service it runs as your login user, so it inherits your
|
||||
Docker access and your `~/.claude` credentials. To keep it running when you are
|
||||
not logged in, enable lingering:
|
||||
|
||||
```bash
|
||||
loginctl enable-linger "$USER"
|
||||
```
|
||||
|
||||
## Requirements at runtime
|
||||
|
||||
- `docker` on `PATH` and usable by the running user.
|
||||
- The configured `job_image` pullable from the registry.
|
||||
- Each personality's tea config file present and readable.
|
||||
- Claude credentials available (subscription `~/.claude` or an
|
||||
`anthropic_api_key` in the config).
|
||||
@@ -0,0 +1,13 @@
|
||||
module git.unkin.net/unkin/teabot
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,102 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// runCLI executes the root command with args and returns combined stdout.
|
||||
func runCLI(t *testing.T, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
root := newRootCmd()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func TestConfigInitWritesAndRefusesOverwrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
out, err := runCLI(t, "config", "init", "--config", path)
|
||||
if err != nil {
|
||||
t.Fatalf("config init: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "wrote example config") {
|
||||
t.Errorf("unexpected output: %q", out)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reading written config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "personalities:") {
|
||||
t.Error("written config missing personalities section")
|
||||
}
|
||||
|
||||
// Second init without --force must fail.
|
||||
if _, err := runCLI(t, "config", "init", "--config", path); err == nil {
|
||||
t.Error("expected error re-initialising existing config without --force")
|
||||
}
|
||||
// With --force it should succeed.
|
||||
if _, err := runCLI(t, "config", "init", "--config", path, "--force"); err != nil {
|
||||
t.Errorf("config init --force: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowValidatesAndPrints(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tea := filepath.Join(dir, "tea.yml")
|
||||
if err := os.WriteFile(tea, []byte("logins:\n - name: b\n url: https://git.unkin.net\n token: tok\n default: true\n user: botuser\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
body := "gitea_url: https://git.unkin.net\nrepos: [unkin/teabot]\npersonalities:\n" +
|
||||
" - {name: solo, tea_config: " + tea + ", role: both, git_name: S, git_email: s@x}\n"
|
||||
if err := os.WriteFile(cfgPath, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, err := runCLI(t, "config", "show", "--config", cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("config show: %v", err)
|
||||
}
|
||||
for _, want := range []string{"unkin/teabot", "solo", "botuser", "agent-dev"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("config show output missing %q\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowReportsInvalidConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
// No repos, no personalities -> validation error.
|
||||
if err := os.WriteFile(cfgPath, []byte("gitea_url: https://git.unkin.net\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := runCLI(t, "config", "show", "--config", cfgPath); err == nil {
|
||||
t.Error("expected validation error for empty config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLoggerLevels(t *testing.T) {
|
||||
for _, lvl := range []string{"debug", "info", "warn", "error", "unknown"} {
|
||||
if l := newLogger(false, lvl); l == nil {
|
||||
t.Errorf("newLogger(%q) returned nil", lvl)
|
||||
}
|
||||
}
|
||||
if l := newLogger(true, "info"); l == nil {
|
||||
t.Error("JSON logger nil")
|
||||
}
|
||||
// Sanity: debug logger actually enables debug level.
|
||||
if !newLogger(false, "debug").Enabled(nil, slog.LevelDebug) { //nolint:staticcheck
|
||||
t.Error("debug logger should enable debug level")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.unkin.net/unkin/teabot/internal/config"
|
||||
)
|
||||
|
||||
func newConfigCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Inspect and scaffold teabot configuration",
|
||||
}
|
||||
cmd.AddCommand(newConfigInitCmd())
|
||||
cmd.AddCommand(newConfigShowCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newConfigInitCmd() *cobra.Command {
|
||||
var force bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Write an example config file",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
path := configPath
|
||||
if _, err := os.Stat(path); err == nil && !force {
|
||||
return fmt.Errorf("config %s already exists (use --force to overwrite)", path)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(config.Example), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "wrote example config to %s\n", path)
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "edit it, then create the referenced tea config files with `tea logins add`.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&force, "force", false, "overwrite an existing config file")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newConfigShowCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Validate and print the effective configuration",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintf(out, "config: %s\n", configPath)
|
||||
fmt.Fprintf(out, "gitea_url: %s\n", cfg.GiteaURL)
|
||||
fmt.Fprintf(out, "state_dir: %s\n", cfg.StateDirOrDefault())
|
||||
fmt.Fprintf(out, "poll_interval: %s\n", cfg.PollInterval)
|
||||
fmt.Fprintf(out, "job_timeout: %s\n", cfg.JobTimeout)
|
||||
fmt.Fprintf(out, "max_concurrent: %d\n", cfg.MaxConcurrent)
|
||||
fmt.Fprintf(out, "job_image: %s\n", cfg.JobImage)
|
||||
fmt.Fprintf(out, "claude_config: %s\n", cfg.ClaudeConfigDir)
|
||||
if cfg.AnthropicBaseURL != "" {
|
||||
fmt.Fprintf(out, "anthropic_base_url: %s\n", cfg.AnthropicBaseURL)
|
||||
}
|
||||
if cfg.AnthropicAPIKey != "" {
|
||||
fmt.Fprintf(out, "anthropic_api_key: (set)\n")
|
||||
}
|
||||
fmt.Fprintf(out, "repos:\n")
|
||||
for _, r := range cfg.Repos {
|
||||
fmt.Fprintf(out, " - %s\n", r)
|
||||
}
|
||||
fmt.Fprintf(out, "personalities:\n")
|
||||
for _, p := range cfg.Personalities {
|
||||
fmt.Fprintf(out, " - %s (role=%s, login=%s)\n", p.Name, p.Role, p.Login)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Package cli wires teabot's cobra command tree.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.unkin.net/unkin/teabot/internal/config"
|
||||
)
|
||||
|
||||
var (
|
||||
// configPath is the --config flag shared by the commands that need it.
|
||||
configPath string
|
||||
buildVer string
|
||||
)
|
||||
|
||||
func newRootCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "teabot",
|
||||
Short: "Watch Gitea repos and dispatch one-shot Claude Code sessions",
|
||||
Long: "teabot polls Gitea repositories for new issues, pull requests, and comments,\n" +
|
||||
"then dispatches one-shot Claude Code sessions in Docker containers to implement\n" +
|
||||
"issues and review pull requests using configurable bot personalities.",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
Version: buildVer,
|
||||
}
|
||||
root.PersistentFlags().StringVarP(&configPath, "config", "c", config.DefaultConfigPath(),
|
||||
"path to teabot config file")
|
||||
|
||||
root.AddCommand(newRunCmd())
|
||||
root.AddCommand(newConfigCmd())
|
||||
return root
|
||||
}
|
||||
|
||||
// Execute runs the CLI with the given build version.
|
||||
func Execute(version string) {
|
||||
buildVer = version
|
||||
if err := newRootCmd().Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "teabot:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.unkin.net/unkin/teabot/internal/config"
|
||||
"git.unkin.net/unkin/teabot/internal/dispatch"
|
||||
"git.unkin.net/unkin/teabot/internal/docker"
|
||||
"git.unkin.net/unkin/teabot/internal/gitea"
|
||||
"git.unkin.net/unkin/teabot/internal/state"
|
||||
)
|
||||
|
||||
func newRunCmd() *cobra.Command {
|
||||
var (
|
||||
once bool
|
||||
logJSON bool
|
||||
logLevel string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "run",
|
||||
Short: "Run the teabot daemon (foreground)",
|
||||
Long: "run starts teabot in the foreground: it polls the configured repos on an\n" +
|
||||
"interval and dispatches Claude sessions. Use --once for a single poll cycle\n" +
|
||||
"(handy for testing). This command is what the systemd user unit executes.",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
logger := newLogger(logJSON, logLevel)
|
||||
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store, err := state.New(cfg.StateDirOrDefault())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// One Gitea client, authenticated as the first personality, is
|
||||
// enough for read-only polling; actions happen inside containers as
|
||||
// the per-event personality.
|
||||
pollTok := cfg.Personalities[0].Token
|
||||
client := gitea.NewClient(cfg.GiteaURL, pollTok)
|
||||
|
||||
runner := docker.NewDockerRunner()
|
||||
runner.Stdout = os.Stderr
|
||||
|
||||
d := dispatch.New(cfg, store, client, runner, logger)
|
||||
|
||||
logger.Info("teabot starting",
|
||||
"repos", cfg.Repos,
|
||||
"personalities", len(cfg.Personalities),
|
||||
"poll_interval", cfg.PollInterval.String(),
|
||||
"max_concurrent", cfg.MaxConcurrent,
|
||||
"job_image", cfg.JobImage,
|
||||
"once", once)
|
||||
|
||||
if once {
|
||||
return d.PollOnce(cmd.Context(), true)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
return d.Run(ctx)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&once, "once", false, "run a single poll cycle then exit")
|
||||
cmd.Flags().BoolVar(&logJSON, "log-json", false, "emit structured JSON logs")
|
||||
cmd.Flags().StringVar(&logLevel, "log-level", "info", "log level: debug, info, warn, error")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newLogger(jsonOut bool, level string) *slog.Logger {
|
||||
var lvl slog.Level
|
||||
switch level {
|
||||
case "debug":
|
||||
lvl = slog.LevelDebug
|
||||
case "warn":
|
||||
lvl = slog.LevelWarn
|
||||
case "error":
|
||||
lvl = slog.LevelError
|
||||
default:
|
||||
lvl = slog.LevelInfo
|
||||
}
|
||||
opts := &slog.HandlerOptions{Level: lvl}
|
||||
var h slog.Handler
|
||||
if jsonOut {
|
||||
h = slog.NewJSONHandler(os.Stderr, opts)
|
||||
} else {
|
||||
h = slog.NewTextHandler(os.Stderr, opts)
|
||||
}
|
||||
return slog.New(h)
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
// Package config loads and validates teabot's daemon configuration and the
|
||||
// per-personality tea config files it references.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
// AppName is used for XDG config/state directory names.
|
||||
AppName = "teabot"
|
||||
|
||||
// DefaultGiteaURL is the Gitea instance teabot watches by default.
|
||||
DefaultGiteaURL = "https://git.unkin.net"
|
||||
|
||||
// DefaultJobImage already ships claude-code plus a Go/Node/Python/tea
|
||||
// developer toolchain, so teabot reuses it instead of building its own.
|
||||
DefaultJobImage = "git.unkin.net/unkin/agent-dev:latest"
|
||||
|
||||
// DefaultPollInterval is how often each repo is polled when unset.
|
||||
DefaultPollInterval = 60 * time.Second
|
||||
|
||||
// DefaultJobTimeout bounds a single Claude session.
|
||||
DefaultJobTimeout = 30 * time.Minute
|
||||
|
||||
// DefaultMaxConcurrent caps simultaneously running job containers.
|
||||
DefaultMaxConcurrent = 2
|
||||
|
||||
// DefaultContainerHome is the home directory inside the job image
|
||||
// (the agent-dev image runs as the unprivileged "agent" user).
|
||||
DefaultContainerHome = "/home/agent"
|
||||
)
|
||||
|
||||
// Role describes what work a personality is allowed to perform.
|
||||
type Role string
|
||||
|
||||
const (
|
||||
// RoleImplementer handles new issues and issue-comment follow-ups.
|
||||
RoleImplementer Role = "implementer"
|
||||
// RoleReviewer handles new pull requests and PR-comment follow-ups.
|
||||
RoleReviewer Role = "reviewer"
|
||||
// RoleBoth handles every event kind.
|
||||
RoleBoth Role = "both"
|
||||
)
|
||||
|
||||
// Personality is a distinct Gitea bot identity backed by its own tea config
|
||||
// file. Different personalities let, for example, an "implementer" account open
|
||||
// PRs while a separate "reviewer" account critiques them.
|
||||
type Personality struct {
|
||||
// Name is the human-readable label used in logs and prompts.
|
||||
Name string `yaml:"name"`
|
||||
// TeaConfig is the path to a tea config.yml holding this bot's Gitea
|
||||
// login (token + url + username). teabot parses it for the API token and
|
||||
// mounts it into the job container so tea acts as this identity.
|
||||
TeaConfig string `yaml:"tea_config"`
|
||||
// Role gates which event kinds this personality reacts to.
|
||||
Role Role `yaml:"role"`
|
||||
// GitName / GitEmail set the commit identity inside the container.
|
||||
GitName string `yaml:"git_name"`
|
||||
GitEmail string `yaml:"git_email"`
|
||||
|
||||
// Login is populated at load time from the parsed tea config: the Gitea
|
||||
// username. Events authored by any personality's login are ignored so the
|
||||
// bot never reacts to its own comments (loop prevention).
|
||||
Login string `yaml:"-"`
|
||||
// Token is populated at load time from the parsed tea config: the API
|
||||
// token used for polling as this identity. Never written back to disk.
|
||||
Token string `yaml:"-"`
|
||||
// URL is populated at load time from the parsed tea config: the instance
|
||||
// URL of this login.
|
||||
URL string `yaml:"-"`
|
||||
}
|
||||
|
||||
// CanImplement reports whether the personality reacts to issue events.
|
||||
func (p Personality) CanImplement() bool { return p.Role == RoleImplementer || p.Role == RoleBoth }
|
||||
|
||||
// CanReview reports whether the personality reacts to pull-request events.
|
||||
func (p Personality) CanReview() bool { return p.Role == RoleReviewer || p.Role == RoleBoth }
|
||||
|
||||
// Config is teabot's top-level configuration (~/.config/teabot/config.yaml).
|
||||
type Config struct {
|
||||
// GiteaURL is the base URL of the Gitea instance to poll.
|
||||
GiteaURL string `yaml:"gitea_url"`
|
||||
// Repos is the list of owner/name repositories to watch.
|
||||
Repos []string `yaml:"repos"`
|
||||
// PollInterval is the delay between poll cycles.
|
||||
PollInterval time.Duration `yaml:"poll_interval"`
|
||||
// StateDir overrides the XDG state directory used to persist processed
|
||||
// events. Empty means $XDG_STATE_HOME/teabot (default ~/.local/state/teabot).
|
||||
StateDir string `yaml:"state_dir"`
|
||||
// MaxConcurrent caps simultaneously running job containers.
|
||||
MaxConcurrent int `yaml:"max_concurrent"`
|
||||
// JobTimeout bounds a single dispatched Claude session.
|
||||
JobTimeout time.Duration `yaml:"job_timeout"`
|
||||
// JobImage is the container image each session runs in.
|
||||
JobImage string `yaml:"job_image"`
|
||||
// ContainerHome is the home directory inside JobImage that mounts target.
|
||||
ContainerHome string `yaml:"container_home"`
|
||||
|
||||
// ClaudeConfigDir is the host directory holding Claude Code credentials
|
||||
// (subscription auth), mounted into each container. Empty means ~/.claude.
|
||||
ClaudeConfigDir string `yaml:"claude_config_dir"`
|
||||
// AnthropicAPIKey, when set, is injected as ANTHROPIC_API_KEY instead of
|
||||
// relying on the mounted subscription credentials.
|
||||
AnthropicAPIKey string `yaml:"anthropic_api_key"`
|
||||
// AnthropicBaseURL, when set, is injected as ANTHROPIC_BASE_URL.
|
||||
AnthropicBaseURL string `yaml:"anthropic_base_url"`
|
||||
|
||||
// Personalities are the bot identities teabot dispatches as.
|
||||
Personalities []Personality `yaml:"personalities"`
|
||||
}
|
||||
|
||||
// Load reads and validates the config at path, applying defaults and resolving
|
||||
// each personality's tea config into a token/login/url.
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading config %s: %w", path, err)
|
||||
}
|
||||
cfg := &Config{}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing config %s: %w", path, err)
|
||||
}
|
||||
cfg.applyDefaults()
|
||||
if err := cfg.resolvePersonalities(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) applyDefaults() {
|
||||
if c.GiteaURL == "" {
|
||||
c.GiteaURL = DefaultGiteaURL
|
||||
}
|
||||
c.GiteaURL = strings.TrimRight(c.GiteaURL, "/")
|
||||
if c.PollInterval <= 0 {
|
||||
c.PollInterval = DefaultPollInterval
|
||||
}
|
||||
if c.MaxConcurrent <= 0 {
|
||||
c.MaxConcurrent = DefaultMaxConcurrent
|
||||
}
|
||||
if c.JobTimeout <= 0 {
|
||||
c.JobTimeout = DefaultJobTimeout
|
||||
}
|
||||
if c.JobImage == "" {
|
||||
c.JobImage = DefaultJobImage
|
||||
}
|
||||
if c.ContainerHome == "" {
|
||||
c.ContainerHome = DefaultContainerHome
|
||||
}
|
||||
if c.ClaudeConfigDir == "" {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
c.ClaudeConfigDir = filepath.Join(home, ".claude")
|
||||
}
|
||||
} else {
|
||||
c.ClaudeConfigDir = expandHome(c.ClaudeConfigDir)
|
||||
}
|
||||
if c.StateDir != "" {
|
||||
c.StateDir = expandHome(c.StateDir)
|
||||
}
|
||||
for i := range c.Personalities {
|
||||
if c.Personalities[i].Role == "" {
|
||||
c.Personalities[i].Role = RoleBoth
|
||||
}
|
||||
c.Personalities[i].TeaConfig = expandHome(c.Personalities[i].TeaConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePersonalities parses each personality's tea config file and fills in
|
||||
// its token, login, and instance URL.
|
||||
func (c *Config) resolvePersonalities() error {
|
||||
for i := range c.Personalities {
|
||||
p := &c.Personalities[i]
|
||||
if p.TeaConfig == "" {
|
||||
return fmt.Errorf("personality %q: tea_config is required", p.Name)
|
||||
}
|
||||
login, err := ParseTeaConfig(p.TeaConfig, c.GiteaURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("personality %q: %w", p.Name, err)
|
||||
}
|
||||
p.Login = login.User
|
||||
p.Token = login.Token
|
||||
p.URL = login.URL
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks the config is internally consistent and usable.
|
||||
func (c *Config) Validate() error {
|
||||
if len(c.Repos) == 0 {
|
||||
return fmt.Errorf("no repos configured")
|
||||
}
|
||||
for _, r := range c.Repos {
|
||||
if !strings.Contains(strings.Trim(r, "/"), "/") {
|
||||
return fmt.Errorf("repo %q must be in owner/name form", r)
|
||||
}
|
||||
}
|
||||
if len(c.Personalities) == 0 {
|
||||
return fmt.Errorf("at least one personality is required")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var haveImpl, haveReview bool
|
||||
for _, p := range c.Personalities {
|
||||
if p.Name == "" {
|
||||
return fmt.Errorf("personality with empty name")
|
||||
}
|
||||
if seen[p.Name] {
|
||||
return fmt.Errorf("duplicate personality name %q", p.Name)
|
||||
}
|
||||
seen[p.Name] = true
|
||||
switch p.Role {
|
||||
case RoleImplementer, RoleReviewer, RoleBoth:
|
||||
default:
|
||||
return fmt.Errorf("personality %q: invalid role %q", p.Name, p.Role)
|
||||
}
|
||||
if p.Token == "" {
|
||||
return fmt.Errorf("personality %q: no token found in tea config", p.Name)
|
||||
}
|
||||
if p.Login == "" {
|
||||
return fmt.Errorf("personality %q: no username found in tea config", p.Name)
|
||||
}
|
||||
haveImpl = haveImpl || p.CanImplement()
|
||||
haveReview = haveReview || p.CanReview()
|
||||
}
|
||||
if !haveImpl {
|
||||
return fmt.Errorf("no personality can implement (role implementer or both)")
|
||||
}
|
||||
if !haveReview {
|
||||
return fmt.Errorf("no personality can review (role reviewer or both)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BotLogins returns the set of Gitea usernames belonging to configured
|
||||
// personalities, used to skip events the bot authored itself.
|
||||
func (c *Config) BotLogins() map[string]bool {
|
||||
m := make(map[string]bool, len(c.Personalities))
|
||||
for _, p := range c.Personalities {
|
||||
if p.Login != "" {
|
||||
m[p.Login] = true
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ImplementerFor returns the personality that should handle issue work, or nil.
|
||||
func (c *Config) ImplementerFor() *Personality {
|
||||
for i := range c.Personalities {
|
||||
if c.Personalities[i].CanImplement() {
|
||||
return &c.Personalities[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReviewerFor returns the personality that should handle PR work, or nil.
|
||||
func (c *Config) ReviewerFor() *Personality {
|
||||
for i := range c.Personalities {
|
||||
if c.Personalities[i].CanReview() {
|
||||
return &c.Personalities[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// expandHome expands a leading ~/ to the user's home directory.
|
||||
func expandHome(p string) string {
|
||||
if p == "~" || strings.HasPrefix(p, "~/") {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
if p == "~" {
|
||||
return home
|
||||
}
|
||||
return filepath.Join(home, p[2:])
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// writeTeaConfig writes a minimal tea config.yml and returns its path.
|
||||
func writeTeaConfig(t *testing.T, dir, name, url, token, user string, isDefault bool) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name+".yml")
|
||||
content := "logins:\n" +
|
||||
" - name: " + name + "\n" +
|
||||
" url: " + url + "\n" +
|
||||
" token: " + token + "\n" +
|
||||
" default: " + boolStr(isDefault) + "\n" +
|
||||
" user: " + user + "\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("writing tea config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func boolStr(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, dir, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("writing config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoadAppliesDefaultsAndResolvesPersonalities(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
impl := writeTeaConfig(t, dir, "impl", "https://git.unkin.net", "tok-impl", "implbot", true)
|
||||
rev := writeTeaConfig(t, dir, "rev", "https://git.unkin.net", "tok-rev", "revbot", false)
|
||||
|
||||
body := `repos:
|
||||
- unkin/teabot
|
||||
personalities:
|
||||
- name: implementer
|
||||
tea_config: ` + impl + `
|
||||
role: implementer
|
||||
git_name: Impl Bot
|
||||
git_email: impl@unkin.net
|
||||
- name: reviewer
|
||||
tea_config: ` + rev + `
|
||||
role: reviewer
|
||||
git_name: Rev Bot
|
||||
git_email: rev@unkin.net
|
||||
`
|
||||
cfg, err := Load(writeConfig(t, dir, body))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
if cfg.GiteaURL != DefaultGiteaURL {
|
||||
t.Errorf("GiteaURL default = %q, want %q", cfg.GiteaURL, DefaultGiteaURL)
|
||||
}
|
||||
if cfg.PollInterval != DefaultPollInterval {
|
||||
t.Errorf("PollInterval default = %s, want %s", cfg.PollInterval, DefaultPollInterval)
|
||||
}
|
||||
if cfg.JobImage != DefaultJobImage {
|
||||
t.Errorf("JobImage default = %q, want %q", cfg.JobImage, DefaultJobImage)
|
||||
}
|
||||
if cfg.MaxConcurrent != DefaultMaxConcurrent {
|
||||
t.Errorf("MaxConcurrent default = %d, want %d", cfg.MaxConcurrent, DefaultMaxConcurrent)
|
||||
}
|
||||
|
||||
// Personalities must be resolved from their tea configs.
|
||||
if got := cfg.Personalities[0]; got.Login != "implbot" || got.Token != "tok-impl" {
|
||||
t.Errorf("implementer resolved = login %q token %q", got.Login, got.Token)
|
||||
}
|
||||
if got := cfg.Personalities[1]; got.Login != "revbot" || got.Token != "tok-rev" {
|
||||
t.Errorf("reviewer resolved = login %q token %q", got.Login, got.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadHonoursOverrides(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tea := writeTeaConfig(t, dir, "both", "https://git.example.com", "tok", "bot", true)
|
||||
body := `gitea_url: https://git.example.com/
|
||||
poll_interval: 5s
|
||||
job_timeout: 10m
|
||||
max_concurrent: 7
|
||||
job_image: example/img:1
|
||||
repos:
|
||||
- foo/bar
|
||||
personalities:
|
||||
- name: both
|
||||
tea_config: ` + tea + `
|
||||
role: both
|
||||
git_name: Bot
|
||||
git_email: bot@example.com
|
||||
`
|
||||
cfg, err := Load(writeConfig(t, dir, body))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.GiteaURL != "https://git.example.com" {
|
||||
t.Errorf("GiteaURL = %q (trailing slash not trimmed?)", cfg.GiteaURL)
|
||||
}
|
||||
if cfg.PollInterval != 5*time.Second {
|
||||
t.Errorf("PollInterval = %s", cfg.PollInterval)
|
||||
}
|
||||
if cfg.JobTimeout != 10*time.Minute {
|
||||
t.Errorf("JobTimeout = %s", cfg.JobTimeout)
|
||||
}
|
||||
if cfg.MaxConcurrent != 7 {
|
||||
t.Errorf("MaxConcurrent = %d", cfg.MaxConcurrent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tea := writeTeaConfig(t, dir, "t", "https://git.unkin.net", "tok", "bot", true)
|
||||
|
||||
cases := map[string]string{
|
||||
"no repos": `personalities:
|
||||
- {name: a, tea_config: ` + tea + `, role: both}
|
||||
`,
|
||||
"bad repo form": `repos: [notaslash]
|
||||
personalities:
|
||||
- {name: a, tea_config: ` + tea + `, role: both}
|
||||
`,
|
||||
"no personalities": `repos: [a/b]
|
||||
`,
|
||||
"only implementer": `repos: [a/b]
|
||||
personalities:
|
||||
- {name: a, tea_config: ` + tea + `, role: implementer}
|
||||
`,
|
||||
"only reviewer": `repos: [a/b]
|
||||
personalities:
|
||||
- {name: a, tea_config: ` + tea + `, role: reviewer}
|
||||
`,
|
||||
"invalid role": `repos: [a/b]
|
||||
personalities:
|
||||
- {name: a, tea_config: ` + tea + `, role: bogus}
|
||||
`,
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := Load(writeConfig(t, t.TempDir(), body))
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q, got nil", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicatePersonalityNameRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tea := writeTeaConfig(t, dir, "t", "https://git.unkin.net", "tok", "bot", true)
|
||||
body := `repos: [a/b]
|
||||
personalities:
|
||||
- {name: dup, tea_config: ` + tea + `, role: implementer}
|
||||
- {name: dup, tea_config: ` + tea + `, role: reviewer}
|
||||
`
|
||||
if _, err := Load(writeConfig(t, dir, body)); err == nil {
|
||||
t.Fatal("expected duplicate-name error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleDefaultsToBoth(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tea := writeTeaConfig(t, dir, "t", "https://git.unkin.net", "tok", "bot", true)
|
||||
body := `repos: [a/b]
|
||||
personalities:
|
||||
- name: solo
|
||||
tea_config: ` + tea + `
|
||||
git_name: X
|
||||
git_email: x@y.z
|
||||
`
|
||||
cfg, err := Load(writeConfig(t, dir, body))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Personalities[0].Role != RoleBoth {
|
||||
t.Errorf("role = %q, want both", cfg.Personalities[0].Role)
|
||||
}
|
||||
if !cfg.Personalities[0].CanImplement() || !cfg.Personalities[0].CanReview() {
|
||||
t.Error("both role should implement and review")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotLoginsAndSelectors(t *testing.T) {
|
||||
cfg := &Config{Personalities: []Personality{
|
||||
{Name: "i", Role: RoleImplementer, Login: "ibot"},
|
||||
{Name: "r", Role: RoleReviewer, Login: "rbot"},
|
||||
}}
|
||||
logins := cfg.BotLogins()
|
||||
if !logins["ibot"] || !logins["rbot"] || len(logins) != 2 {
|
||||
t.Errorf("BotLogins = %v", logins)
|
||||
}
|
||||
if p := cfg.ImplementerFor(); p == nil || p.Name != "i" {
|
||||
t.Errorf("ImplementerFor = %v", p)
|
||||
}
|
||||
if p := cfg.ReviewerFor(); p == nil || p.Name != "r" {
|
||||
t.Errorf("ReviewerFor = %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTeaConfigPrefersMatchingURL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "multi.yml")
|
||||
content := `logins:
|
||||
- name: other
|
||||
url: https://other.example.com
|
||||
token: other-tok
|
||||
default: true
|
||||
user: otheruser
|
||||
- name: target
|
||||
url: https://git.unkin.net
|
||||
token: target-tok
|
||||
user: targetuser
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
login, err := ParseTeaConfig(path, "https://git.unkin.net")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTeaConfig: %v", err)
|
||||
}
|
||||
if login.User != "targetuser" || login.Token != "target-tok" {
|
||||
t.Errorf("matched wrong login: %+v", login)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTeaConfigFallsBackToDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "d.yml")
|
||||
content := `logins:
|
||||
- name: a
|
||||
url: https://a.example.com
|
||||
token: a-tok
|
||||
user: a
|
||||
- name: b
|
||||
url: https://b.example.com
|
||||
token: b-tok
|
||||
default: true
|
||||
user: b
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
login, err := ParseTeaConfig(path, "https://nomatch.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTeaConfig: %v", err)
|
||||
}
|
||||
if login.User != "b" {
|
||||
t.Errorf("expected default login b, got %q", login.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExampleConfigIsValidWhenTeaConfigsExist(t *testing.T) {
|
||||
// The shipped example references tea configs by ~/ path; here we just
|
||||
// verify the example YAML parses into a Config with the expected shape by
|
||||
// substituting resolvable tea configs.
|
||||
dir := t.TempDir()
|
||||
impl := writeTeaConfig(t, dir, "impl", "https://git.unkin.net", "tok", "implbot", true)
|
||||
rev := writeTeaConfig(t, dir, "rev", "https://git.unkin.net", "tok2", "revbot", false)
|
||||
body := `gitea_url: https://git.unkin.net
|
||||
repos: [unkin/teabot]
|
||||
personalities:
|
||||
- {name: implementer, tea_config: ` + impl + `, role: implementer, git_name: I, git_email: i@x}
|
||||
- {name: reviewer, tea_config: ` + rev + `, role: reviewer, git_name: R, git_email: r@x}
|
||||
`
|
||||
if _, err := Load(writeConfig(t, dir, body)); err != nil {
|
||||
t.Fatalf("example-shaped config failed to load: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package config
|
||||
|
||||
// Example is a fully-commented sample config written by `teabot config init`.
|
||||
const Example = `# teabot configuration
|
||||
# Location: $XDG_CONFIG_HOME/teabot/config.yaml (default ~/.config/teabot/config.yaml)
|
||||
|
||||
# Base URL of the Gitea instance to watch.
|
||||
gitea_url: https://git.unkin.net
|
||||
|
||||
# How often to poll each repo.
|
||||
poll_interval: 60s
|
||||
|
||||
# Repositories to watch, in owner/name form.
|
||||
repos:
|
||||
- unkin/teabot
|
||||
|
||||
# Maximum number of Claude job containers running at once.
|
||||
max_concurrent: 2
|
||||
|
||||
# Per-session wall-clock timeout.
|
||||
job_timeout: 30m
|
||||
|
||||
# Container image each session runs in. The default already ships the Claude
|
||||
# CLI plus a Go/Node/Python/tea developer toolchain.
|
||||
job_image: git.unkin.net/unkin/agent-dev:latest
|
||||
|
||||
# Home directory inside job_image (mount target for tea/claude config).
|
||||
container_home: /home/agent
|
||||
|
||||
# Host directory holding Claude Code credentials (subscription auth). A private
|
||||
# copy is mounted into each container so token refreshes never touch this dir.
|
||||
claude_config_dir: ~/.claude
|
||||
|
||||
# Optional: use an Anthropic API key / gateway instead of subscription auth.
|
||||
# When set these are injected as ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL.
|
||||
# anthropic_api_key: ""
|
||||
# anthropic_base_url: ""
|
||||
|
||||
# Optional: override the state directory (default ~/.local/state/teabot).
|
||||
# state_dir: ~/.local/state/teabot
|
||||
|
||||
# Bot personalities. Each is a distinct Gitea account backed by its own tea
|
||||
# config file (create it with: tea logins add --name <bot> ...). teabot reads
|
||||
# the token + username from that file and mounts it into the container so tea
|
||||
# acts as this identity. Roles: implementer, reviewer, both.
|
||||
personalities:
|
||||
- name: implementer
|
||||
tea_config: ~/.config/teabot/tea-implementer.yml
|
||||
role: implementer
|
||||
git_name: Teabot Implementer
|
||||
git_email: teabot-implementer@unkin.net
|
||||
- name: reviewer
|
||||
tea_config: ~/.config/teabot/tea-reviewer.yml
|
||||
role: reviewer
|
||||
git_name: Teabot Reviewer
|
||||
git_email: teabot-reviewer@unkin.net
|
||||
`
|
||||
@@ -0,0 +1,36 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// DefaultConfigPath returns $XDG_CONFIG_HOME/teabot/config.yaml
|
||||
// (default ~/.config/teabot/config.yaml).
|
||||
func DefaultConfigPath() string {
|
||||
base := os.Getenv("XDG_CONFIG_HOME")
|
||||
if base == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
base = filepath.Join(home, ".config")
|
||||
}
|
||||
return filepath.Join(base, AppName, "config.yaml")
|
||||
}
|
||||
|
||||
// DefaultStateDir returns $XDG_STATE_HOME/teabot
|
||||
// (default ~/.local/state/teabot).
|
||||
func DefaultStateDir() string {
|
||||
base := os.Getenv("XDG_STATE_HOME")
|
||||
if base == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
base = filepath.Join(home, ".local", "state")
|
||||
}
|
||||
return filepath.Join(base, AppName)
|
||||
}
|
||||
|
||||
// StateDirOrDefault resolves the effective state directory for the config.
|
||||
func (c *Config) StateDirOrDefault() string {
|
||||
if c.StateDir != "" {
|
||||
return c.StateDir
|
||||
}
|
||||
return DefaultStateDir()
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// TeaLogin is one entry from a tea config.yml `logins:` list. Only the fields
|
||||
// teabot needs are modelled; unknown keys are ignored by the YAML decoder.
|
||||
type TeaLogin struct {
|
||||
Name string `yaml:"name"`
|
||||
URL string `yaml:"url"`
|
||||
Token string `yaml:"token"`
|
||||
Default bool `yaml:"default"`
|
||||
User string `yaml:"user"`
|
||||
}
|
||||
|
||||
// teaConfigFile mirrors the top level of tea's config.yml.
|
||||
type teaConfigFile struct {
|
||||
Logins []TeaLogin `yaml:"logins"`
|
||||
}
|
||||
|
||||
// ParseTeaConfig reads a tea config.yml and returns the login teabot should use.
|
||||
// It prefers a login whose URL matches wantURL, then the default login, then the
|
||||
// first login. This is the same file format as ~/.config/tea/config.yml.
|
||||
func ParseTeaConfig(path, wantURL string) (TeaLogin, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return TeaLogin{}, fmt.Errorf("reading tea config %s: %w", path, err)
|
||||
}
|
||||
var f teaConfigFile
|
||||
if err := yaml.Unmarshal(data, &f); err != nil {
|
||||
return TeaLogin{}, fmt.Errorf("parsing tea config %s: %w", path, err)
|
||||
}
|
||||
if len(f.Logins) == 0 {
|
||||
return TeaLogin{}, fmt.Errorf("tea config %s has no logins", path)
|
||||
}
|
||||
|
||||
want := strings.TrimRight(wantURL, "/")
|
||||
var byURL, byDefault *TeaLogin
|
||||
for i := range f.Logins {
|
||||
l := &f.Logins[i]
|
||||
if want != "" && strings.TrimRight(l.URL, "/") == want && byURL == nil {
|
||||
byURL = l
|
||||
}
|
||||
if l.Default && byDefault == nil {
|
||||
byDefault = l
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case byURL != nil:
|
||||
return *byURL, nil
|
||||
case byDefault != nil:
|
||||
return *byDefault, nil
|
||||
default:
|
||||
return f.Logins[0], nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Package dispatch is teabot's core loop: it polls watched repos, filters
|
||||
// events (dedup + loop prevention), and dispatches one-shot Claude sessions.
|
||||
package dispatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/teabot/internal/config"
|
||||
"git.unkin.net/unkin/teabot/internal/docker"
|
||||
"git.unkin.net/unkin/teabot/internal/gitea"
|
||||
)
|
||||
|
||||
// GiteaClient is the read surface of the Gitea API that the dispatcher needs.
|
||||
// It is an interface so tests can supply a fake without network access.
|
||||
type GiteaClient interface {
|
||||
ListIssues(ctx context.Context, repo string, since time.Time) ([]gitea.Issue, error)
|
||||
ListPulls(ctx context.Context, repo string) ([]gitea.PullRequest, error)
|
||||
ListComments(ctx context.Context, repo string, since time.Time) ([]gitea.Comment, error)
|
||||
GetIssueComments(ctx context.Context, repo string, index int64) ([]gitea.Comment, error)
|
||||
GetIssue(ctx context.Context, repo string, index int64) (gitea.Issue, error)
|
||||
GetPull(ctx context.Context, repo string, index int64) (gitea.PullRequest, error)
|
||||
GetPullDiff(ctx context.Context, repo string, index int64) (string, error)
|
||||
}
|
||||
|
||||
// StateStore is the persistence surface the dispatcher needs.
|
||||
type StateStore interface {
|
||||
IssueProcessed(repo string, index int64) bool
|
||||
PullProcessed(repo string, index int64) bool
|
||||
CommentProcessed(repo string, id int64) bool
|
||||
ActedOnIssue(repo string, index int64) bool
|
||||
ActedOnPull(repo string, index int64) bool
|
||||
MarkIssue(repo string, index int64)
|
||||
MarkPull(repo string, index int64)
|
||||
MarkComment(repo string, id int64)
|
||||
Seeded(repo string) bool
|
||||
MarkSeeded(repo string)
|
||||
LastPoll(repo string) time.Time
|
||||
SetLastPoll(repo string, t time.Time)
|
||||
Save() error
|
||||
}
|
||||
|
||||
// Dispatcher wires configuration, state, the Gitea client, and the job runner.
|
||||
type Dispatcher struct {
|
||||
cfg *config.Config
|
||||
store StateStore
|
||||
client GiteaClient
|
||||
runner docker.Runner
|
||||
log *slog.Logger
|
||||
|
||||
sem chan struct{}
|
||||
wg sync.WaitGroup
|
||||
botLogins map[string]bool
|
||||
gitHost string
|
||||
}
|
||||
|
||||
// New builds a Dispatcher.
|
||||
func New(cfg *config.Config, store StateStore, client GiteaClient, runner docker.Runner, log *slog.Logger) *Dispatcher {
|
||||
host := cfg.GiteaURL
|
||||
if u, err := url.Parse(cfg.GiteaURL); err == nil {
|
||||
host = u.Host
|
||||
}
|
||||
return &Dispatcher{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
client: client,
|
||||
runner: runner,
|
||||
log: log,
|
||||
sem: make(chan struct{}, cfg.MaxConcurrent),
|
||||
botLogins: cfg.BotLogins(),
|
||||
gitHost: host,
|
||||
}
|
||||
}
|
||||
|
||||
// Run polls on an interval until ctx is cancelled, then waits for in-flight jobs.
|
||||
func (d *Dispatcher) Run(ctx context.Context) error {
|
||||
ticker := time.NewTicker(d.cfg.PollInterval)
|
||||
defer ticker.Stop()
|
||||
// Poll immediately, then on each tick.
|
||||
d.pollAll(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
d.log.Info("shutting down, waiting for in-flight jobs")
|
||||
d.wg.Wait()
|
||||
return d.store.Save()
|
||||
case <-ticker.C:
|
||||
d.pollAll(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PollOnce runs a single poll cycle. When wait is true it blocks until every
|
||||
// job dispatched during the cycle has finished (used by `run --once`).
|
||||
func (d *Dispatcher) PollOnce(ctx context.Context, wait bool) error {
|
||||
d.pollAll(ctx)
|
||||
if wait {
|
||||
d.wg.Wait()
|
||||
}
|
||||
return d.store.Save()
|
||||
}
|
||||
|
||||
// Wait blocks until all in-flight jobs finish.
|
||||
func (d *Dispatcher) Wait() { d.wg.Wait() }
|
||||
|
||||
func (d *Dispatcher) pollAll(ctx context.Context) {
|
||||
for _, repo := range d.cfg.Repos {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := d.pollRepo(ctx, repo); err != nil {
|
||||
d.log.Warn("poll failed", "repo", repo, "err", err)
|
||||
}
|
||||
}
|
||||
if err := d.store.Save(); err != nil {
|
||||
d.log.Warn("saving state failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// cloneURL returns the plain HTTPS clone URL for a repo.
|
||||
func (d *Dispatcher) cloneURL(repo string) string {
|
||||
return d.cfg.GiteaURL + "/" + strings.Trim(repo, "/") + ".git"
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package dispatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/teabot/internal/config"
|
||||
"git.unkin.net/unkin/teabot/internal/docker"
|
||||
"git.unkin.net/unkin/teabot/internal/gitea"
|
||||
"git.unkin.net/unkin/teabot/internal/state"
|
||||
)
|
||||
|
||||
// fakeClient is a scripted GiteaClient. Each field is returned as-is; the
|
||||
// Get* methods serve follow-up context lookups.
|
||||
type fakeClient struct {
|
||||
issues []gitea.Issue
|
||||
pulls []gitea.PullRequest
|
||||
comments []gitea.Comment
|
||||
issueByI map[int64]gitea.Issue
|
||||
pullByI map[int64]gitea.PullRequest
|
||||
}
|
||||
|
||||
func (f *fakeClient) ListIssues(_ context.Context, _ string, _ time.Time) ([]gitea.Issue, error) {
|
||||
return f.issues, nil
|
||||
}
|
||||
func (f *fakeClient) ListPulls(_ context.Context, _ string) ([]gitea.PullRequest, error) {
|
||||
return f.pulls, nil
|
||||
}
|
||||
func (f *fakeClient) ListComments(_ context.Context, _ string, _ time.Time) ([]gitea.Comment, error) {
|
||||
return f.comments, nil
|
||||
}
|
||||
func (f *fakeClient) GetIssueComments(_ context.Context, _ string, _ int64) ([]gitea.Comment, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeClient) GetIssue(_ context.Context, _ string, index int64) (gitea.Issue, error) {
|
||||
return f.issueByI[index], nil
|
||||
}
|
||||
func (f *fakeClient) GetPull(_ context.Context, _ string, index int64) (gitea.PullRequest, error) {
|
||||
return f.pullByI[index], nil
|
||||
}
|
||||
func (f *fakeClient) GetPullDiff(_ context.Context, _ string, _ int64) (string, error) {
|
||||
return "diff", nil
|
||||
}
|
||||
|
||||
// recordingRunner captures dispatched jobs; safe for concurrent use.
|
||||
type recordingRunner struct {
|
||||
mu sync.Mutex
|
||||
jobs []docker.Job
|
||||
}
|
||||
|
||||
func (r *recordingRunner) Run(_ context.Context, j docker.Job) (docker.Result, error) {
|
||||
r.mu.Lock()
|
||||
r.jobs = append(r.jobs, j)
|
||||
r.mu.Unlock()
|
||||
return docker.Result{ExitCode: 0}, nil
|
||||
}
|
||||
|
||||
func (r *recordingRunner) labels() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]string, len(r.jobs))
|
||||
for i, j := range r.jobs {
|
||||
out[i] = j.Label
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func testConfig() *config.Config {
|
||||
return &config.Config{
|
||||
GiteaURL: "https://git.unkin.net",
|
||||
Repos: []string{"unkin/teabot"},
|
||||
PollInterval: time.Second,
|
||||
MaxConcurrent: 2,
|
||||
JobTimeout: time.Minute,
|
||||
JobImage: "img:latest",
|
||||
ContainerHome: "/home/agent",
|
||||
Personalities: []config.Personality{
|
||||
{Name: "impl", Role: config.RoleImplementer, Login: "implbot", Token: "it", TeaConfig: "/x/impl.yml"},
|
||||
{Name: "rev", Role: config.RoleReviewer, Login: "revbot", Token: "rt", TeaConfig: "/x/rev.yml"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T, fc *fakeClient) (*Dispatcher, *recordingRunner, *state.Store) {
|
||||
t.Helper()
|
||||
store, err := state.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := &recordingRunner{}
|
||||
d := New(testConfig(), store, fc, rr, discardLogger())
|
||||
return d, rr, store
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, v := range list {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestFirstPollSeedsWithoutDispatch(t *testing.T) {
|
||||
fc := &fakeClient{
|
||||
issues: []gitea.Issue{{Index: 1, Title: "old", Poster: gitea.User{Login: "human"}}},
|
||||
pulls: []gitea.PullRequest{{Index: 2, Title: "oldpr", Poster: gitea.User{Login: "human"}}},
|
||||
comments: []gitea.Comment{{ID: 3, Poster: gitea.User{Login: "human"}}},
|
||||
}
|
||||
d, rr, store := newHarness(t, fc)
|
||||
if err := d.PollOnce(context.Background(), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := rr.labels(); len(got) != 0 {
|
||||
t.Errorf("seeding poll dispatched jobs: %v", got)
|
||||
}
|
||||
if !store.Seeded("unkin/teabot") {
|
||||
t.Error("repo not marked seeded")
|
||||
}
|
||||
if !store.IssueProcessed("unkin/teabot", 1) || !store.PullProcessed("unkin/teabot", 2) {
|
||||
t.Error("existing items not recorded during seeding")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIssueDispatchesImplementer(t *testing.T) {
|
||||
fc := &fakeClient{}
|
||||
d, rr, store := newHarness(t, fc)
|
||||
// Seed with empty state.
|
||||
if err := d.PollOnce(context.Background(), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Now a genuinely new human issue appears.
|
||||
fc.issues = []gitea.Issue{{Index: 10, Title: "please fix", Poster: gitea.User{Login: "human"}}}
|
||||
if err := d.PollOnce(context.Background(), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !contains(rr.labels(), "unkin/teabot#issue-10") {
|
||||
t.Errorf("expected implementer job for issue 10, got %v", rr.labels())
|
||||
}
|
||||
// The dispatched job must carry the implementer identity.
|
||||
if rr.jobs[0].GitUser != "implbot" {
|
||||
t.Errorf("job dispatched as %q, want implbot", rr.jobs[0].GitUser)
|
||||
}
|
||||
if !store.ActedOnIssue("unkin/teabot", 10) {
|
||||
t.Error("issue 10 should be marked acted-on")
|
||||
}
|
||||
|
||||
// Polling again must NOT re-dispatch (dedup).
|
||||
before := len(rr.labels())
|
||||
if err := d.PollOnce(context.Background(), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rr.labels()) != before {
|
||||
t.Errorf("issue re-dispatched: %v", rr.labels())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAuthoredIssueIgnored(t *testing.T) {
|
||||
fc := &fakeClient{}
|
||||
d, rr, _ := newHarness(t, fc)
|
||||
_ = d.PollOnce(context.Background(), true) // seed
|
||||
|
||||
fc.issues = []gitea.Issue{{Index: 20, Poster: gitea.User{Login: "implbot"}}}
|
||||
_ = d.PollOnce(context.Background(), true)
|
||||
if len(rr.labels()) != 0 {
|
||||
t.Errorf("bot-authored issue triggered a job: %v", rr.labels())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPullDispatchesReviewer(t *testing.T) {
|
||||
fc := &fakeClient{}
|
||||
d, rr, store := newHarness(t, fc)
|
||||
_ = d.PollOnce(context.Background(), true) // seed
|
||||
|
||||
fc.pulls = []gitea.PullRequest{{Index: 30, Title: "add x", Poster: gitea.User{Login: "human"}}}
|
||||
_ = d.PollOnce(context.Background(), true)
|
||||
|
||||
if !contains(rr.labels(), "unkin/teabot#pull-30") {
|
||||
t.Errorf("expected reviewer job for pull 30, got %v", rr.labels())
|
||||
}
|
||||
if rr.jobs[0].GitUser != "revbot" {
|
||||
t.Errorf("PR job dispatched as %q, want revbot", rr.jobs[0].GitUser)
|
||||
}
|
||||
if !store.ActedOnPull("unkin/teabot", 30) {
|
||||
t.Error("pull 30 should be acted-on")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentFollowUpOnlyOnActedThreads(t *testing.T) {
|
||||
fc := &fakeClient{
|
||||
issueByI: map[int64]gitea.Issue{40: {Index: 40, Title: "acted issue"}},
|
||||
pullByI: map[int64]gitea.PullRequest{50: {Index: 50, Title: "acted pr"}},
|
||||
}
|
||||
d, rr, store := newHarness(t, fc)
|
||||
_ = d.PollOnce(context.Background(), true) // seed
|
||||
|
||||
// teabot has acted on issue 40 and pull 50.
|
||||
store.MarkIssue("unkin/teabot", 40)
|
||||
store.MarkPull("unkin/teabot", 50)
|
||||
|
||||
issueURL := "https://git.unkin.net/api/v1/repos/unkin/teabot/issues/40"
|
||||
prURL := "https://git.unkin.net/api/v1/repos/unkin/teabot/pulls/50"
|
||||
unactedURL := "https://git.unkin.net/api/v1/repos/unkin/teabot/issues/999"
|
||||
|
||||
fc.comments = []gitea.Comment{
|
||||
{ID: 100, Poster: gitea.User{Login: "human"}, Body: "on acted issue", IssueURL: issueURL},
|
||||
{ID: 101, Poster: gitea.User{Login: "human"}, Body: "on acted pr", PRURL: prURL},
|
||||
{ID: 102, Poster: gitea.User{Login: "human"}, Body: "on unacted thread", IssueURL: unactedURL},
|
||||
{ID: 103, Poster: gitea.User{Login: "implbot"}, Body: "bot comment on acted issue", IssueURL: issueURL},
|
||||
}
|
||||
_ = d.PollOnce(context.Background(), true)
|
||||
|
||||
labels := rr.labels()
|
||||
if !contains(labels, "unkin/teabot#issue-40-followup-100") {
|
||||
t.Errorf("missing issue follow-up: %v", labels)
|
||||
}
|
||||
if !contains(labels, "unkin/teabot#pull-50-followup-101") {
|
||||
t.Errorf("missing pull follow-up: %v", labels)
|
||||
}
|
||||
// Comment on an unacted thread must NOT dispatch.
|
||||
for _, l := range labels {
|
||||
if l == "unkin/teabot#issue-999-followup-102" {
|
||||
t.Error("dispatched follow-up for unacted thread")
|
||||
}
|
||||
}
|
||||
// Bot-authored comment (103) must NOT dispatch (loop prevention).
|
||||
if len(labels) != 2 {
|
||||
t.Errorf("expected exactly 2 follow-ups (issue+pull), got %v", labels)
|
||||
}
|
||||
if !store.CommentProcessed("unkin/teabot", 103) {
|
||||
t.Error("bot comment should still be recorded as processed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFollowUpPersonalityRouting(t *testing.T) {
|
||||
fc := &fakeClient{
|
||||
pullByI: map[int64]gitea.PullRequest{60: {Index: 60, Title: "pr"}},
|
||||
}
|
||||
d, rr, store := newHarness(t, fc)
|
||||
_ = d.PollOnce(context.Background(), true)
|
||||
store.MarkPull("unkin/teabot", 60)
|
||||
|
||||
fc.comments = []gitea.Comment{
|
||||
{ID: 200, Poster: gitea.User{Login: "human"}, Body: "change please",
|
||||
PRURL: "https://git.unkin.net/api/v1/repos/unkin/teabot/pulls/60"},
|
||||
}
|
||||
_ = d.PollOnce(context.Background(), true)
|
||||
|
||||
if len(rr.jobs) != 1 {
|
||||
t.Fatalf("expected 1 job, got %d", len(rr.jobs))
|
||||
}
|
||||
// A PR follow-up must be handled by the reviewer personality.
|
||||
if rr.jobs[0].GitUser != "revbot" {
|
||||
t.Errorf("PR follow-up dispatched as %q, want revbot", rr.jobs[0].GitUser)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package dispatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.unkin.net/unkin/teabot/internal/config"
|
||||
"git.unkin.net/unkin/teabot/internal/docker"
|
||||
"git.unkin.net/unkin/teabot/internal/gitea"
|
||||
"git.unkin.net/unkin/teabot/internal/prompt"
|
||||
)
|
||||
|
||||
// baseJob fills the personality/repo/runtime fields shared by every job kind.
|
||||
func (d *Dispatcher) baseJob(repo string, p config.Personality, label, promptText string) docker.Job {
|
||||
return docker.Job{
|
||||
Label: label,
|
||||
Image: d.cfg.JobImage,
|
||||
ContainerHome: d.cfg.ContainerHome,
|
||||
Prompt: promptText,
|
||||
CloneURL: d.cloneURL(repo),
|
||||
GitHost: d.gitHost,
|
||||
GitName: p.GitName,
|
||||
GitEmail: p.GitEmail,
|
||||
GitUser: p.Login,
|
||||
Token: p.Token,
|
||||
TeaConfigPath: p.TeaConfig,
|
||||
ClaudeConfigDir: d.cfg.ClaudeConfigDir,
|
||||
AnthropicAPIKey: d.cfg.AnthropicAPIKey,
|
||||
AnthropicBaseURL: d.cfg.AnthropicBaseURL,
|
||||
Timeout: d.cfg.JobTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) dispatchIssue(ctx context.Context, repo string, p config.Personality, issue gitea.Issue, comments []gitea.Comment) {
|
||||
text := prompt.Issue(prompt.IssueContext{
|
||||
Repo: repo,
|
||||
PersonalityName: p.Name,
|
||||
Issue: issue,
|
||||
Comments: comments,
|
||||
})
|
||||
label := fmt.Sprintf("%s#issue-%d", repo, issue.Index)
|
||||
d.runJob(ctx, d.baseJob(repo, p, label, text))
|
||||
}
|
||||
|
||||
func (d *Dispatcher) dispatchPull(ctx context.Context, repo string, p config.Personality, pull gitea.PullRequest, diff string, comments []gitea.Comment) {
|
||||
text := prompt.Pull(prompt.PullContext{
|
||||
Repo: repo,
|
||||
PersonalityName: p.Name,
|
||||
Pull: pull,
|
||||
Diff: diff,
|
||||
Comments: comments,
|
||||
})
|
||||
label := fmt.Sprintf("%s#pull-%d", repo, pull.Index)
|
||||
d.runJob(ctx, d.baseJob(repo, p, label, text))
|
||||
}
|
||||
|
||||
func (d *Dispatcher) dispatchIssueFollowUp(ctx context.Context, repo string, p config.Personality, issue gitea.Issue, thread []gitea.Comment, trigger gitea.Comment) {
|
||||
text := prompt.FollowUp(prompt.FollowUpContext{
|
||||
Repo: repo,
|
||||
PersonalityName: p.Name,
|
||||
Kind: prompt.FollowUpIssue,
|
||||
Index: issue.Index,
|
||||
Title: issue.Title,
|
||||
URL: issue.HTMLURL,
|
||||
Comments: thread,
|
||||
NewComment: trigger,
|
||||
})
|
||||
label := fmt.Sprintf("%s#issue-%d-followup-%d", repo, issue.Index, trigger.ID)
|
||||
d.runJob(ctx, d.baseJob(repo, p, label, text))
|
||||
}
|
||||
|
||||
func (d *Dispatcher) dispatchPullFollowUp(ctx context.Context, repo string, p config.Personality, pull gitea.PullRequest, thread []gitea.Comment, trigger gitea.Comment) {
|
||||
text := prompt.FollowUp(prompt.FollowUpContext{
|
||||
Repo: repo,
|
||||
PersonalityName: p.Name,
|
||||
Kind: prompt.FollowUpPull,
|
||||
Index: pull.Index,
|
||||
Title: pull.Title,
|
||||
URL: pull.HTMLURL,
|
||||
Comments: thread,
|
||||
NewComment: trigger,
|
||||
})
|
||||
label := fmt.Sprintf("%s#pull-%d-followup-%d", repo, pull.Index, trigger.ID)
|
||||
d.runJob(ctx, d.baseJob(repo, p, label, text))
|
||||
}
|
||||
|
||||
// runJob launches a job in a bounded goroutine so at most MaxConcurrent
|
||||
// containers run at once.
|
||||
func (d *Dispatcher) runJob(ctx context.Context, job docker.Job) {
|
||||
d.wg.Add(1)
|
||||
go func() {
|
||||
defer d.wg.Done()
|
||||
select {
|
||||
case d.sem <- struct{}{}:
|
||||
defer func() { <-d.sem }()
|
||||
case <-ctx.Done():
|
||||
d.log.Warn("cancelled before start", "job", job.Label)
|
||||
return
|
||||
}
|
||||
d.log.Info("dispatching job", "job", job.Label, "image", job.Image)
|
||||
res, err := d.runner.Run(ctx, job)
|
||||
if err != nil {
|
||||
d.log.Error("job failed", "job", job.Label, "err", err, "output", tail(res.Output))
|
||||
return
|
||||
}
|
||||
if res.ExitCode != 0 {
|
||||
d.log.Warn("job exited non-zero",
|
||||
"job", job.Label, "exit", res.ExitCode, "duration", res.Duration, "output", tail(res.Output))
|
||||
return
|
||||
}
|
||||
d.log.Info("job completed", "job", job.Label, "duration", res.Duration)
|
||||
}()
|
||||
}
|
||||
|
||||
// tail returns the last chunk of output for concise error logging.
|
||||
func tail(s string) string {
|
||||
const max = 2000
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return "..." + s[len(s)-max:]
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package dispatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/teabot/internal/gitea"
|
||||
)
|
||||
|
||||
// pollRepo runs one poll cycle for a single repo: it seeds a baseline on first
|
||||
// contact, then classifies and dispatches new issues, pull requests, and
|
||||
// comments. Events authored by a bot personality are skipped (loop prevention),
|
||||
// and anything already recorded in the state store is skipped (dedup).
|
||||
func (d *Dispatcher) pollRepo(ctx context.Context, repo string) error {
|
||||
since := d.store.LastPoll(repo)
|
||||
now := time.Now()
|
||||
|
||||
issues, err := d.client.ListIssues(ctx, repo, since)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pulls, err := d.client.ListPulls(ctx, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
comments, err := d.client.ListComments(ctx, repo, since)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// First contact: record everything currently open/recent as processed
|
||||
// without dispatching, so a fresh install doesn't stampede old items.
|
||||
if !d.store.Seeded(repo) {
|
||||
for _, i := range issues {
|
||||
d.store.MarkIssue(repo, i.Index)
|
||||
}
|
||||
for _, p := range pulls {
|
||||
d.store.MarkPull(repo, p.Index)
|
||||
}
|
||||
for _, c := range comments {
|
||||
d.store.MarkComment(repo, c.ID)
|
||||
}
|
||||
d.store.MarkSeeded(repo)
|
||||
d.store.SetLastPoll(repo, now)
|
||||
d.log.Info("seeded repo baseline",
|
||||
"repo", repo, "issues", len(issues), "pulls", len(pulls), "comments", len(comments))
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, issue := range issues {
|
||||
d.handleIssue(ctx, repo, issue)
|
||||
}
|
||||
for _, pull := range pulls {
|
||||
d.handlePull(ctx, repo, pull)
|
||||
}
|
||||
for _, comment := range comments {
|
||||
d.handleComment(ctx, repo, comment)
|
||||
}
|
||||
|
||||
d.store.SetLastPoll(repo, now)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleIssue dispatches an implementer session for a genuinely new issue.
|
||||
func (d *Dispatcher) handleIssue(ctx context.Context, repo string, issue gitea.Issue) {
|
||||
if d.store.IssueProcessed(repo, issue.Index) {
|
||||
return
|
||||
}
|
||||
if d.botLogins[issue.Poster.Login] {
|
||||
d.store.MarkIssue(repo, issue.Index) // remember, but never act on our own
|
||||
return
|
||||
}
|
||||
p := d.cfg.ImplementerFor()
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
// Record before dispatch so a duplicate poll cannot double-launch.
|
||||
d.store.MarkIssue(repo, issue.Index)
|
||||
comments, _ := d.client.GetIssueComments(ctx, repo, issue.Index)
|
||||
d.dispatchIssue(ctx, repo, *p, issue, comments)
|
||||
}
|
||||
|
||||
// handlePull dispatches a reviewer session for a genuinely new pull request.
|
||||
func (d *Dispatcher) handlePull(ctx context.Context, repo string, pull gitea.PullRequest) {
|
||||
if d.store.PullProcessed(repo, pull.Index) {
|
||||
return
|
||||
}
|
||||
if d.botLogins[pull.Poster.Login] {
|
||||
d.store.MarkPull(repo, pull.Index)
|
||||
return
|
||||
}
|
||||
p := d.cfg.ReviewerFor()
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
d.store.MarkPull(repo, pull.Index)
|
||||
diff, _ := d.client.GetPullDiff(ctx, repo, pull.Index)
|
||||
comments, _ := d.client.GetIssueComments(ctx, repo, pull.Index)
|
||||
d.dispatchPull(ctx, repo, *p, pull, diff, comments)
|
||||
}
|
||||
|
||||
// handleComment dispatches a follow-up session for a new comment on a thread
|
||||
// teabot previously acted on.
|
||||
func (d *Dispatcher) handleComment(ctx context.Context, repo string, comment gitea.Comment) {
|
||||
if d.store.CommentProcessed(repo, comment.ID) {
|
||||
return
|
||||
}
|
||||
// Always record the comment so it is not reconsidered next cycle.
|
||||
d.store.MarkComment(repo, comment.ID)
|
||||
if d.botLogins[comment.Poster.Login] {
|
||||
return // loop prevention: never react to our own comments
|
||||
}
|
||||
index, ok := gitea.IssueIndexFromCommentURL(comment)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case d.store.ActedOnPull(repo, index):
|
||||
p := d.cfg.ReviewerFor()
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
pull, err := d.client.GetPull(ctx, repo, index)
|
||||
if err != nil {
|
||||
d.log.Warn("fetching pull for follow-up failed", "repo", repo, "index", index, "err", err)
|
||||
return
|
||||
}
|
||||
thread, _ := d.client.GetIssueComments(ctx, repo, index)
|
||||
d.dispatchPullFollowUp(ctx, repo, *p, pull, thread, comment)
|
||||
case d.store.ActedOnIssue(repo, index):
|
||||
p := d.cfg.ImplementerFor()
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
issue, err := d.client.GetIssue(ctx, repo, index)
|
||||
if err != nil {
|
||||
d.log.Warn("fetching issue for follow-up failed", "repo", repo, "index", index, "err", err)
|
||||
return
|
||||
}
|
||||
thread, _ := d.client.GetIssueComments(ctx, repo, index)
|
||||
d.dispatchIssueFollowUp(ctx, repo, *p, issue, thread, comment)
|
||||
default:
|
||||
// Comment on a thread teabot never engaged with: ignore.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// jobScript is the entrypoint executed inside the container. It configures the
|
||||
// git identity and credentials, clones the target repo, and runs Claude in
|
||||
// non-interactive print mode reading the prompt from a mounted file. All inputs
|
||||
// arrive via environment variables so the script itself is static.
|
||||
const jobScript = `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
export HOME="${TEABOT_HOME}"
|
||||
mkdir -p "$HOME/.config"
|
||||
|
||||
git config --global user.name "${TEABOT_GIT_NAME}"
|
||||
git config --global user.email "${TEABOT_GIT_EMAIL}"
|
||||
git config --global credential.helper store
|
||||
git config --global init.defaultBranch main
|
||||
umask 077
|
||||
printf 'https://%s:%s@%s\n' "${TEABOT_GIT_USER}" "${TEABOT_TOKEN}" "${TEABOT_GIT_HOST}" > "$HOME/.git-credentials"
|
||||
|
||||
WORK="$HOME/work"
|
||||
mkdir -p "$WORK"
|
||||
cd "$WORK"
|
||||
echo "teabot: cloning ${TEABOT_CLONE_URL}"
|
||||
git clone --quiet "${TEABOT_CLONE_URL}" repo
|
||||
cd repo
|
||||
|
||||
echo "teabot: starting claude session"
|
||||
claude --print --dangerously-skip-permissions < /teabot/prompt.txt
|
||||
`
|
||||
|
||||
// DockerRunner runs jobs with the local docker CLI.
|
||||
type DockerRunner struct {
|
||||
// DockerPath is the docker binary (default "docker").
|
||||
DockerPath string
|
||||
// SELinuxLabel is the volume relabel suffix. On Fedora this must be "z"
|
||||
// (shared) or "Z" (private) so bind mounts are accessible under SELinux.
|
||||
SELinuxLabel string
|
||||
// WorkRoot is where per-job scratch directories are created
|
||||
// (default os.TempDir()).
|
||||
WorkRoot string
|
||||
// Stdout receives streamed container output (nil discards the stream; the
|
||||
// captured output is always returned in Result regardless).
|
||||
Stdout io.Writer
|
||||
}
|
||||
|
||||
// NewDockerRunner builds a runner with sensible defaults for this host.
|
||||
func NewDockerRunner() *DockerRunner {
|
||||
return &DockerRunner{DockerPath: "docker", SELinuxLabel: "z"}
|
||||
}
|
||||
|
||||
// Run implements Runner.
|
||||
func (r *DockerRunner) Run(ctx context.Context, job Job) (Result, error) {
|
||||
if job.Image == "" {
|
||||
return Result{}, errors.New("job image is empty")
|
||||
}
|
||||
jobDir, err := r.prepareJobDir(job)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer os.RemoveAll(jobDir)
|
||||
|
||||
if job.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, job.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
args := r.buildArgs(job, jobDir)
|
||||
start := time.Now()
|
||||
cmd := exec.CommandContext(ctx, r.DockerPath, args...)
|
||||
var buf bytes.Buffer
|
||||
if r.Stdout != nil {
|
||||
cmd.Stdout = io.MultiWriter(&buf, r.Stdout)
|
||||
cmd.Stderr = io.MultiWriter(&buf, r.Stdout)
|
||||
} else {
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = &buf
|
||||
}
|
||||
|
||||
runErr := cmd.Run()
|
||||
res := Result{Output: buf.String(), Duration: time.Since(start)}
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
res.TimedOut = true
|
||||
res.ExitCode = -1
|
||||
return res, fmt.Errorf("job %q timed out after %s", job.Label, job.Timeout)
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(runErr, &exitErr) {
|
||||
res.ExitCode = exitErr.ExitCode()
|
||||
return res, nil
|
||||
}
|
||||
if runErr != nil {
|
||||
return res, fmt.Errorf("running docker: %w", runErr)
|
||||
}
|
||||
res.ExitCode = 0
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// prepareJobDir materialises the mounted files for a job: the prompt, the job
|
||||
// script, a private copy of the Claude config (so token refreshes never touch
|
||||
// the host's real config), and a copy of the personality's tea config.
|
||||
func (r *DockerRunner) prepareJobDir(job Job) (string, error) {
|
||||
root := r.WorkRoot
|
||||
if root == "" {
|
||||
root = os.TempDir()
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
jobDir, err := os.MkdirTemp(root, "teabot-job-")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(jobDir, "prompt.txt"), []byte(job.Prompt), 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(jobDir, "job.sh"), []byte(jobScript), 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Copy the Claude config dir so the container can refresh subscription
|
||||
// tokens without mutating the host's ~/.claude.
|
||||
if job.ClaudeConfigDir != "" {
|
||||
if _, statErr := os.Stat(job.ClaudeConfigDir); statErr == nil {
|
||||
if err := copyTree(job.ClaudeConfigDir, filepath.Join(jobDir, "claude")); err != nil {
|
||||
return "", fmt.Errorf("copying claude config: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Copy the personality's tea config to the mounted XDG location.
|
||||
if job.TeaConfigPath != "" {
|
||||
teaDir := filepath.Join(jobDir, "tea")
|
||||
if err := os.MkdirAll(teaDir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := copyFile(job.TeaConfigPath, filepath.Join(teaDir, "config.yml")); err != nil {
|
||||
return "", fmt.Errorf("copying tea config: %w", err)
|
||||
}
|
||||
}
|
||||
return jobDir, nil
|
||||
}
|
||||
|
||||
// buildArgs assembles the full `docker run` argument list for a job. It is pure
|
||||
// (given jobDir) so it can be unit-tested without invoking docker.
|
||||
func (r *DockerRunner) buildArgs(job Job, jobDir string) []string {
|
||||
home := job.ContainerHome
|
||||
if home == "" {
|
||||
home = "/home/agent"
|
||||
}
|
||||
label := r.SELinuxLabel
|
||||
mount := func(host, container string, ro bool) string {
|
||||
spec := host + ":" + container
|
||||
if ro {
|
||||
spec += ":ro"
|
||||
if label != "" {
|
||||
spec += "," + label
|
||||
}
|
||||
} else if label != "" {
|
||||
spec += ":" + label
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
args := []string{"run", "--rm", "--entrypoint", "/bin/bash"}
|
||||
|
||||
// Mount the job scratch (prompt + script) read-only.
|
||||
args = append(args, "-v", mount(filepath.Join(jobDir, "prompt.txt"), "/teabot/prompt.txt", true))
|
||||
args = append(args, "-v", mount(filepath.Join(jobDir, "job.sh"), "/teabot/job.sh", true))
|
||||
|
||||
// Mount the private Claude config copy read-write (token refresh).
|
||||
if job.ClaudeConfigDir != "" {
|
||||
args = append(args, "-v", mount(filepath.Join(jobDir, "claude"), home+"/.claude", false))
|
||||
}
|
||||
// Mount the tea config read-only at the XDG path.
|
||||
if job.TeaConfigPath != "" {
|
||||
args = append(args, "-v", mount(filepath.Join(jobDir, "tea", "config.yml"), home+"/.config/tea/config.yml", true))
|
||||
}
|
||||
|
||||
// Environment consumed by job.sh.
|
||||
env := map[string]string{
|
||||
"TEABOT_HOME": home,
|
||||
"TEABOT_GIT_NAME": job.GitName,
|
||||
"TEABOT_GIT_EMAIL": job.GitEmail,
|
||||
"TEABOT_GIT_USER": job.GitUser,
|
||||
"TEABOT_TOKEN": job.Token,
|
||||
"TEABOT_GIT_HOST": job.GitHost,
|
||||
"TEABOT_CLONE_URL": job.CloneURL,
|
||||
"XDG_CONFIG_HOME": home + "/.config",
|
||||
}
|
||||
if job.AnthropicAPIKey != "" {
|
||||
env["ANTHROPIC_API_KEY"] = job.AnthropicAPIKey
|
||||
}
|
||||
if job.AnthropicBaseURL != "" {
|
||||
env["ANTHROPIC_BASE_URL"] = job.AnthropicBaseURL
|
||||
}
|
||||
for _, k := range sortedKeys(env) {
|
||||
args = append(args, "-e", k+"="+env[k])
|
||||
}
|
||||
|
||||
args = append(args, job.Image, "/teabot/job.sh")
|
||||
return args
|
||||
}
|
||||
|
||||
// sortedKeys returns map keys in deterministic order (stable docker args ease
|
||||
// testing and logging).
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
// simple insertion sort avoids importing sort for a tiny map
|
||||
for i := 1; i < len(keys); i++ {
|
||||
for j := i; j > 0 && keys[j-1] > keys[j]; j-- {
|
||||
keys[j-1], keys[j] = keys[j], keys[j-1]
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyTree recursively copies a directory tree (regular files, dirs, and
|
||||
// symlink targets are dereferenced by copyFile via Open).
|
||||
func copyTree(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(target, 0o700)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil // skip sockets/devices; symlinks are followed by Walk's lstat -> handle below
|
||||
}
|
||||
return copyFile(path, target)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// argsString joins docker args for easy substring assertions.
|
||||
func argsString(a []string) string { return strings.Join(a, " ") }
|
||||
|
||||
// hasFlagValue reports whether args contains flag immediately followed by value.
|
||||
func hasFlagValue(args []string, flag, value string) bool {
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == flag && args[i+1] == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func baseJob() Job {
|
||||
return Job{
|
||||
Label: "unkin/teabot#issue-1",
|
||||
Image: "git.unkin.net/unkin/agent-dev:latest",
|
||||
ContainerHome: "/home/agent",
|
||||
Prompt: "do the thing",
|
||||
CloneURL: "https://git.unkin.net/unkin/teabot.git",
|
||||
GitHost: "git.unkin.net",
|
||||
GitName: "Impl Bot",
|
||||
GitEmail: "impl@unkin.net",
|
||||
GitUser: "implbot",
|
||||
Token: "secret-token",
|
||||
TeaConfigPath: "/home/ben/.config/teabot/tea-impl.yml",
|
||||
ClaudeConfigDir: "/home/ben/.claude",
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArgsCoreShape(t *testing.T) {
|
||||
r := &DockerRunner{DockerPath: "docker", SELinuxLabel: "z"}
|
||||
args := r.buildArgs(baseJob(), "/tmp/job123")
|
||||
s := argsString(args)
|
||||
|
||||
if args[0] != "run" {
|
||||
t.Errorf("first arg = %q, want run", args[0])
|
||||
}
|
||||
if !hasFlagValue(args, "--entrypoint", "/bin/bash") {
|
||||
t.Error("missing --entrypoint /bin/bash")
|
||||
}
|
||||
if !strings.Contains(s, "--rm") {
|
||||
t.Error("missing --rm")
|
||||
}
|
||||
// Image and job script must be the trailing args.
|
||||
if args[len(args)-2] != "git.unkin.net/unkin/agent-dev:latest" || args[len(args)-1] != "/teabot/job.sh" {
|
||||
t.Errorf("trailing args = %v", args[len(args)-2:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArgsMountsWithSELinuxLabel(t *testing.T) {
|
||||
r := &DockerRunner{DockerPath: "docker", SELinuxLabel: "z"}
|
||||
args := r.buildArgs(baseJob(), "/tmp/job123")
|
||||
s := argsString(args)
|
||||
|
||||
// Prompt + job script mounted read-only with the SELinux relabel.
|
||||
if !strings.Contains(s, "/tmp/job123/prompt.txt:/teabot/prompt.txt:ro,z") {
|
||||
t.Error("prompt mount missing or wrong flags")
|
||||
}
|
||||
if !strings.Contains(s, "/tmp/job123/job.sh:/teabot/job.sh:ro,z") {
|
||||
t.Error("job.sh mount missing or wrong flags")
|
||||
}
|
||||
// Claude config copy mounted read-write with relabel.
|
||||
if !strings.Contains(s, "/tmp/job123/claude:/home/agent/.claude:z") {
|
||||
t.Error("claude mount missing or wrong flags")
|
||||
}
|
||||
// Tea config mounted read-only at the XDG path.
|
||||
if !strings.Contains(s, "/tmp/job123/tea/config.yml:/home/agent/.config/tea/config.yml:ro,z") {
|
||||
t.Error("tea config mount missing or wrong flags")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArgsInjectsGitAndXDGEnv(t *testing.T) {
|
||||
r := &DockerRunner{SELinuxLabel: "z"}
|
||||
args := r.buildArgs(baseJob(), "/tmp/j")
|
||||
checks := map[string]string{
|
||||
"TEABOT_GIT_USER": "implbot",
|
||||
"TEABOT_TOKEN": "secret-token",
|
||||
"TEABOT_GIT_HOST": "git.unkin.net",
|
||||
"TEABOT_CLONE_URL": "https://git.unkin.net/unkin/teabot.git",
|
||||
"TEABOT_HOME": "/home/agent",
|
||||
"XDG_CONFIG_HOME": "/home/agent/.config",
|
||||
}
|
||||
for k, v := range checks {
|
||||
if !hasFlagValue(args, "-e", k+"="+v) {
|
||||
t.Errorf("missing env -e %s=%s", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArgsAnthropicEnvOptIn(t *testing.T) {
|
||||
r := &DockerRunner{SELinuxLabel: "z"}
|
||||
|
||||
// Without keys, no ANTHROPIC_* env should be injected.
|
||||
args := r.buildArgs(baseJob(), "/tmp/j")
|
||||
if strings.Contains(argsString(args), "ANTHROPIC_API_KEY") {
|
||||
t.Error("ANTHROPIC_API_KEY injected when unset")
|
||||
}
|
||||
|
||||
// With keys, both are injected.
|
||||
j := baseJob()
|
||||
j.AnthropicAPIKey = "sk-test"
|
||||
j.AnthropicBaseURL = "https://gw.example.com"
|
||||
args = r.buildArgs(j, "/tmp/j")
|
||||
if !hasFlagValue(args, "-e", "ANTHROPIC_API_KEY=sk-test") {
|
||||
t.Error("missing ANTHROPIC_API_KEY env")
|
||||
}
|
||||
if !hasFlagValue(args, "-e", "ANTHROPIC_BASE_URL=https://gw.example.com") {
|
||||
t.Error("missing ANTHROPIC_BASE_URL env")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArgsOmitsClaudeMountWhenUnset(t *testing.T) {
|
||||
r := &DockerRunner{SELinuxLabel: "z"}
|
||||
j := baseJob()
|
||||
j.ClaudeConfigDir = ""
|
||||
j.TeaConfigPath = ""
|
||||
s := argsString(r.buildArgs(j, "/tmp/j"))
|
||||
if strings.Contains(s, ".claude") {
|
||||
t.Error("claude mount present despite empty ClaudeConfigDir")
|
||||
}
|
||||
if strings.Contains(s, "tea/config.yml") {
|
||||
t.Error("tea mount present despite empty TeaConfigPath")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobScriptIsBash(t *testing.T) {
|
||||
if !strings.HasPrefix(jobScript, "#!/usr/bin/env bash") {
|
||||
t.Error("job script missing bash shebang")
|
||||
}
|
||||
for _, needed := range []string{"git clone", "claude --print", "credential.helper store", "/teabot/prompt.txt"} {
|
||||
if !strings.Contains(jobScript, needed) {
|
||||
t.Errorf("job script missing %q", needed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fakeRunner demonstrates the Runner interface is satisfiable without docker.
|
||||
type fakeRunner struct{ jobs []Job }
|
||||
|
||||
func (f *fakeRunner) Run(_ context.Context, j Job) (Result, error) {
|
||||
f.jobs = append(f.jobs, j)
|
||||
return Result{ExitCode: 0, Duration: time.Millisecond}, nil
|
||||
}
|
||||
|
||||
func TestRunnerInterfaceSatisfiedByFake(t *testing.T) {
|
||||
var r Runner = &fakeRunner{}
|
||||
res, err := r.Run(context.Background(), baseJob())
|
||||
if err != nil || res.ExitCode != 0 {
|
||||
t.Fatalf("fake runner: res=%+v err=%v", res, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Package docker runs a one-shot Claude Code session inside a container. The
|
||||
// Runner interface keeps dispatch logic testable without a real Docker daemon.
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Job fully describes a single containerised Claude session.
|
||||
type Job struct {
|
||||
// Label is a short identifier used for logging and the job directory name.
|
||||
Label string
|
||||
// Image is the container image to run.
|
||||
Image string
|
||||
// ContainerHome is the home directory inside Image (mount target root).
|
||||
ContainerHome string
|
||||
// Prompt is the full instruction handed to `claude --print`.
|
||||
Prompt string
|
||||
|
||||
// CloneURL is the plain HTTPS clone URL of the repo to work in
|
||||
// (e.g. https://git.unkin.net/unkin/teabot.git). Auth is supplied via a
|
||||
// git credential store built from Token, never embedded in this URL.
|
||||
CloneURL string
|
||||
// GitHost is the host used for the credential store entry (e.g. git.unkin.net).
|
||||
GitHost string
|
||||
|
||||
// GitName / GitEmail set the container's commit identity.
|
||||
GitName string
|
||||
GitEmail string
|
||||
// GitUser is the bot's Gitea username (credential store user).
|
||||
GitUser string
|
||||
// Token is the bot's Gitea token, used for git push and (indirectly) tea.
|
||||
Token string
|
||||
|
||||
// TeaConfigPath is the host path to the personality's tea config.yml,
|
||||
// mounted so tea acts as this identity inside the container.
|
||||
TeaConfigPath string
|
||||
// ClaudeConfigDir is the host directory holding Claude Code credentials.
|
||||
ClaudeConfigDir string
|
||||
|
||||
// AnthropicAPIKey / AnthropicBaseURL, when set, are injected as env vars
|
||||
// instead of relying on the mounted subscription credentials.
|
||||
AnthropicAPIKey string
|
||||
AnthropicBaseURL string
|
||||
|
||||
// Timeout bounds the session.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Result captures the outcome of a job.
|
||||
type Result struct {
|
||||
ExitCode int
|
||||
Output string
|
||||
Duration time.Duration
|
||||
// TimedOut is true when the job was killed for exceeding Timeout.
|
||||
TimedOut bool
|
||||
}
|
||||
|
||||
// Runner executes jobs. DockerRunner is the production implementation; tests
|
||||
// substitute a fake.
|
||||
type Runner interface {
|
||||
Run(ctx context.Context, job Job) (Result, error)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client is an authenticated Gitea REST client scoped to a single token.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewClient builds a client for baseURL (e.g. https://git.unkin.net) using the
|
||||
// given API token.
|
||||
func NewClient(baseURL, token string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// SetHTTPClient overrides the underlying HTTP client (used in tests).
|
||||
func (c *Client) SetHTTPClient(h *http.Client) { c.http = h }
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string, query url.Values, out any) error {
|
||||
u := c.baseURL + "/api/v1" + path
|
||||
if len(query) > 0 {
|
||||
u += "?" + query.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GET %s: %w", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("GET %s: status %d: %s", path, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("decoding %s response: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitRepo splits "owner/name" into its parts.
|
||||
func splitRepo(repo string) (owner, name string, err error) {
|
||||
parts := strings.SplitN(strings.Trim(repo, "/"), "/", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", "", fmt.Errorf("invalid repo %q, want owner/name", repo)
|
||||
}
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
|
||||
// ListIssues returns open issues (excluding pull requests) updated since the
|
||||
// given time. A zero time returns all open issues.
|
||||
func (c *Client) ListIssues(ctx context.Context, repo string, since time.Time) ([]Issue, error) {
|
||||
owner, name, err := splitRepo(repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("type", "issues")
|
||||
q.Set("state", "open")
|
||||
q.Set("limit", "50")
|
||||
if !since.IsZero() {
|
||||
q.Set("since", since.UTC().Format(time.RFC3339))
|
||||
}
|
||||
var issues []Issue
|
||||
if err := c.get(ctx, fmt.Sprintf("/repos/%s/%s/issues", owner, name), q, &issues); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Defensive: the API should exclude PRs given type=issues, but drop any
|
||||
// that slip through.
|
||||
out := issues[:0]
|
||||
for _, i := range issues {
|
||||
if !i.IsPull() {
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListPulls returns open pull requests for a repo, most-recently-updated first.
|
||||
func (c *Client) ListPulls(ctx context.Context, repo string) ([]PullRequest, error) {
|
||||
owner, name, err := splitRepo(repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("state", "open")
|
||||
q.Set("sort", "recentupdate")
|
||||
q.Set("limit", "50")
|
||||
var pulls []PullRequest
|
||||
if err := c.get(ctx, fmt.Sprintf("/repos/%s/%s/pulls", owner, name), q, &pulls); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pulls, nil
|
||||
}
|
||||
|
||||
// ListComments returns issue/PR comments across a repo updated since the given
|
||||
// time. Gitea's repo-level comments endpoint covers both issues and PRs.
|
||||
func (c *Client) ListComments(ctx context.Context, repo string, since time.Time) ([]Comment, error) {
|
||||
owner, name, err := splitRepo(repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("limit", "50")
|
||||
if !since.IsZero() {
|
||||
q.Set("since", since.UTC().Format(time.RFC3339))
|
||||
}
|
||||
var comments []Comment
|
||||
if err := c.get(ctx, fmt.Sprintf("/repos/%s/%s/issues/comments", owner, name), q, &comments); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return comments, nil
|
||||
}
|
||||
|
||||
// GetIssueComments returns all comments on a single issue or PR (by index),
|
||||
// used to build follow-up thread context.
|
||||
func (c *Client) GetIssueComments(ctx context.Context, repo string, index int64) ([]Comment, error) {
|
||||
owner, name, err := splitRepo(repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var comments []Comment
|
||||
path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", owner, name, index)
|
||||
if err := c.get(ctx, path, nil, &comments); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return comments, nil
|
||||
}
|
||||
|
||||
// GetIssue fetches a single issue (or PR, which Gitea also serves here) by index.
|
||||
func (c *Client) GetIssue(ctx context.Context, repo string, index int64) (Issue, error) {
|
||||
owner, name, err := splitRepo(repo)
|
||||
if err != nil {
|
||||
return Issue{}, err
|
||||
}
|
||||
var issue Issue
|
||||
path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, name, index)
|
||||
if err := c.get(ctx, path, nil, &issue); err != nil {
|
||||
return Issue{}, err
|
||||
}
|
||||
return issue, nil
|
||||
}
|
||||
|
||||
// GetPull fetches a single pull request by index.
|
||||
func (c *Client) GetPull(ctx context.Context, repo string, index int64) (PullRequest, error) {
|
||||
owner, name, err := splitRepo(repo)
|
||||
if err != nil {
|
||||
return PullRequest{}, err
|
||||
}
|
||||
var pr PullRequest
|
||||
path := fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, name, index)
|
||||
if err := c.get(ctx, path, nil, &pr); err != nil {
|
||||
return PullRequest{}, err
|
||||
}
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
// GetPullDiff fetches the unified diff of a pull request for review context.
|
||||
func (c *Client) GetPullDiff(ctx context.Context, repo string, index int64) (string, error) {
|
||||
owner, name, err := splitRepo(repo)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
u := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d.diff", c.baseURL, owner, name, index)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("GET pull diff: status %d", resp.StatusCode)
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// IssueIndexFromCommentURL extracts the issue/PR index from a comment's
|
||||
// issue_url or pull_request_url (e.g. ".../issues/42" -> 42).
|
||||
func IssueIndexFromCommentURL(c Comment) (int64, bool) {
|
||||
raw := c.IssueURL
|
||||
if raw == "" {
|
||||
raw = c.PRURL
|
||||
}
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
parts := strings.Split(strings.TrimRight(raw, "/"), "/")
|
||||
if len(parts) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.ParseInt(parts[len(parts)-1], 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestClient(t *testing.T, h http.Handler) *Client {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
c := NewClient(srv.URL, "test-token")
|
||||
return c
|
||||
}
|
||||
|
||||
func TestListIssuesExcludesPullsAndSendsAuth(t *testing.T) {
|
||||
var gotAuth, gotType, gotState, gotSince string
|
||||
c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotType = r.URL.Query().Get("type")
|
||||
gotState = r.URL.Query().Get("state")
|
||||
gotSince = r.URL.Query().Get("since")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// One real issue and one PR-shaped issue that must be filtered out.
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"id":1,"number":5,"title":"real issue","user":{"login":"alice"}},
|
||||
{"id":2,"number":6,"title":"a pr","user":{"login":"bob"},"pull_request":{"merged":false}}
|
||||
]`))
|
||||
}))
|
||||
|
||||
since := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
issues, err := c.ListIssues(context.Background(), "unkin/teabot", since)
|
||||
if err != nil {
|
||||
t.Fatalf("ListIssues: %v", err)
|
||||
}
|
||||
if len(issues) != 1 || issues[0].Index != 5 {
|
||||
t.Fatalf("expected 1 non-PR issue #5, got %+v", issues)
|
||||
}
|
||||
if gotAuth != "token test-token" {
|
||||
t.Errorf("Authorization = %q", gotAuth)
|
||||
}
|
||||
if gotType != "issues" || gotState != "open" {
|
||||
t.Errorf("query type=%q state=%q", gotType, gotState)
|
||||
}
|
||||
if gotSince != "2026-01-02T03:04:05Z" {
|
||||
t.Errorf("since = %q", gotSince)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPulls(t *testing.T) {
|
||||
c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("state") != "open" {
|
||||
t.Errorf("state = %q", r.URL.Query().Get("state"))
|
||||
}
|
||||
_, _ = w.Write([]byte(`[{"id":1,"number":7,"title":"add feature","user":{"login":"carol"},"head":{"ref":"benvin/x"},"base":{"ref":"main"}}]`))
|
||||
}))
|
||||
pulls, err := c.ListPulls(context.Background(), "unkin/teabot")
|
||||
if err != nil {
|
||||
t.Fatalf("ListPulls: %v", err)
|
||||
}
|
||||
if len(pulls) != 1 || pulls[0].Index != 7 || pulls[0].Head.Ref != "benvin/x" {
|
||||
t.Fatalf("unexpected pulls: %+v", pulls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListComments(t *testing.T) {
|
||||
c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`[{"id":42,"body":"looks good","user":{"login":"dave"},"issue_url":"https://git.unkin.net/api/v1/repos/unkin/teabot/issues/5"}]`))
|
||||
}))
|
||||
comments, err := c.ListComments(context.Background(), "unkin/teabot", time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListComments: %v", err)
|
||||
}
|
||||
if len(comments) != 1 || comments[0].ID != 42 {
|
||||
t.Fatalf("unexpected comments: %+v", comments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPullDiff(t *testing.T) {
|
||||
c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/repos/unkin/teabot/pulls/7.diff" {
|
||||
t.Errorf("path = %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte("diff --git a/x b/x\n+hello\n"))
|
||||
}))
|
||||
diff, err := c.GetPullDiff(context.Background(), "unkin/teabot", 7)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPullDiff: %v", err)
|
||||
}
|
||||
if diff == "" || diff[:4] != "diff" {
|
||||
t.Errorf("unexpected diff: %q", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetErrorsOnNon2xx(t *testing.T) {
|
||||
c := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "nope", http.StatusForbidden)
|
||||
}))
|
||||
if _, err := c.ListIssues(context.Background(), "a/b", time.Time{}); err == nil {
|
||||
t.Error("expected error on 403")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitRepoValidation(t *testing.T) {
|
||||
c := NewClient("https://example.com", "t")
|
||||
if _, err := c.ListIssues(context.Background(), "noslash", time.Time{}); err == nil {
|
||||
t.Error("expected error for repo without slash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueIndexFromCommentURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
comment Comment
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{"issue url", Comment{IssueURL: "https://git.unkin.net/api/v1/repos/unkin/teabot/issues/5"}, 5, true},
|
||||
{"pr url", Comment{PRURL: "https://git.unkin.net/api/v1/repos/unkin/teabot/pulls/12"}, 12, true},
|
||||
{"trailing slash", Comment{IssueURL: "https://x/issues/8/"}, 8, true},
|
||||
{"no url", Comment{}, 0, false},
|
||||
{"non-numeric", Comment{IssueURL: "https://x/issues/abc"}, 0, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := IssueIndexFromCommentURL(tc.comment)
|
||||
if ok != tc.ok || got != tc.want {
|
||||
t.Errorf("got (%d,%v), want (%d,%v)", got, ok, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Package gitea is a small read-mostly client for the Gitea REST API covering
|
||||
// the endpoints teabot needs: listing issues, pull requests, and comments.
|
||||
package gitea
|
||||
|
||||
import "time"
|
||||
|
||||
// User is the subset of a Gitea user teabot cares about.
|
||||
type User struct {
|
||||
Login string `json:"login"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
// Issue represents a Gitea issue. Gitea's issues endpoint also returns pull
|
||||
// requests; the PullRequest field is non-nil for those.
|
||||
type Issue struct {
|
||||
ID int64 `json:"id"`
|
||||
Index int64 `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
State string `json:"state"`
|
||||
Poster User `json:"user"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
PullRequest *PullRequestRef `json:"pull_request,omitempty"`
|
||||
}
|
||||
|
||||
// IsPull reports whether this issue is actually a pull request.
|
||||
func (i Issue) IsPull() bool { return i.PullRequest != nil }
|
||||
|
||||
// PullRequestRef is the marker Gitea attaches to issues that are PRs.
|
||||
type PullRequestRef struct {
|
||||
Merged bool `json:"merged"`
|
||||
}
|
||||
|
||||
// PullRequest represents a Gitea pull request.
|
||||
type PullRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Index int64 `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
State string `json:"state"`
|
||||
Poster User `json:"user"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Head Branch `json:"head"`
|
||||
Base Branch `json:"base"`
|
||||
}
|
||||
|
||||
// Branch is one side of a pull request.
|
||||
type Branch struct {
|
||||
Ref string `json:"ref"`
|
||||
Sha string `json:"sha"`
|
||||
}
|
||||
|
||||
// Comment is a comment on an issue or pull request.
|
||||
type Comment struct {
|
||||
ID int64 `json:"id"`
|
||||
Body string `json:"body"`
|
||||
Poster User `json:"user"`
|
||||
Created time.Time `json:"created_at"`
|
||||
Updated time.Time `json:"updated_at"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
IssueURL string `json:"issue_url"`
|
||||
PRURL string `json:"pull_request_url"`
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Package prompt builds the task-specific instructions handed to a one-shot
|
||||
// Claude Code session for each kind of Gitea event.
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.unkin.net/unkin/teabot/internal/gitea"
|
||||
)
|
||||
|
||||
// conventions is appended to every prompt so sessions follow the house rules
|
||||
// (branch naming, PR body shape, and the loop-safety expectation of finishing
|
||||
// in one pass).
|
||||
const conventions = `Conventions you MUST follow:
|
||||
- Work on a branch named benvin/<short-descriptive-name>; never push to the default branch.
|
||||
- Use HTTPS remotes for git.unkin.net (SSH is blocked). The clone is already authenticated.
|
||||
- PR descriptions have a short "why" paragraph followed by present-tense "how" bullets.
|
||||
- Use the tea CLI (already configured for your bot identity) for Gitea actions such as creating PRs and posting comments; never use gh.
|
||||
- This is a single non-interactive session: complete the task in one pass, then stop. Do not wait for input.`
|
||||
|
||||
// IssueContext carries everything needed to prompt an implementer session for a
|
||||
// newly opened issue.
|
||||
type IssueContext struct {
|
||||
Repo string
|
||||
PersonalityName string
|
||||
Issue gitea.Issue
|
||||
Comments []gitea.Comment
|
||||
}
|
||||
|
||||
// Issue builds the prompt for reviewing and possibly implementing an issue.
|
||||
func Issue(c IssueContext) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "You are %q, an autonomous implementer bot acting on the Gitea repository %s.\n\n",
|
||||
c.PersonalityName, c.Repo)
|
||||
fmt.Fprintf(&b, "A new issue was opened. Review it and decide whether it warrants a code change.\n")
|
||||
fmt.Fprintf(&b, "If it does, implement the change on a new branch and open a pull request that links the issue with \"Closes #%d\".\n", c.Issue.Index)
|
||||
fmt.Fprintf(&b, "If it does NOT warrant a code change (question, discussion, invalid, needs clarification), post a brief comment on the issue explaining your assessment instead of opening a PR.\n\n")
|
||||
fmt.Fprintf(&b, "Issue #%d: %s\n", c.Issue.Index, c.Issue.Title)
|
||||
fmt.Fprintf(&b, "Opened by: %s\n", c.Issue.Poster.Login)
|
||||
fmt.Fprintf(&b, "URL: %s\n\n", c.Issue.HTMLURL)
|
||||
b.WriteString("Issue body:\n")
|
||||
b.WriteString(bodyOrNone(c.Issue.Body))
|
||||
b.WriteString("\n")
|
||||
writeComments(&b, c.Comments)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(conventions)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// PullContext carries everything needed to prompt a reviewer session for a PR.
|
||||
type PullContext struct {
|
||||
Repo string
|
||||
PersonalityName string
|
||||
Pull gitea.PullRequest
|
||||
Diff string
|
||||
Comments []gitea.Comment
|
||||
}
|
||||
|
||||
// Pull builds the prompt for reviewing a pull request.
|
||||
func Pull(c PullContext) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "You are %q, an autonomous reviewer bot acting on the Gitea repository %s.\n\n",
|
||||
c.PersonalityName, c.Repo)
|
||||
fmt.Fprintf(&b, "A new pull request was opened. Review it thoroughly and decide whether the change is correct and ready.\n")
|
||||
b.WriteString("Judge: does the diff actually satisfy the PR description? Is it correct, tested, and consistent with the codebase's style?\n")
|
||||
fmt.Fprintf(&b, "Post your review on PR #%d using tea: an approving review if it is good, or a review requesting changes with specific, actionable comments if not.\n\n", c.Pull.Index)
|
||||
fmt.Fprintf(&b, "PR #%d: %s\n", c.Pull.Index, c.Pull.Title)
|
||||
fmt.Fprintf(&b, "Opened by: %s\n", c.Pull.Poster.Login)
|
||||
fmt.Fprintf(&b, "Branch: %s -> %s\n", c.Pull.Head.Ref, c.Pull.Base.Ref)
|
||||
fmt.Fprintf(&b, "URL: %s\n\n", c.Pull.HTMLURL)
|
||||
b.WriteString("PR description:\n")
|
||||
b.WriteString(bodyOrNone(c.Pull.Body))
|
||||
b.WriteString("\n")
|
||||
writeComments(&b, c.Comments)
|
||||
if strings.TrimSpace(c.Diff) != "" {
|
||||
b.WriteString("\nUnified diff:\n")
|
||||
b.WriteString(truncate(c.Diff, 60000))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(conventions)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// FollowUpKind distinguishes a follow-up on an issue vs a pull request.
|
||||
type FollowUpKind string
|
||||
|
||||
const (
|
||||
// FollowUpIssue is a new comment on an issue teabot acted on.
|
||||
FollowUpIssue FollowUpKind = "issue"
|
||||
// FollowUpPull is a new comment on a pull request teabot acted on.
|
||||
FollowUpPull FollowUpKind = "pull"
|
||||
)
|
||||
|
||||
// FollowUpContext carries everything needed to prompt a follow-up session for a
|
||||
// new comment on a thread teabot previously engaged with.
|
||||
type FollowUpContext struct {
|
||||
Repo string
|
||||
PersonalityName string
|
||||
Kind FollowUpKind
|
||||
Index int64
|
||||
Title string
|
||||
URL string
|
||||
Comments []gitea.Comment // full thread, oldest first
|
||||
NewComment gitea.Comment // the comment that triggered this follow-up
|
||||
}
|
||||
|
||||
// FollowUp builds the prompt for responding to a new comment.
|
||||
func FollowUp(c FollowUpContext) string {
|
||||
var b strings.Builder
|
||||
noun := "issue"
|
||||
if c.Kind == FollowUpPull {
|
||||
noun = "pull request"
|
||||
}
|
||||
fmt.Fprintf(&b, "You are %q, an autonomous bot acting on the Gitea repository %s.\n\n",
|
||||
c.PersonalityName, c.Repo)
|
||||
fmt.Fprintf(&b, "A new comment was posted on %s #%d, a thread you previously worked on. Read the full thread and respond or take action as appropriate.\n", noun, c.Index)
|
||||
if c.Kind == FollowUpPull {
|
||||
b.WriteString("If the comment requests changes, check out the PR branch, make the changes, and push them; then reply summarising what you did.\n")
|
||||
} else {
|
||||
b.WriteString("If the comment asks for a change or clarifies the request, implement it on a branch and open or update the PR; otherwise reply with a helpful comment.\n")
|
||||
}
|
||||
fmt.Fprintf(&b, "\n%s #%d: %s\n", strings.ToUpper(noun[:1])+noun[1:], c.Index, c.Title)
|
||||
fmt.Fprintf(&b, "URL: %s\n\n", c.URL)
|
||||
b.WriteString("Full thread (oldest first):\n")
|
||||
writeComments(&b, c.Comments)
|
||||
fmt.Fprintf(&b, "\nThe new comment that triggered this task was posted by %s:\n", c.NewComment.Poster.Login)
|
||||
b.WriteString(bodyOrNone(c.NewComment.Body))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(conventions)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeComments(b *strings.Builder, comments []gitea.Comment) {
|
||||
if len(comments) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString("\nComments (oldest first):\n")
|
||||
for _, c := range comments {
|
||||
fmt.Fprintf(b, "- %s: %s\n", c.Poster.Login, oneLine(c.Body))
|
||||
}
|
||||
}
|
||||
|
||||
func bodyOrNone(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "(no description provided)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func oneLine(s string) string {
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "\n... [diff truncated] ..."
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.unkin.net/unkin/teabot/internal/gitea"
|
||||
)
|
||||
|
||||
func TestIssuePromptContainsContextAndConventions(t *testing.T) {
|
||||
p := Issue(IssueContext{
|
||||
Repo: "unkin/teabot",
|
||||
PersonalityName: "implementer",
|
||||
Issue: gitea.Issue{
|
||||
Index: 42,
|
||||
Title: "Add a flag",
|
||||
Body: "Please add --verbose",
|
||||
Poster: gitea.User{Login: "alice"},
|
||||
HTMLURL: "https://git.unkin.net/unkin/teabot/issues/42",
|
||||
},
|
||||
Comments: []gitea.Comment{{Poster: gitea.User{Login: "bob"}, Body: "agreed"}},
|
||||
})
|
||||
|
||||
mustContain(t, p, "implementer")
|
||||
mustContain(t, p, "unkin/teabot")
|
||||
mustContain(t, p, "Add a flag")
|
||||
mustContain(t, p, "Please add --verbose")
|
||||
mustContain(t, p, "Closes #42") // must instruct linking the issue
|
||||
mustContain(t, p, "bob: agreed")
|
||||
mustContain(t, p, "benvin/") // branch-naming convention
|
||||
mustContain(t, p, "present-tense")
|
||||
}
|
||||
|
||||
func TestIssuePromptHandlesEmptyBody(t *testing.T) {
|
||||
p := Issue(IssueContext{
|
||||
Repo: "a/b",
|
||||
Issue: gitea.Issue{Index: 1, Title: "t"},
|
||||
})
|
||||
mustContain(t, p, "(no description provided)")
|
||||
}
|
||||
|
||||
func TestPullPromptIncludesDiffAndReviewInstruction(t *testing.T) {
|
||||
p := Pull(PullContext{
|
||||
Repo: "unkin/teabot",
|
||||
PersonalityName: "reviewer",
|
||||
Pull: gitea.PullRequest{
|
||||
Index: 7,
|
||||
Title: "Implement thing",
|
||||
Body: "does the thing",
|
||||
Poster: gitea.User{Login: "carol"},
|
||||
Head: gitea.Branch{Ref: "benvin/thing"},
|
||||
Base: gitea.Branch{Ref: "main"},
|
||||
},
|
||||
Diff: "diff --git a/x b/x\n+added line\n",
|
||||
})
|
||||
mustContain(t, p, "reviewer")
|
||||
mustContain(t, p, "PR #7")
|
||||
mustContain(t, p, "does the diff actually satisfy")
|
||||
mustContain(t, p, "benvin/thing -> main")
|
||||
mustContain(t, p, "diff --git a/x b/x")
|
||||
mustContain(t, p, "+added line")
|
||||
}
|
||||
|
||||
func TestPullPromptTruncatesHugeDiff(t *testing.T) {
|
||||
huge := strings.Repeat("x", 70000)
|
||||
p := Pull(PullContext{Repo: "a/b", Pull: gitea.PullRequest{Index: 1}, Diff: huge})
|
||||
mustContain(t, p, "[diff truncated]")
|
||||
if len(p) > 70000 {
|
||||
t.Errorf("prompt not truncated, len=%d", len(p))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFollowUpIssueVsPullWording(t *testing.T) {
|
||||
issueThread := []gitea.Comment{{Poster: gitea.User{Login: "a"}, Body: "first"}}
|
||||
trigger := gitea.Comment{Poster: gitea.User{Login: "human"}, Body: "please tweak it"}
|
||||
|
||||
ip := FollowUp(FollowUpContext{
|
||||
Repo: "a/b", Kind: FollowUpIssue, Index: 3, Title: "T",
|
||||
URL: "u", Comments: issueThread, NewComment: trigger,
|
||||
})
|
||||
mustContain(t, ip, "issue #3")
|
||||
mustContain(t, ip, "please tweak it")
|
||||
mustContain(t, ip, "posted by human")
|
||||
|
||||
pp := FollowUp(FollowUpContext{
|
||||
Repo: "a/b", Kind: FollowUpPull, Index: 4, Title: "T2",
|
||||
URL: "u", Comments: issueThread, NewComment: trigger,
|
||||
})
|
||||
mustContain(t, pp, "pull request #4")
|
||||
mustContain(t, pp, "check out the PR branch")
|
||||
}
|
||||
|
||||
func mustContain(t *testing.T, haystack, needle string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(haystack, needle) {
|
||||
t.Errorf("prompt missing %q\n---\n%s", needle, haystack)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// Package state persists which Gitea events teabot has already handled so a
|
||||
// restart does not re-trigger work. State is a single JSON file under the
|
||||
// configured state directory (default ~/.local/state/teabot/state.json).
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StateFileName is the JSON file holding processed-event bookkeeping.
|
||||
const StateFileName = "state.json"
|
||||
|
||||
// RepoState tracks what has been handled for one repository.
|
||||
type RepoState struct {
|
||||
// ProcessedIssues holds issue indexes already dispatched.
|
||||
ProcessedIssues map[int64]bool `json:"processed_issues"`
|
||||
// ProcessedPulls holds pull-request indexes already dispatched.
|
||||
ProcessedPulls map[int64]bool `json:"processed_pulls"`
|
||||
// ProcessedComments holds comment IDs already dispatched.
|
||||
ProcessedComments map[int64]bool `json:"processed_comments"`
|
||||
// ActedIssues/ActedPulls record which issues/PRs teabot ran a session
|
||||
// for, so comment follow-ups only fire on threads the bot engaged with.
|
||||
ActedIssues map[int64]bool `json:"acted_issues"`
|
||||
ActedPulls map[int64]bool `json:"acted_pulls"`
|
||||
// Seeded is set the first time a repo is polled: existing open issues/PRs
|
||||
// and recent comments are recorded as processed WITHOUT dispatching, so a
|
||||
// fresh install does not stampede every open item.
|
||||
Seeded bool `json:"seeded"`
|
||||
// LastPoll is the time of the last completed poll, used to bound `since`
|
||||
// queries on subsequent cycles.
|
||||
LastPoll time.Time `json:"last_poll"`
|
||||
}
|
||||
|
||||
func newRepoState() *RepoState {
|
||||
return &RepoState{
|
||||
ProcessedIssues: map[int64]bool{},
|
||||
ProcessedPulls: map[int64]bool{},
|
||||
ProcessedComments: map[int64]bool{},
|
||||
ActedIssues: map[int64]bool{},
|
||||
ActedPulls: map[int64]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
// data is the on-disk document.
|
||||
type data struct {
|
||||
Repos map[string]*RepoState `json:"repos"`
|
||||
}
|
||||
|
||||
// Store is a thread-safe, file-backed processed-event tracker.
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
d *data
|
||||
}
|
||||
|
||||
// New loads the store from dir, creating an empty one if the file is absent.
|
||||
func New(dir string) (*Store, error) {
|
||||
s := &Store{
|
||||
path: filepath.Join(dir, StateFileName),
|
||||
d: &data{Repos: map[string]*RepoState{}},
|
||||
}
|
||||
raw, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return s, nil
|
||||
}
|
||||
return nil, fmt.Errorf("reading state %s: %w", s.path, err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return s, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, s.d); err != nil {
|
||||
return nil, fmt.Errorf("parsing state %s: %w", s.path, err)
|
||||
}
|
||||
if s.d.Repos == nil {
|
||||
s.d.Repos = map[string]*RepoState{}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// repo returns the RepoState for repo, creating it if needed. Caller holds mu.
|
||||
func (s *Store) repo(repo string) *RepoState {
|
||||
rs := s.d.Repos[repo]
|
||||
if rs == nil {
|
||||
rs = newRepoState()
|
||||
s.d.Repos[repo] = rs
|
||||
}
|
||||
// Guard against a partially-populated document loaded from disk.
|
||||
if rs.ProcessedIssues == nil {
|
||||
rs.ProcessedIssues = map[int64]bool{}
|
||||
}
|
||||
if rs.ProcessedPulls == nil {
|
||||
rs.ProcessedPulls = map[int64]bool{}
|
||||
}
|
||||
if rs.ProcessedComments == nil {
|
||||
rs.ProcessedComments = map[int64]bool{}
|
||||
}
|
||||
if rs.ActedIssues == nil {
|
||||
rs.ActedIssues = map[int64]bool{}
|
||||
}
|
||||
if rs.ActedPulls == nil {
|
||||
rs.ActedPulls = map[int64]bool{}
|
||||
}
|
||||
return rs
|
||||
}
|
||||
|
||||
// IssueProcessed reports whether an issue index was already handled.
|
||||
func (s *Store) IssueProcessed(repo string, index int64) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.repo(repo).ProcessedIssues[index]
|
||||
}
|
||||
|
||||
// PullProcessed reports whether a PR index was already handled.
|
||||
func (s *Store) PullProcessed(repo string, index int64) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.repo(repo).ProcessedPulls[index]
|
||||
}
|
||||
|
||||
// CommentProcessed reports whether a comment ID was already handled.
|
||||
func (s *Store) CommentProcessed(repo string, id int64) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.repo(repo).ProcessedComments[id]
|
||||
}
|
||||
|
||||
// ActedOnIssue reports whether teabot ran a session for an issue.
|
||||
func (s *Store) ActedOnIssue(repo string, index int64) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.repo(repo).ActedIssues[index]
|
||||
}
|
||||
|
||||
// ActedOnPull reports whether teabot ran a session for a PR.
|
||||
func (s *Store) ActedOnPull(repo string, index int64) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.repo(repo).ActedPulls[index]
|
||||
}
|
||||
|
||||
// MarkIssue records an issue index as processed and acted-on.
|
||||
func (s *Store) MarkIssue(repo string, index int64) {
|
||||
s.mu.Lock()
|
||||
rs := s.repo(repo)
|
||||
rs.ProcessedIssues[index] = true
|
||||
rs.ActedIssues[index] = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// MarkPull records a PR index as processed and acted-on.
|
||||
func (s *Store) MarkPull(repo string, index int64) {
|
||||
s.mu.Lock()
|
||||
rs := s.repo(repo)
|
||||
rs.ProcessedPulls[index] = true
|
||||
rs.ActedPulls[index] = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// MarkComment records a comment ID as processed.
|
||||
func (s *Store) MarkComment(repo string, id int64) {
|
||||
s.mu.Lock()
|
||||
s.repo(repo).ProcessedComments[id] = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Seeded reports whether a repo has completed its baseline seeding poll.
|
||||
func (s *Store) Seeded(repo string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.repo(repo).Seeded
|
||||
}
|
||||
|
||||
// MarkSeeded records that a repo has completed baseline seeding.
|
||||
func (s *Store) MarkSeeded(repo string) {
|
||||
s.mu.Lock()
|
||||
s.repo(repo).Seeded = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// LastPoll returns the time of the last completed poll for a repo.
|
||||
func (s *Store) LastPoll(repo string) time.Time {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.repo(repo).LastPoll
|
||||
}
|
||||
|
||||
// SetLastPoll records the time of the last completed poll for a repo.
|
||||
func (s *Store) SetLastPoll(repo string, t time.Time) {
|
||||
s.mu.Lock()
|
||||
s.repo(repo).LastPoll = t
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Save atomically writes the state document to disk.
|
||||
func (s *Store) Save() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||
return fmt.Errorf("creating state dir: %w", err)
|
||||
}
|
||||
raw, err := json.MarshalIndent(s.d, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
|
||||
return fmt.Errorf("writing state: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, s.path); err != nil {
|
||||
return fmt.Errorf("committing state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMarkAndQuery(t *testing.T) {
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const repo = "unkin/teabot"
|
||||
|
||||
if s.IssueProcessed(repo, 1) {
|
||||
t.Error("fresh store should not report issue 1 processed")
|
||||
}
|
||||
s.MarkIssue(repo, 1)
|
||||
if !s.IssueProcessed(repo, 1) {
|
||||
t.Error("issue 1 should be processed after MarkIssue")
|
||||
}
|
||||
if !s.ActedOnIssue(repo, 1) {
|
||||
t.Error("MarkIssue should also record acted-on")
|
||||
}
|
||||
if s.ActedOnPull(repo, 1) {
|
||||
t.Error("issue mark must not set acted-on-pull")
|
||||
}
|
||||
|
||||
s.MarkPull(repo, 2)
|
||||
if !s.PullProcessed(repo, 2) || !s.ActedOnPull(repo, 2) {
|
||||
t.Error("pull 2 should be processed and acted-on")
|
||||
}
|
||||
|
||||
s.MarkComment(repo, 99)
|
||||
if !s.CommentProcessed(repo, 99) {
|
||||
t.Error("comment 99 should be processed")
|
||||
}
|
||||
if s.CommentProcessed(repo, 100) {
|
||||
t.Error("comment 100 was never marked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistenceRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s1, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const repo = "a/b"
|
||||
s1.MarkIssue(repo, 10)
|
||||
s1.MarkPull(repo, 11)
|
||||
s1.MarkComment(repo, 12)
|
||||
s1.MarkSeeded(repo)
|
||||
now := time.Now().Truncate(time.Second)
|
||||
s1.SetLastPoll(repo, now)
|
||||
if err := s1.Save(); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
// A fresh Store loaded from the same dir must see the persisted state.
|
||||
s2, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s2.IssueProcessed(repo, 10) || !s2.PullProcessed(repo, 11) || !s2.CommentProcessed(repo, 12) {
|
||||
t.Error("processed sets did not survive reload")
|
||||
}
|
||||
if !s2.ActedOnIssue(repo, 10) || !s2.ActedOnPull(repo, 11) {
|
||||
t.Error("acted-on sets did not survive reload")
|
||||
}
|
||||
if !s2.Seeded(repo) {
|
||||
t.Error("seeded flag did not survive reload")
|
||||
}
|
||||
if !s2.LastPoll(repo).Equal(now) {
|
||||
t.Errorf("LastPoll = %v, want %v", s2.LastPoll(repo), now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveIsAtomicFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.MarkIssue("a/b", 1)
|
||||
if err := s.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, StateFileName)); err != nil {
|
||||
t.Errorf("state file missing after Save: %v", err)
|
||||
}
|
||||
// No leftover temp file.
|
||||
if _, err := os.Stat(filepath.Join(dir, StateFileName+".tmp")); !os.IsNotExist(err) {
|
||||
t.Error("temp file should not remain after atomic rename")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeededIndependentPerRepo(t *testing.T) {
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.MarkSeeded("a/b")
|
||||
if s.Seeded("c/d") {
|
||||
t.Error("seeding a/b must not seed c/d")
|
||||
}
|
||||
if !s.Seeded("a/b") {
|
||||
t.Error("a/b should be seeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCorruptStateFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, StateFileName), []byte("{not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := New(dir); err == nil {
|
||||
t.Error("expected error loading corrupt state file")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Command teabot is a daemon that watches Gitea repositories and dispatches
|
||||
// one-shot Claude Code sessions in Docker containers to work issues and review
|
||||
// pull requests.
|
||||
package main
|
||||
|
||||
import "git.unkin.net/unkin/teabot/internal/cli"
|
||||
|
||||
// version is overridden at build time via -ldflags "-X main.version=...".
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
cli.Execute(version)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
# nfpm config for building the teabot RPM.
|
||||
# Rendered through envsubst (see scripts/build-rpm.sh) then fed to `nfpm pkg`.
|
||||
|
||||
name: ${PACKAGE_NAME}
|
||||
version: ${PACKAGE_VERSION}
|
||||
release: ${PACKAGE_RELEASE}
|
||||
arch: ${PACKAGE_ARCH}
|
||||
platform: ${PACKAGE_PLATFORM}
|
||||
section: default
|
||||
priority: extra
|
||||
description: "${PACKAGE_DESCRIPTION}"
|
||||
|
||||
maintainer: ${PACKAGE_MAINTAINER}
|
||||
homepage: ${PACKAGE_HOMEPAGE}
|
||||
license: ${PACKAGE_LICENSE}
|
||||
|
||||
disable_globbing: false
|
||||
|
||||
replaces:
|
||||
- teabot
|
||||
provides:
|
||||
- teabot
|
||||
|
||||
contents:
|
||||
# The daemon binary.
|
||||
- src: dist/teabot
|
||||
dst: /usr/bin/teabot
|
||||
file_info:
|
||||
mode: 0755
|
||||
owner: root
|
||||
group: root
|
||||
|
||||
# systemd user unit (enable with: systemctl --user enable --now teabot).
|
||||
- src: systemd/teabot.service
|
||||
dst: /usr/lib/systemd/user/teabot.service
|
||||
file_info:
|
||||
mode: 0644
|
||||
|
||||
# Example config, installed as documentation.
|
||||
- src: config.example.yaml
|
||||
dst: /usr/share/doc/teabot/config.example.yaml
|
||||
file_info:
|
||||
mode: 0644
|
||||
|
||||
# Shell completions (generated by scripts/build-rpm.sh before packaging).
|
||||
- src: dist/completions/teabot.bash
|
||||
dst: /usr/share/bash-completion/completions/teabot
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: dist/completions/_teabot
|
||||
dst: /usr/share/zsh/site-functions/_teabot
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: dist/completions/teabot.fish
|
||||
dst: /usr/share/fish/vendor_completions.d/teabot.fish
|
||||
file_info:
|
||||
mode: 0644
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Package the (already built) teabot binary into an RPM with nfpm, bundling
|
||||
# generated bash/zsh/fish shell completions, the systemd user unit, and the
|
||||
# example config.
|
||||
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
|
||||
VERSION="${VERSION#v}" # strip a leading v
|
||||
BINARY="teabot"
|
||||
DIST="dist"
|
||||
|
||||
if [ ! -f "${DIST}/${BINARY}" ]; then
|
||||
echo "ERROR: ${DIST}/${BINARY} not found; run 'make build' first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Generate shell completions from the freshly built binary so they always match
|
||||
# the shipped flags/subcommands.
|
||||
COMP_DIR="${DIST}/completions"
|
||||
mkdir -p "${COMP_DIR}"
|
||||
"./${DIST}/${BINARY}" completion bash >"${COMP_DIR}/${BINARY}.bash"
|
||||
"./${DIST}/${BINARY}" completion zsh >"${COMP_DIR}/_${BINARY}"
|
||||
"./${DIST}/${BINARY}" completion fish >"${COMP_DIR}/${BINARY}.fish"
|
||||
|
||||
export PACKAGE_NAME="${BINARY}"
|
||||
export PACKAGE_VERSION="${VERSION}"
|
||||
export PACKAGE_RELEASE="1"
|
||||
export PACKAGE_ARCH="amd64"
|
||||
export PACKAGE_PLATFORM="linux"
|
||||
export PACKAGE_DESCRIPTION="A daemon that watches Gitea repos and dispatches one-shot Claude Code sessions in Docker to work issues and review PRs"
|
||||
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
|
||||
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/teabot"
|
||||
export PACKAGE_LICENSE="MIT"
|
||||
|
||||
envsubst <packaging/nfpm.yaml >"${DIST}/nfpm.yaml"
|
||||
nfpm pkg --config "${DIST}/nfpm.yaml" --target "${DIST}" --packager rpm
|
||||
|
||||
echo "Built:"
|
||||
ls -1 "${DIST}"/*.rpm
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=teabot - Gitea watcher dispatching one-shot Claude Code sessions
|
||||
Documentation=https://git.unkin.net/unkin/teabot
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Run in the foreground; teabot logs to stderr which the journal captures.
|
||||
ExecStart=/usr/bin/teabot run
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
# teabot shells out to docker; keep the user's environment (PATH, DOCKER_HOST).
|
||||
Environment=XDG_CONFIG_HOME=%h/.config
|
||||
Environment=XDG_STATE_HOME=%h/.local/state
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
Reference in New Issue
Block a user