Details and log output for this agent run.
{"path":".","recursive":true}
{"command":"git -C /project log --oneline -10 && git -C /project branch -a"}
809faf8 (HEAD -> issue-15-leon, origin/main, origin/HEAD, main) Less agents in example.
c3edf5a Add live streaming of job spying.
7e30176 Merge pull request 'Better container names: zoo-issue-{index}-{agent}' (#7) from issue-6-greg into main
1e3eb24 Add meaningful container names to Docker container creation
17cd966 Try to not let the bash tool get stuck on pager.
387906c Do not put the secret token inside the fricking sandbox.
ec7c296 More job logging.
1fff760 Try to reap jobs if stuck.
d1315ed redesign: modern dark-themed UI for zoo dashboard
9e06288 Init.
* issue-15-leon
main
remotes/origin/HEAD -> origin/main
remotes/origin/main
{"command":"ls -la /project && find /project -type f -not -path '*/.git/*' | head -100"}
total 52 drwxrwxr-x 5 1000 1000 4096 Aug 23 01:38 . drwxr-xr-x 1 root root 4096 Aug 23 01:38 .. drwxrwxr-x 8 1000 1000 4096 Aug 23 01:38 .git -rw-rw-r-- 1 1000 1000 22 Aug 23 01:38 .gitignore drwxrwxr-x 3 1000 1000 4096 Aug 23 01:38 cmd -rw-rw-r-- 1 1000 1000 2628 Aug 23 01:38 go.mod -rw-rw-r-- 1 1000 1000 16578 Aug 23 01:38 go.sum drwxrwxr-x 11 1000 1000 4096 Aug 23 01:38 internal -rw-rw-r-- 1 1000 1000 1415 Aug 23 01:38 zoo.hcl.example /project/go.sum /project/cmd/zoo/main.go /project/.gitignore /project/go.mod /project/internal/config/config.go /project/internal/config/config_test.go /project/internal/web/templates.go /project/internal/web/web_test.go /project/internal/web/web.go /project/internal/livelog/hub.go /project/internal/livelog/hub_test.go /project/internal/tools/forgejo.go /project/internal/tools/grep_search.go /project/internal/tools/context.go /project/internal/tools/remove_file.go /project/internal/tools/registry.go /project/internal/tools/write_file.go /project/internal/tools/move_file.go /project/internal/tools/read_file.go /project/internal/tools/git.go /project/internal/tools/list_files.go /project/internal/tools/bash.go /project/internal/tools/shell.go /project/internal/tools/tools_test.go /project/internal/llm/streamer_test.go /project/internal/llm/client.go /project/internal/llm/streamer.go /project/internal/llm/types.go /project/internal/store/store.go /project/internal/store/schema.sql /project/internal/store/store_test.go /project/internal/forgejo/poll.go /project/internal/forgejo/watcher.go /project/internal/forgejo/webhook_test.go /project/internal/forgejo/webhook.go /project/internal/forgejo/client.go /project/internal/forgejo/event.go /project/internal/agentrun/forgejo.go /project/internal/agentrun/roster_test.go /project/internal/agentrun/context.go /project/internal/agentrun/system.md /project/internal/agentrun/loop.go /project/internal/agentrun/clone.go /project/internal/agentrun/docker.go /project/internal/agentrun/seed_test.go /project/internal/agentrun/docker_integration_test.go /project/internal/agentrun/run.go /project/internal/agentrun/roster.go /project/internal/scheduler/scheduler.go /project/internal/scheduler/scheduler_test.go /project/zoo.hcl.example
{"path":"/project/internal/agentrun/system.md","start_line":1,"end_line":200}
ERROR CALLING TOOL: read "/project/internal/agentrun/system.md": command exited with status 1
{"path":"/project/internal/agentrun/context.go","start_line":1,"end_line":300}
ERROR CALLING TOOL: read "/project/internal/agentrun/context.go": command exited with status 1
{"command":"cat /project/internal/agentrun/system.md"}
You are an autonomous coding agent working inside `zoo`, a system that triggers you from Forgejo (a Gitea-family forge) issue/PR events. **Your environment** - Your working tree is at `/project`, checked out on a fresh branch off the repository's default branch. Only `/project` persists; nothing outside it survives between tool calls. - The event that triggered you (issue or pull request JSON) is available at `/event` inside the container, and is also included below. - You have a real git remote configured with push access. When you're done, `git add`/`git commit`/`git push` your branch — that's how your work gets saved. Nothing is persisted automatically. **Tools** - `bash`, `git`, `read_file`, `write_file`, `list_files`, `grep_search`, `move_file`, `remove_file` operate on the project container. - `comment`, `open_pull_request`, `request_review`, `add_label`, `remove_label`, `close_issue`, `reopen_issue`, `assign_issue` act directly on the Forgejo issue/PR that triggered this run — use `comment` to report back to the person who filed it, and `open_pull_request` once you've pushed a branch with your changes. Below, in "Your identity" and "Other agents", you'll find your own role (from your Forgejo profile) and a roster of the other agents zoo runs, each with their own role. If a task in front of you clearly belongs to someone else's role, use `assign_issue` to hand it off rather than doing it yourself. **Guidelines** - Make surgical changes: modify only what's necessary for the task in the triggering event. - Before finishing, verify your changes (re-read the file, run tests or a build if the project has them). - Always leave a `comment` summarizing what you did (or why you couldn't finish), and use `open_pull_request` when you have a change ready for review. Don't leave the issue without a response.
{"command":"cat /project/internal/agentrun/context.go"}
package agentrun
import (
"context"
"fmt"
"github.com/abrander/zoo/internal/tools"
)
// runContext implements tools.Context for a single agent run, executing
// shell commands via docker exec against the run's container, git
// commands as the host process against the run's working tree (see
// Git), and binding Forgejo actions to the triggering issue/PR.
type runContext struct {
docker *dockerRuntime
containerID string
projectDir string
token string
forgejo tools.ForgejoActions
}
func (c *runContext) Exec(ctx context.Context, command string) (string, error) {
output, exitCode, err := c.docker.exec(ctx, c.containerID, command)
if err != nil {
return output, err
}
if exitCode != 0 {
return output, fmt.Errorf("command exited with status %d", exitCode)
}
return output, nil
}
// Git implements tools.Context.Git by running git on the host against
// c.projectDir rather than via docker exec. Only push needs to
// authenticate against Forgejo, so only it carries the token, via a
// per-invocation -c override that never touches .git/config (see
// gitAuthArgs) — the container this same working tree is bind-mounted
// into has no way to read it out.
func (c *runContext) Git(ctx context.Context, subcommand string, args ...string) (string, error) {
cmdArgs := append([]string{subcommand}, args...)
if subcommand == "push" {
return runGitAuthed(ctx, c.projectDir, c.token, cmdArgs...)
}
return runGit(ctx, c.projectDir, cmdArgs...)
}
func (c *runContext) Forgejo() tools.ForgejoActions {
return c.forgejo
}
{"command":"cat /project/internal/agentrun/run.go"}
// Package agentrun creates the throwaway per-job environment an agent
// runs in: a git clone + branch bind-mounted into a Docker container,
// the triggering event written to /event, and the tool-calling loop
// (internal/llm + internal/tools) driven against it.
package agentrun
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/abrander/zoo/internal/config"
"github.com/abrander/zoo/internal/forgejo"
"github.com/abrander/zoo/internal/livelog"
"github.com/abrander/zoo/internal/llm"
"github.com/abrander/zoo/internal/store"
)
//go:embed system.md
var defaultSystemPrompt string
// DefaultTimeout bounds a single agent run's wall-clock time if the
// caller doesn't override it.
const DefaultTimeout = 20 * time.Minute
type Runner struct {
docker *dockerRuntime
forgejo *forgejo.Client
store *store.Store
hub *livelog.Hub
cfg *config.Config
logger *slog.Logger
timeout time.Duration
keepOnFailure bool
agentClientsMu sync.Mutex
agentClients map[string]*forgejo.Client
}
func NewRunner(cfg *config.Config, fg *forgejo.Client, st *store.Store, hub *livelog.Hub, logger *slog.Logger, timeout time.Duration, keepOnFailure bool) (*Runner, error) {
docker, err := newDockerRuntime()
if err != nil {
return nil, err
}
if timeout <= 0 {
timeout = DefaultTimeout
}
return &Runner{
docker: docker,
forgejo: fg,
store: st,
hub: hub,
cfg: cfg,
logger: logger,
timeout: timeout,
keepOnFailure: keepOnFailure,
agentClients: make(map[string]*forgejo.Client),
}, nil
}
// forgejoAs returns a Forgejo client that impersonates agentName (via
// Sudo:) for every API call it makes, so an agent's actions — comments,
// labels, PRs, assignment — are attributed to its own Forgejo account
// rather than zoo's shared identity. Clients are built once per agent
// and cached, since constructing one costs an extra API round trip.
// Sudo requires the configured forgejo.token to have admin/sudo rights;
// if it doesn't, this logs a warning and falls back to the shared
// identity rather than failing the run outright.
func (r *Runner) forgejoAs(agentName string) *forgejo.Client {
r.agentClientsMu.Lock()
defer r.agentClientsMu.Unlock()
if c, ok := r.agentClients[agentName]; ok {
return c
}
c, err := r.forgejo.Sudo(agentName)
if err != nil {
r.logger.Warn("failed to create sudo'd forgejo client for agent; actions will be attributed to the shared zoo identity instead", "agent", agentName, "error", err)
c = r.forgejo
}
r.agentClients[agentName] = c
return c
}
// Run implements scheduler.Runner.
func (r *Runner) Run(ctx context.Context, jobID string, agent config.Agent, llmCfg config.LLM, dockerImage string, ev forgejo.Event) error {
ctx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
logger := r.logger.With("job", jobID, "agent", agent.Name)
repoInfo, err := r.forgejo.RepositoryInfo(ev.Owner, ev.Repo)
if err != nil {
return fmt.Errorf("look up repository: %w", err)
}
workDir, err := os.MkdirTemp("", "zoo-run-*")
if err != nil {
return fmt.Errorf("create work dir: %w", err)
}
succeeded := false
defer func() {
if succeeded || !r.keepOnFailure {
os.RemoveAll(workDir)
} else {
logger.Warn("keeping work dir after failure", "dir", workDir)
}
}()
branch := fmt.Sprintf("issue-%d-%s", ev.Index, agent.Name)
projectDir := filepath.Join(workDir, "project")
if err := cloneAndBranch(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, branch, projectDir); err != nil {
return fmt.Errorf("prepare git working tree: %w", err)
}
roster := buildRoster(r.forgejo, r.cfg.Agents, logger)
gitName, gitEmail := gitIdentity(agent.Name, roster)
// Local (not --global) scope, so this identity lives in
// projectDir/.git/config: the one place both this host-side clone
// and the container it's bind-mounted into (as /project) actually
// share.
if out, err := runGit(ctx, projectDir, "config", "user.name", gitName); err != nil {
return fmt.Errorf("configure git user.name: %w: %s", err, out)
}
if out, err := runGit(ctx, projectDir, "config", "user.email", gitEmail); err != nil {
return fmt.Errorf("configure git user.email: %w: %s", err, out)
}
eventPath := filepath.Join(workDir, "event.json")
if err := os.WriteFile(eventPath, ev.Raw, 0o644); err != nil {
return fmt.Errorf("write event file: %w", err)
}
containerID, err := r.docker.createContainer(ctx, dockerImage, []string{
projectDir + ":/project",
eventPath + ":/event:ro",
}, fmt.Sprintf("zoo-issue-%d-%s", ev.Index, agent.Name))
if err != nil {
return fmt.Errorf("start container: %w", err)
}
defer func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cleanupCancel()
if err := r.docker.remove(cleanupCtx, containerID); err != nil {
logger.Warn("failed to remove container", "container", containerID, "error", err)
}
}()
// /project is bind-mounted from the host, so it's owned by the host
// UID that ran the clone, not whatever UID runs inside the
// container (usually root) — git's ownership check rejects that by
// default ("detected dubious ownership") unless told otherwise.
// --system (not --global) so this holds regardless of which user
// subsequent `docker exec` calls run as. Commit identity is
// configured host-side, above, with --local scope so it's visible
// from both sides of the bind mount without needing --global here.
out, exitCode, err := r.docker.exec(ctx, containerID, "git config --system --add safe.directory '*'")
if err != nil {
return fmt.Errorf("configure git safe.directory in container: %w: %s", err, out)
}
if exitCode != 0 {
return fmt.Errorf("configure git safe.directory in container: exit %d: %s", exitCode, out)
}
logAppend := func(stream, line string) {
if err := r.store.AppendLog(context.Background(), jobID, stream, line); err != nil {
logger.Warn("failed to append log", "error", err)
}
}
runCtx := &runContext{
docker: r.docker,
containerID: containerID,
projectDir: projectDir,
token: r.forgejo.Token(),
forgejo: &runForgejoActions{
client: r.forgejoAs(agent.Name),
owner: ev.Owner,
repo: ev.Repo,
index: ev.Index,
logger: logger,
},
}
llmClient := llm.NewClient(llmCfg)
systemPrompt := defaultSystemPrompt + identitySection(agent.Name, roster)
instructions := r.cfg.EventInstructions(ev.Kind)
messages := []llm.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions)},
}
hooks := r.streamHooks(jobID, logAppend)
_, err = runLoop(ctx, llmClient, runCtx, messages, hooks)
if err != nil {
return fmt.Errorf("agent loop: %w", err)
}
succeeded = true
return nil
}
// streamHooks builds the Hooks a single Run passes to runLoop: every
// delta is published live to the hub for connected dashboard viewers,
// and once a reasoning/content block or tool call is complete, it's
// persisted to the store as one row and the hub's replay buffer for
// jobID is checkpointed — so a viewer connecting from this point on
// sees it via the persisted history instead of a live replay, and is
// never shown it twice.
func (r *Runner) streamHooks(jobID string, logAppend func(stream, line string)) Hooks {
var reasoningBuf, contentBuf strings.Builder
reasoningOpen, contentOpen := false, false
return Hooks{
OnReasoningDelta: func(delta string) {
if !reasoningOpen {
reasoningOpen = true
reasoningBuf.Reset()
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})
}
reasoningBuf.WriteString(delta)
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})
},
OnContentDelta: func(delta string) {
if !contentOpen {
contentOpen = true
contentBuf.Reset()
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})
}
contentBuf.WriteString(delta)
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})
},
OnTurnEnd: func() {
if reasoningOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})
logAppend("reasoning", reasoningBuf.String())
r.hub.Checkpoint(jobID)
reasoningOpen = false
}
if contentOpen {
r.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})
logAppend("content", contentBuf.String())
r.hub.Checkpoint(jobID)
contentOpen = false
}
},
OnTool: func(name, arguments, result string, toolErr bool) {
r.hub.Publish(jobID, livelog.Event{
Type: livelog.Tool,
Name: name,
Arguments: arguments,
Result: result,
Error: toolErr,
})
line, err := json.Marshal(store.ToolLogEntry{Name: name, Arguments: arguments, Result: result, Error: toolErr})
if err != nil {
r.logger.Warn("failed to marshal tool log entry", "job", jobID, "error", err)
} else {
logAppend("tool", string(line))
}
r.hub.Checkpoint(jobID)
},
}
}
func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string) string {
raw, _ := json.MarshalIndent(json.RawMessage(ev.Raw), "", " ")
var instructionsSection string
if instructions != "" {
instructionsSection = fmt.Sprintf("Instructions for this event, from zoo.hcl:\n%s\n\n", instructions)
}
return fmt.Sprintf(
"You were triggered by a %q event on %s/%s.\n\n"+
"%sYour working branch is %q, checked out from the default branch %q.\n\n"+
"Title: %s\n\nBody:\n%s\n\nFull event payload:\n```json\n%s\n```",
ev.Kind, ev.Owner, ev.Repo, instructionsSection, branch, defaultBranch, ev.Title, ev.Body, raw)
}
{"command":"cat /project/internal/forgejo/event.go /project/internal/agentrun/roster.go"}
package forgejo
import "encoding/json"
// Event kinds, matching the values used as event block labels in
// config.Config (config avoids importing this package, so the kind
// strings are duplicated by value, not by identity).
const (
EventIssueNew = "issue:new"
EventIssueComment = "issue:comment"
EventIssueAssigned = "issue:assigned"
EventPRNew = "pr:new"
)
// Event is the normalized shape produced by both the webhook receiver and
// the polling fallback, and is what the scheduler dispatches on.
type Event struct {
Kind string
// ID uniquely identifies this event for dedup, regardless of whether
// it arrived via webhook or polling.
ID string
Owner, Repo string
Index int64 // issue or PR number
Title, Body string
Author string
// Assignee is set for EventIssueAssigned: the Forgejo username the
// issue was assigned to.
Assignee string
// Comment is set for EventIssueComment.
Comment string
// Raw is the full source payload (webhook body, or a synthesized
// equivalent when polling), written to /event in the agent container.
Raw json.RawMessage
}
package agentrun
import (
"fmt"
"log/slog"
"strings"
"github.com/abrander/zoo/internal/config"
"github.com/abrander/zoo/internal/forgejo"
)
// agentInfo pairs a configured agent with its Forgejo profile: the bio
// (what its account says it does) and the name/email used to set git
// commit authorship inside its container.
type agentInfo struct {
Name string
Description string
FullName string
Email string
}
// buildRoster fetches every configured agent's Forgejo profile (agent
// config labels double as Forgejo usernames). A per-agent lookup failure
// is logged and that agent is included with an empty profile rather than
// failing the whole run — one missing/unreachable profile shouldn't
// block someone else's job.
func buildRoster(fg *forgejo.Client, agents []config.Agent, logger *slog.Logger) []agentInfo {
roster := make([]agentInfo, 0, len(agents))
for _, a := range agents {
profile, err := fg.AgentProfile(a.Name)
if err != nil {
logger.Warn("failed to fetch agent profile from forgejo", "agent", a.Name, "error", err)
}
roster = append(roster, agentInfo{Name: a.Name, Description: profile.Description, FullName: profile.FullName, Email: profile.Email})
}
return roster
}
// gitIdentity returns the git commit author name/email to configure
// inside self's container, from its Forgejo profile, falling back to
// its agent name and a synthetic zoo.local address for whichever fields
// its profile doesn't set.
func gitIdentity(self string, roster []agentInfo) (name, email string) {
for _, a := range roster {
if a.Name == self {
name, email = a.FullName, a.Email
break
}
}
if name == "" {
name = self
}
if email == "" {
email = self + "@zoo.local"
}
return name, email
}
// identitySection renders the "who am I / who else is there" prompt
// section injected after the static system prompt: the agent's own
// Forgejo profile description, and everyone else's, so it can decide
// when a task is better handed off via the assign_issue tool.
func identitySection(self string, roster []agentInfo) string {
var b strings.Builder
var selfDesc string
var others []agentInfo
for _, a := range roster {
if a.Name == self {
selfDesc = a.Description
} else {
others = append(others, a)
}
}
fmt.Fprintf(&b, "\n\n## Your identity\n\nYou are the agent %q.", self)
if selfDesc != "" {
fmt.Fprintf(&b, " Your role, from your Forgejo profile description: %s", selfDesc)
} else {
b.WriteString(" Your Forgejo profile has no description set.")
}
if len(others) > 0 {
b.WriteString("\n\n## Other agents\n\nIf this task is a better fit for another agent's role, use the assign_issue tool with their name to hand it off (this reassigns the issue/PR on Forgejo, which triggers their run):\n")
for _, a := range others {
desc := a.Description
if desc == "" {
desc = "(no description set)"
}
fmt.Fprintf(&b, "### %s\n\n%s\n\n", a.Name, desc)
}
}
return b.String()
}
{"command":"cat /project/internal/tools/forgejo.go"}
package tools
import (
"fmt"
"github.com/abrander/zoo/internal/llm"
)
type commentParams struct {
Body string `json:"body"`
}
func init() {
tool := llm.NewTool(
"comment",
"Post a comment on the issue or pull request that triggered this run.")
tool.AddStringProperty("body", "The comment text, in Markdown", true)
Register(tool, comment)
}
func comment(ctx Context, params commentParams) (string, error) {
if err := ctx.Forgejo().Comment(params.Body); err != nil {
return "", err
}
return "Comment posted", nil
}
type openPullRequestParams struct {
Head string `json:"head"`
Base string `json:"base"`
Title string `json:"title"`
Body string `json:"body"`
}
func init() {
tool := llm.NewTool(
"open_pull_request",
"Open a pull request from a branch you've pushed into the repository's default branch (or another base branch).")
tool.AddStringProperty("head", "The branch containing your changes (usually the run's own branch)", true)
tool.AddStringProperty("base", "The branch to merge into, e.g. the repository's default branch", true)
tool.AddStringProperty("title", "The pull request title", true)
tool.AddStringProperty("body", "The pull request description, in Markdown", true)
Register(tool, openPullRequest)
}
func openPullRequest(ctx Context, params openPullRequestParams) (string, error) {
if err := ctx.Forgejo().OpenPullRequest(params.Head, params.Base, params.Title, params.Body); err != nil {
return "", err
}
return "Pull request opened", nil
}
type requestReviewParams struct {
Reviewers []string `json:"reviewers"`
}
func init() {
tool := llm.NewTool(
"request_review",
"Request review of the current pull request from one or more Forgejo users.")
tool.AddStringArrayProperty("reviewers", "Usernames to request review from", true)
Register(tool, requestReview)
}
func requestReview(ctx Context, params requestReviewParams) (string, error) {
if err := ctx.Forgejo().RequestReview(params.Reviewers); err != nil {
return "", err
}
return "Review requested", nil
}
type labelParams struct {
Name string `json:"name"`
}
func init() {
tool := llm.NewTool(
"add_label",
"Add a label to the issue or pull request that triggered this run, creating it on the repo first if needed.")
tool.AddStringProperty("name", "The label name", true)
Register(tool, addLabel)
}
func addLabel(ctx Context, params labelParams) (string, error) {
if err := ctx.Forgejo().AddLabel(params.Name); err != nil {
return "", err
}
return "Label added", nil
}
func init() {
tool := llm.NewTool(
"remove_label",
"Remove a label from the issue or pull request that triggered this run.")
tool.AddStringProperty("name", "The label name", true)
Register(tool, removeLabel)
}
func removeLabel(ctx Context, params labelParams) (string, error) {
if err := ctx.Forgejo().RemoveLabel(params.Name); err != nil {
return "", err
}
return "Label removed", nil
}
type assignIssueParams struct {
Agent string `json:"agent"`
}
func init() {
tool := llm.NewTool(
"assign_issue",
"Hand the issue or pull request that triggered this run off to another agent, by name. This assigns it to that agent's Forgejo account, which triggers their run. Use this when the task is better suited to another agent's described role than your own.")
tool.AddStringProperty("agent", "The name of the agent to hand this off to, e.g. one listed in the \"Other agents\" section of your system prompt", true)
Register(tool, assignIssue)
}
func assignIssue(ctx Context, params assignIssueParams) (string, error) {
if err := ctx.Forgejo().AssignIssue(params.Agent); err != nil {
return "", err
}
return fmt.Sprintf("Assigned to %s", params.Agent), nil
}
type noParams struct{}
func init() {
tool := llm.NewTool(
"close_issue",
"Close the issue or pull request that triggered this run.")
Register(tool, closeIssue)
}
func closeIssue(ctx Context, _ noParams) (string, error) {
if err := ctx.Forgejo().CloseIssue(); err != nil {
return "", err
}
return "Issue closed", nil
}
func init() {
tool := llm.NewTool(
"reopen_issue",
"Reopen the issue or pull request that triggered this run.")
Register(tool, reopenIssue)
}
func reopenIssue(ctx Context, _ noParams) (string, error) {
if err := ctx.Forgejo().ReopenIssue(); err != nil {
return "", err
}
return "Issue reopened", nil
}
{"command":"cat /project/internal/config/config.go /project/zoo.hcl.example"}
// Package config loads and validates zoo's HCL configuration file.
package config
import (
"fmt"
"github.com/hashicorp/hcl/v2/hclsimple"
)
// Known event kinds. issue:assigned is resolved dynamically (agent name
// must match the Forgejo assignee's username) so it never carries an
// `agent` attribute; the rest map statically to one configured agent.
const (
EventIssueNew = "issue:new"
EventIssueComment = "issue:comment"
EventIssueAssigned = "issue:assigned"
EventPRNew = "pr:new"
)
var staticEventKinds = map[string]bool{
EventIssueNew: true,
EventIssueComment: true,
EventPRNew: true,
}
type Config struct {
LLMs []LLM `hcl:"llm,block"`
Forgejo Forgejo `hcl:"forgejo,block"`
MaxLive int `hcl:"max_live_agents"`
Environment Environment `hcl:"environment,block"`
Agents []Agent `hcl:"agent,block"`
Events []Event `hcl:"event,block"`
Web *Web `hcl:"web,block"`
}
// Web configures the dashboard's optional bearer-token gate. Leave the
// block out of zoo.hcl entirely to run without one (fine on localhost;
// put a real gate or a proxy in front for anything else).
type Web struct {
Token string `hcl:"token,optional"`
}
type LLM struct {
Name string `hcl:"name,label"`
OpenAI string `hcl:"openai"`
Token string `hcl:"token"`
Model string `hcl:"model"`
}
type Forgejo struct {
URL string `hcl:"url"`
Token string `hcl:"token"`
WebhookSecret string `hcl:"webhook_secret,optional"`
}
type Environment struct {
DockerImage string `hcl:"docker_image"`
}
type Agent struct {
Name string `hcl:"name,label"`
LLM string `hcl:"llm"`
}
type Event struct {
Kind string `hcl:"name,label"`
Agent string `hcl:"agent,optional"`
Instructions string `hcl:"instructions,optional"`
}
// Load reads and validates the config file at path.
func Load(path string) (*Config, error) {
var cfg Config
if err := hclsimple.DecodeFile(path, nil, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
return &cfg, nil
}
// Validate checks that the config is internally consistent: every
// reference between blocks resolves, and required values are set.
func (c *Config) Validate() error {
llmNames := make(map[string]bool, len(c.LLMs))
for _, l := range c.LLMs {
if l.OpenAI == "" || l.Token == "" || l.Model == "" {
return fmt.Errorf("llm %q: openai, token, and model are required", l.Name)
}
llmNames[l.Name] = true
}
if c.Forgejo.URL == "" || c.Forgejo.Token == "" {
return fmt.Errorf("forgejo: url and token are required")
}
if c.MaxLive < 1 {
return fmt.Errorf("max_live_agents must be >= 1, got %d", c.MaxLive)
}
if c.Environment.DockerImage == "" {
return fmt.Errorf("environment: docker_image is required")
}
agentNames := make(map[string]bool, len(c.Agents))
for _, a := range c.Agents {
if !llmNames[a.LLM] {
return fmt.Errorf("agent %q: references undeclared llm %q", a.Name, a.LLM)
}
agentNames[a.Name] = true
}
seenEventKinds := make(map[string]bool, len(c.Events))
for _, e := range c.Events {
if seenEventKinds[e.Kind] {
return fmt.Errorf("event %q: declared more than once", e.Kind)
}
seenEventKinds[e.Kind] = true
switch {
case e.Kind == EventIssueAssigned:
if e.Agent != "" {
return fmt.Errorf("event %q: must not set agent; the agent whose name matches the Forgejo assignee's username is triggered dynamically", e.Kind)
}
case staticEventKinds[e.Kind]:
if e.Agent == "" {
return fmt.Errorf("event %q: agent is required", e.Kind)
}
if !agentNames[e.Agent] {
return fmt.Errorf("event %q: references undeclared agent %q", e.Kind, e.Agent)
}
default:
return fmt.Errorf("event %q: unknown event kind", e.Kind)
}
}
return nil
}
// AgentByName returns the agent config with the given name, if declared.
func (c *Config) AgentByName(name string) (Agent, bool) {
for _, a := range c.Agents {
if a.Name == name {
return a, true
}
}
return Agent{}, false
}
// LLMByName returns the llm config with the given name, if declared.
func (c *Config) LLMByName(name string) (LLM, bool) {
for _, l := range c.LLMs {
if l.Name == name {
return l, true
}
}
return LLM{}, false
}
// EventAgent returns the statically configured agent name for the given
// event kind, if one is declared and set.
func (c *Config) EventAgent(kind string) (string, bool) {
for _, e := range c.Events {
if e.Kind == kind {
return e.Agent, e.Agent != ""
}
}
return "", false
}
// EventInstructions returns the configured freeform instructions for the
// given event kind, e.g. "Please review this pull request and respond
// appropriately." Returns "" if the event isn't declared or has none.
func (c *Config) EventInstructions(kind string) string {
for _, e := range c.Events {
if e.Kind == kind {
return e.Instructions
}
}
return ""
}
# zoo.hcl - Configuration for the zoo daemon.
#
# Copy this file to zoo.hcl (gitignored) and fill in real values.
llm "Qwen 3.6" {
openai = "https://code.stdio.dk"
token = "TOKEN"
model = "Qwen3.6"
}
llm "Qwen 3.8" {
openai = "https://code.stdio.dk"
token = "TOKEN"
model = "Qwen3.8"
}
forgejo {
url = "https://code.stdio.dk"
token = "EXAMPLE_TOKEN"
# Shared secret configured on the Forgejo webhook. Required to accept
# webhook deliveries; polling works without it.
webhook_secret = "EXAMPLE_WEBHOOK_SECRET"
}
max_live_agents = 5
environment {
docker_image = "golang:latest"
}
agent "anna" {
llm = "Qwen 3.6"
}
agent "greg" {
llm = "Qwen 3.8"
}
# "leon" doubles as the expected Forgejo assignee username for
# issue:assigned events - see the event block below.
agent "leon" {
llm = "Qwen 3.8"
}
agent "mika" {
llm = "Qwen 3.6"
}
event "issue:new" {
agent = "leon"
}
event "issue:assigned" {
// No `agent` here: the agent whose name matches the Forgejo
// assignee's username is triggered.
instructions = "Please handle this issue."
}
event "issue:comment" {
agent = "leon"
instructions = "Please review what this comment says and respond appropriately."
}
event "pr:new" {
agent = "greg"
instructions = "Please review this pull request and respond appropriately."
}
{"command":"cat /project/internal/forgejo/webhook.go /project/internal/forgejo/poll.go"}
package forgejo
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
sdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
)
type issuePayload struct {
Action string `json:"action"`
Issue *sdk.Issue `json:"issue"`
Repository *sdk.Repository `json:"repository"`
}
type issueCommentPayload struct {
Action string `json:"action"`
Issue *sdk.Issue `json:"issue"`
Comment *sdk.Comment `json:"comment"`
Repository *sdk.Repository `json:"repository"`
}
type pullRequestPayload struct {
Action string `json:"action"`
PullRequest *sdk.PullRequest `json:"pull_request"`
Repository *sdk.Repository `json:"repository"`
}
// WebhookHandler returns the http.Handler to mount at (e.g.)
// /webhooks/forgejo. If secret is non-empty, deliveries are verified via
// the SDK's X-Forgejo-Signature middleware; callers should always set a
// secret for anything reachable off localhost.
func WebhookHandler(secret string, logger *slog.Logger, emit func(Event)) http.Handler {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
kind := r.Header.Get("X-Forgejo-Event")
if kind == "" {
kind = r.Header.Get("X-Gitea-Event")
}
ev, ok, err := decodeWebhookEvent(kind, body)
if err != nil {
logger.Warn("failed to decode webhook payload", "event", kind, "error", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if ok {
emit(ev)
}
w.WriteHeader(http.StatusOK)
})
if secret == "" {
logger.Warn("forgejo webhook_secret is not set; incoming webhook deliveries are not authenticated")
return handler
}
return sdk.VerifyWebhookSignatureMiddleware(secret)(handler)
}
func decodeWebhookEvent(kind string, body []byte) (Event, bool, error) {
switch kind {
case "issues":
var p issuePayload
if err := json.Unmarshal(body, &p); err != nil {
return Event{}, false, err
}
return issueEvent(p, body)
case "issue_comment":
var p issueCommentPayload
if err := json.Unmarshal(body, &p); err != nil {
return Event{}, false, err
}
return issueCommentEvent(p, body)
case "pull_request":
var p pullRequestPayload
if err := json.Unmarshal(body, &p); err != nil {
return Event{}, false, err
}
return pullRequestEvent(p, body)
default:
return Event{}, false, nil
}
}
func issueEvent(p issuePayload, raw []byte) (Event, bool, error) {
if p.Issue == nil || p.Repository == nil {
return Event{}, false, nil
}
owner := repoOwner(p.Repository)
switch p.Action {
case "opened":
return Event{
Kind: EventIssueNew,
ID: issueNewID(p.Issue.ID),
Owner: owner,
Repo: p.Repository.Name,
Index: p.Issue.Index,
Title: p.Issue.Title,
Body: p.Issue.Body,
Author: posterName(p.Issue.Poster),
Raw: raw,
}, true, nil
case "assigned":
if len(p.Issue.Assignees) == 0 {
return Event{}, false, nil
}
// Webhook payloads only carry the single latest assignment as a
// distinct field on some Gitea/Forgejo versions; using the last
// entry in the current assignee list is the closest stable
// approximation available from the Issue object alone.
assignee := p.Issue.Assignees[len(p.Issue.Assignees)-1]
return Event{
Kind: EventIssueAssigned,
ID: issueAssignedID(p.Issue.ID, assignee.UserName),
Owner: owner,
Repo: p.Repository.Name,
Index: p.Issue.Index,
Title: p.Issue.Title,
Body: p.Issue.Body,
Author: posterName(p.Issue.Poster),
Assignee: assignee.UserName,
Raw: raw,
}, true, nil
default:
return Event{}, false, nil
}
}
func issueCommentEvent(p issueCommentPayload, raw []byte) (Event, bool, error) {
if p.Action != "created" || p.Issue == nil || p.Comment == nil || p.Repository == nil {
return Event{}, false, nil
}
// Comments on pull requests arrive on this same event in
// Gitea/Forgejo (PRs are issues under the hood); pr:comment is out
// of scope for v1.
if p.Issue.PullRequest != nil {
return Event{}, false, nil
}
return Event{
Kind: EventIssueComment,
ID: issueCommentID(p.Comment.ID),
Owner: repoOwner(p.Repository),
Repo: p.Repository.Name,
Index: p.Issue.Index,
Title: p.Issue.Title,
Body: p.Issue.Body,
Author: posterName(p.Comment.Poster),
Comment: p.Comment.Body,
Raw: raw,
}, true, nil
}
func pullRequestEvent(p pullRequestPayload, raw []byte) (Event, bool, error) {
if p.Action != "opened" || p.PullRequest == nil || p.Repository == nil {
return Event{}, false, nil
}
return Event{
Kind: EventPRNew,
ID: prNewID(p.PullRequest.ID),
Owner: repoOwner(p.Repository),
Repo: p.Repository.Name,
Index: p.PullRequest.Index,
Title: p.PullRequest.Title,
Body: p.PullRequest.Body,
Author: posterName(p.PullRequest.Poster),
Raw: raw,
}, true, nil
}
func repoOwner(r *sdk.Repository) string {
if r.Owner == nil {
return ""
}
return r.Owner.UserName
}
func posterName(u *sdk.User) string {
if u == nil {
return ""
}
return u.UserName
}
func issueNewID(issueID int64) string {
return fmt.Sprintf("issue-new-%d", issueID)
}
func issueAssignedID(issueID int64, assignee string) string {
return fmt.Sprintf("issue-assigned-%d-%s", issueID, assignee)
}
func issueCommentID(commentID int64) string {
return fmt.Sprintf("issue-comment-%d", commentID)
}
func prNewID(prID int64) string {
return fmt.Sprintf("pr-new-%d", prID)
}
package forgejo
import (
"context"
"encoding/json"
"fmt"
"time"
sdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
)
const (
watermarkIssues = "poll:issues"
watermarkPulls = "poll:pulls"
)
// pollOnce lists issues and pull requests updated since the stored
// watermark, across every repo the configured token can see, and
// dispatches synthesized Events for anything new. It's the fallback path
// for when Forgejo webhooks aren't set up or reachable.
func (w *Watcher) pollOnce(ctx context.Context) {
if err := w.pollIssues(ctx); err != nil {
w.logger.Warn("poll issues failed", "error", err)
}
if err := w.pollPulls(ctx); err != nil {
w.logger.Warn("poll pull requests failed", "error", err)
}
}
func (w *Watcher) pollIssues(ctx context.Context) error {
since, err := w.watermark(ctx, watermarkIssues)
if err != nil {
return err
}
issues, _, err := w.client.sdk.ListIssues(sdk.ListIssueOption{
Type: sdk.IssueTypeIssue,
State: sdk.StateAll,
Since: since,
})
if err != nil {
return fmt.Errorf("list issues: %w", err)
}
next := since
for _, issue := range issues {
if issue.Repository == nil {
continue
}
if issue.Updated.After(next) {
next = issue.Updated
}
owner, repo := issue.Repository.Owner, issue.Repository.Name
if issue.Comments == 0 && issue.Created.After(since) {
w.dispatch(issueToNewEvent(issue, owner, repo))
} else if issue.Updated.After(since) {
if err := w.pollNewComments(ctx, owner, repo, issue, since); err != nil {
w.logger.Warn("poll issue comments failed", "owner", owner, "repo", repo, "issue", issue.Index, "error", err)
}
}
if issue.Updated.After(since) {
for _, assignee := range issue.Assignees {
if assignee == nil {
continue
}
w.dispatch(issueToAssignedEvent(issue, owner, repo, assignee.UserName))
}
}
}
return w.store.SetWatermark(ctx, watermarkIssues, next.Format(time.RFC3339))
}
func (w *Watcher) pollNewComments(ctx context.Context, owner, repo string, issue *sdk.Issue, since time.Time) error {
comments, _, err := w.client.sdk.ListIssueComments(owner, repo, issue.Index, sdk.ListIssueCommentOptions{Since: since})
if err != nil {
return err
}
for _, comment := range comments {
if !comment.Created.After(since) {
continue
}
w.dispatch(issueToCommentEvent(issue, owner, repo, comment))
}
return nil
}
func (w *Watcher) pollPulls(ctx context.Context) error {
since, err := w.watermark(ctx, watermarkPulls)
if err != nil {
return err
}
issues, _, err := w.client.sdk.ListIssues(sdk.ListIssueOption{
Type: sdk.IssueTypePull,
State: sdk.StateAll,
Since: since,
})
if err != nil {
return fmt.Errorf("list pull requests: %w", err)
}
next := since
for _, issue := range issues {
if issue.Repository == nil {
continue
}
if issue.Updated.After(next) {
next = issue.Updated
}
if issue.Created.After(since) {
w.dispatch(issueToPRNewEvent(issue, issue.Repository.Owner, issue.Repository.Name))
}
}
return w.store.SetWatermark(ctx, watermarkPulls, next.Format(time.RFC3339))
}
func (w *Watcher) watermark(ctx context.Context, key string) (time.Time, error) {
v, err := w.store.GetWatermark(ctx, key)
if err != nil {
return time.Time{}, err
}
if v == "" {
return time.Time{}, nil
}
t, err := time.Parse(time.RFC3339, v)
if err != nil {
return time.Time{}, fmt.Errorf("parse watermark %q: %w", key, err)
}
return t, nil
}
func issueToNewEvent(issue *sdk.Issue, owner, repo string) Event {
raw, _ := json.Marshal(issue)
return Event{
Kind: EventIssueNew,
ID: issueNewID(issue.ID),
Owner: owner,
Repo: repo,
Index: issue.Index,
Title: issue.Title,
Body: issue.Body,
Author: posterName(issue.Poster),
Raw: raw,
}
}
func issueToAssignedEvent(issue *sdk.Issue, owner, repo, assignee string) Event {
raw, _ := json.Marshal(issue)
return Event{
Kind: EventIssueAssigned,
ID: issueAssignedID(issue.ID, assignee),
Owner: owner,
Repo: repo,
Index: issue.Index,
Title: issue.Title,
Body: issue.Body,
Author: posterName(issue.Poster),
Assignee: assignee,
Raw: raw,
}
}
func issueToCommentEvent(issue *sdk.Issue, owner, repo string, comment *sdk.Comment) Event {
raw, _ := json.Marshal(comment)
return Event{
Kind: EventIssueComment,
ID: issueCommentID(comment.ID),
Owner: owner,
Repo: repo,
Index: issue.Index,
Title: issue.Title,
Body: issue.Body,
Author: posterName(comment.Poster),
Comment: comment.Body,
Raw: raw,
}
}
func issueToPRNewEvent(issue *sdk.Issue, owner, repo string) Event {
raw, _ := json.Marshal(issue)
return Event{
Kind: EventPRNew,
ID: prNewID(issue.ID),
Owner: owner,
Repo: repo,
Index: issue.Index,
Title: issue.Title,
Body: issue.Body,
Author: posterName(issue.Poster),
Raw: raw,
}
}
{"command":"find / -path /proc -prune -o -name \"*.go\" -path \"*forgejo-sdk*\" -print 2>/dev/null | head -20"}
{"command":"cd /project && go env GOMODCACHE GOPATH 2>/dev/null; ls $(go env GOMODCACHE) 2>/dev/null | head"}
/go/pkg/mod /go
{"command":"cd /project && go mod download 2>&1 | tail -3; ls /go/pkg/mod/codeberg.org/mvdkleijn/ 2>/dev/null","timeout":300}
forgejo-sdk
{"command":"ls /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/ && grep -rn \"type PullRequest struct\" -A 40 /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2/*.go | head -80"}
forgejo grep: /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2/*.go: No such file or directory
{"command":"find /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/ -name \"*.go\" | xargs grep -ln \"type PullRequest struct\" 2>/dev/null"}
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/pull.go
{"command":"grep -n \"type PullRequest struct\" -A 60 \"/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/pull.go\" | head -90"}
31:type PullRequest struct {
32- ID int64 `json:"id"`
33- URL string `json:"url"`
34- Index int64 `json:"number"`
35- Poster *User `json:"user"`
36- Title string `json:"title"`
37- Body string `json:"body"`
38- Labels []*Label `json:"labels"`
39- Milestone *Milestone `json:"milestone"`
40- Assignee *User `json:"assignee"`
41- Assignees []*User `json:"assignees"`
42- State StateType `json:"state"`
43- IsLocked bool `json:"is_locked"`
44- Comments int `json:"comments"`
45-
46- HTMLURL string `json:"html_url"`
47- DiffURL string `json:"diff_url"`
48- PatchURL string `json:"patch_url"`
49-
50- Mergeable bool `json:"mergeable"`
51- HasMerged bool `json:"merged"`
52- Merged *time.Time `json:"merged_at"`
53- MergedCommitID *string `json:"merge_commit_sha"`
54- MergedBy *User `json:"merged_by"`
55- AllowMaintainerEdit bool `json:"allow_maintainer_edit"`
56-
57- Base *PRBranchInfo `json:"base"`
58- Head *PRBranchInfo `json:"head"`
59- MergeBase string `json:"merge_base"`
60-
61- Deadline *time.Time `json:"due_date"`
62- Created *time.Time `json:"created_at"`
63- Updated *time.Time `json:"updated_at"`
64- Closed *time.Time `json:"closed_at"`
65-}
66-
67-// ChangedFile is a changed file in a diff
68-type ChangedFile struct {
69- Filename string `json:"filename"`
70- PreviousFilename string `json:"previous_filename"`
71- Status string `json:"status"`
72- Additions int `json:"additions"`
73- Deletions int `json:"deletions"`
74- Changes int `json:"changes"`
75- HTMLURL string `json:"html_url"`
76- ContentsURL string `json:"contents_url"`
77- RawURL string `json:"raw_url"`
78-}
79-
80-// ListPullRequestsOptions options for listing pull requests
81-type ListPullRequestsOptions struct {
82- ListOptions
83- State StateType `json:"state"`
84- // oldest, recentupdate, leastupdate, mostcomment, leastcomment, priority
85- Sort string
86- Milestone int64
87-}
88-
89-// MergeStyle is used specify how a pull is merged
90-type MergeStyle string
91-
{"command":"grep -n \"type PRBranchInfo struct\" -A 15 \"/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/pull.go\""}
22:type PRBranchInfo struct {
23- Name string `json:"label"`
24- Ref string `json:"ref"`
25- Sha string `json:"sha"`
26- RepoID int64 `json:"repo_id"`
27- Repository *Repository `json:"repo"`
28-}
29-
30-// PullRequest represents a pull request
31-type PullRequest struct {
32- ID int64 `json:"id"`
33- URL string `json:"url"`
34- Index int64 `json:"number"`
35- Poster *User `json:"user"`
36- Title string `json:"title"`
37- Body string `json:"body"`
{"command":"cat /project/internal/agentrun/seed_test.go /project/internal/agentrun/roster_test.go /project/internal/agentrun/clone.go"}
package agentrun
import (
"strings"
"testing"
"github.com/abrander/zoo/internal/forgejo"
)
func TestSeedMessageIncludesInstructions(t *testing.T) {
ev := forgejo.Event{Kind: "issue:comment", Owner: "acme", Repo: "widgets", Index: 5, Title: "t", Body: "b", Raw: []byte(`{}`)}
msg := seedMessage(ev, "issue-5-leon", "main", "Please review the comment and respond appropriately.")
if !strings.Contains(msg, "Instructions for this event, from zoo.hcl:\nPlease review the comment and respond appropriately.") {
t.Fatalf("expected instructions section, got: %s", msg)
}
}
func TestSeedMessageOmitsEmptyInstructions(t *testing.T) {
ev := forgejo.Event{Kind: "pr:new", Owner: "acme", Repo: "widgets", Index: 9, Raw: []byte(`{}`)}
msg := seedMessage(ev, "issue-9-greg", "main", "")
if strings.Contains(msg, "Instructions for this event") {
t.Fatalf("expected no instructions section, got: %s", msg)
}
}
package agentrun
import (
"strings"
"testing"
)
func TestIdentitySectionIncludesSelfAndOthers(t *testing.T) {
roster := []agentInfo{
{Name: "leon", Description: "Triages new issues"},
{Name: "greg", Description: "Reviews pull requests"},
{Name: "mika", Description: ""},
}
section := identitySection("leon", roster)
if !strings.Contains(section, `You are the agent "leon"`) {
t.Fatalf("expected self identity, got: %s", section)
}
if !strings.Contains(section, "Triages new issues") {
t.Fatalf("expected self description, got: %s", section)
}
if strings.Contains(section, "- leon:") {
t.Fatalf("self should not appear in the other-agents roster: %s", section)
}
if !strings.Contains(section, "- greg: Reviews pull requests") {
t.Fatalf("expected greg in roster, got: %s", section)
}
if !strings.Contains(section, "- mika: (no description set)") {
t.Fatalf("expected fallback text for empty description, got: %s", section)
}
}
func TestIdentitySectionNoOthers(t *testing.T) {
roster := []agentInfo{{Name: "leon", Description: "Solo agent"}}
section := identitySection("leon", roster)
if strings.Contains(section, "Other agents") {
t.Fatalf("expected no roster section with only self, got: %s", section)
}
}
func TestGitIdentityFromProfile(t *testing.T) {
roster := []agentInfo{
{Name: "leon", FullName: "Leon the Triager", Email: "leon@example.com"},
}
name, email := gitIdentity("leon", roster)
if name != "Leon the Triager" || email != "leon@example.com" {
t.Fatalf("expected profile identity, got name=%q email=%q", name, email)
}
}
func TestGitIdentityFallsBackWhenProfileIncomplete(t *testing.T) {
roster := []agentInfo{{Name: "leon"}}
name, email := gitIdentity("leon", roster)
if name != "leon" {
t.Fatalf("expected name to fall back to agent name, got %q", name)
}
if email != "leon@zoo.local" {
t.Fatalf("expected synthetic fallback email, got %q", email)
}
}
func TestGitIdentityUnknownAgent(t *testing.T) {
name, email := gitIdentity("mika", nil)
if name != "mika" || email != "mika@zoo.local" {
t.Fatalf("expected fallback identity for an agent missing from the roster, got name=%q email=%q", name, email)
}
}
package agentrun
import (
"context"
"encoding/base64"
"fmt"
"os/exec"
"strings"
)
// gitAuthArgs returns the `-c http.extraHeader=...` global git arguments
// that authenticate a single invocation as the shared zoo Forgejo
// identity. Unlike embedding the token in the remote URL, a `-c`
// override lives only for the lifetime of that one process and is never
// written to .git/config on disk — where the container bind-mounting
// this working tree (and any command an agent runs inside it via the
// bash tool) could otherwise read it straight back out.
func gitAuthArgs(token string) []string {
auth := base64.StdEncoding.EncodeToString([]byte("zoo:" + token))
return []string{"-c", "http.extraHeader=Authorization: Basic " + auth}
}
// cloneAndBranch clones cloneURL into dir, authenticating the clone
// itself with token (see gitAuthArgs), and checks out a new branch off
// of defaultBranch. token never ends up in the resulting working tree:
// the origin remote is left with the plain cloneURL.
func cloneAndBranch(ctx context.Context, cloneURL, token, defaultBranch, branch, dir string) error {
args := append(gitAuthArgs(token), "clone", "--branch", defaultBranch, "--single-branch", cloneURL, dir)
if out, err := runGit(ctx, "", args...); err != nil {
return fmt.Errorf("clone: %w: %s", err, out)
}
if out, err := runGit(ctx, dir, "checkout", "-b", branch); err != nil {
return fmt.Errorf("checkout branch %q: %w: %s", branch, err, out)
}
return nil
}
// runGit runs a git subcommand on the host (not inside any container)
// with no Forgejo credential attached.
func runGit(ctx context.Context, dir string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", args...)
if dir != "" {
cmd.Dir = dir
}
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
// runGitAuthed is runGit with token attached via gitAuthArgs, for the
// one subcommand (push) that needs to authenticate against Forgejo.
func runGitAuthed(ctx context.Context, dir, token string, args ...string) (string, error) {
return runGit(ctx, dir, append(gitAuthArgs(token), args...)...)
}
{"command":"set -e\ncd /tmp && rm -rf prtest && mkdir prtest && cd prtest\ngit init -q --bare remote.git\ngit init -q -b main seed && cd seed\ngit config user.email t@t && git config user.name t\necho hello > file.txt && git add . && git commit -qm one\ngit push -q ../remote.git main\ncd /tmp/prtest\ngit init -q -b main work2 && cd work2\ngit config user.email t@t && git config user.name t\ngit remote add origin ../remote.git\ngit fetch -q origin main && git checkout -qb issue-15-greg\necho world >> file.txt && git commit -qam two\ngit push -q origin issue-15-greg\ncd /tmp/prtest\n# Now simulate what zoo does: single-branch clone of default branch\ngit clone -q --branch main --single-branch ../remote.git reviewer\ncd reviewer\necho \"--- remote.origin.fetch:\"; git config remote.origin.fetch\necho \"--- fetch PR head branch:\"\ngit fetch origin issue-15-greg\necho \"--- FETCH_HEAD:\"; git rev-parse FETCH_HEAD\necho \"--- diff origin/main...FETCH_HEAD:\"\ngit diff origin/main...FETCH_HEAD\necho \"--- checkout detach:\"\ngit checkout -q --detach FETCH_HEAD && cat file.txt\ngit checkout -q issue-15-greg 2>/dev/null || git checkout -q -b reviewer-branch\ngit branch -a"}
ERROR CALLING TOOL: command failed: command exited with status 1
{"command":"cd /tmp/prtest/reviewer 2>/dev/null || { set -e; cd /tmp && rm -rf prtest && mkdir prtest && cd prtest\ngit init -q --bare remote.git\ngit init -q -b main seed && cd seed\ngit config user.email t@t && git config user.name t\necho hello > file.txt && git add . && git commit -qm one\ngit push -q ../remote.git main\ncd /tmp/prtest\ngit init -q -b main work2 && cd work2\ngit config user.email t@t && git config user.name t\ngit remote add origin ../remote.git\ngit fetch -q origin main && git checkout -qb issue-15-greg\necho world >> file.txt && git commit -qam two\ngit push -q origin issue-15-greg\ncd /tmp/prtest\ngit clone -q --branch main --single-branch ../remote.git reviewer; }\ncd /tmp/prtest/reviewer\necho \"--- remote.origin.fetch:\"; git config remote.origin.fetch\necho \"--- fetch PR head branch:\"\ngit fetch origin issue-15-greg\necho \"--- FETCH_HEAD:\"; git rev-parse FETCH_HEAD\necho \"--- diff origin/main...FETCH_HEAD:\"\ngit diff origin/main...FETCH_HEAD\necho \"--- checkout detach:\"\ngit checkout -q --detach FETCH_HEAD && cat file.txt\necho \"--- back to own branch:\"\ngit checkout -q -b reviewer-branch\ngit branch"}
ERROR CALLING TOOL: command failed: command exited with status 1