Details and log output for this agent run.
{"path": ".", "recursive": true}
/project/cmd /project/cmd/zoo /project/cmd/zoo/main.go /project/go.mod /project/go.sum /project/internal /project/internal/agentrun /project/internal/agentrun/clone.go /project/internal/agentrun/context.go /project/internal/agentrun/docker.go /project/internal/agentrun/docker_integration_test.go /project/internal/agentrun/forgejo.go /project/internal/agentrun/loop.go /project/internal/agentrun/roster.go /project/internal/agentrun/roster_test.go /project/internal/agentrun/run.go /project/internal/agentrun/seed_test.go /project/internal/agentrun/system.md /project/internal/config /project/internal/config/config.go /project/internal/config/config_test.go /project/internal/forgejo /project/internal/forgejo/client.go /project/internal/forgejo/event.go /project/internal/forgejo/poll.go /project/internal/forgejo/watcher.go /project/internal/forgejo/webhook.go /project/internal/forgejo/webhook_test.go /project/internal/livelog /project/internal/livelog/hub.go /project/internal/livelog/hub_test.go /project/internal/llm /project/internal/llm/client.go /project/internal/llm/streamer.go /project/internal/llm/streamer_test.go /project/internal/llm/types.go /project/internal/scheduler /project/internal/scheduler/scheduler.go /project/internal/scheduler/scheduler_test.go /project/internal/store /project/internal/store/schema.sql /project/internal/store/store.go /project/internal/store/store_test.go /project/internal/tools /project/internal/tools/bash.go /project/internal/tools/context.go /project/internal/tools/edit-file.go /project/internal/tools/forgejo.go /project/internal/tools/git.go /project/internal/tools/grep_search.go /project/internal/tools/list_files.go /project/internal/tools/move_file.go /project/internal/tools/read_file.go /project/internal/tools/registry.go /project/internal/tools/remove_file.go /project/internal/tools/shell.go /project/internal/tools/tools_test.go /project/internal/tools/write_file.go /project/internal/web /project/internal/web/templates.go /project/internal/web/web.go /project/internal/web/web_test.go /project/zoo.hcl.example
{"command": "cd /project && git log --oneline -10 && git status"}
c8784fa (HEAD -> issue-33-greg, origin/main, origin/HEAD, main) Merge pull request 'fix(tools): make grep_search actually use regex (ERE)' (#32) from issue-31-greg into main 2c5b978 fix(tools): make grep_search actually use regex (ERE) ea073a3 Merge pull request 'Add job overview on dashboard' (#26) from issue-24-anna into main 3b38675 feat(dashboard): add job overview on dashboard 1c33680 Merge pull request 'Move max_live_agents into the environment block' (#23) from issue-22-mika into main 7cf22ff Move max_live_agents into the environment block 94b8ca1 Merge pull request 'Add per-agent Forgejo tokens, deprecate Sudo: header approach' (#21) from issue-16-mika into main f8c45dc Merge pull request 'web: show agent profile photos on the jobs pages' (#20) from issue-17-greg into main 4a31b24 Add per-agent Forgejo tokens, deprecate Sudo: header approach e1bf291 web: show agent profile photos on the jobs pages On branch issue-33-greg nothing to commit, working tree clean
{"path": "/project/internal/forgejo/event.go", "start_line": 1, "end_line": 200}
{"path":"/project/internal/forgejo/event.go","content":"package forgejo\r\n\r\nimport \"encoding/json\"\r\n\r\n// Event kinds, matching the values used as event block labels in\r\n// config.Config (config avoids importing this package, so the kind\r\n// strings are duplicated by value, not by identity).\r\nconst (\r\n\tEventIssueNew = \"issue:new\"\r\n\tEventIssueComment = \"issue:comment\"\r\n\tEventIssueAssigned = \"issue:assigned\"\r\n\tEventPRNew = \"pr:new\"\r\n)\r\n\r\n// Event is the normalized shape produced by both the webhook receiver and\r\n// the polling fallback, and is what the scheduler dispatches on.\r\ntype Event struct {\r\n\tKind string\r\n\r\n\t// ID uniquely identifies this event for dedup, regardless of whether\r\n\t// it arrived via webhook or polling.\r\n\tID string\r\n\r\n\tOwner, Repo string\r\n\tIndex int64 // issue or PR number\r\n\r\n\tTitle, Body string\r\n\tAuthor string\r\n\r\n\t// Assignee is set for EventIssueAssigned: the Forgejo username the\r\n\t// issue was assigned to.\r\n\tAssignee string\r\n\r\n\t// Comment is set for EventIssueComment.\r\n\tComment string\r\n\r\n\t// Raw is the full source payload (webhook body, or a synthesized\r\n\t// equivalent when polling), written to /event in the agent container.\r\n\tRaw json.RawMessage\r\n}\r","start_line":1,"end_line":40,"total_lines":40,"truncated":false}
{"path": "/project/internal/agentrun/context.go", "start_line": 1, "end_line": 200}
{"path":"/project/internal/agentrun/context.go","content":"package agentrun\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"fmt\"\r\n\r\n\t\"github.com/abrander/zoo/internal/tools\"\r\n)\r\n\r\n// runContext implements tools.Context for a single agent run, executing\r\n// shell commands via docker exec against the run's container, git\r\n// commands as the host process against the run's working tree (see\r\n// Git), and binding Forgejo actions to the triggering issue/PR.\r\ntype runContext struct {\r\n\tdocker *dockerRuntime\r\n\tcontainerID string\r\n\tprojectDir string\r\n\ttoken string\r\n\tforgejo tools.ForgejoActions\r\n}\r\n\r\nfunc (c *runContext) Exec(ctx context.Context, command string) (string, error) {\r\n\toutput, exitCode, err := c.docker.exec(ctx, c.containerID, command)\r\n\r\n\tif err != nil {\r\n\t\treturn output, err\r\n\t}\r\n\r\n\tif exitCode != 0 {\r\n\t\treturn output, fmt.Errorf(\"command exited with status %d\", exitCode)\r\n\t}\r\n\r\n\treturn output, nil\r\n}\r\n\r\n// Git implements tools.Context.Git by running git on the host against\r\n// c.projectDir rather than via docker exec. Only push needs to\r\n// authenticate against Forgejo, so only it carries the token, via a\r\n// per-invocation -c override that never touches .git/config (see\r\n// gitAuthArgs) — the container this same working tree is bind-mounted\r\n// into has no way to read it out.\r\nfunc (c *runContext) Git(ctx context.Context, subcommand string, args ...string) (string, error) {\r\n\tcmdArgs := append([]string{subcommand}, args...)\r\n\r\n\tif subcommand == \"push\" {\r\n\t\treturn runGitAuthed(ctx, c.projectDir, c.token, cmdArgs...)\r\n\t}\r\n\r\n\treturn runGit(ctx, c.projectDir, cmdArgs...)\r\n}\r\n\r\nfunc (c *runContext) Forgejo() tools.ForgejoActions {\r\n\treturn c.forgejo\r\n}\r","start_line":1,"end_line":54,"total_lines":54,"truncated":false}
{"path": "/project/internal/agentrun/run.go", "start_line": 1, "end_line": 250}
{"path":"/project/internal/agentrun/run.go","content":"// Package agentrun creates the throwaway per-job environment an agent\r\n// runs in: a git clone + branch bind-mounted into a Docker container,\r\n// the triggering event written to /event, and the tool-calling loop\r\n// (internal/llm + internal/tools) driven against it.\r\npackage agentrun\r\n\r\nimport (\r\n\t\"context\"\r\n\t_ \"embed\"\r\n\t\"encoding/json\"\r\n\t\"fmt\"\r\n\t\"log/slog\"\r\n\t\"os\"\r\n\t\"path/filepath\"\r\n\t\"strings\"\r\n\t\"sync\"\r\n\t\"time\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n\t\"github.com/abrander/zoo/internal/forgejo\"\r\n\t\"github.com/abrander/zoo/internal/livelog\"\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n\t\"github.com/abrander/zoo/internal/store\"\r\n)\r\n\r\n//go:embed system.md\r\nvar defaultSystemPrompt string\r\n\r\n// DefaultTimeout bounds a single agent run's wall-clock time if the\r\n// caller doesn't override it.\r\nconst DefaultTimeout = 120 * time.Minute\r\n\r\ntype Runner struct {\r\n\tdocker *dockerRuntime\r\n\tforgejo *forgejo.Client\r\n\tstore *store.Store\r\n\thub *livelog.Hub\r\n\tcfg *config.Config\r\n\tlogger *slog.Logger\r\n\ttimeout time.Duration\r\n\tkeepOnFailure bool\r\n\r\n\tagentClientsMu sync.Mutex\r\n\tagentClients map[string]*forgejo.Client\r\n}\r\n\r\nfunc NewRunner(cfg *config.Config, fg *forgejo.Client, st *store.Store, hub *livelog.Hub, logger *slog.Logger, timeout time.Duration, keepOnFailure bool) (*Runner, error) {\r\n\tdocker, err := newDockerRuntime()\r\n\tif err != nil {\r\n\t\treturn nil, err\r\n\t}\r\n\r\n\tif timeout \u003c= 0 {\r\n\t\ttimeout = DefaultTimeout\r\n\t}\r\n\r\n\treturn \u0026Runner{\r\n\t\tdocker: docker,\r\n\t\tforgejo: fg,\r\n\t\tstore: st,\r\n\t\thub: hub,\r\n\t\tcfg: cfg,\r\n\t\tlogger: logger,\r\n\t\ttimeout: timeout,\r\n\t\tkeepOnFailure: keepOnFailure,\r\n\t\tagentClients: make(map[string]*forgejo.Client),\r\n\t}, nil\r\n}\r\n\r\n// forgejoAs returns a Forgejo client that authenticates as the given\r\n// agent (using the agent's own token from config). This lets each agent\r\n// act as themselves on Forgejo without needing a global token with sudo\r\n// privileges. Clients are built once per agent and cached, since\r\n// constructing one costs an extra API round trip.\r\n//\r\n// If the agent has no token configured, falls back to the shared zoo\r\n// identity so existing deployments without per-agent tokens still work.\r\nfunc (r *Runner) forgejoAs(agentName, token string) *forgejo.Client {\r\n\tr.agentClientsMu.Lock()\r\n\tdefer r.agentClientsMu.Unlock()\r\n\r\n\tif c, ok := r.agentClients[agentName]; ok {\r\n\t\treturn c\r\n\t}\r\n\r\n\tvar c *forgejo.Client\r\n\tif token != \"\" {\r\n\t\tc = r.forgejo.As(token)\r\n\t} else {\r\n\t\t// Fallback: use shared identity. Optionally log a warning\r\n\t\t// if we ever want to enforce per-agent tokens.\r\n\t\tc = r.forgejo\r\n\t}\r\n\r\n\tr.agentClients[agentName] = c\r\n\r\n\treturn c\r\n}\r\n\r\n// Run implements scheduler.Runner.\r\nfunc (r *Runner) Run(ctx context.Context, jobID string, agent config.AgentConfig, llmCfg config.LLM, dockerImage string, ev forgejo.Event) error {\r\n\tctx, cancel := context.WithTimeout(ctx, r.timeout)\r\n\tdefer cancel()\r\n\r\n\tlogger := r.logger.With(\"job\", jobID, \"agent\", agent.Name)\r\n\r\n\trepoInfo, err := r.forgejo.RepositoryInfo(ev.Owner, ev.Repo)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"look up repository: %w\", err)\r\n\t}\r\n\r\n\tworkDir, err := os.MkdirTemp(\"\", \"zoo-run-*\")\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"create work dir: %w\", err)\r\n\t}\r\n\r\n\tsucceeded := false\r\n\r\n\tdefer func() {\r\n\t\tif succeeded || !r.keepOnFailure {\r\n\t\t\tos.RemoveAll(workDir)\r\n\t\t} else {\r\n\t\t\tlogger.Warn(\"keeping work dir after failure\", \"dir\", workDir)\r\n\t\t}\r\n\t}()\r\n\r\n\tbranch := fmt.Sprintf(\"issue-%d-%s\", ev.Index, agent.Name)\r\n\tprojectDir := filepath.Join(workDir, \"project\")\r\n\r\n\tif err := cloneAndBranch(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, branch, projectDir); err != nil {\r\n\t\treturn fmt.Errorf(\"prepare git working tree: %w\", err)\r\n\t}\r\n\r\n\troster := buildRoster(r.forgejo, r.cfg.Agents, logger)\r\n\tgitName, gitEmail := gitIdentity(agent.Name, roster)\r\n\r\n\t// Local (not --global) scope, so this identity lives in\r\n\t// projectDir/.git/config: the one place both this host-side clone\r\n\t// and the container it's bind-mounted into (as /project) actually\r\n\t// share.\r\n\tif out, err := runGit(ctx, projectDir, \"config\", \"user.name\", gitName); err != nil {\r\n\t\treturn fmt.Errorf(\"configure git user.name: %w: %s\", err, out)\r\n\t}\r\n\tif out, err := runGit(ctx, projectDir, \"config\", \"user.email\", gitEmail); err != nil {\r\n\t\treturn fmt.Errorf(\"configure git user.email: %w: %s\", err, out)\r\n\t}\r\n\r\n\teventPath := filepath.Join(workDir, \"event.json\")\r\n\tif err := os.WriteFile(eventPath, ev.Raw, 0o644); err != nil {\r\n\t\treturn fmt.Errorf(\"write event file: %w\", err)\r\n\t}\r\n\r\n\tcontainerID, err := r.docker.createContainer(ctx, dockerImage, []string{\r\n\t\tprojectDir + \":/project\",\r\n\t\teventPath + \":/event:ro\",\r\n\t}, fmt.Sprintf(\"zoo-issue-%d-%s\", ev.Index, agent.Name))\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"start container: %w\", err)\r\n\t}\r\n\r\n\tdefer func() {\r\n\t\tcleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)\r\n\t\tdefer cleanupCancel()\r\n\t\tif err := r.docker.remove(cleanupCtx, containerID); err != nil {\r\n\t\t\tlogger.Warn(\"failed to remove container\", \"container\", containerID, \"error\", err)\r\n\t\t}\r\n\t}()\r\n\r\n\t// /project is bind-mounted from the host, so it's owned by the host\r\n\t// UID that ran the clone, not whatever UID runs inside the\r\n\t// container (usually root) — git's ownership check rejects that by\r\n\t// default (\"detected dubious ownership\") unless told otherwise.\r\n\t// --system (not --global) so this holds regardless of which user\r\n\t// subsequent `docker exec` calls run as. Commit identity is\r\n\t// configured host-side, above, with --local scope so it's visible\r\n\t// from both sides of the bind mount without needing --global here.\r\n\tout, exitCode, err := r.docker.exec(ctx, containerID, \"git config --system --add safe.directory '*'\")\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"configure git safe.directory in container: %w: %s\", err, out)\r\n\t}\r\n\tif exitCode != 0 {\r\n\t\treturn fmt.Errorf(\"configure git safe.directory in container: exit %d: %s\", exitCode, out)\r\n\t}\r\n\r\n\tlogAppend := func(stream, line string) {\r\n\t\tif err := r.store.AppendLog(context.Background(), jobID, stream, line); err != nil {\r\n\t\t\tlogger.Warn(\"failed to append log\", \"error\", err)\r\n\t\t}\r\n\t}\r\n\r\n\trunCtx := \u0026runContext{\r\n\t\tdocker: r.docker,\r\n\t\tcontainerID: containerID,\r\n\t\tprojectDir: projectDir,\r\n\t\ttoken: r.forgejo.Token(),\r\n\t\tforgejo: \u0026runForgejoActions{\r\n\t\t\tclient: r.forgejoAs(agent.Name, agent.Token),\r\n\t\t\towner: ev.Owner,\r\n\t\t\trepo: ev.Repo,\r\n\t\t\tindex: ev.Index,\r\n\t\t\tlogger: logger,\r\n\t\t},\r\n\t}\r\n\r\n\tllmClient := llm.NewClient(llmCfg)\r\n\r\n\tsystemPrompt := defaultSystemPrompt + identitySection(agent.Name, roster)\r\n\r\n\tinstructions := r.cfg.EventInstructions(ev.Kind)\r\n\r\n\tmessages := []llm.Message{\r\n\t\t{Role: \"system\", Content: systemPrompt},\r\n\t\t{Role: \"user\", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions)},\r\n\t}\r\n\r\n\thooks := r.streamHooks(jobID, logAppend)\r\n\r\n\t_, err = runLoop(ctx, llmClient, runCtx, messages, hooks)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"agent loop: %w\", err)\r\n\t}\r\n\r\n\tsucceeded = true\r\n\r\n\treturn nil\r\n}\r\n\r\n// streamHooks builds the Hooks a single Run passes to runLoop: every\r\n// delta is published live to the hub for connected dashboard viewers,\r\n// and once a reasoning/content block or tool call is complete, it's\r\n// persisted to the store as one row and the hub's replay buffer for\r\n// jobID is checkpointed — so a viewer connecting from this point on\r\n// sees it via the persisted history instead of a live replay, and is\r\n// never shown it twice.\r\nfunc (r *Runner) streamHooks(jobID string, logAppend func(stream, line string)) Hooks {\r\n\tvar reasoningBuf, contentBuf strings.Builder\r\n\r\n\treasoningOpen, contentOpen := false, false\r\n\r\n\treturn Hooks{\r\n\t\tOnReasoningDelta: func(delta string) {\r\n\t\t\tif !reasoningOpen {\r\n\t\t\t\treasoningOpen = true\r\n\t\t\t\treasoningBuf.Reset()\r\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningStart})\r\n\t\t\t}\r\n\r\n\t\t\treasoningBuf.WriteString(delta)\r\n\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningDelta, Text: delta})\r\n\t\t},\r","start_line":1,"end_line":250,"total_lines":310,"truncated":true}
{"path": "/project/internal/agentrun/forgejo.go", "start_line": 1, "end_line": 250}
{"path":"/project/internal/agentrun/forgejo.go","content":"package agentrun\r\n\r\nimport (\r\n\t\"log/slog\"\r\n\r\n\t\"github.com/abrander/zoo/internal/forgejo\"\r\n)\r\n\r\n// runForgejoActions implements tools.ForgejoActions, binding the\r\n// generic forgejo.Client to the single issue/PR that triggered this run\r\n// so agent tool calls don't need to specify owner/repo/index themselves.\r\ntype runForgejoActions struct {\r\n\tclient *forgejo.Client\r\n\towner, repo string\r\n\tindex int64\r\n\tlogger *slog.Logger\r\n}\r\n\r\n// logResult logs a forge action once it's known to have succeeded or\r\n// failed, so every comment, PR, label, or issue-state change an agent\r\n// makes is traceable in zoo's own logs, not just in Forgejo's history.\r\nfunc (a *runForgejoActions) logResult(action string, err error, args ...any) {\r\n\tfields := append([]any{\"owner\", a.owner, \"repo\", a.repo, \"index\", a.index}, args...)\r\n\r\n\tif err != nil {\r\n\t\ta.logger.Warn(\"forge action failed\", append(append([]any{\"action\", action}, fields...), \"error\", err)...)\r\n\t\treturn\r\n\t}\r\n\r\n\ta.logger.Info(\"forge action\", append([]any{\"action\", action}, fields...)...)\r\n}\r\n\r\nfunc (a *runForgejoActions) Comment(body string) error {\r\n\terr := a.client.CreateIssueComment(a.owner, a.repo, a.index, body)\r\n\ta.logResult(\"comment\", err)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) OpenPullRequest(head, base, title, body string) error {\r\n\terr := a.client.CreatePullRequest(a.owner, a.repo, head, base, title, body)\r\n\ta.logResult(\"open_pull_request\", err, \"head\", head, \"base\", base, \"title\", title)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) RequestReview(reviewers []string) error {\r\n\terr := a.client.RequestReview(a.owner, a.repo, a.index, reviewers)\r\n\ta.logResult(\"request_review\", err, \"reviewers\", reviewers)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) AddLabel(name string) error {\r\n\terr := a.client.AddLabel(a.owner, a.repo, a.index, name)\r\n\ta.logResult(\"add_label\", err, \"label\", name)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) RemoveLabel(name string) error {\r\n\terr := a.client.RemoveLabel(a.owner, a.repo, a.index, name)\r\n\ta.logResult(\"remove_label\", err, \"label\", name)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) CloseIssue() error {\r\n\terr := a.client.CloseIssue(a.owner, a.repo, a.index)\r\n\ta.logResult(\"close_issue\", err)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) ReopenIssue() error {\r\n\terr := a.client.ReopenIssue(a.owner, a.repo, a.index)\r\n\ta.logResult(\"reopen_issue\", err)\r\n\r\n\treturn err\r\n}\r\n\r\nfunc (a *runForgejoActions) AssignIssue(agentName string) error {\r\n\terr := a.client.AssignIssue(a.owner, a.repo, a.index, agentName)\r\n\ta.logResult(\"assign_issue\", err, \"assignee\", agentName)\r\n\r\n\treturn err\r\n}\r","start_line":1,"end_line":87,"total_lines":87,"truncated":false}
{"path": "/project/internal/forgejo/client.go", "start_line": 1, "end_line": 300}
{"path":"/project/internal/forgejo/client.go","content":"// Package forgejo wraps the Forgejo SDK and turns webhook deliveries and\r\n// polling results into a common Event stream for the scheduler.\r\npackage forgejo\r\n\r\nimport (\r\n\t\"fmt\"\r\n\r\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n)\r\n\r\n// Client is zoo's single shared Forgejo identity, used both for the\r\n// event sources (webhook/poll) and for actions agents/scheduler take\r\n// (comments, labels, PRs).\r\ntype Client struct {\r\n\tsdk *sdk.Client\r\n\r\n\tbaseURL string\r\n\ttoken string\r\n}\r\n\r\nfunc NewClient(cfg config.Forgejo) (*Client, error) {\r\n\tc, err := sdk.NewClient(cfg.URL, sdk.SetToken(cfg.Token))\r\n\tif err != nil {\r\n\t\treturn nil, fmt.Errorf(\"forgejo client: %w\", err)\r\n\t}\r\n\r\n\treturn \u0026Client{sdk: c, baseURL: cfg.URL, token: cfg.Token}, nil\r\n}\r\n\r\n// Token returns the shared zoo Forgejo identity's token, e.g. for\r\n// authenticating a host-side git clone/push against Forgejo (see\r\n// internal/agentrun) without ever writing the credential into a working\r\n// tree an agent's container can read.\r\nfunc (c *Client) Token() string {\r\n\treturn c.token\r\n}\r\n\r\n// As returns a new Client that authenticates as the given token.\r\n// This is used to create per-agent clients so each agent acts as\r\n// themselves on Forgejo, without needing a global token with sudo\r\n// privileges.\r\nfunc (c *Client) As(token string) *Client {\r\n\tclient, _ := sdk.NewClient(c.baseURL, sdk.SetToken(token))\r\n\treturn \u0026Client{sdk: client, baseURL: c.baseURL, token: token}\r\n}\r\n\r\n// Sudo returns a new Client that impersonates username (via Forgejo's\r\n// \"Sudo:\" header) on every API call it makes, using the same underlying\r\n// token. Actions an agent takes through it — comments, labels, PRs,\r\n// assignment — are attributed to that agent's own Forgejo account\r\n// instead of the shared zoo identity. The token must belong to a user\r\n// with sudo scope/admin rights for this to work; Forgejo rejects the\r\n// header otherwise.\r\n//\r\n// Deprecated: use As(token) with a per-agent token instead. Kept for\r\n// backward compatibility during migration.\r\nfunc (c *Client) Sudo(username string) (*Client, error) {\r\n\tsudoClient, err := sdk.NewClient(c.baseURL, sdk.SetToken(c.token), sdk.SetSudo(username))\r\n\tif err != nil {\r\n\t\treturn nil, fmt.Errorf(\"forgejo client sudo %q: %w\", username, err)\r\n\t}\r\n\r\n\treturn \u0026Client{sdk: sudoClient, baseURL: c.baseURL, token: c.token}, nil\r\n}\r\n\r\n// CreateIssueComment posts a comment on the given issue or pull request\r\n// (Forgejo/Gitea treat PRs as issues for commenting purposes).\r\nfunc (c *Client) CreateIssueComment(owner, repo string, index int64, body string) error {\r\n\t_, _, err := c.sdk.CreateIssueComment(owner, repo, index, sdk.CreateIssueCommentOption{Body: body})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"comment on %s/%s#%d: %w\", owner, repo, index, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// AddLabel attaches the label with the given name to an issue/PR,\r\n// creating the label (with a default color) on the repo first if it\r\n// doesn't already exist.\r\nfunc (c *Client) AddLabel(owner, repo string, index int64, name string) error {\r\n\tid, err := c.labelID(owner, repo, name)\r\n\tif err != nil {\r\n\t\treturn err\r\n\t}\r\n\r\n\t_, _, err = c.sdk.AddIssueLabels(owner, repo, index, sdk.IssueLabelsOption{Labels: []int64{id}})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"add label %q to %s/%s#%d: %w\", name, owner, repo, index, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// RemoveLabel detaches the label with the given name from an issue/PR, if\r\n// both the label and the attachment exist.\r\nfunc (c *Client) RemoveLabel(owner, repo string, index int64, name string) error {\r\n\tlabels, _, err := c.sdk.GetIssueLabels(owner, repo, index, sdk.ListLabelsOptions{})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"list labels on %s/%s#%d: %w\", owner, repo, index, err)\r\n\t}\r\n\r\n\tfor _, l := range labels {\r\n\t\tif l.Name == name {\r\n\t\t\t_, err := c.sdk.DeleteIssueLabel(owner, repo, index, l.ID)\r\n\t\t\tif err != nil {\r\n\t\t\t\treturn fmt.Errorf(\"remove label %q from %s/%s#%d: %w\", name, owner, repo, index, err)\r\n\t\t\t}\r\n\t\t\treturn nil\r\n\t\t}\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\nfunc (c *Client) labelID(owner, repo, name string) (int64, error) {\r\n\tlabels, _, err := c.sdk.ListRepoLabels(owner, repo, sdk.ListLabelsOptions{})\r\n\tif err != nil {\r\n\t\treturn 0, fmt.Errorf(\"list labels on %s/%s: %w\", owner, repo, err)\r\n\t}\r\n\r\n\tfor _, l := range labels {\r\n\t\tif l.Name == name {\r\n\t\t\treturn l.ID, nil\r\n\t\t}\r\n\t}\r\n\r\n\tcreated, _, err := c.sdk.CreateLabel(owner, repo, sdk.CreateLabelOption{\r\n\t\tName: name,\r\n\t\tColor: \"#ee0000\",\r\n\t})\r\n\tif err != nil {\r\n\t\treturn 0, fmt.Errorf(\"create label %q on %s/%s: %w\", name, owner, repo, err)\r\n\t}\r\n\r\n\treturn created.ID, nil\r\n}\r\n\r\n// CreatePullRequest opens a PR from head into base.\r\nfunc (c *Client) CreatePullRequest(owner, repo, head, base, title, body string) error {\r\n\t_, _, err := c.sdk.CreatePullRequest(owner, repo, sdk.CreatePullRequestOption{\r\n\t\tHead: head,\r\n\t\tBase: base,\r\n\t\tTitle: title,\r\n\t\tBody: body,\r\n\t})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"create pull request %s/%s %s-\u003e%s: %w\", owner, repo, head, base, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// RequestReview asks the given users to review the pull request.\r\nfunc (c *Client) RequestReview(owner, repo string, index int64, reviewers []string) error {\r\n\t_, err := c.sdk.CreateReviewRequests(owner, repo, index, sdk.PullReviewRequestOptions{Reviewers: reviewers})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"request review on %s/%s#%d: %w\", owner, repo, index, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// CloseIssue closes the given issue or pull request.\r\nfunc (c *Client) CloseIssue(owner, repo string, index int64) error {\r\n\treturn c.setIssueState(owner, repo, index, sdk.StateClosed)\r\n}\r\n\r\n// ReopenIssue reopens the given issue or pull request.\r\nfunc (c *Client) ReopenIssue(owner, repo string, index int64) error {\r\n\treturn c.setIssueState(owner, repo, index, sdk.StateOpen)\r\n}\r\n\r\n// RepositoryInfo returns the pieces of repo metadata agentrun needs to\r\n// clone and branch off of the right place.\r\ntype RepositoryInfo struct {\r\n\tDefaultBranch string\r\n\tCloneURL string\r\n}\r\n\r\nfunc (c *Client) RepositoryInfo(owner, repo string) (RepositoryInfo, error) {\r\n\tr, _, err := c.sdk.GetRepo(owner, repo)\r\n\tif err != nil {\r\n\t\treturn RepositoryInfo{}, fmt.Errorf(\"get repo %s/%s: %w\", owner, repo, err)\r\n\t}\r\n\r\n\treturn RepositoryInfo{DefaultBranch: r.DefaultBranch, CloneURL: r.CloneURL}, nil\r\n}\r\n\r\nfunc (c *Client) setIssueState(owner, repo string, index int64, state sdk.StateType) error {\r\n\t_, _, err := c.sdk.EditIssue(owner, repo, index, sdk.EditIssueOption{State: \u0026state})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"set state %q on %s/%s#%d: %w\", state, owner, repo, index, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// AgentProfile is what zoo reads off an agent's Forgejo account: its bio\r\n// (surfaced in the system prompt), the name/email used to set git commit\r\n// authorship inside that agent's container, and its avatar URL (surfaced\r\n// on the dashboard).\r\ntype AgentProfile struct {\r\n\tDescription string\r\n\tFullName string\r\n\tEmail string\r\n\tAvatarURL string\r\n}\r\n\r\n// AgentProfile fetches the Forgejo profile of the user account named\r\n// username. Agent config labels double as their Forgejo usernames (see\r\n// issue:assigned resolution), so this is how an agent's \"what it does\"\r\n// text, and its commit identity, are authored: by editing that account's\r\n// profile on Forgejo, not in zoo.hcl.\r\nfunc (c *Client) AgentProfile(username string) (AgentProfile, error) {\r\n\tu, _, err := c.sdk.GetUserInfo(username)\r\n\tif err != nil {\r\n\t\treturn AgentProfile{}, fmt.Errorf(\"get user %q: %w\", username, err)\r\n\t}\r\n\r\n\tp := AgentProfile{\r\n\t\tDescription: u.Description,\r\n\t\tFullName: u.FullName,\r\n\t\tEmail: u.Email,\r\n\t\tAvatarURL: u.AvatarURL,\r\n\t}\r\n\r\n\treturn p, nil\r\n}\r\n\r\n// AssignIssue sets the sole assignee of an issue/PR to username. Used by\r\n// the assign_issue tool so agents can hand work off to each other: the\r\n// resulting issue:assigned event (webhook or poll) triggers whichever\r\n// agent's config label matches username.\r\nfunc (c *Client) AssignIssue(owner, repo string, index int64, username string) error {\r\n\t_, _, err := c.sdk.EditIssue(owner, repo, index, sdk.EditIssueOption{Assignees: []string{username}})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"assign %s/%s#%d to %q: %w\", owner, repo, index, username, err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// IssueTitle fetches the title of an issue or pull request from Forgejo.\r\n// Returns an empty string (not an error) if the client is nil or the\r\n// issue cannot be fetched — the caller should degrade gracefully.\r\nfunc (c *Client) IssueTitle(owner, repo string, index int64) string {\r\n\tif c == nil || c.sdk == nil {\r\n\t\treturn \"\"\r\n\t}\r\n\r\n\tissue, _, err := c.sdk.GetIssue(owner, repo, index)\r\n\tif err != nil {\r\n\t\treturn \"\"\r\n\t}\r\n\r\n\treturn issue.Title\r\n}\r","start_line":1,"end_line":259,"total_lines":259,"truncated":false}
{"path": "/project/internal/agentrun/loop.go", "start_line": 1, "end_line": 200}
{"path":"/project/internal/agentrun/loop.go","content":"package agentrun\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"fmt\"\r\n\t\"io\"\r\n\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n\t\"github.com/abrander/zoo/internal/tools\"\r\n)\r\n\r\n// Hooks lets a caller observe a runLoop invocation live, as the model\r\n// streams and tool calls execute, in addition to the final []llm.Message\r\n// it returns. Any of these may be nil.\r\ntype Hooks struct {\r\n\t// OnReasoningDelta and OnContentDelta fire with just the newly\r\n\t// streamed text for the current turn, not the accumulated total.\r\n\tOnReasoningDelta func(delta string)\r\n\tOnContentDelta func(delta string)\r\n\r\n\t// OnTurnEnd fires once per completed streamer round-trip, after the\r\n\t// model's message for that turn is fully received and before any of\r\n\t// its tool calls run.\r\n\tOnTurnEnd func()\r\n\r\n\t// OnTool fires once per tool call, after it has run.\r\n\tOnTool func(name, arguments, result string, toolErr bool)\r\n}\r\n\r\n// runLoop is a headless port of ../a's App.generate(): send messages +\r\n// tool defs, get a completion, run any tool_calls and append their\r\n// results, repeat until a plain finish or ctx is done.\r\nfunc runLoop(ctx context.Context, client *llm.Client, toolsCtx tools.Context, messages []llm.Message, hooks Hooks) ([]llm.Message, error) {\r\n\tfor {\r\n\t\tif err := ctx.Err(); err != nil {\r\n\t\t\treturn messages, err\r\n\t\t}\r\n\r\n\t\tstreamer, err := client.StreamChatCompletion(ctx, \u0026llm.ChatCompletionRequest{\r\n\t\t\tMessages: messages,\r\n\t\t\tStream: true,\r\n\t\t\tTools: tools.All(),\r\n\t\t})\r\n\t\tif err != nil {\r\n\t\t\treturn messages, fmt.Errorf(\"chat completion: %w\", err)\r\n\t\t}\r\n\r\n\t\tvar completion *llm.ChatCompletion\r\n\r\n\t\tvar prevContent, prevReasoning string\r\n\r\n\t\tfor {\r\n\t\t\tc, err := streamer.Get()\r\n\t\t\tif err == io.EOF {\r\n\t\t\t\tbreak\r\n\t\t\t}\r\n\r\n\t\t\tif err != nil {\r\n\t\t\t\treturn messages, fmt.Errorf(\"stream completion: %w\", err)\r\n\t\t\t}\r\n\r\n\t\t\tcompletion = c\r\n\r\n\t\t\tif len(c.Choices) \u003e 0 {\r\n\t\t\t\tmsg := c.Choices[0].Message\r\n\r\n\t\t\t\tif hooks.OnReasoningDelta != nil \u0026\u0026 len(msg.ReasoningContent) \u003e len(prevReasoning) {\r\n\t\t\t\t\thooks.OnReasoningDelta(msg.ReasoningContent[len(prevReasoning):])\r\n\t\t\t\t}\r\n\t\t\t\tprevReasoning = msg.ReasoningContent\r\n\r\n\t\t\t\tif hooks.OnContentDelta != nil \u0026\u0026 len(msg.Content) \u003e len(prevContent) {\r\n\t\t\t\t\thooks.OnContentDelta(msg.Content[len(prevContent):])\r\n\t\t\t\t}\r\n\t\t\t\tprevContent = msg.Content\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif hooks.OnTurnEnd != nil {\r\n\t\t\thooks.OnTurnEnd()\r\n\t\t}\r\n\r\n\t\tif completion == nil || len(completion.Choices) == 0 {\r\n\t\t\treturn messages, fmt.Errorf(\"model returned an empty completion\")\r\n\t\t}\r\n\r\n\t\tchoice := completion.Choices[0]\r\n\t\tmessages = append(messages, choice.Message)\r\n\r\n\t\tif choice.FinishReason != \"tool_calls\" || len(choice.Message.ToolCalls) == 0 {\r\n\t\t\treturn messages, nil\r\n\t\t}\r\n\r\n\t\tfor _, tc := range choice.Message.ToolCalls {\r\n\t\t\tif err := ctx.Err(); err != nil {\r\n\t\t\t\treturn messages, err\r\n\t\t\t}\r\n\r\n\t\t\tresult, err := tools.Call(toolsCtx, tc.FunctionCall)\r\n\r\n\t\t\ttoolErr := err != nil\r\n\t\t\tif toolErr {\r\n\t\t\t\tresult = fmt.Sprintf(\"ERROR CALLING TOOL: %s\", err.Error())\r\n\t\t\t}\r\n\r\n\t\t\tif hooks.OnTool != nil {\r\n\t\t\t\thooks.OnTool(tc.FunctionCall.Name, tc.FunctionCall.Arguments, result, toolErr)\r\n\t\t\t}\r\n\r\n\t\t\tmessages = append(messages, llm.Message{\r\n\t\t\t\tRole: \"tool\",\r\n\t\t\t\tContent: result,\r\n\t\t\t\tToolCallID: tc.Id,\r\n\t\t\t\tName: tc.FunctionCall.Name,\r\n\t\t\t})\r\n\t\t}\r\n\t}\r\n}\r","start_line":1,"end_line":118,"total_lines":118,"truncated":false}
{"end_line": 310, "path": "/project/internal/agentrun/run.go", "start_line": 250}
{"path":"/project/internal/agentrun/run.go","content":"\t\t},\r\n\t\tOnContentDelta: func(delta string) {\r\n\t\t\tif !contentOpen {\r\n\t\t\t\tcontentOpen = true\r\n\t\t\t\tcontentBuf.Reset()\r\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentStart})\r\n\t\t\t}\r\n\r\n\t\t\tcontentBuf.WriteString(delta)\r\n\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentDelta, Text: delta})\r\n\t\t},\r\n\t\tOnTurnEnd: func() {\r\n\t\t\tif reasoningOpen {\r\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ReasoningEnd})\r\n\t\t\t\tlogAppend(\"reasoning\", reasoningBuf.String())\r\n\t\t\t\tr.hub.Checkpoint(jobID)\r\n\t\t\t\treasoningOpen = false\r\n\t\t\t}\r\n\r\n\t\t\tif contentOpen {\r\n\t\t\t\tr.hub.Publish(jobID, livelog.Event{Type: livelog.ContentEnd})\r\n\t\t\t\tlogAppend(\"content\", contentBuf.String())\r\n\t\t\t\tr.hub.Checkpoint(jobID)\r\n\t\t\t\tcontentOpen = false\r\n\t\t\t}\r\n\t\t},\r\n\t\tOnTool: func(name, arguments, result string, toolErr bool) {\r\n\t\t\tr.hub.Publish(jobID, livelog.Event{\r\n\t\t\t\tType: livelog.Tool,\r\n\t\t\t\tName: name,\r\n\t\t\t\tArguments: arguments,\r\n\t\t\t\tResult: result,\r\n\t\t\t\tError: toolErr,\r\n\t\t\t})\r\n\r\n\t\t\tline, err := json.Marshal(store.ToolLogEntry{Name: name, Arguments: arguments, Result: result, Error: toolErr})\r\n\t\t\tif err != nil {\r\n\t\t\t\tr.logger.Warn(\"failed to marshal tool log entry\", \"job\", jobID, \"error\", err)\r\n\t\t\t} else {\r\n\t\t\t\tlogAppend(\"tool\", string(line))\r\n\t\t\t}\r\n\r\n\t\t\tr.hub.Checkpoint(jobID)\r\n\t\t},\r\n\t}\r\n}\r\n\r\nfunc seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string) string {\r\n\traw, _ := json.MarshalIndent(json.RawMessage(ev.Raw), \"\", \" \")\r\n\r\n\tvar instructionsSection string\r\n\tif instructions != \"\" {\r\n\t\tinstructionsSection = fmt.Sprintf(\"Instructions for this event, from zoo.hcl:\\n%s\\n\\n\", instructions)\r\n\t}\r\n\r\n\treturn fmt.Sprintf(\r\n\t\t\"You were triggered by a %q event on %s/%s.\\n\\n\"+\r\n\t\t\t\"%sYour working branch is %q, checked out from the default branch %q.\\n\\n\"+\r\n\t\t\t\"Title: %s\\n\\nBody:\\n%s\\n\\nFull event payload:\\n```json\\n%s\\n```\",\r\n\t\tev.Kind, ev.Owner, ev.Repo, instructionsSection, branch, defaultBranch, ev.Title, ev.Body, raw)\r\n}\r","start_line":250,"end_line":310,"total_lines":310,"truncated":false}
{"path": "/project/internal/forgejo/webhook.go", "start_line": 1, "end_line": 250}
{"path":"/project/internal/forgejo/webhook.go","content":"package forgejo\r\n\r\nimport (\r\n\t\"encoding/json\"\r\n\t\"fmt\"\r\n\t\"io\"\r\n\t\"log/slog\"\r\n\t\"net/http\"\r\n\t\"time\"\r\n\r\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\r\n)\r\n\r\ntype issuePayload struct {\r\n\tAction string `json:\"action\"`\r\n\tIssue *sdk.Issue `json:\"issue\"`\r\n\tRepository *sdk.Repository `json:\"repository\"`\r\n}\r\n\r\ntype issueCommentPayload struct {\r\n\tAction string `json:\"action\"`\r\n\tIssue *sdk.Issue `json:\"issue\"`\r\n\tComment *sdk.Comment `json:\"comment\"`\r\n\tRepository *sdk.Repository `json:\"repository\"`\r\n}\r\n\r\ntype pullRequestPayload struct {\r\n\tAction string `json:\"action\"`\r\n\tPullRequest *sdk.PullRequest `json:\"pull_request\"`\r\n\tRepository *sdk.Repository `json:\"repository\"`\r\n}\r\n\r\n// WebhookHandler returns the http.Handler to mount at (e.g.)\r\n// /webhooks/forgejo. If secret is non-empty, deliveries are verified via\r\n// the SDK's X-Forgejo-Signature middleware; callers should always set a\r\n// secret for anything reachable off localhost.\r\nfunc WebhookHandler(secret string, logger *slog.Logger, emit func(Event)) http.Handler {\r\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\r\n\t\tbody, err := io.ReadAll(r.Body)\r\n\t\tif err != nil {\r\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\r\n\t\t\treturn\r\n\t\t}\r\n\r\n\t\tkind := r.Header.Get(\"X-Forgejo-Event\")\r\n\t\tif kind == \"\" {\r\n\t\t\tkind = r.Header.Get(\"X-Gitea-Event\")\r\n\t\t}\r\n\r\n\t\tev, ok, err := decodeWebhookEvent(kind, body)\r\n\t\tif err != nil {\r\n\t\t\tlogger.Warn(\"failed to decode webhook payload\", \"event\", kind, \"error\", err)\r\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\r\n\t\t\treturn\r\n\t\t}\r\n\r\n\t\tif ok {\r\n\t\t\temit(ev)\r\n\t\t}\r\n\r\n\t\tw.WriteHeader(http.StatusOK)\r\n\t})\r\n\r\n\tif secret == \"\" {\r\n\t\tlogger.Warn(\"forgejo webhook_secret is not set; incoming webhook deliveries are not authenticated\")\r\n\r\n\t\treturn handler\r\n\t}\r\n\r\n\treturn sdk.VerifyWebhookSignatureMiddleware(secret)(handler)\r\n}\r\n\r\nfunc decodeWebhookEvent(kind string, body []byte) (Event, bool, error) {\r\n\tswitch kind {\r\n\tcase \"issues\":\r\n\t\tvar p issuePayload\r\n\r\n\t\tif err := json.Unmarshal(body, \u0026p); err != nil {\r\n\t\t\treturn Event{}, false, err\r\n\t\t}\r\n\r\n\t\treturn issueEvent(p, body)\r\n\r\n\tcase \"issue_comment\":\r\n\t\tvar p issueCommentPayload\r\n\r\n\t\tif err := json.Unmarshal(body, \u0026p); err != nil {\r\n\t\t\treturn Event{}, false, err\r\n\t\t}\r\n\r\n\t\treturn issueCommentEvent(p, body)\r\n\r\n\tcase \"pull_request\":\r\n\t\tvar p pullRequestPayload\r\n\r\n\t\tif err := json.Unmarshal(body, \u0026p); err != nil {\r\n\t\t\treturn Event{}, false, err\r\n\t\t}\r\n\r\n\t\treturn pullRequestEvent(p, body)\r\n\r\n\tdefault:\r\n\t\treturn Event{}, false, nil\r\n\t}\r\n}\r\n\r\nfunc issueEvent(p issuePayload, raw []byte) (Event, bool, error) {\r\n\tif p.Issue == nil || p.Repository == nil {\r\n\t\treturn Event{}, false, nil\r\n\t}\r\n\r\n\towner := repoOwner(p.Repository)\r\n\r\n\tswitch p.Action {\r\n\tcase \"opened\":\r\n\t\treturn Event{\r\n\t\t\tKind: EventIssueNew,\r\n\t\t\tID: issueNewID(p.Issue.ID),\r\n\t\t\tOwner: owner,\r\n\t\t\tRepo: p.Repository.Name,\r\n\t\t\tIndex: p.Issue.Index,\r\n\t\t\tTitle: p.Issue.Title,\r\n\t\t\tBody: p.Issue.Body,\r\n\t\t\tAuthor: posterName(p.Issue.Poster),\r\n\t\t\tRaw: raw,\r\n\t\t}, true, nil\r\n\r\n\tcase \"assigned\":\r\n\t\tif len(p.Issue.Assignees) == 0 {\r\n\t\t\treturn Event{}, false, nil\r\n\t\t}\r\n\r\n\t\t// Webhook payloads only carry the single latest assignment as a\r\n\t\t// distinct field on some Gitea/Forgejo versions; using the last\r\n\t\t// entry in the current assignee list is the closest stable\r\n\t\t// approximation available from the Issue object alone.\r\n\t\tassignee := p.Issue.Assignees[len(p.Issue.Assignees)-1]\r\n\r\n\t\treturn Event{\r\n\t\t\tKind: EventIssueAssigned,\r\n\t\t\tID: issueAssignedID(p.Issue.ID, assignee.UserName, p.Issue.Updated),\r\n\t\t\tOwner: owner,\r\n\t\t\tRepo: p.Repository.Name,\r\n\t\t\tIndex: p.Issue.Index,\r\n\t\t\tTitle: p.Issue.Title,\r\n\t\t\tBody: p.Issue.Body,\r\n\t\t\tAuthor: posterName(p.Issue.Poster),\r\n\t\t\tAssignee: assignee.UserName,\r\n\t\t\tRaw: raw,\r\n\t\t}, true, nil\r\n\r\n\tdefault:\r\n\t\treturn Event{}, false, nil\r\n\t}\r\n}\r\n\r\nfunc issueCommentEvent(p issueCommentPayload, raw []byte) (Event, bool, error) {\r\n\tif p.Action != \"created\" || p.Issue == nil || p.Comment == nil || p.Repository == nil {\r\n\t\treturn Event{}, false, nil\r\n\t}\r\n\r\n\t// Comments on pull requests arrive on this same event in\r\n\t// Gitea/Forgejo (PRs are issues under the hood); pr:comment is out\r\n\t// of scope for v1.\r\n\tif p.Issue.PullRequest != nil {\r\n\t\treturn Event{}, false, nil\r\n\t}\r\n\r\n\treturn Event{\r\n\t\tKind: EventIssueComment,\r\n\t\tID: issueCommentID(p.Comment.ID),\r\n\t\tOwner: repoOwner(p.Repository),\r\n\t\tRepo: p.Repository.Name,\r\n\t\tIndex: p.Issue.Index,\r\n\t\tTitle: p.Issue.Title,\r\n\t\tBody: p.Issue.Body,\r\n\t\tAuthor: posterName(p.Comment.Poster),\r\n\t\tComment: p.Comment.Body,\r\n\t\tRaw: raw,\r\n\t}, true, nil\r\n}\r\n\r\nfunc pullRequestEvent(p pullRequestPayload, raw []byte) (Event, bool, error) {\r\n\tif p.Action != \"opened\" || p.PullRequest == nil || p.Repository == nil {\r\n\t\treturn Event{}, false, nil\r\n\t}\r\n\r\n\treturn Event{\r\n\t\tKind: EventPRNew,\r\n\t\tID: prNewID(p.PullRequest.ID),\r\n\t\tOwner: repoOwner(p.Repository),\r\n\t\tRepo: p.Repository.Name,\r\n\t\tIndex: p.PullRequest.Index,\r\n\t\tTitle: p.PullRequest.Title,\r\n\t\tBody: p.PullRequest.Body,\r\n\t\tAuthor: posterName(p.PullRequest.Poster),\r\n\t\tRaw: raw,\r\n\t}, true, nil\r\n}\r\n\r\nfunc repoOwner(r *sdk.Repository) string {\r\n\tif r.Owner == nil {\r\n\t\treturn \"\"\r\n\t}\r\n\r\n\treturn r.Owner.UserName\r\n}\r\n\r\nfunc posterName(u *sdk.User) string {\r\n\tif u == nil {\r\n\t\treturn \"\"\r\n\t}\r\n\r\n\treturn u.UserName\r\n}\r\n\r\nfunc issueNewID(issueID int64) string {\r\n\treturn fmt.Sprintf(\"issue-new-%d\", issueID)\r\n}\r\n\r\n// issueAssignedID keys one *occurrence* of an assignment, not the\r\n// (issue, assignee) pair: Forgejo bumps the issue's updated timestamp on\r\n// every assignment, so assigning the same issue to the same agent again\r\n// yields a new id and triggers a new run, while the webhook delivery and\r\n// the poller's later sighting of that same assignment still collapse\r\n// onto one id and run once.\r\nfunc issueAssignedID(issueID int64, assignee string, assignedAt time.Time) string {\r\n\treturn fmt.Sprintf(\"issue-assigned-%d-%s-%d\", issueID, assignee, assignedAt.Unix())\r\n}\r\n\r\nfunc issueCommentID(commentID int64) string {\r\n\treturn fmt.Sprintf(\"issue-comment-%d\", commentID)\r\n}\r\n\r\nfunc prNewID(prID int64) string {\r\n\treturn fmt.Sprintf(\"pr-new-%d\", prID)\r\n}\r","start_line":1,"end_line":237,"total_lines":237,"truncated":false}
{"command": "cd /project && cat go.mod && grep -rn \"ListIssueComments\\|ListComments\" $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@*/ 2>/dev/null | head -20"}
module github.com/abrander/zoo go 1.26.4 require ( codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 github.com/docker/docker v28.5.2+incompatible github.com/google/uuid v1.6.0 github.com/hashicorp/hcl/v2 v2.24.0 modernc.org/sqlite v1.56.0 ) require ( github.com/42wim/httpsig v1.2.3 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/agext/levenshtein v1.2.1 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.8.1 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.1.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/zclconf/go-cty v1.16.3 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect go.opentelemetry.io/otel v1.45.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 // indirect go.opentelemetry.io/otel/metric v1.45.0 // indirect go.opentelemetry.io/otel/trace v1.45.0 // indirect golang.org/x/crypto v0.39.0 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.47.0 // indirect gotest.tools/v3 v3.5.2 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect )
{"command": "cd /project && go env GOMODCACHE && ls $(go env GOMODCACHE)/codeberg.org/mvdkleijn/ 2>/dev/null; ls $(go env GOMODCACHE) 2>/dev/null | head"}
/go/pkg/mod
{"command": "cd /project && go mod download 2>&1 | tail -5; ls /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/ 2>/dev/null", "timeout": 300}
v2@v2.2.0
{"command": "SDK=/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && grep -n \"func (c \\*Client) ListIssueComments\\|func (c \\*Client) ListComments\" $SDK/*.go && grep -n \"type Comment struct\" -A 30 $SDK/issues.go | head -50"}
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/issue_comment.go:53:func (c *Client) ListIssueComments(owner, repo string, index int64, opt ListIssueCommentOptions) ([]*Comment, *Response, error) {
grep: /go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/issues.go: No such file or directory
{"command": "SDK=/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n '1,80p' $SDK/issue_comment.go && grep -rn \"type Comment struct\" $SDK/*.go"}
// Copyright 2024 The Forgejo Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
// Copyright 2016 The Gogs Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package forgejo
import (
"bytes"
"encoding/json"
"fmt"
"net/url"
"time"
)
// Comment represents a comment on a commit or issue
type Comment struct {
ID int64 `json:"id"`
HTMLURL string `json:"html_url"`
PRURL string `json:"pull_request_url"`
IssueURL string `json:"issue_url"`
Poster *User `json:"user"`
OriginalAuthor string `json:"original_author"`
OriginalAuthorID int64 `json:"original_author_id"`
Body string `json:"body"`
Created time.Time `json:"created_at"`
Updated time.Time `json:"updated_at"`
}
// ListIssueCommentOptions list comment options
type ListIssueCommentOptions struct {
ListOptions
Since time.Time
Before time.Time
}
// QueryEncode turns options into querystring argument
func (opt *ListIssueCommentOptions) QueryEncode() string {
query := opt.getURLQuery()
if !opt.Since.IsZero() {
query.Add("since", opt.Since.Format(time.RFC3339))
}
if !opt.Before.IsZero() {
query.Add("before", opt.Before.Format(time.RFC3339))
}
return query.Encode()
}
// ListIssueComments list comments on an issue.
func (c *Client) ListIssueComments(owner, repo string, index int64, opt ListIssueCommentOptions) ([]*Comment, *Response, error) {
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
return nil, nil, err
}
opt.setDefaults()
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/issues/%d/comments", owner, repo, index))
link.RawQuery = opt.QueryEncode()
comments := make([]*Comment, 0, opt.PageSize)
resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &comments)
return comments, resp, err
}
// ListRepoIssueComments list comments for a given repo.
func (c *Client) ListRepoIssueComments(owner, repo string, opt ListIssueCommentOptions) ([]*Comment, *Response, error) {
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
return nil, nil, err
}
opt.setDefaults()
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/issues/comments", owner, repo))
link.RawQuery = opt.QueryEncode()
comments := make([]*Comment, 0, opt.PageSize)
resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &comments)
return comments, resp, err
}
// GetIssueComment get a comment for a given repo by id.
func (c *Client) GetIssueComment(owner, repo string, id int64) (*Comment, *Response, error) {
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/issue_comment.go:20:type Comment struct {
{"command": "SDK=/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && grep -rn \"func (opt \\*ListOptions)\" -A 12 $SDK/*.go | head -40 && grep -rn \"type ListOptions struct\" -A 8 $SDK/*.go"}
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/list_options.go:17:type ListOptions struct {
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/list_options.go-18- // Setting Page to -1 disables pagination on endpoints that support it.
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/list_options.go-19- // Page numbering starts at 1.
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/list_options.go-20- Page int
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/list_options.go-21- // The default value depends on the server config DEFAULT_PAGING_NUM
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/list_options.go-22- // The highest valid value depends on the server config MAX_RESPONSE_ITEMS
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/list_options.go-23- PageSize int
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/list_options.go-24-}
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/list_options.go-25-
{"command": "SDK=/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n '1,80p' $SDK/list_options.go"}
// Copyright 2024 The Forgejo Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
// Copyright 2020 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package forgejo
import (
"fmt"
"net/url"
)
// ListOptions options for using Forgejo's API pagination
type ListOptions struct {
// Setting Page to -1 disables pagination on endpoints that support it.
// Page numbering starts at 1.
Page int
// The default value depends on the server config DEFAULT_PAGING_NUM
// The highest valid value depends on the server config MAX_RESPONSE_ITEMS
PageSize int
}
func (o ListOptions) getURLQuery() url.Values {
query := make(url.Values)
query.Add("page", fmt.Sprintf("%d", o.Page))
query.Add("limit", fmt.Sprintf("%d", o.PageSize))
return query
}
// setDefaults applies default pagination options.
// If .Page is set to -1, it will disable pagination.
// WARNING: This function is not idempotent, make sure to never call this method twice!
func (o *ListOptions) setDefaults() {
if o.Page < 0 {
o.Page, o.PageSize = 0, 0
return
} else if o.Page == 0 {
o.Page = 1
}
}
{"path": "/project/internal/forgejo/poll.go", "start_line": 1, "end_line": 200}
{"path":"/project/internal/forgejo/poll.go","content":"package forgejo\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"encoding/json\"\r\n\t\"fmt\"\r\n\t\"time\"\r\n\r\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\r\n)\r\n\r\nconst (\r\n\twatermarkIssues = \"poll:issues\"\r\n\twatermarkPulls = \"poll:pulls\"\r\n)\r\n\r\n// pollOnce lists issues and pull requests updated since the stored\r\n// watermark, across every repo the configured token can see, and\r\n// dispatches synthesized Events for anything new. It's the fallback path\r\n// for when Forgejo webhooks aren't set up or reachable.\r\nfunc (w *Watcher) pollOnce(ctx context.Context) {\r\n\tif err := w.pollIssues(ctx); err != nil {\r\n\t\tw.logger.Warn(\"poll issues failed\", \"error\", err)\r\n\t}\r\n\r\n\tif err := w.pollPulls(ctx); err != nil {\r\n\t\tw.logger.Warn(\"poll pull requests failed\", \"error\", err)\r\n\t}\r\n}\r\n\r\nfunc (w *Watcher) pollIssues(ctx context.Context) error {\r\n\tsince, err := w.watermark(ctx, watermarkIssues)\r\n\tif err != nil {\r\n\t\treturn err\r\n\t}\r\n\r\n\tissues, _, err := w.client.sdk.ListIssues(sdk.ListIssueOption{\r\n\t\tType: sdk.IssueTypeIssue,\r\n\t\tState: sdk.StateAll,\r\n\t\tSince: since,\r\n\t})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"list issues: %w\", err)\r\n\t}\r\n\r\n\tnext := since\r\n\r\n\tfor _, issue := range issues {\r\n\t\tif issue.Repository == nil {\r\n\t\t\tcontinue\r\n\t\t}\r\n\r\n\t\tif issue.Updated.After(next) {\r\n\t\t\tnext = issue.Updated\r\n\t\t}\r\n\r\n\t\towner, repo := issue.Repository.Owner, issue.Repository.Name\r\n\r\n\t\tif issue.Comments == 0 \u0026\u0026 issue.Created.After(since) {\r\n\t\t\tw.dispatch(issueToNewEvent(issue, owner, repo))\r\n\t\t} else if issue.Updated.After(since) {\r\n\t\t\tif err := w.pollNewComments(ctx, owner, repo, issue, since); err != nil {\r\n\t\t\t\tw.logger.Warn(\"poll issue comments failed\", \"owner\", owner, \"repo\", repo, \"issue\", issue.Index, \"error\", err)\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tw.pollAssignments(ctx, owner, repo, issue)\r\n\t}\r\n\r\n\treturn w.store.SetWatermark(ctx, watermarkIssues, next.Format(time.RFC3339))\r\n}\r\n\r\n// pollAssignments dispatches an assigned event for each assignee that\r\n// wasn't on the issue the last time we looked. Listing only ever shows\r\n// current state, so without that comparison every unrelated update to an\r\n// assigned issue (a comment, an edit) would look like a fresh\r\n// assignment; and because the event id now varies per assignment\r\n// occurrence, dedup no longer masks that.\r\n//\r\n// The tradeoff is that an unassign and a re-assign to the same user\r\n// landing inside a single poll interval look like no change at all, and\r\n// only the webhook path catches them.\r\nfunc (w *Watcher) pollAssignments(ctx context.Context, owner, repo string, issue *sdk.Issue) {\r\n\tcurrent := make([]string, 0, len(issue.Assignees))\r\n\r\n\tfor _, assignee := range issue.Assignees {\r\n\t\tif assignee == nil {\r\n\t\t\tcontinue\r\n\t\t}\r\n\r\n\t\tcurrent = append(current, assignee.UserName)\r\n\t}\r\n\r\n\tadded, err := w.store.SyncAssignees(ctx, issue.ID, current)\r\n\tif err != nil {\r\n\t\tw.logger.Warn(\"sync assignees failed\", \"owner\", owner, \"repo\", repo, \"issue\", issue.Index, \"error\", err)\r\n\t\treturn\r\n\t}\r\n\r\n\tfor _, assignee := range added {\r\n\t\tw.dispatch(issueToAssignedEvent(issue, owner, repo, assignee))\r\n\t}\r\n}\r\n\r\nfunc (w *Watcher) pollNewComments(ctx context.Context, owner, repo string, issue *sdk.Issue, since time.Time) error {\r\n\tcomments, _, err := w.client.sdk.ListIssueComments(owner, repo, issue.Index, sdk.ListIssueCommentOptions{Since: since})\r\n\tif err != nil {\r\n\t\treturn err\r\n\t}\r\n\r\n\tfor _, comment := range comments {\r\n\t\tif !comment.Created.After(since) {\r\n\t\t\tcontinue\r\n\t\t}\r\n\r\n\t\tw.dispatch(issueToCommentEvent(issue, owner, repo, comment))\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\nfunc (w *Watcher) pollPulls(ctx context.Context) error {\r\n\tsince, err := w.watermark(ctx, watermarkPulls)\r\n\tif err != nil {\r\n\t\treturn err\r\n\t}\r\n\r\n\tissues, _, err := w.client.sdk.ListIssues(sdk.ListIssueOption{\r\n\t\tType: sdk.IssueTypePull,\r\n\t\tState: sdk.StateAll,\r\n\t\tSince: since,\r\n\t})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"list pull requests: %w\", err)\r\n\t}\r\n\r\n\tnext := since\r\n\r\n\tfor _, issue := range issues {\r\n\t\tif issue.Repository == nil {\r\n\t\t\tcontinue\r\n\t\t}\r\n\r\n\t\tif issue.Updated.After(next) {\r\n\t\t\tnext = issue.Updated\r\n\t\t}\r\n\r\n\t\tif issue.Created.After(since) {\r\n\t\t\tw.dispatch(issueToPRNewEvent(issue, issue.Repository.Owner, issue.Repository.Name))\r\n\t\t}\r\n\t}\r\n\r\n\treturn w.store.SetWatermark(ctx, watermarkPulls, next.Format(time.RFC3339))\r\n}\r\n\r\nfunc (w *Watcher) watermark(ctx context.Context, key string) (time.Time, error) {\r\n\tv, err := w.store.GetWatermark(ctx, key)\r\n\tif err != nil {\r\n\t\treturn time.Time{}, err\r\n\t}\r\n\r\n\tif v == \"\" {\r\n\t\treturn time.Time{}, nil\r\n\t}\r\n\r\n\tt, err := time.Parse(time.RFC3339, v)\r\n\tif err != nil {\r\n\t\treturn time.Time{}, fmt.Errorf(\"parse watermark %q: %w\", key, err)\r\n\t}\r\n\r\n\treturn t, nil\r\n}\r\n\r\nfunc issueToNewEvent(issue *sdk.Issue, owner, repo string) Event {\r\n\traw, _ := json.Marshal(issue)\r\n\r\n\treturn Event{\r\n\t\tKind: EventIssueNew,\r\n\t\tID: issueNewID(issue.ID),\r\n\t\tOwner: owner,\r\n\t\tRepo: repo,\r\n\t\tIndex: issue.Index,\r\n\t\tTitle: issue.Title,\r\n\t\tBody: issue.Body,\r\n\t\tAuthor: posterName(issue.Poster),\r\n\t\tRaw: raw,\r\n\t}\r\n}\r\n\r\nfunc issueToAssignedEvent(issue *sdk.Issue, owner, repo, assignee string) Event {\r\n\traw, _ := json.Marshal(issue)\r\n\r\n\treturn Event{\r\n\t\tKind: EventIssueAssigned,\r\n\t\tID: issueAssignedID(issue.ID, assignee, issue.Updated),\r\n\t\tOwner: owner,\r\n\t\tRepo: repo,\r\n\t\tIndex: issue.Index,\r\n\t\tTitle: issue.Title,\r\n\t\tBody: issue.Body,\r","start_line":1,"end_line":200,"total_lines":238,"truncated":true}
{"path": "/project/internal/agentrun/seed_test.go", "start_line": 1, "end_line": 120}
{"path":"/project/internal/agentrun/seed_test.go","content":"package agentrun\r\n\r\nimport (\r\n\t\"strings\"\r\n\t\"testing\"\r\n\r\n\t\"github.com/abrander/zoo/internal/forgejo\"\r\n)\r\n\r\nfunc TestSeedMessageIncludesInstructions(t *testing.T) {\r\n\tev := forgejo.Event{Kind: \"issue:comment\", Owner: \"acme\", Repo: \"widgets\", Index: 5, Title: \"t\", Body: \"b\", Raw: []byte(`{}`)}\r\n\r\n\tmsg := seedMessage(ev, \"issue-5-leon\", \"main\", \"Please review the comment and respond appropriately.\")\r\n\r\n\tif !strings.Contains(msg, \"Instructions for this event, from zoo.hcl:\\nPlease review the comment and respond appropriately.\") {\r\n\t\tt.Fatalf(\"expected instructions section, got: %s\", msg)\r\n\t}\r\n}\r\n\r\nfunc TestSeedMessageOmitsEmptyInstructions(t *testing.T) {\r\n\tev := forgejo.Event{Kind: \"pr:new\", Owner: \"acme\", Repo: \"widgets\", Index: 9, Raw: []byte(`{}`)}\r\n\r\n\tmsg := seedMessage(ev, \"issue-9-greg\", \"main\", \"\")\r\n\r\n\tif strings.Contains(msg, \"Instructions for this event\") {\r\n\t\tt.Fatalf(\"expected no instructions section, got: %s\", msg)\r\n\t}\r\n}\r","start_line":1,"end_line":28,"total_lines":28,"truncated":false}
{"end_line": 238, "path": "/project/internal/forgejo/poll.go", "start_line": 200}
{"path":"/project/internal/forgejo/poll.go","content":"\t\tBody: issue.Body,\r\n\t\tAuthor: posterName(issue.Poster),\r\n\t\tAssignee: assignee,\r\n\t\tRaw: raw,\r\n\t}\r\n}\r\n\r\nfunc issueToCommentEvent(issue *sdk.Issue, owner, repo string, comment *sdk.Comment) Event {\r\n\traw, _ := json.Marshal(comment)\r\n\r\n\treturn Event{\r\n\t\tKind: EventIssueComment,\r\n\t\tID: issueCommentID(comment.ID),\r\n\t\tOwner: owner,\r\n\t\tRepo: repo,\r\n\t\tIndex: issue.Index,\r\n\t\tTitle: issue.Title,\r\n\t\tBody: issue.Body,\r\n\t\tAuthor: posterName(comment.Poster),\r\n\t\tComment: comment.Body,\r\n\t\tRaw: raw,\r\n\t}\r\n}\r\n\r\nfunc issueToPRNewEvent(issue *sdk.Issue, owner, repo string) Event {\r\n\traw, _ := json.Marshal(issue)\r\n\r\n\treturn Event{\r\n\t\tKind: EventPRNew,\r\n\t\tID: prNewID(issue.ID),\r\n\t\tOwner: owner,\r\n\t\tRepo: repo,\r\n\t\tIndex: issue.Index,\r\n\t\tTitle: issue.Title,\r\n\t\tBody: issue.Body,\r\n\t\tAuthor: posterName(issue.Poster),\r\n\t\tRaw: raw,\r\n\t}\r\n}\r","start_line":200,"end_line":238,"total_lines":238,"truncated":false}
{"command": "cd /project && head -60 internal/agentrun/docker_integration_test.go && go build ./... 2>&1 | head"}
//go:build docker
// Run with: go test -tags docker ./internal/agentrun/...
// Requires a reachable Docker daemon (DOCKER_HOST or the default socket).
package agentrun
import (
"context"
"os/exec"
"strings"
"testing"
"time"
)
func TestDockerRuntimeExecRoundTrip(t *testing.T) {
rt, err := newDockerRuntime()
if err != nil {
t.Fatalf("docker client: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
containerID, err := rt.createContainer(ctx, "debian:unstable", nil, "zoo-test-run")
if err != nil {
t.Fatalf("create container: %v", err)
}
defer rt.remove(context.Background(), containerID)
output, exitCode, err := rt.exec(ctx, containerID, "echo hello-from-zoo")
if err != nil {
t.Fatalf("exec: %v", err)
}
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
}
if !strings.Contains(output, "hello-from-zoo") {
t.Fatalf("unexpected output: %q", output)
}
_, exitCode, err = rt.exec(ctx, containerID, "exit 3")
if err != nil {
t.Fatalf("exec: %v", err)
}
if exitCode != 3 {
t.Fatalf("expected exit code 3, got %d", exitCode)
}
}
// TestDockerRuntimeGitSafeDirectory reproduces the "detected dubious
// ownership" error git raises against a bind-mounted repo owned by a
// different UID than the container runs as, and confirms the `git
// config --system --add safe.directory '*'` fix Run() applies (see
// run.go) actually clears it, against the same golang:latest image
// zoo.hcl now defaults to.
func TestDockerRuntimeGitSafeDirectory(t *testing.T) {
projectDir := t.TempDir()
for _, args := range [][]string{
{"init", "-q", projectDir},
{"command": "cd /project && grep -n \"type Forgejo\" -A 10 internal/config/config.go && head -50 internal/forgejo/webhook_test.go"}
49:type Forgejo struct {
50- URL string `hcl:"url"`
51- Token string `hcl:"token"`
52- WebhookSecret string `hcl:"webhook_secret,optional"`
53-}
54-
55-type Environment struct {
56- DockerImage string `hcl:"docker_image"`
57- MaxLive int `hcl:"max_live_agents"`
58-}
59-
package forgejo
import (
"testing"
"time"
)
const issueOpenedPayload = `{
"action": "opened",
"issue": {
"id": 101,
"number": 5,
"title": "Something is broken",
"body": "Steps to reproduce...",
"user": {"login": "alice"},
"assignees": []
},
"repository": {
"name": "widgets",
"owner": {"login": "acme"}
}
}`
const issueAssignedPayload = `{
"action": "assigned",
"issue": {
"id": 101,
"number": 5,
"title": "Something is broken",
"updated_at": "2026-08-20T10:00:00Z",
"user": {"login": "alice"},
"assignees": [{"login": "leon"}]
},
"repository": {
"name": "widgets",
"owner": {"login": "acme"}
}
}`
// The same issue assigned to the same agent a second time, later.
const issueReassignedPayload = `{
"action": "assigned",
"issue": {
"id": 101,
"number": 5,
"title": "Something is broken",
"updated_at": "2026-08-20T11:30:00Z",
"user": {"login": "alice"},
"assignees": [{"login": "leon"}]
},
{"path": "/project/internal/forgejo/client.go", "find": "import (\n\t\"fmt\"\n\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n)", "replace": "import (\n\t\"fmt\"\n\t\"time\"\n\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n)"}
1 occurrences replaced
{"path": "/project/internal/forgejo/client.go", "find": "// AddLabel attaches the label with the given name to an issue/PR,", "replace": "// IssueComment is one comment on an issue or pull request, in the\n// shape zoo needs when briefing an agent: who said what, and when.\ntype IssueComment struct {\n\tAuthor string\n\tBody string\n\tCreated time.Time\n}\n\n// ListIssueComments fetches every comment on the given issue or pull\n// request, oldest first. PRs are issues under the hood in Forgejo, so\n// the same endpoint serves both. Pages are walked until exhausted so\n// the result isn't capped by the server's default page size.\nfunc (c *Client) ListIssueComments(owner, repo string, index int64) ([]IssueComment, error) {\n\tconst pageSize = 50\n\n\tvar all []*sdk.Comment\n\n\tfor page := 1; ; page++ {\n\t\tbatch, _, err := c.sdk.ListIssueComments(owner, repo, index, sdk.ListIssueCommentOptions{\n\t\t\tListOptions: sdk.ListOptions{Page: page, PageSize: pageSize},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"list comments on %s/%s#%d (page %d): %w\", owner, repo, index, page, err)\n\t\t}\n\n\t\tall = append(all, batch...)\n\n\t\tif len(batch) < pageSize {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tout := make([]IssueComment, 0, len(all))\n\tfor _, cm := range all {\n\t\tauthor := \"\"\n\t\tif cm.Poster != nil {\n\t\t\tauthor = cm.Poster.UserName\n\t\t}\n\n\t\tout = append(out, IssueComment{Author: author, Body: cm.Body, Created: cm.Created})\n\t}\n\n\treturn out, nil\n}\n\n// AddLabel attaches the label with the given name to an issue/PR,"}
1 occurrences replaced
{"path": "/project/internal/agentrun/run.go", "find": "\tsystemPrompt := defaultSystemPrompt + identitySection(agent.Name, roster)\n\n\tinstructions := r.cfg.EventInstructions(ev.Kind)\n\n\tmessages := []llm.Message{\n\t\t{Role: \"system\", Content: systemPrompt},\n\t\t{Role: \"user\", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions)},\n\t}", "replace": "\tsystemPrompt := defaultSystemPrompt + identitySection(agent.Name, roster)\n\n\tinstructions := r.cfg.EventInstructions(ev.Kind)\n\n\t// Fetch the full comment thread so the agent sees everything that's\n\t// been said on the issue/PR, not just the triggering event (which\n\t// only carries the latest comment, if any). A failure degrades to\n\t// no comments rather than failing the run: the agent can still do\n\t// its job, just without prior context.\n\tcomments, err := r.forgejo.ListIssueComments(ev.Owner, ev.Repo, ev.Index)\n\tif err != nil {\n\t\tlogger.Warn(\"fetch issue comments failed; agent will not see prior comments\", \"error\", err)\n\t\tcomments = nil\n\t}\n\n\tmessages := []llm.Message{\n\t\t{Role: \"system\", Content: systemPrompt},\n\t\t{Role: \"user\", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions, comments)},\n\t}"}
1 occurrences replaced
{"path": "/project/internal/agentrun/run.go", "find": "func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string) string {\n\traw, _ := json.MarshalIndent(json.RawMessage(ev.Raw), \"\", \" \")\n\n\tvar instructionsSection string\n\tif instructions != \"\" {\n\t\tinstructionsSection = fmt.Sprintf(\"Instructions for this event, from zoo.hcl:\\n%s\\n\\n\", instructions)\n\t}\n\n\treturn fmt.Sprintf(\n\t\t\"You were triggered by a %q event on %s/%s.\\n\\n\"+\n\t\t\t\"%sYour working branch is %q, checked out from the default branch %q.\\n\\n\"+\n\t\t\t\"Title: %s\\n\\nBody:\\n%s\\n\\nFull event payload:\\n```json\\n%s\\n```\",\n\t\tev.Kind, ev.Owner, ev.Repo, instructionsSection, branch, defaultBranch, ev.Title, ev.Body, raw)\n}", "replace": "func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, comments []forgejo.IssueComment) string {\n\traw, _ := json.MarshalIndent(json.RawMessage(ev.Raw), \"\", \" \")\n\n\tvar instructionsSection string\n\tif instructions != \"\" {\n\t\tinstructionsSection = fmt.Sprintf(\"Instructions for this event, from zoo.hcl:\\n%s\\n\\n\", instructions)\n\t}\n\n\tvar commentsSection string\n\tif len(comments) > 0 {\n\t\tvar b strings.Builder\n\t\tfmt.Fprintf(&b, \"Comments (%d):\\n\\n\", len(comments))\n\n\t\tfor i, c := range comments {\n\t\t\tfmt.Fprintf(&b, \"%d. %s (%s):\\n%s\\n\\n\", i+1, c.Author, c.Created.Format(time.RFC3339), c.Body)\n\t\t}\n\n\t\tcommentsSection = b.String()\n\t}\n\n\treturn fmt.Sprintf(\n\t\t\"You were triggered by a %q event on %s/%s.\\n\\n\"+\n\t\t\t\"%sYour working branch is %q, checked out from the default branch %q.\\n\\n\"+\n\t\t\t\"Title: %s\\n\\nBody:\\n%s\\n\\n%sFull event payload:\\n```json\\n%s\\n```\",\n\t\tev.Kind, ev.Owner, ev.Repo, instructionsSection, branch, defaultBranch, ev.Title, ev.Body, commentsSection, raw)\n}"}
1 occurrences replaced
{"path": "/project/internal/agentrun/seed_test.go", "content": "package agentrun\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/abrander/zoo/internal/forgejo\"\n)\n\nfunc TestSeedMessageIncludesInstructions(t *testing.T) {\n\tev := forgejo.Event{Kind: \"issue:comment\", Owner: \"acme\", Repo: \"widgets\", Index: 5, Title: \"t\", Body: \"b\", Raw: []byte(`{}`)}\n\n\tmsg := seedMessage(ev, \"issue-5-leon\", \"main\", \"Please review the comment and respond appropriately.\", nil)\n\n\tif !strings.Contains(msg, \"Instructions for this event, from zoo.hcl:\\nPlease review the comment and respond appropriately.\") {\n\t\tt.Fatalf(\"expected instructions section, got: %s\", msg)\n\t}\n}\n\nfunc TestSeedMessageOmitsEmptyInstructions(t *testing.T) {\n\tev := forgejo.Event{Kind: \"pr:new\", Owner: \"acme\", Repo: \"widgets\", Index: 9, Raw: []byte(`{}`)}\n\n\tmsg := seedMessage(ev, \"issue-9-greg\", \"main\", \"\", nil)\n\n\tif strings.Contains(msg, \"Instructions for this event\") {\n\t\tt.Fatalf(\"expected no instructions section, got: %s\", msg)\n\t}\n}\n\nfunc TestSeedMessageIncludesAllComments(t *testing.T) {\n\tev := forgejo.Event{Kind: \"issue:assigned\", Owner: \"acme\", Repo: \"widgets\", Index: 5, Title: \"t\", Body: \"b\", Raw: []byte(`{}`)}\n\n\tcomments := []forgejo.IssueComment{\n\t\t{Author: \"alice\", Body: \"Please also handle the edge case.\", Created: time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC)},\n\t\t{Author: \"bob\", Body: \"And add a test for it.\", Created: time.Date(2026, 8, 24, 9, 5, 0, 0, time.UTC)},\n\t}\n\n\tmsg := seedMessage(ev, \"issue-5-greg\", \"main\", \"\", comments)\n\n\tfor _, want := range []string{\n\t\t\"Comments (2):\",\n\t\t\"1. alice (2026-08-24T09:00:00Z):\\nPlease also handle the edge case.\",\n\t\t\"2. bob (2026-08-24T09:05:00Z):\\nAnd add a test for it.\",\n\t} {\n\t\tif !strings.Contains(msg, want) {\n\t\t\tt.Fatalf(\"expected %q in seed message, got:\\n%s\", want, msg)\n\t\t}\n\t}\n\n\t// Comments come before the raw payload, so the agent reads them\n\t// as part of the briefing, not buried in JSON.\n\tif !strings.Contains(msg, \"Comments (2):\") ||\n\t\tstrings.Index(msg, \"Comments (2):\") > strings.Index(msg, \"Full event payload:\") {\n\t\tt.Fatalf(\"expected comments section before the raw payload, got:\\n%s\", msg)\n\t}\n}\n\nfunc TestSeedMessageOmitsEmptyComments(t *testing.T) {\n\tev := forgejo.Event{Kind: \"issue:new\", Owner: \"acme\", Repo: \"widgets\", Index: 5, Title: \"t\", Body: \"b\", Raw: []byte(`{}`)}\n\n\tmsg := seedMessage(ev, \"issue-5-greg\", \"main\", \"\", nil)\n\n\tif strings.Contains(msg, \"Comments\") {\n\t\tt.Fatalf(\"expected no comments section, got: %s\", msg)\n\t}\n}\n"}
OK
{"content": "package forgejo\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n)\n\n// commentPage is one page of the /repos/{owner}/{repo}/issues/{index}/comments\n// endpoint, as Forgejo serves it.\ntype commentPage struct {\n\tID int64 `json:\"id\"`\n\tLogin string `json:\"login\"`\n\tBody string `json:\"body\"`\n\tCreated string `json:\"created_at\"`\n}\n\nfunc TestListIssueCommentsFetchesAllPages(t *testing.T) {\n\tconst total = 120 // 3 pages at the client's page size of 50\n\n\tmux := http.NewServeMux()\n\n\tmux.HandleFunc(\"/repos/acme/widgets/issues/5/comments\", func(w http.ResponseWriter, r *http.Request) {\n\t\tpage, _ := strconv.ParseInt(r.URL.Query().Get(\"page\"), 10, 64)\n\t\tlimit, _ := strconv.ParseInt(r.URL.Query().Get(\"limit\"), 10, 64)\n\n\t\tstart := (page - 1) * limit\n\t\tend := start + limit\n\t\tif end > total {\n\t\t\tend = total\n\t\t}\n\n\t\tpage := make([]commentPage, 0, end-start)\n\t\tfor i := start; i < end; i++ {\n\t\t\tpage = append(page, commentPage{\n\t\t\t\tID: i + 1,\n\t\t\t\tLogin: fmt.Sprintf(\"user-%d\", i+1),\n\t\t\t\tBody: fmt.Sprintf(\"comment %d\", i+1),\n\t\t\t\tCreated: time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Minute).Format(time.RFC3339),\n\t\t\t})\n\t\t}\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(page)\n\t})\n\n\tserver := httptest.NewServer(mux)\n\tdefer server.Close()\n\n\tclient, err := NewClient(config.Forgejo{URL: server.URL, Token: \"test\"})\n\tif err != nil {\n\t\tt.Fatalf(\"new client: %v\", err)\n\t}\n\n\tcomments, err := client.ListIssueComments(\"acme\", \"widgets\", 5)\n\tif err != nil {\n\t\tt.Fatalf(\"list comments: %v\", err)\n\t}\n\n\tif len(comments) != total {\n\t\tt.Fatalf(\"expected %d comments, got %d\", total, len(comments))\n\t}\n\n\tfor i, c := range comments {\n\t\tif c.Author != fmt.Sprintf(\"user-%d\", i+1) || c.Body != fmt.Sprintf(\"comment %d\", i+1) {\n\t\t\tt.Fatalf(\"comment %d: got author %q body %q\", i, c.Author, c.Body)\n\t\t}\n\n\t\twant := time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Minute)\n\t\tif !c.Created.Equal(want) {\n\t\t\tt.Fatalf(\"comment %d: got created %s, want %s\", i, c.Created, want)\n\t\t}\n\t}\n}\n\nfunc TestListIssueCommentsEmpty(t *testing.T) {\n\tmux := http.NewServeMux()\n\n\tmux.HandleFunc(\"/repos/acme/widgets/issues/7/comments\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`[]`))\n\t})\n\n\tserver := httptest.NewServer(mux)\n\tdefer server.Close()\n\n\tclient, err := NewClient(config.Forgejo{URL: server.URL, Token: \"test\"})\n\tif err != nil {\n\t\tt.Fatalf(\"new client: %v\", err)\n\t}\n\n\tcomments, err := client.ListIssueComments(\"acme\", \"widgets\", 7)\n\tif err != nil {\n\t\tt.Fatalf(\"list comments: %v\", err)\n\t}\n\n\tif len(comments) != 0 {\n\t\tt.Fatalf(\"expected no comments, got %d\", len(comments))\n\t}\n}\n", "path": "/project/internal/forgejo/client_test.go"}
OK
{"path": "/project/internal/forgejo/client_test.go", "find": "\t\tpage, _ := strconv.ParseInt(r.URL.Query().Get(\"page\"), 10, 64)\n\t\tlimit, _ := strconv.ParseInt(r.URL.Query().Get(\"limit\"), 10, 64)\n\n\t\tstart := (page - 1) * limit\n\t\tend := start + limit\n\t\tif end > total {\n\t\t\tend = total\n\t\t}\n\n\t\tpage := make([]commentPage, 0, end-start)\n\t\tfor i := start; i < end; i++ {\n\t\t\tpage = append(page, commentPage{", "replace": "\t\tpage, _ := strconv.ParseInt(r.URL.Query().Get(\"page\"), 10, 64)\n\t\tlimit, _ := strconv.ParseInt(r.URL.Query().Get(\"limit\"), 10, 64)\n\n\t\tstart := (page - 1) * limit\n\t\tend := start + limit\n\t\tif end > total {\n\t\t\tend = total\n\t\t}\n\n\t\tbody := make([]commentPage, 0, end-start)\n\t\tfor i := start; i < end; i++ {\n\t\t\tbody = append(body, commentPage{"}
1 occurrences replaced
{"content": "package forgejo\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n)\n\n// Wire shapes matching the SDK's Comment type, as Forgejo serves the\n// /repos/{owner}/{repo}/issues/{index}/comments endpoint.\ntype wireUser struct {\n\tLogin string `json:\"login\"`\n}\n\ntype wireComment struct {\n\tID int64 `json:\"id\"`\n\tPoster wireUser `json:\"user\"`\n\tBody string `json:\"body\"`\n\tCreated time.Time `json:\"created_at\"`\n}\n\nfunc TestListIssueCommentsFetchesAllPages(t *testing.T) {\n\tconst total = 120 // 3 pages at the client's page size of 50\n\n\tmux := http.NewServeMux()\n\n\tmux.HandleFunc(\"/repos/acme/widgets/issues/5/comments\", func(w http.ResponseWriter, r *http.Request) {\n\t\tpage, _ := strconv.ParseInt(r.URL.Query().Get(\"page\"), 10, 64)\n\t\tlimit, _ := strconv.ParseInt(r.URL.Query().Get(\"limit\"), 10, 64)\n\n\t\tstart := (page - 1) * limit\n\t\tend := start + limit\n\t\tif end > total {\n\t\t\tend = total\n\t\t}\n\n\t\tbody := make([]wireComment, 0, end-start)\n\t\tfor i := start; i < end; i++ {\n\t\t\tbody = append(body, wireComment{\n\t\t\t\tID: i + 1,\n\t\t\t\tPoster: wireUser{Login: fmt.Sprintf(\"user-%d\", i+1)},\n\t\t\t\tBody: fmt.Sprintf(\"comment %d\", i+1),\n\t\t\t\tCreated: time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Minute),\n\t\t\t})\n\t\t}\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(body)\n\t})\n\n\tserver := httptest.NewServer(mux)\n\tdefer server.Close()\n\n\tclient, err := NewClient(config.Forgejo{URL: server.URL, Token: \"test\"})\n\tif err != nil {\n\t\tt.Fatalf(\"new client: %v\", err)\n\t}\n\n\tcomments, err := client.ListIssueComments(\"acme\", \"widgets\", 5)\n\tif err != nil {\n\t\tt.Fatalf(\"list comments: %v\", err)\n\t}\n\n\tif len(comments) != total {\n\t\tt.Fatalf(\"expected %d comments, got %d\", total, len(comments))\n\t}\n\n\tfor i, c := range comments {\n\t\tif c.Author != fmt.Sprintf(\"user-%d\", i+1) || c.Body != fmt.Sprintf(\"comment %d\", i+1) {\n\t\t\tt.Fatalf(\"comment %d: got author %q body %q\", i, c.Author, c.Body)\n\t\t}\n\n\t\twant := time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Minute)\n\t\tif !c.Created.Equal(want) {\n\t\t\tt.Fatalf(\"comment %d: got created %s, want %s\", i, c.Created, want)\n\t\t}\n\t}\n}\n\nfunc TestListIssueCommentsEmpty(t *testing.T) {\n\tmux := http.NewServeMux()\n\n\tmux.HandleFunc(\"/repos/acme/widgets/issues/7/comments\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`[]`))\n\t})\n\n\tserver := httptest.NewServer(mux)\n\tdefer server.Close()\n\n\tclient, err := NewClient(config.Forgejo{URL: server.URL, Token: \"test\"})\n\tif err != nil {\n\t\tt.Fatalf(\"new client: %v\", err)\n\t}\n\n\tcomments, err := client.ListIssueComments(\"acme\", \"widgets\", 7)\n\tif err != nil {\n\t\tt.Fatalf(\"list comments: %v\", err)\n\t}\n\n\tif len(comments) != 0 {\n\t\tt.Fatalf(\"expected no comments, got %d\", len(comments))\n\t}\n}\n", "path": "/project/internal/forgejo/client_test.go"}
OK
{"command": "cd /project && go build ./... && go vet ./... && go test ./... 2>&1 | tail -20", "timeout": 300}
? github.com/abrander/zoo/cmd/zoo [no test files]
ok github.com/abrander/zoo/internal/agentrun 0.004s
ok github.com/abrander/zoo/internal/config 0.004s
--- FAIL: TestListIssueCommentsFetchesAllPages (0.00s)
client_test.go:62: new client: forgejo client: unknown API Error: 404
Request: '/api/v1/version' with 'GET' method and '404 page not found
' body
--- FAIL: TestListIssueCommentsEmpty (0.00s)
client_test.go:99: new client: forgejo client: unknown API Error: 404
Request: '/api/v1/version' with 'GET' method and '404 page not found
' body
FAIL
FAIL github.com/abrander/zoo/internal/forgejo 0.004s
ok github.com/abrander/zoo/internal/livelog 0.052s
ok github.com/abrander/zoo/internal/llm 0.003s
ok github.com/abrander/zoo/internal/scheduler 2.285s
ok github.com/abrander/zoo/internal/store 1.963s
ok github.com/abrander/zoo/internal/tools 0.003s
ok github.com/abrander/zoo/internal/web 0.815s
FAIL
{"command": "SDK=/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && grep -rn \"version\" $SDK/client.go | head -20 && grep -rn \"api/v1/version\\|GetVersion\\|type Version\" $SDK/*.go | head -10"}
25: version "github.com/hashicorp/go-version"
30:// Version return the library version
49: serverVersion *version.Version
80: if err := client.checkServerVersionGreaterThanOrEqual(version1_11_0); err != nil {
136: if err := client.checkServerVersionGreaterThanOrEqual(version1_17_0); err != nil {
159: if err := client.checkServerVersionGreaterThanOrEqual(version1_17_0); err != nil {
{"command": "SDK=/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n '55,135p' $SDK/client.go"}
type Response struct {
*http.Response
FirstPage int
PrevPage int
NextPage int
LastPage int
}
// ClientOption are functions used to init a new client
type ClientOption func(*Client) error
// NewClient initializes and returns a API client.
// Usage of all forgejo.Client methods is concurrency-safe.
func NewClient(url string, options ...ClientOption) (*Client, error) {
client := &Client{
url: strings.TrimSuffix(url, "/"),
client: &http.Client{},
ctx: context.Background(),
}
for _, opt := range options {
if err := opt(client); err != nil {
return nil, err
}
}
if err := client.checkServerVersionGreaterThanOrEqual(version1_11_0); err != nil {
if errors.Is(err, &ErrUnknownVersion{}) {
return client, err
}
return nil, err
}
return client, nil
}
// NewClientWithHTTP creates an API client with a custom http client
// Deprecated: use SetHTTPClient option
func NewClientWithHTTP(url string, httpClient *http.Client) *Client {
client, _ := NewClient(url, SetHTTPClient(httpClient))
return client
}
// SetHTTPClient is an option for NewClient to set custom http client
func SetHTTPClient(httpClient *http.Client) ClientOption {
return func(client *Client) error {
client.SetHTTPClient(httpClient)
return nil
}
}
// SetHTTPClient replaces default http.Client with user given one.
func (c *Client) SetHTTPClient(client *http.Client) {
c.mutex.Lock()
c.client = client
c.mutex.Unlock()
}
// SetToken is an option for NewClient to set token
func SetToken(token string) ClientOption {
return func(client *Client) error {
client.mutex.Lock()
client.accessToken = token
client.mutex.Unlock()
return nil
}
}
// SetBasicAuth is an option for NewClient to set username and password
func SetBasicAuth(username, password string) ClientOption {
return func(client *Client) error {
client.SetBasicAuth(username, password)
return nil
}
}
// UseSSHCert is an option for NewClient to enable SSH certificate authentication via HTTPSign
// If you want to auth against the ssh-agent you'll need to set a principal, if you want to
// use a file on disk you'll need to specify sshKey.
// If you have an encrypted sshKey you'll need to also set the passphrase.
func UseSSHCert(principal, sshKey, passphrase string) ClientOption {
return func(client *Client) error {
{"command": "SDK=/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && grep -rn \"checkServerVersionGreaterThanOrEqual\\|serverVersion\\|/version\" $SDK/client.go | head && grep -rn \"func (c \\*Client) checkServerVersion\" -A 30 $SDK/client.go | head -45"}
49: serverVersion *version.Version
80: if err := client.checkServerVersionGreaterThanOrEqual(version1_11_0); err != nil {
136: if err := client.checkServerVersionGreaterThanOrEqual(version1_17_0); err != nil {
159: if err := client.checkServerVersionGreaterThanOrEqual(version1_17_0); err != nil {
{"command": "SDK=/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && grep -rln \"checkServerVersionGreaterThanOrEqual\" $SDK/ && grep -rn \"func (c \\*Client) checkServerVersionGreaterThanOrEqual\" -A 25 $SDK/version.go 2>/dev/null || grep -rn \"func (c \\*Client) checkServerVersionGreaterThanOrEqual\" -A 25 $SDK/*.go | head -35"}
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/repo_tag.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/issue_subscription.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/user_settings.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/repo_branch_protection.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/oauth2.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/repo_compare.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/pull_review.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/repo_branch.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/issue.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/version.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/issue_milestone.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/pull.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/repo_team.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/issue_comment.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/client.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/repo_commit.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/repo_collaborator.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/repo_migrate.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/repo_transfer.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/user_app.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/version_test.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/issue_label.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/repo_file.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/settings.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/httpsign.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/admin_cron.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/notifications.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/release.go
/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/repo.go
97:func (c *Client) checkServerVersionGreaterThanOrEqual(v *version.Version) error {
98- if c.ignoreVersion {
99- return nil
100- }
101- if err := c.loadServerVersion(); err != nil {
102- return err
103- }
104-
105- if !c.serverVersion.GreaterThanOrEqual(v) {
106- c.mutex.RLock()
107- url := c.url
108- c.mutex.RUnlock()
109- return fmt.Errorf("forgejo server at %s is older than %s", url, v.Original())
110- }
111- return nil
112-}
113-
114-// loadServerVersion init the serverVersion variable
115-func (c *Client) loadServerVersion() (err error) {
116- c.getVersionOnce.Do(func() {
117- raw, _, err2 := c.ServerVersion()
118- if err2 != nil {
119- err = err2
120- return
121- }
122- if c.serverVersion, err = version.NewVersion(raw); err != nil {
{"command": "SDK=/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n '1,60p' $SDK/version_test.go && grep -n \"func (c \\*Client) ServerVersion\" -A 15 $SDK/version.go"}
// Copyright 2024 The Forgejo Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
// Copyright 2020 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package forgejo
import (
"log"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestVersion(t *testing.T) {
log.Printf("== TestVersion ==")
c := newTestClient()
rawVersion, _, err := c.ServerVersion()
require.NoError(t, err)
assert.NotEqual(t, "", rawVersion)
require.NoError(t, c.checkServerVersionGreaterThanOrEqual(version8_0_3))
require.Error(t, c.CheckServerVersionConstraint("< 8.0.3"))
require.NoError(t, c.checkServerVersionGreaterThanOrEqual(version1_11_0))
require.Error(t, c.CheckServerVersionConstraint("< 1.11.0"))
c.serverVersion = version1_11_0
require.Error(t, c.checkServerVersionGreaterThanOrEqual(version1_15_0))
c.ignoreVersion = true
require.NoError(t, c.checkServerVersionGreaterThanOrEqual(version1_15_0))
c, err = NewClient(getForgejoURL(), newTestClientAuth(), SetForgejoVersion("1.12.123"))
require.NoError(t, err)
require.NoError(t, c.CheckServerVersionConstraint("=1.12.123"))
}
19:func (c *Client) ServerVersion() (string, *Response, error) {
20- v := struct {
21- Version string `json:"version"`
22- }{}
23- resp, err := c.getParsedResponse("GET", "/version", nil, nil, &v)
24- return v.Version, resp, err
25-}
26-
27-// CheckServerVersionConstraint validates that the login's server satisfies a
28-// given version constraint such as ">= 1.11.0+dev"
29-func (c *Client) CheckServerVersionConstraint(constraint string) error {
30- if err := c.loadServerVersion(); err != nil {
31- return err
32- }
33-
34- check, err := version.NewConstraint(constraint)
{"command": "SDK=/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && grep -n \"func SetForgejoVersion\" -A 12 $SDK/version.go"}
50:func SetForgejoVersion(v string) ClientOption {
51- if v == "" {
52- return func(c *Client) error {
53- c.ignoreVersion = true
54- return nil
55- }
56- }
57- return func(c *Client) (err error) {
58- c.getVersionOnce.Do(func() {
59- c.serverVersion, err = version.NewVersion(v)
60- })
61- return //nolint
62- }
{"path": "/project/internal/forgejo/client_test.go", "find": "func TestListIssueCommentsFetchesAllPages(t *testing.T) {\n\tconst total = 120 // 3 pages at the client's page size of 50\n\n\tmux := http.NewServeMux()\n\n\tmux.HandleFunc(\"/repos/acme/widgets/issues/5/comments\", func(w http.ResponseWriter, r *http.Request) {", "replace": "// newTestServer returns an httptest server that answers the SDK's\n// /api/v1/version probe (made by NewClient) plus the routes registered\n// on the returned mux.\nfunc newTestServer(t *testing.T) (*httptest.Server, *http.ServeMux) {\n\tt.Helper()\n\n\tmux := http.NewServeMux()\n\n\tmux.HandleFunc(\"/api/v1/version\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`{\"version\":\"1.23.0\"}`))\n\t})\n\n\tserver := httptest.NewServer(mux)\n\tt.Cleanup(server.Close)\n\n\treturn server, mux\n}\n\nfunc TestListIssueCommentsFetchesAllPages(t *testing.T) {\n\tconst total = 120 // 3 pages at the client's page size of 50\n\n\tserver, mux := newTestServer(t)\n\n\tmux.HandleFunc(\"/repos/acme/widgets/issues/5/comments\", func(w http.ResponseWriter, r *http.Request) {"}
1 occurrences replaced
{"path": "/project/internal/forgejo/client_test.go", "find": "\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(body)\n\t})\n\n\tserver := httptest.NewServer(mux)\n\tdefer server.Close()\n\n\tclient, err := NewClient(config.Forgejo{URL: server.URL, Token: \"test\"})\n\tif err != nil {\n\t\tt.Fatalf(\"new client: %v\", err)\n\t}\n\n\tcomments, err := client.ListIssueComments(\"acme\", \"widgets\", 5)", "replace": "\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(body)\n\t})\n\n\tclient, err := NewClient(config.Forgejo{URL: server.URL, Token: \"test\"})\n\tif err != nil {\n\t\tt.Fatalf(\"new client: %v\", err)\n\t}\n\n\tcomments, err := client.ListIssueComments(\"acme\", \"widgets\", 5)"}
1 occurrences replaced
{"path": "/project/internal/forgejo/client_test.go", "find": "func TestListIssueCommentsEmpty(t *testing.T) {\n\tmux := http.NewServeMux()\n\n\tmux.HandleFunc(\"/repos/acme/widgets/issues/7/comments\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`[]`))\n\t})\n\n\tserver := httptest.NewServer(mux)\n\tdefer server.Close()\n\n\tclient, err := NewClient(config.Forgejo{URL: server.URL, Token: \"test\"})", "replace": "func TestListIssueCommentsEmpty(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tmux.HandleFunc(\"/repos/acme/widgets/issues/7/comments\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`[]`))\n\t})\n\n\tclient, err := NewClient(config.Forgejo{URL: server.URL, Token: \"test\"})"}
1 occurrences replaced
{"command": "cd /project && go build ./... && go vet ./... && go test ./... 2>&1 | tail -15", "timeout": 300}
Request: '/api/v1/repos/acme/widgets/issues/5/comments' with 'GET' method and '404 page not found
' body
--- FAIL: TestListIssueCommentsEmpty (0.00s)
client_test.go:117: list comments: list comments on acme/widgets#7 (page 1): unknown API Error: 404
Request: '/api/v1/repos/acme/widgets/issues/7/comments' with 'GET' method and '404 page not found
' body
FAIL
FAIL github.com/abrander/zoo/internal/forgejo 0.005s
ok github.com/abrander/zoo/internal/livelog (cached)
ok github.com/abrander/zoo/internal/llm (cached)
ok github.com/abrander/zoo/internal/scheduler (cached)
ok github.com/abrander/zoo/internal/store (cached)
ok github.com/abrander/zoo/internal/tools (cached)
ok github.com/abrander/zoo/internal/web (cached)
FAIL
{"command": "cd /project && sed -i 's|mux.HandleFunc(\"/repos/acme/widgets/issues/|mux.HandleFunc(\"/api/v1/repos/acme/widgets/issues/|' internal/forgejo/client_test.go && go test ./internal/forgejo/... 2>&1 | tail -5", "timeout": 120}
ok github.com/abrander/zoo/internal/forgejo 0.005s
{"command": "cd /project && go build ./... && go vet ./... && go test ./... 2>&1 | tail -15", "timeout": 300}
? github.com/abrander/zoo/cmd/zoo [no test files] ok github.com/abrander/zoo/internal/agentrun (cached) ok github.com/abrander/zoo/internal/config (cached) ok github.com/abrander/zoo/internal/forgejo (cached) ok github.com/abrander/zoo/internal/livelog (cached) ok github.com/abrander/zoo/internal/llm (cached) ok github.com/abrander/zoo/internal/scheduler (cached) ok github.com/abrander/zoo/internal/store (cached) ok github.com/abrander/zoo/internal/tools (cached) ok github.com/abrander/zoo/internal/web (cached)
{"command": "cd /project && git diff && git status --short"}
diff --git a/internal/agentrun/run.go b/internal/agentrun/run.go
index f346fb1..9555f56 100644
--- a/internal/agentrun/run.go
+++ b/internal/agentrun/run.go
@@ -208,9 +208,20 @@ func (r *Runner) Run(ctx context.Context, jobID string, agent config.AgentConfig
instructions := r.cfg.EventInstructions(ev.Kind)
+ // Fetch the full comment thread so the agent sees everything that's
+ // been said on the issue/PR, not just the triggering event (which
+ // only carries the latest comment, if any). A failure degrades to
+ // no comments rather than failing the run: the agent can still do
+ // its job, just without prior context.
+ comments, err := r.forgejo.ListIssueComments(ev.Owner, ev.Repo, ev.Index)
+ if err != nil {
+ logger.Warn("fetch issue comments failed; agent will not see prior comments", "error", err)
+ comments = nil
+ }
+
messages := []llm.Message{
{Role: "system", Content: systemPrompt},
- {Role: "user", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions)},
+ {Role: "user", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions, comments)},
}
hooks := r.streamHooks(jobID, logAppend)
@@ -294,7 +305,7 @@ func (r *Runner) streamHooks(jobID string, logAppend func(stream, line string))
}
}
-func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string) string {
+func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, comments []forgejo.IssueComment) string {
raw, _ := json.MarshalIndent(json.RawMessage(ev.Raw), "", " ")
var instructionsSection string
@@ -302,9 +313,21 @@ func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string) s
instructionsSection = fmt.Sprintf("Instructions for this event, from zoo.hcl:\n%s\n\n", instructions)
}
+ var commentsSection string
+ if len(comments) > 0 {
+ var b strings.Builder
+ fmt.Fprintf(&b, "Comments (%d):\n\n", len(comments))
+
+ for i, c := range comments {
+ fmt.Fprintf(&b, "%d. %s (%s):\n%s\n\n", i+1, c.Author, c.Created.Format(time.RFC3339), c.Body)
+ }
+
+ commentsSection = b.String()
+ }
+
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)
+ "Title: %s\n\nBody:\n%s\n\n%sFull event payload:\n```json\n%s\n```",
+ ev.Kind, ev.Owner, ev.Repo, instructionsSection, branch, defaultBranch, ev.Title, ev.Body, commentsSection, raw)
}
diff --git a/internal/agentrun/seed_test.go b/internal/agentrun/seed_test.go
index 341ae6b..c50ca6d 100644
--- a/internal/agentrun/seed_test.go
+++ b/internal/agentrun/seed_test.go
@@ -3,6 +3,7 @@ package agentrun
import (
"strings"
"testing"
+ "time"
"github.com/abrander/zoo/internal/forgejo"
)
@@ -10,7 +11,7 @@ import (
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.")
+ msg := seedMessage(ev, "issue-5-leon", "main", "Please review the comment and respond appropriately.", nil)
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)
@@ -20,9 +21,47 @@ func TestSeedMessageIncludesInstructions(t *testing.T) {
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", "")
+ msg := seedMessage(ev, "issue-9-greg", "main", "", nil)
if strings.Contains(msg, "Instructions for this event") {
t.Fatalf("expected no instructions section, got: %s", msg)
}
}
+
+func TestSeedMessageIncludesAllComments(t *testing.T) {
+ ev := forgejo.Event{Kind: "issue:assigned", Owner: "acme", Repo: "widgets", Index: 5, Title: "t", Body: "b", Raw: []byte(`{}`)}
+
+ comments := []forgejo.IssueComment{
+ {Author: "alice", Body: "Please also handle the edge case.", Created: time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC)},
+ {Author: "bob", Body: "And add a test for it.", Created: time.Date(2026, 8, 24, 9, 5, 0, 0, time.UTC)},
+ }
+
+ msg := seedMessage(ev, "issue-5-greg", "main", "", comments)
+
+ for _, want := range []string{
+ "Comments (2):",
+ "1. alice (2026-08-24T09:00:00Z):\nPlease also handle the edge case.",
+ "2. bob (2026-08-24T09:05:00Z):\nAnd add a test for it.",
+ } {
+ if !strings.Contains(msg, want) {
+ t.Fatalf("expected %q in seed message, got:\n%s", want, msg)
+ }
+ }
+
+ // Comments come before the raw payload, so the agent reads them
+ // as part of the briefing, not buried in JSON.
+ if !strings.Contains(msg, "Comments (2):") ||
+ strings.Index(msg, "Comments (2):") > strings.Index(msg, "Full event payload:") {
+ t.Fatalf("expected comments section before the raw payload, got:\n%s", msg)
+ }
+}
+
+func TestSeedMessageOmitsEmptyComments(t *testing.T) {
+ ev := forgejo.Event{Kind: "issue:new", Owner: "acme", Repo: "widgets", Index: 5, Title: "t", Body: "b", Raw: []byte(`{}`)}
+
+ msg := seedMessage(ev, "issue-5-greg", "main", "", nil)
+
+ if strings.Contains(msg, "Comments") {
+ t.Fatalf("expected no comments section, got: %s", msg)
+ }
+}
diff --git a/internal/forgejo/client.go b/internal/forgejo/client.go
index 858102c..cc97bfe 100644
--- a/internal/forgejo/client.go
+++ b/internal/forgejo/client.go
@@ -4,6 +4,7 @@ package forgejo
import (
"fmt"
+ "time"
sdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
@@ -76,6 +77,51 @@ func (c *Client) CreateIssueComment(owner, repo string, index int64, body string
return nil
}
+// IssueComment is one comment on an issue or pull request, in the
+// shape zoo needs when briefing an agent: who said what, and when.
+type IssueComment struct {
+ Author string
+ Body string
+ Created time.Time
+}
+
+// ListIssueComments fetches every comment on the given issue or pull
+// request, oldest first. PRs are issues under the hood in Forgejo, so
+// the same endpoint serves both. Pages are walked until exhausted so
+// the result isn't capped by the server's default page size.
+func (c *Client) ListIssueComments(owner, repo string, index int64) ([]IssueComment, error) {
+ const pageSize = 50
+
+ var all []*sdk.Comment
+
+ for page := 1; ; page++ {
+ batch, _, err := c.sdk.ListIssueComments(owner, repo, index, sdk.ListIssueCommentOptions{
+ ListOptions: sdk.ListOptions{Page: page, PageSize: pageSize},
+ })
+ if err != nil {
+ return nil, fmt.Errorf("list comments on %s/%s#%d (page %d): %w", owner, repo, index, page, err)
+ }
+
+ all = append(all, batch...)
+
+ if len(batch) < pageSize {
+ break
+ }
+ }
+
+ out := make([]IssueComment, 0, len(all))
+ for _, cm := range all {
+ author := ""
+ if cm.Poster != nil {
+ author = cm.Poster.UserName
+ }
+
+ out = append(out, IssueComment{Author: author, Body: cm.Body, Created: cm.Created})
+ }
+
+ return out, nil
+}
+
// AddLabel attaches the label with the given name to an issue/PR,
// creating the label (with a default color) on the repo first if it
// doesn't already exist.
M internal/agentrun/run.go
M internal/agentrun/seed_test.go
M internal/forgejo/client.go
?? internal/forgejo/client_test.go
{"command": "cd /project && grep -rn \"seedMessage\" --include=\"*.go\" . && go test ./... 2>&1 | tail -12", "timeout": 300}
./internal/agentrun/seed_test.go:14: msg := seedMessage(ev, "issue-5-leon", "main", "Please review the comment and respond appropriately.", nil)
./internal/agentrun/seed_test.go:24: msg := seedMessage(ev, "issue-9-greg", "main", "", nil)
./internal/agentrun/seed_test.go:39: msg := seedMessage(ev, "issue-5-greg", "main", "", comments)
./internal/agentrun/seed_test.go:62: msg := seedMessage(ev, "issue-5-greg", "main", "", nil)
./internal/agentrun/run.go:224: {Role: "user", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions, comments)},
./internal/agentrun/run.go:308:func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, comments []forgejo.IssueComment) string {
? github.com/abrander/zoo/cmd/zoo [no test files]
ok github.com/abrander/zoo/internal/agentrun (cached)
ok github.com/abrander/zoo/internal/config (cached)
ok github.com/abrander/zoo/internal/forgejo (cached)
ok github.com/abrander/zoo/internal/livelog (cached)
ok github.com/abrander/zoo/internal/llm (cached)
ok github.com/abrander/zoo/internal/scheduler (cached)
ok github.com/abrander/zoo/internal/store (cached)
ok github.com/abrander/zoo/internal/tools (cached)
ok github.com/abrander/zoo/internal/web (cached)
{"command": "cd /project && gofmt -l . && git add -A && git commit -m \"Include full issue comment thread in the agent's seed message\n\nWhen an agent is triggered by an issue (or PR) event, the seed message\nonly carried the triggering event's raw payload — for issue:assigned\nand issue:new that means zero comments, and for issue:comment only the\nlatest one. A human who comments 'please also do X' and then assigns\nthe issue had no way to get that context to the agent.\n\n- forgejo.Client.ListIssueComments: fetch every comment on an\n issue/PR (PRs are issues under the hood), walking pages until\n exhausted so the result isn't capped by the server page size.\n- agentrun.Run: fetch the thread before building the seed message;\n a fetch failure degrades to no comments (warn log) rather than\n failing the run.\n- seedMessage: render a 'Comments (N):' section (author, timestamp,\n body, oldest first) between the issue body and the raw payload.\n\nCloses #33\" && git push -u origin issue-33-greg 2>&1 | tail -3", "timeout": 120}
internal/scheduler/scheduler_test.go [issue-33-greg 9e5dece] Include full issue comment thread in the agent's seed message 4 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 internal/forgejo/client_test.go fatal: could not read Username for 'https://code.stdio.dk': terminal prompts disabled
{"subcommand": "push", "args": ["-u", "origin", "issue-33-greg"]}
To https://code.stdio.dk/abrander/zoo.git * [new branch] issue-33-greg -> issue-33-greg branch 'issue-33-greg' set up to track 'origin/issue-33-greg'.
{"head": "issue-33-greg", "base": "main", "title": "Include full issue comment thread in the agent's seed message", "body": "## Problem\n\nWhen an agent is triggered by an issue event, the seed message only carried the triggering event's raw payload. For `issue:assigned` and `issue:new` that means **zero** comments (the payload only has a `comments` count), and for `issue:comment` only the latest one. A human who comments \"please also do X\" and then assigns the issue had no way to get that context to the agent.\n\n## Changes\n\n- **`internal/forgejo/client.go`** — new `Client.ListIssueComments(owner, repo, index)`: fetches every comment on an issue/PR (PRs are issues under the hood in Forgejo), walking pages until exhausted so the result isn't capped by the server's default page size. Returns author, body, timestamp — oldest first.\n- **`internal/agentrun/run.go`** — `Run` fetches the thread right before building the seed message, so the agent sees the latest state. A fetch failure degrades to no comments (warn log) rather than failing the whole run.\n- **`seedMessage`** — renders a `Comments (N):` section between the issue body and the raw event payload:\n\n ```\n Title: Issue including comments\n\n Body:\n When an agent receives an issue, make sure to include all comments.\n\n Comments (2):\n\n 1. abrander (2026-08-24T00:01:00+02:00):\n Please also do X.\n\n 2. greg (2026-08-24T00:02:00+02:00):\n On it.\n\n Full event payload:\n ```\n\n The section is omitted entirely when there are no comments, keeping the briefing clean.\n\n## Tests\n\n- `seed_test.go`: comments section present with author/timestamp/body, ordered before the raw payload; omitted when empty.\n- `client_test.go` (new): `ListIssueComments` against an httptest Forgejo server — verifies all 3 pages (120 comments) are fetched in order, and the empty case.\n\n`go build`, `go vet`, and `go test ./...` all pass.\n\nCloses #33"}
Pull request opened
{"body": "Done — agents now receive the full comment thread when triggered by an issue (or PR) event.\n\n**The gap:** the seed message only carried the raw event payload, which for `issue:assigned`/`issue:new` contains no comments at all (just a count), and for `issue:comment` only the latest one. So context left in comments before an assignment never reached the agent.\n\n**The fix (PR opened from `issue-33-greg`):**\n1. `forgejo.Client.ListIssueComments` — fetches every comment on the issue/PR, paginating until exhausted so nothing is capped by the server page size.\n2. `agentrun.Run` fetches the thread just before building the seed message (latest state wins); a fetch failure logs a warning and degrades to no comments rather than failing the run.\n3. `seedMessage` renders a `Comments (N):` section — author, timestamp, body, oldest first — between the issue body and the raw payload. Omitted when there are no comments.\n\nCovered by new/updated unit tests (seed message rendering + client pagination against a mock Forgejo server); `go build`, `go vet`, and the full test suite pass."}
Comment posted