Status timed_out
Event issue:new on abrander/zoo#15
Agent leon leon
Error agent loop: chat completion: Post "https://hallnet.hallucinators.ai/v1/chat/completions": context deadline exceeded

Log

Thinking
Let me understand this task. The issue title is "Ensure that PR reviewers have the tools needed to actually review the changes," and the body says "A good description in the prompt might be sufficient." This is the `zoo` repository, which is a system that triggers agents from Forgejo events. In other words, this is a meta-task about the zoo system itself — the "prompt" refers to the system prompt given to agents (like me, leon, greg, mika, anna). The issue is asking that when a PR is created (or when a review is requested), the reviewer agent should be given the tools/context needed to actually review the changes — for example, knowing which files were changed, how to run the build/tests, and so on. Let me first explore the repository to understand its structure.
Tool 🔧 list_files {"path":".","recursive":true}
{"path":".","recursive":true}

        
Tool 🔧 bash {"command":"git -C /project log --oneline -10 && git -C /project branch -a"}
{"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
Thinking
The list_files with recursive=true returned nothing. Let me try again.
Tool 🔧 bash {"command":"ls -la /project && find /project -type f -not -path '*/.git/*' | head -100"}
{"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
Thinking
This is the Go project for the zoo system itself. The issue is about the prompt (system prompt) given to agents — "maybe a good explanation in the prompt would be enough." In other words, the task is: when an agent is triggered to review a PR (e.g., via `request_review`), that agent should have the tools/context needed to actually review the changes. Let's take a look at the system prompt template `internal/agentrun/system.md` and the context-building code `internal/agentrun/context.go`.
Tool 🔧 read_file {"path":"/project/internal/agentrun/system.md","start_line":1,"end_line":200}
{"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
Tool 🔧 read_file {"path":"/project/internal/agentrun/context.go","start_line":1,"end_line":300}
{"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
Thinking
Weird error. Let me try again, or use bash's cat.
Tool 🔧 bash {"command":"cat /project/internal/agentrun/system.md"}
{"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.
Thinking
The `read_file` tool failed, but bash's cat works. Let me try `read_file` again — it might be a temporary issue, or maybe it's a path problem. In fact, the error was "command exited with status 1" — maybe the tool is using some kind of wrapper. For now, let me just use bash for reading. Next, let me read context.go.
Tool 🔧 bash {"command":"cat /project/internal/agentrun/context.go"}
{"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
}
Thinking
Next, let's look at run.go, which probably assembles the prompt (system.md + identity + roster + event), and see how the run is triggered, especially for PR review events.
Tool 🔧 bash {"command":"cat /project/internal/agentrun/run.go"}
{"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)
}
Thinking
Now I understand how the prompt is assembled. Let's check the following: 1. `internal/forgejo/event.go` — to understand Event's kind (issue:new, pr:new, pr:review, etc.) 2. `internal/agentrun/roster.go` — identitySection 3. `internal/config/config.go` — EventInstructions 4. `internal/tools/forgejo.go` — the available Forgejo actions (request_review, etc.) The issue: "Make sure that PR reviewers have the tools they need to actually review the changes. A good explanation in the prompt might be enough." In other words, when an agent is triggered to review a PR (for example, when `request_review` is called from another agent, or when a PR is opened/updated), that agent's working tree is checked out on a fresh branch from the default branch — not on the PR's head branch! So the reviewer can't see the PR's changes in the working tree. They need to fetch the PR's branch (e.g., `git fetch origin <head-branch>`) and diff it against the base. Wait — let me verify: cloneAndBranch clones the repo and creates a fresh branch from the default branch. That means the PR's changes don't exist in the working tree. A reviewer agent triggered by a PR event (e.g., "pr:review_requested" or "pr:updated") would need to fetch the PR's head branch and diff it. The issue says "Make sure that PR reviewers have the tools they need to actually review the changes." The tools do exist: `git` (fetch, diff, show, log) and `bash`. But the prompt doesn't tell the agent how to obtain the PR's changes. "A good explanation in the prompt might be enough" — so the fix is probably to add guidance to the system prompt (system.md) explaining how to review a PR: fetch the head branch, diff against the base, read the diff, etc. Let me look at event.go to check which event kinds exist, and check the forgejo actions (request_review) in tools/forgejo.go.
Tool 🔧 bash {"command":"cat /project/internal/forgejo/event.go /project/internal/agentrun/roster.go"}
{"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()
}
Thinking
Next, let me check the forgejo.go in tools (the request_review tool) and the config's EventInstructions to see which instructions exist. I'll also check the PR event handling — the `pr:new` event. When a PR is created, the agent is triggered with the PR payload. The working tree is checked out from the default branch, not from the PR's head branch. In other words, the agent (who might be the PR's author or a reviewer) can't see the PR's diff in the working tree. Let me check tools/forgejo.go.
Tool 🔧 bash {"command":"cat /project/internal/tools/forgejo.go"}
{"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
}
Thinking
Next, let me check the config's `EventInstructions` and `zoo.hcl.example` to see how the instructions are provided, and how the PR event is handled (which fields the PR payload has — head branch, base branch, diff URL, etc.).
Tool 🔧 bash {"command":"cat /project/internal/config/config.go /project/zoo.hcl.example"}
{"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."
}
Thinking
Now the whole picture is coming into view. Key facts: 1. When a `pr:new` event fires, the configured reviewer agent (greg in the example) is triggered. 2. The reviewer's working tree is cloned from the **default branch**, and a new branch `issue-{index}-{agent}` is created from the default branch. In other words, the PR's changes **do not** exist in the working tree. 3. The event payload `/event` contains the full PR JSON from Forgejo, which includes `pull_request.head.ref` (the head branch) and `pull_request.base.ref`. 4. The system prompt (system.md) says "Your working tree is at /project, checked out on a new branch from the repository's default branch." — but it doesn't say anything about how to fetch and review the PR's changes. So the problem is: "Make sure PR reviewers have the tools they need to actually review the changes. A good description in the prompt might be enough." The tools themselves exist (git fetch, git diff, git show, read_file, bash). What's missing is the *description* in the prompt explaining how to obtain the PR's changes for review: - The PR's changes are not in your working tree (it's checked out from the default branch). - To review: `git fetch origin <head-branch>` (the head ref is in the event payload's `pull_request.head.ref`), then `git diff origin/<base>...FETCH_HEAD` or `git log -p`, etc. Let's check the shape of the PR event payload — webhook.go / poll.go — to figure out exactly which fields are available (e.g., `pull_request.head.ref`, `pull_request.base.ref`).
Tool 🔧 bash {"command":"cat /project/internal/forgejo/webhook.go /project/internal/forgejo/poll.go"}
{"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,
	}
}
Thinking
Now I understand the overall flow. Let's confirm the following: 1. The `pr:new` event payload contains a complete PR JSON (sdk.PullRequest), which includes `head.ref`, `base.ref`, `diff_url`, `merge_base`, etc. 2. The reviewer's working tree is cloned from the default branch — the PR's changes are not included in the working tree. 3. The system prompt doesn't tell the reviewer how to fetch and diff the PR's changes. Let me check the sdk.PullRequest struct to know the exact JSON field names. I'll take a look at the vendored SDK or the go module cache.
Tool 🔧 bash {"command":"find / -path /proc -prune -o -name \"*.go\" -path \"*forgejo-sdk*\" -print 2>/dev/null | head -20"}
{"command":"find / -path /proc -prune -o -name \"*.go\" -path \"*forgejo-sdk*\" -print 2>/dev/null | head -20"}

        
Thinking
The module cache hasn't been downloaded yet (or it's in a different location). Let me check the Go module cache, or run `go mod download` and then take a look.
Tool 🔧 bash {"command":"cd /project && go env GOMODCACHE GOPATH 2>/dev/null; ls $(go env GOMODCACHE) 2>/dev/null | head"}
{"command":"cd /project && go env GOMODCACHE GOPATH 2>/dev/null; ls $(go env GOMODCACHE) 2>/dev/null | head"}
/go/pkg/mod
/go
Thinking
The module cache is empty. Let me download the modules so I can check the SDK's PullRequest struct and run the build/tests.
Tool 🔧 bash {"command":"cd /project && go mod download 2>&1 | tail -3; ls /go/pkg/mod/codeberg.org/mvdkleijn/ 2>/dev/null","timeout":300}
{"command":"cd /project && go mod download 2>&1 | tail -3; ls /go/pkg/mod/codeberg.org/mvdkleijn/ 2>/dev/null","timeout":300}
forgejo-sdk
Thinking
Let me check the PullRequest struct in the SDK and figure out which fields are available in the raw event payload (head.ref, base.ref, merge_base, diff_url, etc.).
Tool 🔧 bash {"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"}
{"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
Thinking
Let me check the structure of the SDK in order to understand the fields of the PullRequest struct.
Tool 🔧 bash {"command":"find /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/ -name \"*.go\" | xargs grep -ln \"type PullRequest struct\" 2>/dev/null"}
{"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
Thinking
Let me check the PullRequest struct to see what fields are available in the raw event payload.
Tool 🔧 bash {"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"}
{"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-
Thinking
The PR payload (raw event JSON) includes the following: - `pull_request.head.ref` (head branch name) - `pull_request.base.ref` (base branch name) - `pull_request.merge_base` (SHA of the merge base) - `pull_request.diff_url`, `pull_request.patch_url` Let me check the PRBranchInfo struct.
Tool 🔧 bash {"command":"grep -n \"type PRBranchInfo struct\" -A 15 \"/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/pull.go\""}
{"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"`
Thinking
Now I have everything I need. The situation is as follows: **Problem**: When a `pr:new` event triggers the reviewer agent (the agent configured in `event "pr:new"`), the reviewer's working tree is a fresh clone checked out from the **default branch** — the PR's changes are **not** in the working tree. The system prompt tells the agent that "your working tree is at /project, checked out on a fresh branch from the repo's default branch," but doesn't say anything about how to actually *see* the PR's changes. The tools (`git fetch`, `git diff`, `git log`, `read_file`, `bash`) exist, but the agent doesn't know it should fetch the PR's head branch (which is available in the event payload at `pull_request.head.ref`) and diff it against the base. The issue says: "Make sure PR reviewers have the tools they need to actually review the changes. A good description in the prompt might be enough." So the intended fix (following the issue author's own suggestion) is: add a clear description to the system prompt (system.md) explaining how to review a PR in this environment: 1. The working tree is checked out from the default branch — the PR's changes are not there. 2. The event payload (at /event and in the message) contains `pull_request.head.ref` and `pull_request.base.ref`. 3. To review: `git fetch origin <head-ref>`, then `git diff origin/<base-ref>...FETCH_HEAD` (or `git log -p <merge-base>..FETCH_HEAD`, or `git checkout FETCH_HEAD --detach` to inspect files). 4. Then use `comment` to post review feedback. Also, the `request_review` tool description says "request review of the current pull request" — when an agent requests review from another agent (e.g. greg), that agent will be triggered by... hmm, wait. Actually, does requesting review trigger the other agent's run? Let me check: `request_review` calls `ctx.Forgejo().RequestReview(params.Reviewers)`. That's an API call to add a reviewer to the PR. Does adding a reviewer trigger a Forgejo event that zoo handles? The webhook only handles `pull_request` with action `opened`. So requesting review doesn't trigger a new run in zoo's current event handling. Hmm, but the `assign_issue` tool does trigger a run (assignment event). So the "review" flow in zoo is: the PR author's agent opens the PR and can `request_review` a human (abrander) or an agent. If an agent is requested as reviewer... the `review_requested` action isn't handled by the webhook. Only `opened` is handled. Wait, let me re-read the issue: "Make sure PR reviewers have the tools they need to actually review the changes." Hmm. There are two interpretations: 1. **Prompt-only fix** (what the issue suggests): when an agent is triggered by `pr:new` (the configured reviewer, e.g. greg), it needs to know how to fetch the PR's changes. Add a description to the system prompt. 2. **Tooling fix**: add a `review_pull_request` tool or something. But the issue says "a good description in the prompt might be enough" — the author is leaning toward a prompt description. Given the issue author's hint, the minimal and correct change is: update `system.md` to include a section explaining how to review a PR in this environment (fetch the head branch from the event payload, diff against base, inspect, and comment the review). But wait — should I also consider the `request_review` flow? When leon (or another agent) opens a PR and requests review from greg, greg's run is... not triggered by the current webhook handling (only `opened` action). Actually, wait — does Forgejo send a `pull_request` webhook with action `review_requested` when a reviewer is added? Yes, Forgejo/Gitea sends `pull_request` events with actions like `review_requested`. But zoo's `pullRequestEvent` only handles `action == "opened"`. So requesting review from an agent doesn't trigger that agent. Hmm, but is that in scope? The issue is "make sure PR reviewers have the tools they need to actually review the changes." The core problem: a reviewer agent that gets triggered (via pr:new) has a working tree that doesn't contain the PR's changes, and the prompt doesn't tell it how to get them. "A good description in the prompt might be enough" — the author is saying the fix could just be a good description in the prompt. Let me focus on the prompt description in system.md. That's the surgical change the issue asks for. Actually, let me think a bit more carefully about what "the tools they need to actually review the changes" means. Available tools: bash, git, read_file, write_file, list_files, grep_search, move_file, remove_file, comment, open_pull_request, request_review, add_label, remove_label, close_issue, reopen_issue, assign_issue. For a reviewer: - To see the changes: `git fetch origin <head-branch>` + `git diff` — available via the `git` tool and `bash`. - To read files: read_file — but the working tree is on the base branch, so reading files shows the pre-change state. The reviewer can fetch the head branch and diff, or check out the head branch in detached mode. - To respond: comment — available. So all the tools are there. What's missing is the knowledge: the prompt doesn't tell the agent how to get the PR's diff. The fix: add a "Reviewing pull requests" section to system.md: - When triggered by a PR event, the working tree is checked out from the default branch — the PR's changes are not in the working tree. - The event payload contains `pull_request.head.ref` (the PR's branch) and `pull_request.base.ref` (the branch it targets), plus `pull_request.merge_base`. - To review: - `git fetch origin <head.ref>` (e.g. `git fetch origin issue-15-greg`) - `git diff origin/<base.ref>...FETCH_HEAD` to see the full diff - `git log --oneline <merge-base>..FETCH_HEAD` for the commit list - Optionally `git checkout --detach FETCH_HEAD` to inspect the changed files directly with read_file/grep_search (then `git checkout` back to your own branch). - Review by posting a `comment` on the PR with your findings; be specific (file, line, what's wrong, why). Hmm, wait, one nuance: after `git checkout --detach FETCH_HEAD`, the agent's own branch `issue-{index}-{agent}` is still there, and the agent can go back with `git checkout <branch>`. But actually, for a reviewer, the agent usually doesn't push anything. The prompt says "when done, git add/commit/push your branch — that's how your work is saved." For a reviewer, the work is the comment. That's fine. Also, there's a subtlety with `git fetch origin <branch>`: the git tool has a "fetch" subcommand, so the agent can use the git tool. Good. Another consideration: the PR's head branch might be from a fork (in which case `pull_request.head.repo` is a different repo, and `origin` doesn't have that branch). In that case, fetching from origin would fail. In that case, the reviewer can use `diff_url`/`patch_url` — but those URLs require authentication (Forgejo API token). Hmm, `diff_url` is an HTML URL like `https://code.stdio.dk/abrander/zoo/pulls/15.diff` — actually, for Gitea/Forgejo, the `.diff` suffix on the PR URL works with token auth. The agent doesn't have a token inside the container (the secret token isn't put in the sandbox — see commit 387906c "Do not put the secret token inside the fricking sandbox"). So fetching from a forked repo isn't possible without a token. But in zoo's setup, agents push to branches in the same repo (e.g. `issue-15-greg`), so same-repo fetches work. I'll mention the same-repo case primarily and note that for PRs from forks, the raw payload's `diff_url` exists but may require auth. Actually, let me keep the prompt focused and practical: ``` **Reviewing pull requests** When you're triggered to review a pull request, note that your working tree is checked out from the default branch — the PR's changes are NOT in it. The full PR payload (at /event and in the message above) carries `pull_request.head.ref` (the PR's source branch) and `pull_request.base.ref` (the branch it targets). To get the changes: - `git fetch origin <head.ref>` — brings the PR's commits into your clone without touching your working tree. - `git diff origin/<base.ref>...FETCH_HEAD` — the full diff to review. - `git log --oneline <merge-base>..FETCH_HEAD` — the PR's commits. - `git checkout --detach FETCH_HEAD` — inspect the changed files directly (read_file, grep_search, run tests); go back with `git checkout <your branch>` when done. Review by posting a `comment` on the PR: specific, file/line-level findings, what's wrong and why, and a clear approve/request-changes verdict. Don't push code changes as a reviewer — your job is the review, and the author (often another agent) acts on it. ``` Hmm, wait. Should the reviewer be allowed to push fixes? In many agent workflows, the reviewer can also push fixes. But the issue is about "having the tools they need to actually review the changes" — the review capability. Let me keep the guidance about the verdict and leave the fix-pushing as an option... actually, let me not over-engineer. The key missing piece is: how to get the PR's changes into view. Let me also double-check how the seed message looks for a PR event: "You were triggered by a \"pr:new\" event on abrander/zoo. ... Title: ... Body: ... Full event payload: ...". The full payload includes `pull_request.head.ref` etc. Good. Now, is there anything else needed? Let me re-read the issue title: "Make sure PR reviewers have the tools they need to actually review the changes." Another angle: the `request_review` tool. When an agent requests review from another agent (e.g. leon requests greg), does greg get triggered? Looking at the webhook: only `action == "opened"` is handled. So no. But is that in scope for this issue? The issue says "make sure PR reviewers have the tools they need to actually review the changes" — if greg is never triggered at all, then greg has no tools at all. Hmm. But wait — how does a reviewer get triggered in the current system? Via `event "pr:new" { agent = "greg" ... }` — the statically configured reviewer. In the example config, greg is the PR reviewer. So the flow is: an agent opens a PR → the `pr:new` webhook fires → greg is triggered to review. That's the review flow. And greg's run has a working tree from the default branch, with no PR changes, and the prompt doesn't tell it how to get them. That's exactly the gap the issue describes: "make sure PR reviewers have the tools they need to actually review the changes. A good description in the prompt might be enough." So the prompt description is the fix. Now, where exactly to add it? In the system prompt in `system.md`. That's the embedded default system prompt. I could also add it to the `pr:new` instructions in `zoo.hcl.example`, but the system prompt is the right place — it applies to all agents and all repos. The issue says "a good description in the prompt might be enough" — "the prompt" = the system prompt. Let me also check `internal/agentrun/seed_test.go` and `roster_test.go` to see if there are tests that assert on the system prompt content, so I don't break anything.
Now let me look at the tests and the remaining relevant files to understand what's asserted and how the PR review flow works end-to-end.
Tool 🔧 bash {"command":"cat /project/internal/agentrun/seed_test.go /project/internal/agentrun/roster_test.go /project/internal/agentrun/clone.go"}
{"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...)...)
}
Thinking
Important detail: the clone is done with `--single-branch` against the default branch. That means the clone only has the refs for the default branch. The reviewer can still fetch the PR's head branch with `git fetch origin <head-ref>` (a fetch with an explicit refspec works even with a single-branch clone — it just downloads that ref). Let me confirm: `git clone --single-branch --branch main` sets `remote.origin.fetch = +refs/heads/main:refs/remotes/origin/main`. Running `git fetch origin issue-15-greg` downloads `refs/heads/issue-15-greg` into FETCH_HEAD (and, if configured, into refs/remotes/origin/issue-15-greg too — with a single-branch clone, fetching an explicit ref name updates FETCH_HEAD. It only creates the remote-tracking ref if the refspec matches... actually, `git fetch origin <branch>` uses the refspec `refs/heads/<branch>:refs/remotes/origin/<branch>` if the branch matches... let me think. `git fetch origin <name>`: git resolves `<name>` against the configured refspecs. If no refspec matches, it treats it as a literal ref and... actually, the rule is: if `<name>` matches exactly one configured refspec's remote side, that refspec is used. Otherwise, git tries to interpret it as a remote-tracking ref, a tag, etc. For a single-branch clone with `+refs/heads/main:refs/remotes/origin/main`, fetching `issue-15-greg` doesn't match the refspec. Git falls back: it tries `refs/heads/issue-15-greg`, `refs/tags/issue-15-greg`, etc. on the remote, and stores it in FETCH_HEAD. Yes — `git fetch origin somebranch` works and populates FETCH_HEAD even if somebranch isn't in the refspec. It just doesn't create a persistent remote-tracking branch. So for the reviewer: - `git fetch origin <head.ref>` → FETCH_HEAD contains the PR's tip. - `git diff origin/<base.ref>...FETCH_HEAD` → the full diff. - `git checkout --detach FETCH_HEAD` → inspect the tree. To be safe, I could also suggest an explicit refspec: `git fetch origin <head.ref>:refs/remotes/origin/<head.ref>` — but that's more complex. FETCH_HEAD is enough and simpler. To make sure my prompt guidance is accurate, let me verify this fetch behavior with a quick local experiment. Actually, let me test it in a scratch directory: 1. Create a bare "remote" repo with a main branch. 2. Clone with --single-branch --branch main. 3. Create a feature branch in a second clone and push it. 4. In the first clone, run `git fetch origin feature` and check FETCH_HEAD, then `git diff origin/main...FETCH_HEAD`. Let me run this to confirm.
I've grasped the overall picture. I'll verify an important detail: since the clone is done with `--single-branch` against the default branch, I need to confirm that the reviewer can still fetch the PR's head branch and get a diff. Let me test this exact flow locally.
Tool 🔧 bash {"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"}
{"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
Thinking
Something failed. Probably the last `git checkout -q issue-15-greg` failed (that branch doesn't exist in the reviewer's clone — it's a single-branch clone). Let me try running it step by step to see where it fails.
Tool 🔧 bash {"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"}
{"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