Status succeeded
Event issue:assigned on abrander/zoo#36
Agent greg greg

Log

Thinking
First, let's start by exploring the project to understand what Zoo is and how it works. The task is as follows: "PR reviews - Agents need to respond to and react to PR reviews. Define the workflow and implement the Zoo side." So, Zoo is a system that triggers agents from Forgejo (Gitea) issue/PR events. This issue is asking to define a workflow where agents respond to and react to PR reviews, and to implement the "Zoo side" of it. Let's take a look at the project structure first.
Tool 🔧 list_files {"path": ".", "recursive": true}
{"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/client_test.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
Tool 🔧 read_file {"path": "/event", "start_line": 1, "end_line": 50}
{"path": "/event", "start_line": 1, "end_line": 50}
{"path":"/event","content":"{\"id\":67,\"url\":\"https://code.stdio.dk/api/v1/repos/abrander/zoo/issues/36\",\"html_url\":\"https://code.stdio.dk/abrander/zoo/issues/36\",\"number\":36,\"user\":{\"id\":1,\"login\":\"abrander\",\"login_name\":\"\",\"source_id\":0,\"full_name\":\"Anders Brander\",\"email\":\"anders@brander.dk\",\"html_url\":\"https://code.stdio.dk/abrander\",\"avatar_url\":\"https://code.stdio.dk/avatar/d87ec4c86bb6c9d188d5e32e2424583d\",\"language\":\"en-US\",\"is_admin\":true,\"last_login\":\"2026-08-23T13:24:16+02:00\",\"created\":\"2025-03-27T21:24:00+01:00\",\"restricted\":false,\"active\":true,\"prohibit_login\":false,\"location\":\"\",\"website\":\"\",\"description\":\"Real human :-)\\r\\n\\r\\nI manage all this. Talk to me if you get stuck.\",\"visibility\":\"limited\",\"followers_count\":0,\"following_count\":0,\"starred_repos_count\":0},\"original_author\":\"\",\"original_author_id\":0,\"title\":\"PR reviews\",\"body\":\"We need agents to respond to and react to PR reviews. Define a workflow and implement the Zoo side.\",\"ref\":\"\",\"labels\":[],\"milestone\":null,\"assignees\":[{\"id\":2,\"login\":\"greg\",\"login_name\":\"\",\"source_id\":0,\"full_name\":\"Greg Coolio\",\"email\":\"greg@noreply.localhost\",\"html_url\":\"https://code.stdio.dk/greg\",\"avatar_url\":\"https://code.stdio.dk/avatars/893e287248431844784e5109d4b0b7eaa5fe7ea602efdec156f7b57c7034aa57\",\"language\":\"\",\"is_admin\":false,\"last_login\":\"0001-01-01T00:00:00Z\",\"created\":\"2026-08-18T20:13:06+02:00\",\"restricted\":false,\"active\":false,\"prohibit_login\":false,\"location\":\"\",\"website\":\"\",\"description\":\"\\n### Senior Developer \\u0026 Architect\\n\\n- 15+ years building scalable systems and leading teams.\\n- Expert in architecture, clean code and problem solving.\\n- Turns complex problems into elegant solutions.\\n\\t\\t\",\"visibility\":\"limited\",\"followers_count\":0,\"following_count\":0,\"starred_repos_count\":0}],\"state\":\"open\",\"is_locked\":false,\"comments\":1,\"created_at\":\"2026-08-24T00:31:38+02:00\",\"updated_at\":\"2026-08-24T00:31:50+02:00\",\"closed_at\":null,\"due_date\":null,\"pull_request\":null,\"repository\":{\"id\":6,\"name\":\"zoo\",\"owner\":\"abrander\",\"full_name\":\"abrander/zoo\"}}","start_line":1,"end_line":0,"total_lines":0,"truncated":false}
Thinking
Let's explore the codebase to understand the architecture. I'll read the main files: 1. `internal/forgejo/event.go` - Event handling 2. `internal/forgejo/poll.go` - Polling logic 3. `internal/forgejo/watcher.go` - Watcher 4. `internal/forgejo/webhook.go` - Webhook 5. `internal/agentrun/run.go` - Run logic 6. `internal/agentrun/loop.go` - Loop 7. `internal/agentrun/context.go` - Context 8. `internal/scheduler/scheduler.go` - Scheduler 9. `internal/store/store.go` - Store 10. `internal/tools/forgejo.go` - Forgejo tools Let's read these.
Tool 🔧 read_file {"path": "internal/forgejo/event.go", "start_line": 1, "end_line": 200}
{"path": "internal/forgejo/event.go", "start_line": 1, "end_line": 200}
{"path":"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}
Tool 🔧 read_file {"path": "internal/forgejo/poll.go", "start_line": 1, "end_line": 200}
{"path": "internal/forgejo/poll.go", "start_line": 1, "end_line": 200}
{"path":"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}
Thinking
Let's read the rest of poll.go and the other main files.
Tool 🔧 read_file {"path": "internal/forgejo/poll.go", "start_line": 200, "end_line": 238}
{"path": "internal/forgejo/poll.go", "start_line": 200, "end_line": 238}
{"path":"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}
Tool 🔧 read_file {"path": "internal/forgejo/webhook.go", "start_line": 1, "end_line": 250}
{"path": "internal/forgejo/webhook.go", "start_line": 1, "end_line": 250}
{"path":"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}
Thinking
Next, let's take a look at the scheduler, watcher, store, and the agentrun package.
Tool 🔧 read_file {"path": "internal/scheduler/scheduler.go", "start_line": 1, "end_line": 250}
{"path": "internal/scheduler/scheduler.go", "start_line": 1, "end_line": 250}
{"path":"internal/scheduler/scheduler.go","content":"// Package scheduler resolves incoming Forgejo events to configured\r\n// agents and runs them, bounded by max_live_agents.\r\npackage scheduler\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"errors\"\r\n\t\"fmt\"\r\n\t\"log/slog\"\r\n\t\"sync\"\r\n\r\n\t\"github.com/google/uuid\"\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/store\"\r\n)\r\n\r\n// forgejoActions is the narrow slice of Client the scheduler needs for\r\n// its own failure-reporting side effects (defined here, not in\r\n// internal/forgejo, so tests can inject a fake).\r\ntype forgejoActions interface {\r\n\tCreateIssueComment(owner, repo string, index int64, body string) error\r\n\tAddLabel(owner, repo string, index int64, name string) error\r\n}\r\n\r\n// FailureLabel is applied to the triggering issue/PR, alongside a\r\n// comment, whenever an agent run fails or times out.\r\nconst FailureLabel = \"zoo:failed\"\r\n\r\n// Runner runs a single agent invocation to completion. Implemented by\r\n// internal/agentrun.Run; a narrow interface here so the scheduler is\r\n// testable without Docker.\r\ntype Runner interface {\r\n\tRun(ctx context.Context, jobID string, agent config.AgentConfig, llm config.LLM, dockerImage string, ev forgejo.Event) error\r\n}\r\n\r\ntype Scheduler struct {\r\n\tcfg     *config.Config\r\n\tstore   *store.Store\r\n\tforgejo forgejoActions\r\n\trunner  Runner\r\n\thub     *livelog.Hub\r\n\tlogger  *slog.Logger\r\n\r\n\tsem chan struct{}\r\n\twg  sync.WaitGroup\r\n}\r\n\r\nfunc New(cfg *config.Config, st *store.Store, fg forgejoActions, runner Runner, hub *livelog.Hub, logger *slog.Logger) *Scheduler {\r\n\treturn \u0026Scheduler{\r\n\t\tcfg:     cfg,\r\n\t\tstore:   st,\r\n\t\tforgejo: fg,\r\n\t\trunner:  runner,\r\n\t\thub:     hub,\r\n\t\tlogger:  logger,\r\n\t\tsem:     make(chan struct{}, cfg.Environment.MaxLive),\r\n\t}\r\n}\r\n\r\n// resolveAgent returns the name of the agent that should handle ev, if\r\n// any. issue:assigned resolves dynamically: the agent whose config label\r\n// matches the Forgejo assignee's username. Every other event kind uses\r\n// the static event-\u003eagent mapping from config.\r\nfunc resolveAgent(cfg *config.Config, ev forgejo.Event) (string, bool) {\r\n\tif ev.Kind == config.EventIssueAssigned {\r\n\t\tif _, ok := cfg.AgentByName(ev.Assignee); ok {\r\n\t\t\treturn ev.Assignee, true\r\n\t\t}\r\n\r\n\t\treturn \"\", false\r\n\t}\r\n\r\n\treturn cfg.EventAgent(ev.Kind)\r\n}\r\n\r\n// Run consumes events until ctx is canceled or the channel closes,\r\n// dispatching each to its resolved agent and blocking on the\r\n// max_live_agents semaphore before starting a run.\r\nfunc (s *Scheduler) Run(ctx context.Context, events \u003c-chan forgejo.Event) {\r\n\tfor {\r\n\t\tselect {\r\n\t\tcase \u003c-ctx.Done():\r\n\t\t\treturn\r\n\r\n\t\tcase ev, ok := \u003c-events:\r\n\t\t\tif !ok {\r\n\t\t\t\treturn\r\n\t\t\t}\r\n\r\n\t\t\ts.handle(ctx, ev)\r\n\t\t}\r\n\t}\r\n}\r\n\r\nfunc (s *Scheduler) handle(ctx context.Context, ev forgejo.Event) {\r\n\tagentName, ok := resolveAgent(s.cfg, ev)\r\n\tif !ok {\r\n\t\ts.logger.Debug(\"no agent resolved for event, dropping\", \"kind\", ev.Kind, \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index)\r\n\t\treturn\r\n\t}\r\n\r\n\t// An agent's own actions (e.g. a comment posted via the `comment`\r\n\t// tool, authenticated with its own per-agent token) can themselves\r\n\t// show up as new events. Don't let an agent trigger itself off its\r\n\t// own activity — that's a self-reinforcing loop, not new work.\r\n\tif ev.Author != \"\" \u0026\u0026 ev.Author == agentName {\r\n\t\ts.logger.Debug(\"dropping event authored by the agent it would trigger\", \"kind\", ev.Kind, \"agent\", agentName, \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index)\r\n\t\treturn\r\n\t}\r\n\r\n\tagent, ok := s.cfg.AgentByName(agentName)\r\n\tif !ok {\r\n\t\ts.logger.Error(\"resolved agent not declared in config\", \"agent\", agentName)\r\n\t\treturn\r\n\t}\r\n\r\n\tllm, ok := s.cfg.LLMByName(agent.LLM)\r\n\tif !ok {\r\n\t\ts.logger.Error(\"agent references undeclared llm\", \"agent\", agentName, \"llm\", agent.LLM)\r\n\t\treturn\r\n\t}\r\n\r\n\tjobID := uuid.NewString()\r\n\r\n\tif err := s.store.CreateJob(ctx, store.Job{\r\n\t\tID:         jobID,\r\n\t\tEventKind:  ev.Kind,\r\n\t\tAgent:      agentName,\r\n\t\tOwner:      ev.Owner,\r\n\t\tRepo:       ev.Repo,\r\n\t\tIssueIndex: ev.Index,\r\n\t\tTitle:      ev.Title,\r\n\t}); err != nil {\r\n\t\ts.logger.Error(\"failed to record job\", \"job\", jobID, \"error\", err)\r\n\t\treturn\r\n\t}\r\n\r\n\tselect {\r\n\tcase s.sem \u003c- struct{}{}:\r\n\r\n\tcase \u003c-ctx.Done():\r\n\t\treturn\r\n\t}\r\n\r\n\ts.wg.Add(1)\r\n\r\n\tgo func() {\r\n\t\tdefer s.wg.Done()\r\n\t\tdefer func() { \u003c-s.sem }()\r\n\r\n\t\ts.run(ctx, jobID, agent, llm, ev)\r\n\t}()\r\n}\r\n\r\nfunc (s *Scheduler) run(ctx context.Context, jobID string, agent config.AgentConfig, llm config.LLM, ev forgejo.Event) {\r\n\tlogger := s.logger.With(\"job\", jobID, \"agent\", agent.Name, \"event\", ev.Kind, \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index)\r\n\r\n\t// Job status writes use a context detached from ctx, not ctx itself:\r\n\t// ctx is canceled on daemon shutdown to unwind the in-flight run, and\r\n\t// an already-canceled ctx would make these UPDATEs fail instantly,\r\n\t// leaving the job stuck at \"running\" forever even though the process\r\n\t// has exited.\r\n\tif err := s.store.MarkJobStarted(context.Background(), jobID); err != nil {\r\n\t\tlogger.Error(\"failed to mark job started\", \"error\", err)\r\n\t}\r\n\r\n\tlogger.Info(\"agent run starting\")\r\n\r\n\terr := s.runner.Run(ctx, jobID, agent, llm, s.cfg.Environment.DockerImage, ev)\r\n\r\n\tstatus := store.JobSucceeded\r\n\terrMsg := \"\"\r\n\r\n\tif err != nil {\r\n\t\terrMsg = err.Error()\r\n\t\t// The daemon-wide ctx passed in here isn't what times a run out\r\n\t\t// (agentrun.Runner.Run applies its own per-run deadline\r\n\t\t// internally); a timed-out run surfaces as a wrapped\r\n\t\t// context.DeadlineExceeded in the returned error instead.\r\n\t\tif errors.Is(err, context.DeadlineExceeded) {\r\n\t\t\tstatus = store.JobTimedOut\r\n\t\t} else {\r\n\t\t\tstatus = store.JobFailed\r\n\t\t}\r\n\r\n\t\tlogger.Error(\"agent run failed\", \"status\", status, \"error\", err)\r\n\t\ts.reportFailure(ev, status, err)\r\n\t} else {\r\n\t\tlogger.Info(\"agent run succeeded\")\r\n\t}\r\n\r\n\tif err := s.store.MarkJobFinished(context.Background(), jobID, status, errMsg); err != nil {\r\n\t\tlogger.Error(\"failed to mark job finished\", \"error\", err)\r\n\t}\r\n\r\n\t// Closing the hub here, only after the terminal status is durably\r\n\t// written, is what lets a dashboard tab's auto-reload (triggered by\r\n\t// this Close) see the finished status immediately. Closing it inside\r\n\t// Runner.Run instead — before this status write — raced a fast\r\n\t// reload against MarkJobFinished: the reloaded page could still see\r\n\t// \"running\", open a fresh SSE subscription against a hub entry that\r\n\t// no longer exists, and be stuck showing a stale status forever.\r\n\ts.hub.Close(jobID)\r\n}\r\n\r\n// reportFailure implements the locked-in failure policy: comment on the\r\n// triggering issue/PR with an error summary, and apply FailureLabel.\r\nfunc (s *Scheduler) reportFailure(ev forgejo.Event, status store.JobStatus, runErr error) {\r\n\tbody := fmt.Sprintf(\"zoo agent run %s: %s\", status, runErr.Error())\r\n\r\n\tif err := s.forgejo.CreateIssueComment(ev.Owner, ev.Repo, ev.Index, body); err != nil {\r\n\t\ts.logger.Error(\"failed to post failure comment\", \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index, \"error\", err)\r\n\t}\r\n\r\n\tif err := s.forgejo.AddLabel(ev.Owner, ev.Repo, ev.Index, FailureLabel); err != nil {\r\n\t\ts.logger.Error(\"failed to add failure label\", \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index, \"error\", err)\r\n\t}\r\n}\r\n\r\n// Shutdown stops accepting new work is the caller's responsibility (stop\r\n// feeding Run's channel); Shutdown just waits for in-flight runs, up to\r\n// ctx's deadline.\r\nfunc (s *Scheduler) Shutdown(ctx context.Context) error {\r\n\tdone := make(chan struct{})\r\n\r\n\tgo func() {\r\n\t\ts.wg.Wait()\r\n\t\tclose(done)\r\n\t}()\r\n\r\n\tselect {\r\n\tcase \u003c-done:\r\n\t\treturn nil\r\n\r\n\tcase \u003c-ctx.Done():\r\n\t\treturn ctx.Err()\r\n\t}\r\n}\r","start_line":1,"end_line":241,"total_lines":241,"truncated":false}
Tool 🔧 read_file {"path": "internal/forgejo/watcher.go", "start_line": 1, "end_line": 150}
{"path": "internal/forgejo/watcher.go", "start_line": 1, "end_line": 150}
{"path":"internal/forgejo/watcher.go","content":"package forgejo\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"log/slog\"\r\n\t\"net/http\"\r\n\t\"time\"\r\n\r\n\t\"github.com/abrander/zoo/internal/store\"\r\n)\r\n\r\nconst PollInterval = 10 * time.Second\r\n\r\n// Watcher merges the webhook receiver and the polling fallback into a\r\n// single deduplicated Event stream.\r\ntype Watcher struct {\r\n\tclient *Client\r\n\tstore  *store.Store\r\n\tsecret string\r\n\tlogger *slog.Logger\r\n\r\n\tevents chan Event\r\n}\r\n\r\nfunc NewWatcher(client *Client, st *store.Store, webhookSecret string, logger *slog.Logger) *Watcher {\r\n\treturn \u0026Watcher{\r\n\t\tclient: client,\r\n\t\tstore:  st,\r\n\t\tsecret: webhookSecret,\r\n\t\tlogger: logger,\r\n\t\tevents: make(chan Event, 64),\r\n\t}\r\n}\r\n\r\n// Handler returns the http.Handler to mount for incoming webhook\r\n// deliveries.\r\nfunc (w *Watcher) Handler() http.Handler {\r\n\treturn WebhookHandler(w.secret, w.logger, w.dispatch)\r\n}\r\n\r\n// Events returns the deduplicated stream consumed by the scheduler.\r\nfunc (w *Watcher) Events() \u003c-chan Event {\r\n\treturn w.events\r\n}\r\n\r\n// Run drives the polling fallback until ctx is canceled. The webhook\r\n// handler runs independently as part of the daemon's HTTP server.\r\nfunc (w *Watcher) Run(ctx context.Context) {\r\n\tticker := time.NewTicker(PollInterval)\r\n\tdefer ticker.Stop()\r\n\r\n\tfor {\r\n\t\tselect {\r\n\t\tcase \u003c-ctx.Done():\r\n\t\t\treturn\r\n\r\n\t\tcase \u003c-ticker.C:\r\n\t\t\tw.pollOnce(ctx)\r\n\t\t}\r\n\t}\r\n}\r\n\r\n// dispatch dedupes ev against the store (regardless of whether it came\r\n// from the webhook or the poller) and, if new, forwards it to Events().\r\nfunc (w *Watcher) dispatch(ev Event) {\r\n\tisNew, err := w.store.MarkSeen(context.Background(), ev.ID)\r\n\tif err != nil {\r\n\t\tw.logger.Error(\"dedup check failed, dropping event\", \"id\", ev.ID, \"error\", err)\r\n\t\treturn\r\n\t}\r\n\r\n\tif !isNew {\r\n\t\treturn\r\n\t}\r\n\r\n\tselect {\r\n\tcase w.events \u003c- ev:\r\n\r\n\tdefault:\r\n\t\tw.logger.Warn(\"event channel full, dropping event\", \"id\", ev.ID, \"kind\", ev.Kind)\r\n\t}\r\n}\r","start_line":1,"end_line":82,"total_lines":82,"truncated":false}
Thinking
Next, let's take a look at the config package, the store, and the agentrun package.
Tool 🔧 read_file {"path": "internal/config/config.go", "start_line": 1, "end_line": 300}
{"path": "internal/config/config.go", "start_line": 1, "end_line": 300}
{"path":"internal/config/config.go","content":"// Package config loads and validates zoo's HCL configuration file.\r\npackage config\r\n\r\nimport (\r\n\t\"fmt\"\r\n\r\n\t\"github.com/hashicorp/hcl/v2/hclsimple\"\r\n)\r\n\r\n// Known event kinds. issue:assigned is resolved dynamically (agent name\r\n// must match the Forgejo assignee's username) so it never carries an\r\n// `agent` attribute; the rest map statically to one configured agent.\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\nvar staticEventKinds = map[string]bool{\r\n\tEventIssueNew:     true,\r\n\tEventIssueComment: true,\r\n\tEventPRNew:        true,\r\n}\r\n\r\ntype Config struct {\r\n\tLLMs        []LLM       `hcl:\"llm,block\"`\r\n\tForgejo     Forgejo     `hcl:\"forgejo,block\"`\r\n\tEnvironment Environment `hcl:\"environment,block\"`\r\n\tAgents      []Agent     `hcl:\"agent,block\"`\r\n\tEvents      []Event     `hcl:\"event,block\"`\r\n\tWeb         *Web        `hcl:\"web,block\"`\r\n}\r\n\r\n// Web configures the dashboard's optional bearer-token gate. Leave the\r\n// block out of zoo.hcl entirely to run without one (fine on localhost;\r\n// put a real gate or a proxy in front for anything else).\r\ntype Web struct {\r\n\tToken string `hcl:\"token,optional\"`\r\n}\r\n\r\ntype LLM struct {\r\n\tName   string `hcl:\"name,label\"`\r\n\tOpenAI string `hcl:\"openai\"`\r\n\tToken  string `hcl:\"token\"`\r\n\tModel  string `hcl:\"model\"`\r\n}\r\n\r\ntype Forgejo struct {\r\n\tURL           string `hcl:\"url\"`\r\n\tToken         string `hcl:\"token\"`\r\n\tWebhookSecret string `hcl:\"webhook_secret,optional\"`\r\n}\r\n\r\ntype Environment struct {\r\n\tDockerImage string `hcl:\"docker_image\"`\r\n\tMaxLive     int    `hcl:\"max_live_agents\"`\r\n}\r\n\r\ntype Agent struct {\r\n\tName  string `hcl:\"name,label\"`\r\n\tLLM   string `hcl:\"llm\"`\r\n\tToken string `hcl:\"token,optional\"`\r\n}\r\n\r\ntype Event struct {\r\n\tKind         string `hcl:\"name,label\"`\r\n\tAgent        string `hcl:\"agent,optional\"`\r\n\tInstructions string `hcl:\"instructions,optional\"`\r\n}\r\n\r\n// Load reads and validates the config file at path.\r\nfunc Load(path string) (*Config, error) {\r\n\tvar cfg Config\r\n\r\n\tif err := hclsimple.DecodeFile(path, nil, \u0026cfg); err != nil {\r\n\t\treturn nil, fmt.Errorf(\"parse config: %w\", err)\r\n\t}\r\n\r\n\tif err := cfg.Validate(); err != nil {\r\n\t\treturn nil, fmt.Errorf(\"invalid config: %w\", err)\r\n\t}\r\n\r\n\treturn \u0026cfg, nil\r\n}\r\n\r\n// Validate checks that the config is internally consistent: every\r\n// reference between blocks resolves, and required values are set.\r\nfunc (c *Config) Validate() error {\r\n\tllmNames := make(map[string]bool, len(c.LLMs))\r\n\tfor _, l := range c.LLMs {\r\n\t\tif l.OpenAI == \"\" || l.Token == \"\" || l.Model == \"\" {\r\n\t\t\treturn fmt.Errorf(\"llm %q: openai, token, and model are required\", l.Name)\r\n\t\t}\r\n\t\tllmNames[l.Name] = true\r\n\t}\r\n\r\n\tif c.Forgejo.URL == \"\" || c.Forgejo.Token == \"\" {\r\n\t\treturn fmt.Errorf(\"forgejo: url and token are required\")\r\n\t}\r\n\r\n\tif c.Environment.MaxLive \u003c 1 {\r\n\t\treturn fmt.Errorf(\"environment: max_live_agents must be \u003e= 1, got %d\", c.Environment.MaxLive)\r\n\t}\r\n\r\n\tif c.Environment.DockerImage == \"\" {\r\n\t\treturn fmt.Errorf(\"environment: docker_image is required\")\r\n\t}\r\n\r\n\tagentNames := make(map[string]bool, len(c.Agents))\r\n\tfor _, a := range c.Agents {\r\n\t\tif !llmNames[a.LLM] {\r\n\t\t\treturn fmt.Errorf(\"agent %q: references undeclared llm %q\", a.Name, a.LLM)\r\n\t\t}\r\n\t\tagentNames[a.Name] = true\r\n\t}\r\n\r\n\tseenEventKinds := make(map[string]bool, len(c.Events))\r\n\tfor _, e := range c.Events {\r\n\t\tif seenEventKinds[e.Kind] {\r\n\t\t\treturn fmt.Errorf(\"event %q: declared more than once\", e.Kind)\r\n\t\t}\r\n\t\tseenEventKinds[e.Kind] = true\r\n\r\n\t\tswitch {\r\n\t\tcase e.Kind == EventIssueAssigned:\r\n\t\t\tif e.Agent != \"\" {\r\n\t\t\t\treturn fmt.Errorf(\"event %q: must not set agent; the agent whose name matches the Forgejo assignee's username is triggered dynamically\", e.Kind)\r\n\t\t\t}\r\n\r\n\t\tcase staticEventKinds[e.Kind]:\r\n\t\t\tif e.Agent == \"\" {\r\n\t\t\t\treturn fmt.Errorf(\"event %q: agent is required\", e.Kind)\r\n\t\t\t}\r\n\t\t\tif !agentNames[e.Agent] {\r\n\t\t\t\treturn fmt.Errorf(\"event %q: references undeclared agent %q\", e.Kind, e.Agent)\r\n\t\t\t}\r\n\r\n\t\tdefault:\r\n\t\t\treturn fmt.Errorf(\"event %q: unknown event kind\", e.Kind)\r\n\t\t}\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// AgentConfig holds an agent's full configuration including its\r\n// Forgejo token (if configured).\r\ntype AgentConfig struct {\r\n\tName  string\r\n\tLLM   string\r\n\tToken string\r\n}\r\n\r\n// AgentByName returns the agent config with the given name, if declared.\r\nfunc (c *Config) AgentByName(name string) (AgentConfig, bool) {\r\n\tfor _, a := range c.Agents {\r\n\t\tif a.Name == name {\r\n\t\t\treturn AgentConfig{\r\n\t\t\t\tName:  a.Name,\r\n\t\t\t\tLLM:   a.LLM,\r\n\t\t\t\tToken: a.Token,\r\n\t\t\t}, true\r\n\t\t}\r\n\t}\r\n\r\n\treturn AgentConfig{}, false\r\n}\r\n\r\n// LLMByName returns the llm config with the given name, if declared.\r\nfunc (c *Config) LLMByName(name string) (LLM, bool) {\r\n\tfor _, l := range c.LLMs {\r\n\t\tif l.Name == name {\r\n\t\t\treturn l, true\r\n\t\t}\r\n\t}\r\n\r\n\treturn LLM{}, false\r\n}\r\n\r\n// EventAgent returns the statically configured agent name for the given\r\n// event kind, if one is declared and set.\r\nfunc (c *Config) EventAgent(kind string) (string, bool) {\r\n\tfor _, e := range c.Events {\r\n\t\tif e.Kind == kind {\r\n\t\t\treturn e.Agent, e.Agent != \"\"\r\n\t\t}\r\n\t}\r\n\r\n\treturn \"\", false\r\n}\r\n\r\n// EventInstructions returns the configured freeform instructions for the\r\n// given event kind, e.g. \"Please review this pull request and respond\r\n// appropriately.\" Returns \"\" if the event isn't declared or has none.\r\nfunc (c *Config) EventInstructions(kind string) string {\r\n\tfor _, e := range c.Events {\r\n\t\tif e.Kind == kind {\r\n\t\t\treturn e.Instructions\r\n\t\t}\r\n\t}\r\n\r\n\treturn \"\"\r\n}\r","start_line":1,"end_line":204,"total_lines":204,"truncated":false}
Tool 🔧 read_file {"path": "zoo.hcl.example", "start_line": 1, "end_line": 150}
{"path": "zoo.hcl.example", "start_line": 1, "end_line": 150}
{"path":"zoo.hcl.example","content":"# zoo.hcl - Configuration for the zoo daemon.\r\n#\r\n# Copy this file to zoo.hcl (gitignored) and fill in real values.\r\n\r\nllm \"Qwen 3.6\" {\r\n    openai = \"https://code.stdio.dk\"\r\n    token  = \"TOKEN\"\r\n    model  = \"Qwen3.6\"\r\n}\r\n\r\nllm \"Qwen 3.8\" {\r\n    openai = \"https://code.stdio.dk\"\r\n    token  = \"TOKEN\"\r\n    model  = \"Qwen3.8\"\r\n}\r\n\r\nforgejo {\r\n    url            = \"https://code.stdio.dk\"\r\n    token          = \"ZOO_SERVICE_TOKEN\"\r\n    # Shared secret configured on the Forgejo webhook. Required to accept\r\n    # webhook deliveries; polling works without it.\r\n    webhook_secret = \"EXAMPLE_WEBHOOK_SECRET\"\r\n}\r\n\r\nenvironment {\r\n    docker_image   = \"golang:latest\"\r\n    max_live_agents = 5\r\n}\r\n\r\nagent \"anna\" {\r\n    llm   = \"Qwen 3.6\"\r\n    token = \"ANNA_FORGEJO_TOKEN\"\r\n}\r\n\r\nagent \"greg\" {\r\n    llm   = \"Qwen 3.8\"\r\n    token = \"GREG_FORGEJO_TOKEN\"\r\n}\r\n\r\n# \"leon\" doubles as the expected Forgejo assignee username for\r\n# issue:assigned events - see the event block below.\r\nagent \"leon\" {\r\n    llm   = \"Qwen 3.8\"\r\n    token = \"LEON_FORGEJO_TOKEN\"\r\n}\r\n\r\nagent \"mika\" {\r\n    llm   = \"Qwen 3.6\"\r\n    token = \"MIKA_FORGEJO_TOKEN\"\r\n}\r\n\r\nevent \"issue:new\" {\r\n    agent = \"leon\"\r\n}\r\n\r\nevent \"issue:assigned\" {\r\n    // No `agent` here: the agent whose name matches the Forgejo\r\n    // assignee's username is triggered.\r\n    instructions = \"Please handle this issue.\"\r\n}\r\n\r\nevent \"issue:comment\" {\r\n    agent        = \"leon\"\r\n    instructions = \"Please review what this comment says and respond appropriately.\"\r\n}\r\n\r\nevent \"pr:new\" {\r\n    agent        = \"greg\"\r\n    instructions = \"Please review this pull request and respond appropriately.\"\r\n}\r","start_line":1,"end_line":70,"total_lines":70,"truncated":false}
Thinking
Next, let's take a look at the agentrun package, particularly run.go, context.go, and loop.go.
Tool 🔧 read_file {"path": "internal/agentrun/run.go", "start_line": 1, "end_line": 250}
{"path": "internal/agentrun/run.go", "start_line": 1, "end_line": 250}
{"path":"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\t// Fetch the full comment thread so the agent sees everything that's\r\n\t// been said on the issue/PR, not just the triggering event (which\r\n\t// only carries the latest comment, if any). A failure degrades to\r\n\t// no comments rather than failing the run: the agent can still do\r\n\t// its job, just without prior context.\r\n\tcomments, err := r.forgejo.ListIssueComments(ev.Owner, ev.Repo, ev.Index)\r\n\tif err != nil {\r\n\t\tlogger.Warn(\"fetch issue comments failed; agent will not see prior comments\", \"error\", err)\r\n\t\tcomments = nil\r\n\t}\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, comments)},\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","start_line":1,"end_line":250,"total_lines":333,"truncated":true}
Tool 🔧 read_file {"path": "internal/agentrun/context.go", "start_line": 1, "end_line": 200}
{"path": "internal/agentrun/context.go", "start_line": 1, "end_line": 200}
{"path":"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}
Thinking
Let's take a look at the rest of run.go (seedMessage), loop.go, and the tools (forgejo.go, registry.go).
Tool 🔧 read_file {"path": "internal/agentrun/run.go", "start_line": 250, "end_line": 333}
{"path": "internal/agentrun/run.go", "start_line": 250, "end_line": 333}
{"path":"internal/agentrun/run.go","content":"\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\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, comments []forgejo.IssueComment) 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\tvar commentsSection string\r\n\tif len(comments) \u003e 0 {\r\n\t\tvar b strings.Builder\r\n\t\tfmt.Fprintf(\u0026b, \"Comments (%d):\\n\\n\", len(comments))\r\n\r\n\t\tfor i, c := range comments {\r\n\t\t\tfmt.Fprintf(\u0026b, \"%d. %s (%s):\\n%s\\n\\n\", i+1, c.Author, c.Created.Format(time.RFC3339), c.Body)\r\n\t\t}\r\n\r\n\t\tcommentsSection = b.String()\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\\n%sFull event payload:\\n```json\\n%s\\n```\",\r\n\t\tev.Kind, ev.Owner, ev.Repo, instructionsSection, branch, defaultBranch, ev.Title, ev.Body, commentsSection, raw)\r\n}\r","start_line":250,"end_line":333,"total_lines":333,"truncated":false}
Tool 🔧 read_file {"path": "internal/agentrun/loop.go", "start_line": 1, "end_line": 120}
{"path": "internal/agentrun/loop.go", "start_line": 1, "end_line": 120}
{"path":"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}
Thinking
Next, let's take a look at the tools package (forgejo.go, registry.go, context.go) and the store package.
Tool 🔧 read_file {"path": "internal/tools/forgejo.go", "start_line": 1, "end_line": 200}
{"path": "internal/tools/forgejo.go", "start_line": 1, "end_line": 200}
{"path":"internal/tools/forgejo.go","content":"package tools\r\n\r\nimport (\r\n\t\"fmt\"\r\n\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n)\r\n\r\ntype commentParams struct {\r\n\tBody string `json:\"body\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"comment\",\r\n\t\t\"Post a comment on the issue or pull request that triggered this run.\")\r\n\r\n\ttool.AddStringProperty(\"body\", \"The comment text, in Markdown\", true)\r\n\r\n\tRegister(tool, comment)\r\n}\r\n\r\nfunc comment(ctx Context, params commentParams) (string, error) {\r\n\tif err := ctx.Forgejo().Comment(params.Body); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Comment posted\", nil\r\n}\r\n\r\ntype openPullRequestParams struct {\r\n\tHead  string `json:\"head\"`\r\n\tBase  string `json:\"base\"`\r\n\tTitle string `json:\"title\"`\r\n\tBody  string `json:\"body\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"open_pull_request\",\r\n\t\t\"Open a pull request from a branch you've pushed into the repository's default branch (or another base branch).\")\r\n\r\n\ttool.AddStringProperty(\"head\", \"The branch containing your changes (usually the run's own branch)\", true)\r\n\ttool.AddStringProperty(\"base\", \"The branch to merge into, e.g. the repository's default branch\", true)\r\n\ttool.AddStringProperty(\"title\", \"The pull request title\", true)\r\n\ttool.AddStringProperty(\"body\", \"The pull request description, in Markdown\", true)\r\n\r\n\tRegister(tool, openPullRequest)\r\n}\r\n\r\nfunc openPullRequest(ctx Context, params openPullRequestParams) (string, error) {\r\n\tif err := ctx.Forgejo().OpenPullRequest(params.Head, params.Base, params.Title, params.Body); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Pull request opened\", nil\r\n}\r\n\r\ntype requestReviewParams struct {\r\n\tReviewers []string `json:\"reviewers\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"request_review\",\r\n\t\t\"Request review of the current pull request from one or more Forgejo users.\")\r\n\r\n\ttool.AddStringArrayProperty(\"reviewers\", \"Usernames to request review from\", true)\r\n\r\n\tRegister(tool, requestReview)\r\n}\r\n\r\nfunc requestReview(ctx Context, params requestReviewParams) (string, error) {\r\n\tif err := ctx.Forgejo().RequestReview(params.Reviewers); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Review requested\", nil\r\n}\r\n\r\ntype labelParams struct {\r\n\tName string `json:\"name\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"add_label\",\r\n\t\t\"Add a label to the issue or pull request that triggered this run, creating it on the repo first if needed.\")\r\n\r\n\ttool.AddStringProperty(\"name\", \"The label name\", true)\r\n\r\n\tRegister(tool, addLabel)\r\n}\r\n\r\nfunc addLabel(ctx Context, params labelParams) (string, error) {\r\n\tif err := ctx.Forgejo().AddLabel(params.Name); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Label added\", nil\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"remove_label\",\r\n\t\t\"Remove a label from the issue or pull request that triggered this run.\")\r\n\r\n\ttool.AddStringProperty(\"name\", \"The label name\", true)\r\n\r\n\tRegister(tool, removeLabel)\r\n}\r\n\r\nfunc removeLabel(ctx Context, params labelParams) (string, error) {\r\n\tif err := ctx.Forgejo().RemoveLabel(params.Name); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Label removed\", nil\r\n}\r\n\r\ntype assignIssueParams struct {\r\n\tAgent string `json:\"agent\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"assign_issue\",\r\n\t\t\"Hand the issue or pull request that triggered this run off to another agent, by name. This assigns it to that agent's Forgejo account, which triggers their run. Use this when the task is better suited to another agent's described role than your own.\")\r\n\r\n\ttool.AddStringProperty(\"agent\", \"The name of the agent to hand this off to, e.g. one listed in the \\\"Other agents\\\" section of your system prompt\", true)\r\n\r\n\tRegister(tool, assignIssue)\r\n}\r\n\r\nfunc assignIssue(ctx Context, params assignIssueParams) (string, error) {\r\n\tif err := ctx.Forgejo().AssignIssue(params.Agent); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn fmt.Sprintf(\"Assigned to %s\", params.Agent), nil\r\n}\r\n\r\ntype noParams struct{}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"close_issue\",\r\n\t\t\"Close the issue or pull request that triggered this run.\")\r\n\r\n\tRegister(tool, closeIssue)\r\n}\r\n\r\nfunc closeIssue(ctx Context, _ noParams) (string, error) {\r\n\tif err := ctx.Forgejo().CloseIssue(); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Issue closed\", nil\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"reopen_issue\",\r\n\t\t\"Reopen the issue or pull request that triggered this run.\")\r\n\r\n\tRegister(tool, reopenIssue)\r\n}\r\n\r\nfunc reopenIssue(ctx Context, _ noParams) (string, error) {\r\n\tif err := ctx.Forgejo().ReopenIssue(); err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\treturn \"Issue reopened\", nil\r\n}\r","start_line":1,"end_line":175,"total_lines":175,"truncated":false}
Tool 🔧 read_file {"path": "internal/tools/context.go", "start_line": 1, "end_line": 100}
{"path": "internal/tools/context.go", "start_line": 1, "end_line": 100}
{"path":"internal/tools/context.go","content":"// Package tools is the registry of functions an agent's tool-calling\r\n// loop can invoke, ported from ../a/tools but retargeted at a Docker\r\n// container (via Exec) and the Forgejo issue/PR that triggered the run\r\n// (via Forgejo()) instead of a local sandboxed filesystem.\r\npackage tools\r\n\r\nimport \"context\"\r\n\r\n// Context is bound to a single agent run (job), unlike ../a's single\r\n// global Context, since multiple agents run concurrently in zoo.\r\ntype Context interface {\r\n\t// Exec runs command inside the run's container via `sh -c` and\r\n\t// returns combined stdout+stderr. The container never holds a\r\n\t// Forgejo credential, so this cannot reach the Forgejo API or\r\n\t// authenticate git operations against it — see Git.\r\n\tExec(ctx context.Context, command string) (string, error)\r\n\r\n\t// Git runs a git subcommand against the run's working tree on the\r\n\t// host, outside the container, so that operations needing a Forgejo\r\n\t// credential (currently just push) can authenticate without that\r\n\t// credential ever being written to disk where the container (and\r\n\t// anything run inside it via Exec) could read it.\r\n\tGit(ctx context.Context, subcommand string, args ...string) (string, error)\r\n\r\n\t// Forgejo returns the actions bound to the issue/PR that triggered\r\n\t// this run, so tools don't need to be told which repo/issue to act\r\n\t// on.\r\n\tForgejo() ForgejoActions\r\n}\r\n\r\n// ForgejoActions are the actions available to an agent against the\r\n// triggering issue/PR. Implemented by internal/agentrun, backed by\r\n// internal/forgejo.Client plus the run's owner/repo/issue.\r\ntype ForgejoActions interface {\r\n\tComment(body string) error\r\n\tOpenPullRequest(head, base, title, body string) error\r\n\tRequestReview(reviewers []string) error\r\n\tAddLabel(name string) error\r\n\tRemoveLabel(name string) error\r\n\tCloseIssue() error\r\n\tReopenIssue() error\r\n\r\n\t// AssignIssue hands the triggering issue/PR off to another agent by\r\n\t// their name (which doubles as their Forgejo username); the\r\n\t// resulting issue:assigned event triggers that agent's run.\r\n\tAssignIssue(agentName string) error\r\n}\r","start_line":1,"end_line":47,"total_lines":47,"truncated":false}
Thinking
Next, let's take a look at the forgejo client, store, and the remaining files.
Tool 🔧 read_file {"path": "internal/forgejo/client.go", "start_line": 1, "end_line": 300}
{"path": "internal/forgejo/client.go", "start_line": 1, "end_line": 300}
{"path":"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\t\"time\"\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// IssueComment is one comment on an issue or pull request, in the\r\n// shape zoo needs when briefing an agent: who said what, and when.\r\ntype IssueComment struct {\r\n\tAuthor  string\r\n\tBody    string\r\n\tCreated time.Time\r\n}\r\n\r\n// ListIssueComments fetches every comment on the given issue or pull\r\n// request, oldest first. PRs are issues under the hood in Forgejo, so\r\n// the same endpoint serves both. Pages are walked until exhausted so\r\n// the result isn't capped by the server's default page size.\r\nfunc (c *Client) ListIssueComments(owner, repo string, index int64) ([]IssueComment, error) {\r\n\tconst pageSize = 50\r\n\r\n\tvar all []*sdk.Comment\r\n\r\n\tfor page := 1; ; page++ {\r\n\t\tbatch, _, err := c.sdk.ListIssueComments(owner, repo, index, sdk.ListIssueCommentOptions{\r\n\t\t\tListOptions: sdk.ListOptions{Page: page, PageSize: pageSize},\r\n\t\t})\r\n\t\tif err != nil {\r\n\t\t\treturn nil, fmt.Errorf(\"list comments on %s/%s#%d (page %d): %w\", owner, repo, index, page, err)\r\n\t\t}\r\n\r\n\t\tall = append(all, batch...)\r\n\r\n\t\tif len(batch) \u003c pageSize {\r\n\t\t\tbreak\r\n\t\t}\r\n\t}\r\n\r\n\tout := make([]IssueComment, 0, len(all))\r\n\tfor _, cm := range all {\r\n\t\tauthor := \"\"\r\n\t\tif cm.Poster != nil {\r\n\t\t\tauthor = cm.Poster.UserName\r\n\t\t}\r\n\r\n\t\tout = append(out, IssueComment{Author: author, Body: cm.Body, Created: cm.Created})\r\n\t}\r\n\r\n\treturn out, 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","start_line":1,"end_line":300,"total_lines":305,"truncated":true}
Thinking
Let's check the rest of client.go, the store package, and the remaining files (roster.go, system.md, clone.go, docker.go, main.go, and the tests).
Tool 🔧 read_file {"path": "internal/forgejo/client.go", "start_line": 300, "end_line": 305}
{"path": "internal/forgejo/client.go", "start_line": 300, "end_line": 305}
{"path":"internal/forgejo/client.go","content":"\tif err != nil {\r\n\t\treturn \"\"\r\n\t}\r\n\r\n\treturn issue.Title\r\n}\r","start_line":300,"end_line":305,"total_lines":305,"truncated":false}
Tool 🔧 read_file {"path": "internal/store/store.go", "start_line": 1, "end_line": 250}
{"path": "internal/store/store.go", "start_line": 1, "end_line": 250}
{"path":"internal/store/store.go","content":"// Package store provides zoo's embedded persistence: the Forgejo\r\n// poll watermark, event dedup, and job/log history for the dashboard.\r\npackage store\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"database/sql\"\r\n\t_ \"embed\"\r\n\t\"fmt\"\r\n\t\"time\"\r\n\r\n\t_ \"modernc.org/sqlite\"\r\n)\r\n\r\n//go:embed schema.sql\r\nvar schema string\r\n\r\ntype Store struct {\r\n\tdb *sql.DB\r\n}\r\n\r\n// Open opens (creating if necessary) the SQLite database at path and\r\n// applies the schema.\r\nfunc Open(path string) (*Store, error) {\r\n\tdb, err := sql.Open(\"sqlite\", path)\r\n\tif err != nil {\r\n\t\treturn nil, fmt.Errorf(\"open database: %w\", err)\r\n\t}\r\n\r\n\t// SQLite only supports one writer at a time; serialize access rather\r\n\t// than fighting SQLITE_BUSY errors under concurrent agent runs.\r\n\tdb.SetMaxOpenConns(1)\r\n\r\n\tif _, err := db.Exec(schema); err != nil {\r\n\t\tdb.Close()\r\n\r\n\t\treturn nil, fmt.Errorf(\"apply schema: %w\", err)\r\n\t}\r\n\r\n\treturn \u0026Store{db: db}, nil\r\n}\r\n\r\nfunc (s *Store) Close() error {\r\n\treturn s.db.Close()\r\n}\r\n\r\n// MarkSeen records that event id has been processed. It returns false if\r\n// the event was already seen (by webhook or poll), so callers can dedupe\r\n// regardless of source.\r\nfunc (s *Store) MarkSeen(ctx context.Context, id string) (isNew bool, err error) {\r\n\tres, err := s.db.ExecContext(ctx,\r\n\t\t`INSERT OR IGNORE INTO seen_events (id, seen_at) VALUES (?, ?)`,\r\n\t\tid, time.Now().UTC())\r\n\tif err != nil {\r\n\t\treturn false, fmt.Errorf(\"mark seen: %w\", err)\r\n\t}\r\n\r\n\tn, err := res.RowsAffected()\r\n\tif err != nil {\r\n\t\treturn false, fmt.Errorf(\"mark seen: %w\", err)\r\n\t}\r\n\r\n\treturn n \u003e 0, nil\r\n}\r\n\r\n// SyncAssignees records assignees as the current assignee set for the\r\n// issue and returns the subset that wasn't already recorded — i.e. the\r\n// assignments that happened since the last call. Assignees that have\r\n// gone away are forgotten, so assigning the same user again later counts\r\n// as a fresh assignment rather than being swallowed as already-known.\r\n//\r\n// The poller uses this because listing issues only ever shows current\r\n// state: without it, every unrelated update to an assigned issue (a\r\n// comment, an edit) looks exactly like a new assignment.\r\nfunc (s *Store) SyncAssignees(ctx context.Context, issueID int64, assignees []string) (added []string, err error) {\r\n\ttx, err := s.db.BeginTx(ctx, nil)\r\n\tif err != nil {\r\n\t\treturn nil, fmt.Errorf(\"sync assignees: %w\", err)\r\n\t}\r\n\tdefer tx.Rollback()\r\n\r\n\trows, err := tx.QueryContext(ctx, `SELECT assignee FROM issue_assignees WHERE issue_id = ?`, issueID)\r\n\tif err != nil {\r\n\t\treturn nil, fmt.Errorf(\"sync assignees: %w\", err)\r\n\t}\r\n\r\n\tknown := map[string]bool{}\r\n\r\n\tfor rows.Next() {\r\n\t\tvar name string\r\n\r\n\t\tif err := rows.Scan(\u0026name); err != nil {\r\n\t\t\trows.Close()\r\n\r\n\t\t\treturn nil, fmt.Errorf(\"sync assignees: %w\", err)\r\n\t\t}\r\n\r\n\t\tknown[name] = true\r\n\t}\r\n\r\n\trows.Close()\r\n\r\n\tif err := rows.Err(); err != nil {\r\n\t\treturn nil, fmt.Errorf(\"sync assignees: %w\", err)\r\n\t}\r\n\r\n\tcurrent := map[string]bool{}\r\n\r\n\tfor _, name := range assignees {\r\n\t\tif current[name] {\r\n\t\t\tcontinue\r\n\t\t}\r\n\r\n\t\tcurrent[name] = true\r\n\r\n\t\tif !known[name] {\r\n\t\t\tadded = append(added, name)\r\n\t\t}\r\n\t}\r\n\r\n\tfor name := range known {\r\n\t\tif current[name] {\r\n\t\t\tcontinue\r\n\t\t}\r\n\r\n\t\tif _, err := tx.ExecContext(ctx, `DELETE FROM issue_assignees WHERE issue_id = ? AND assignee = ?`, issueID, name); err != nil {\r\n\t\t\treturn nil, fmt.Errorf(\"sync assignees: %w\", err)\r\n\t\t}\r\n\t}\r\n\r\n\tfor _, name := range added {\r\n\t\tif _, err := tx.ExecContext(ctx,\r\n\t\t\t`INSERT OR IGNORE INTO issue_assignees (issue_id, assignee) VALUES (?, ?)`,\r\n\t\t\tissueID, name); err != nil {\r\n\t\t\treturn nil, fmt.Errorf(\"sync assignees: %w\", err)\r\n\t\t}\r\n\t}\r\n\r\n\tif err := tx.Commit(); err != nil {\r\n\t\treturn nil, fmt.Errorf(\"sync assignees: %w\", err)\r\n\t}\r\n\r\n\treturn added, nil\r\n}\r\n\r\n// GetWatermark returns the stored value for key, or \"\" if unset.\r\nfunc (s *Store) GetWatermark(ctx context.Context, key string) (string, error) {\r\n\tvar value string\r\n\r\n\terr := s.db.QueryRowContext(ctx, `SELECT value FROM watermark WHERE key = ?`, key).Scan(\u0026value)\r\n\tif err == sql.ErrNoRows {\r\n\t\treturn \"\", nil\r\n\t}\r\n\tif err != nil {\r\n\t\treturn \"\", fmt.Errorf(\"get watermark: %w\", err)\r\n\t}\r\n\r\n\treturn value, nil\r\n}\r\n\r\n// SetWatermark stores value for key, overwriting any previous value.\r\nfunc (s *Store) SetWatermark(ctx context.Context, key, value string) error {\r\n\t_, err := s.db.ExecContext(ctx,\r\n\t\t`INSERT INTO watermark (key, value) VALUES (?, ?)\r\n\t\t ON CONFLICT (key) DO UPDATE SET value = excluded.value`,\r\n\t\tkey, value)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"set watermark: %w\", err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\ntype JobStatus string\r\n\r\nconst (\r\n\tJobPending   JobStatus = \"pending\"\r\n\tJobRunning   JobStatus = \"running\"\r\n\tJobSucceeded JobStatus = \"succeeded\"\r\n\tJobFailed    JobStatus = \"failed\"\r\n\tJobTimedOut  JobStatus = \"timed_out\"\r\n)\r\n\r\ntype Job struct {\r\n\tID         string\r\n\tEventKind  string\r\n\tAgent      string\r\n\tOwner      string\r\n\tRepo       string\r\n\tIssueIndex int64\r\n\tTitle      string\r\n\tStatus     JobStatus\r\n\tCreatedAt  time.Time\r\n\tStartedAt  sql.NullTime\r\n\tFinishedAt sql.NullTime\r\n\tError      string\r\n}\r\n\r\n// CreateJob inserts a new job in JobPending status.\r\nfunc (s *Store) CreateJob(ctx context.Context, j Job) error {\r\n\tif j.Status == \"\" {\r\n\t\tj.Status = JobPending\r\n\t}\r\n\r\n\tif j.CreatedAt.IsZero() {\r\n\t\tj.CreatedAt = time.Now().UTC()\r\n\t}\r\n\r\n\t_, err := s.db.ExecContext(ctx,\r\n\t\t`INSERT INTO jobs (id, event_kind, agent, owner, repo, issue_index, status, created_at, error)\r\n\t\t VALUES (?, ?, ?, ?, ?, ?, ?, ?, '')`,\r\n\t\tj.ID, j.EventKind, j.Agent, j.Owner, j.Repo, j.IssueIndex, j.Status, j.CreatedAt)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"create job: %w\", err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// ReapOrphanedJobs transitions any job still in JobRunning status to\r\n// JobFailed. It's meant to be called once at daemon startup: a job left\r\n// \"running\" in the database can only be one abandoned by a previous\r\n// process instance that died (crash, OOM kill, host restart) before it\r\n// could record a terminal status — no process in the current instance is\r\n// running it. Returns the number of jobs reaped.\r\nfunc (s *Store) ReapOrphanedJobs(ctx context.Context) (int64, error) {\r\n\tres, err := s.db.ExecContext(ctx,\r\n\t\t`UPDATE jobs SET status = ?, finished_at = ?, error = ? WHERE status = ?`,\r\n\t\tJobFailed, time.Now().UTC(), \"orphaned: process restarted while job was running\", JobRunning)\r\n\tif err != nil {\r\n\t\treturn 0, fmt.Errorf(\"reap orphaned jobs: %w\", err)\r\n\t}\r\n\r\n\tn, err := res.RowsAffected()\r\n\tif err != nil {\r\n\t\treturn 0, fmt.Errorf(\"reap orphaned jobs: %w\", err)\r\n\t}\r\n\r\n\treturn n, nil\r\n}\r\n\r\n// MarkJobStarted transitions a job to JobRunning and records the start time.\r\nfunc (s *Store) MarkJobStarted(ctx context.Context, id string) error {\r\n\t_, err := s.db.ExecContext(ctx,\r\n\t\t`UPDATE jobs SET status = ?, started_at = ? WHERE id = ?`,\r\n\t\tJobRunning, time.Now().UTC(), id)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"mark job started: %w\", err)\r\n\t}\r\n\r","start_line":1,"end_line":250,"total_lines":360,"truncated":true}
Thinking
Let's check the remaining files: main.go, roster.go, system.md, and the tests. Also, let's check the SDK to understand the review API (ListReviews, ListReviewComments, etc.).
Tool 🔧 read_file {"path": "cmd/zoo/main.go", "start_line": 1, "end_line": 200}
{"path": "cmd/zoo/main.go", "start_line": 1, "end_line": 200}
{"path":"cmd/zoo/main.go","content":"// Command zoo runs the daemon: it watches a Forgejo instance for\r\n// issue/PR events, dispatches them to configured AI agents running in\r\n// Docker containers, and serves a small dashboard over the result.\r\npackage main\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"flag\"\r\n\t\"fmt\"\r\n\t\"log/slog\"\r\n\t\"net/http\"\r\n\t\"os\"\r\n\t\"os/signal\"\r\n\t\"syscall\"\r\n\t\"time\"\r\n\r\n\t\"github.com/abrander/zoo/internal/agentrun\"\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/scheduler\"\r\n\t\"github.com/abrander/zoo/internal/store\"\r\n\t\"github.com/abrander/zoo/internal/web\"\r\n)\r\n\r\nfunc main() {\r\n\tif err := run(); err != nil {\r\n\t\tfmt.Fprintln(os.Stderr, \"zoo:\", err)\r\n\r\n\t\tos.Exit(1)\r\n\t}\r\n}\r\n\r\nfunc run() error {\r\n\tvar (\r\n\t\tconfigPath    = flag.String(\"config\", \"zoo.hcl\", \"path to the zoo.hcl config file\")\r\n\t\tdbPath        = flag.String(\"db\", \"zoo.db\", \"path to the sqlite state database\")\r\n\t\tlisten        = flag.String(\"listen\", \":8080\", \"address to serve webhooks and the dashboard on\")\r\n\t\trunTimeout    = flag.Duration(\"run-timeout\", agentrun.DefaultTimeout, \"wall-clock timeout for a single agent run\")\r\n\t\tkeepOnFailure = flag.Bool(\"keep-on-failure\", false, \"keep the container and clone around after a failed run, for debugging\")\r\n\t)\r\n\r\n\tflag.Parse()\r\n\r\n\tlogger := slog.New(slog.NewTextHandler(os.Stderr, nil))\r\n\r\n\tcfg, err := config.Load(*configPath)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"load config: %w\", err)\r\n\t}\r\n\r\n\tst, err := store.Open(*dbPath)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"open store: %w\", err)\r\n\t}\r\n\tdefer st.Close()\r\n\r\n\tif n, err := st.ReapOrphanedJobs(context.Background()); err != nil {\r\n\t\tlogger.Warn(\"failed to reap orphaned jobs\", \"error\", err)\r\n\t} else if n \u003e 0 {\r\n\t\tlogger.Warn(\"reaped jobs left running by a previous process instance\", \"count\", n)\r\n\t}\r\n\r\n\tfgClient, err := forgejo.NewClient(cfg.Forgejo)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"forgejo client: %w\", err)\r\n\t}\r\n\r\n\twatcher := forgejo.NewWatcher(fgClient, st, cfg.Forgejo.WebhookSecret, logger.With(\"component\", \"forgejo\"))\r\n\r\n\thub := livelog.NewHub()\r\n\r\n\trunner, err := agentrun.NewRunner(cfg, fgClient, st, hub, logger.With(\"component\", \"agentrun\"), *runTimeout, *keepOnFailure)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"agent runner: %w\", err)\r\n\t}\r\n\r\n\tsched := scheduler.New(cfg, st, fgClient, runner, hub, logger.With(\"component\", \"scheduler\"))\r\n\tdashboard := web.New(cfg, st, hub, fgClient)\r\n\r\n\tmux := http.NewServeMux()\r\n\r\n\tmux.Handle(\"/webhooks/forgejo\", watcher.Handler())\r\n\r\n\tmux.HandleFunc(\"/healthz\", func(w http.ResponseWriter, r *http.Request) {\r\n\t\tw.WriteHeader(http.StatusOK)\r\n\t\tw.Write([]byte(\"ok\"))\r\n\t})\r\n\r\n\tmux.Handle(\"/\", dashboard.Handler())\r\n\r\n\tserver := \u0026http.Server{Addr: *listen, Handler: mux}\r\n\r\n\tctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)\r\n\tdefer stop()\r\n\r\n\tgo watcher.Run(ctx)\r\n\tgo sched.Run(ctx, watcher.Events())\r\n\r\n\tserveErr := make(chan error, 1)\r\n\r\n\tgo func() {\r\n\t\tlogger.Info(\"listening\", \"addr\", *listen)\r\n\t\tif err := server.ListenAndServe(); err != nil \u0026\u0026 err != http.ErrServerClosed {\r\n\t\t\tserveErr \u003c- err\r\n\t\t}\r\n\t}()\r\n\r\n\tselect {\r\n\tcase \u003c-ctx.Done():\r\n\t\tlogger.Info(\"shutting down\")\r\n\r\n\tcase err := \u003c-serveErr:\r\n\t\tstop()\r\n\r\n\t\tlogger.Error(\"http server failed\", \"error\", err)\r\n\t}\r\n\r\n\tshutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\r\n\tdefer cancel()\r\n\r\n\tif err := server.Shutdown(shutdownCtx); err != nil {\r\n\t\tlogger.Warn(\"http server shutdown\", \"error\", err)\r\n\t}\r\n\r\n\tif err := sched.Shutdown(shutdownCtx); err != nil {\r\n\t\tlogger.Warn(\"scheduler did not drain in time\", \"error\", err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r","start_line":1,"end_line":131,"total_lines":131,"truncated":false}
Tool 🔧 read_file {"path": "internal/agentrun/roster.go", "start_line": 1, "end_line": 150}
{"path": "internal/agentrun/roster.go", "start_line": 1, "end_line": 150}
{"path":"internal/agentrun/roster.go","content":"package agentrun\r\n\r\nimport (\r\n\t\"fmt\"\r\n\t\"log/slog\"\r\n\t\"strings\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n\t\"github.com/abrander/zoo/internal/forgejo\"\r\n)\r\n\r\n// agentInfo pairs a configured agent with its Forgejo profile: the bio\r\n// (what its account says it does) and the name/email used to set git\r\n// commit authorship inside its container.\r\ntype agentInfo struct {\r\n\tName        string\r\n\tDescription string\r\n\tFullName    string\r\n\tEmail       string\r\n}\r\n\r\n// buildRoster fetches every configured agent's Forgejo profile (agent\r\n// config labels double as Forgejo usernames). A per-agent lookup failure\r\n// is logged and that agent is included with an empty profile rather than\r\n// failing the whole run — one missing/unreachable profile shouldn't\r\n// block someone else's job.\r\nfunc buildRoster(fg *forgejo.Client, agents []config.Agent, logger *slog.Logger) []agentInfo {\r\n\troster := make([]agentInfo, 0, len(agents))\r\n\r\n\tfor _, a := range agents {\r\n\t\tprofile, err := fg.AgentProfile(a.Name)\r\n\t\tif err != nil {\r\n\t\t\tlogger.Warn(\"failed to fetch agent profile from forgejo\", \"agent\", a.Name, \"error\", err)\r\n\t\t}\r\n\r\n\t\troster = append(roster, agentInfo{Name: a.Name, Description: profile.Description, FullName: profile.FullName, Email: profile.Email})\r\n\t}\r\n\r\n\treturn roster\r\n}\r\n\r\n// gitIdentity returns the git commit author name/email to configure\r\n// inside self's container, from its Forgejo profile, falling back to\r\n// its agent name and a synthetic zoo.local address for whichever fields\r\n// its profile doesn't set.\r\nfunc gitIdentity(self string, roster []agentInfo) (name, email string) {\r\n\tfor _, a := range roster {\r\n\t\tif a.Name == self {\r\n\t\t\tname, email = a.FullName, a.Email\r\n\t\t\tbreak\r\n\t\t}\r\n\t}\r\n\r\n\tif name == \"\" {\r\n\t\tname = self\r\n\t}\r\n\r\n\tif email == \"\" {\r\n\t\temail = self + \"@zoo.local\"\r\n\t}\r\n\r\n\treturn name, email\r\n}\r\n\r\n// identitySection renders the \"who am I / who else is there\" prompt\r\n// section injected after the static system prompt: the agent's own\r\n// Forgejo profile description, and everyone else's, so it can decide\r\n// when a task is better handed off via the assign_issue tool.\r\nfunc identitySection(self string, roster []agentInfo) string {\r\n\tvar b strings.Builder\r\n\r\n\tvar selfDesc string\r\n\tvar others []agentInfo\r\n\r\n\tfor _, a := range roster {\r\n\t\tif a.Name == self {\r\n\t\t\tselfDesc = a.Description\r\n\t\t} else {\r\n\t\t\tothers = append(others, a)\r\n\t\t}\r\n\t}\r\n\r\n\tfmt.Fprintf(\u0026b, \"\\n\\n## Your identity\\n\\nYou are the agent %q.\", self)\r\n\r\n\tif selfDesc != \"\" {\r\n\t\tfmt.Fprintf(\u0026b, \" Your role, from your Forgejo profile description: %s\", selfDesc)\r\n\t} else {\r\n\t\tb.WriteString(\" Your Forgejo profile has no description set.\")\r\n\t}\r\n\r\n\tif len(others) \u003e 0 {\r\n\t\tb.WriteString(\"\\n\\n## Other agents\\n\\nIf this task is a better fit for another agent's role, use the assign_issue tool with their name to hand it off (this reassigns the issue/PR on Forgejo, which triggers their run):\\n\")\r\n\r\n\t\tfor _, a := range others {\r\n\t\t\tdesc := a.Description\r\n\t\t\tif desc == \"\" {\r\n\t\t\t\tdesc = \"(no description set)\"\r\n\t\t\t}\r\n\r\n\t\t\tfmt.Fprintf(\u0026b, \"### %s\\n\\n%s\\n\\n\", a.Name, desc)\r\n\t\t}\r\n\t}\r\n\r\n\treturn b.String()\r\n}\r","start_line":1,"end_line":105,"total_lines":105,"truncated":false}
Thinking
Next, let's check the SDK to understand the review API. I'll look for the SDK package inside the Go module cache or vendor directory.
Tool 🔧 bash {"command": "cd /project && cat go.mod && ls $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/ 2>/dev/null || find / -name \"forgejo-sdk\" -type d 2>/dev/null | head -5"}
{"command": "cd /project && cat go.mod && ls $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/ 2>/dev/null || find / -name \"forgejo-sdk\" -type d 2>/dev/null | head -5"}
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
)
Thinking
The SDK isn't in the module cache yet. Let me try running `go mod download` to fetch it, and then investigate the review-related API.
Tool 🔧 bash {"command": "cd /project && go mod download 2>&1 | tail -3; ls $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/ 2>/dev/null", "timeout": 300}
{"command": "cd /project && go mod download 2>&1 | tail -3; ls $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/ 2>/dev/null", "timeout": 300}
v2@v2.2.0
Thinking
Let's take a look at the SDK's review-related API.
Tool 🔧 bash {"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && ls | grep -i review; echo ---; grep -l \"Review\" *.go | head -20"}
{"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && ls | grep -i review; echo ---; grep -l \"Review\" *.go | head -20"}
pull_review.go
pull_review_test.go
---
pull_review.go
pull_review_test.go
repo_branch_protection.go
repo_collaborator.go
repo_collaborator_test.go
Thinking
Let's take a look at the SDK's pull_review.go to understand the review API.
Tool 🔧 read_file {"path": "/root/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/pull_review.go", "start_line": 1, "end_line": 200}
{"path": "/root/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/pull_review.go", "start_line": 1, "end_line": 200}
ERROR CALLING TOOL: read "/root/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/pull_review.go": command exited with status 1
Thinking
Let's use bash to read the SDK file.
Tool 🔧 bash {"command": "cat $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/pull_review.go"}
{"command": "cat $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/pull_review.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 (
	"bytes"
	"encoding/json"
	"fmt"
	"net/url"
	"strings"
	"time"
)

// ReviewStateType review state type
type ReviewStateType string

const (
	// ReviewStateApproved pr is approved
	ReviewStateApproved ReviewStateType = "APPROVED"
	// ReviewStatePending pr state is pending
	ReviewStatePending ReviewStateType = "PENDING"
	// ReviewStateComment is a comment review
	ReviewStateComment ReviewStateType = "COMMENT"
	// ReviewStateRequestChanges changes for pr are requested
	ReviewStateRequestChanges ReviewStateType = "REQUEST_CHANGES"
	// ReviewStateRequestReview review is requested from user
	ReviewStateRequestReview ReviewStateType = "REQUEST_REVIEW"
	// ReviewStateUnknown state of pr is unknown
	ReviewStateUnknown ReviewStateType = ""
)

// PullReview represents a pull request review
type PullReview struct {
	ID           int64           `json:"id"`
	Reviewer     *User           `json:"user"`
	ReviewerTeam *Team           `json:"team"`
	State        ReviewStateType `json:"state"`
	Body         string          `json:"body"`
	CommitID     string          `json:"commit_id"`
	// Stale indicates if the pull has changed since the review
	Stale bool `json:"stale"`
	// Official indicates if the review counts towards the required approval limit, if PR base is a protected branch
	Official          bool      `json:"official"`
	Dismissed         bool      `json:"dismissed"`
	CodeCommentsCount int       `json:"comments_count"`
	Submitted         time.Time `json:"submitted_at"`

	HTMLURL     string `json:"html_url"`
	HTMLPullURL string `json:"pull_request_url"`
}

// PullReviewComment represents a comment on a pull request review
type PullReviewComment struct {
	ID       int64  `json:"id"`
	Body     string `json:"body"`
	Reviewer *User  `json:"user"`
	ReviewID int64  `json:"pull_request_review_id"`
	Resolver *User  `json:"resolver"`

	Created time.Time `json:"created_at"`
	Updated time.Time `json:"updated_at"`

	Path         string `json:"path"`
	CommitID     string `json:"commit_id"`
	OrigCommitID string `json:"original_commit_id"`
	DiffHunk     string `json:"diff_hunk"`
	LineNum      uint64 `json:"position"`
	OldLineNum   uint64 `json:"original_position"`

	HTMLURL     string `json:"html_url"`
	HTMLPullURL string `json:"pull_request_url"`
}

// CreatePullReviewOptions are options to create a pull review
type CreatePullReviewOptions struct {
	State    ReviewStateType           `json:"event"`
	Body     string                    `json:"body"`
	CommitID string                    `json:"commit_id"`
	Comments []CreatePullReviewComment `json:"comments"`
}

// CreatePullReviewComment represent a review comment for creation api
type CreatePullReviewComment struct {
	// the tree path
	Path string `json:"path"`
	Body string `json:"body"`
	// if comment to old file line or 0
	OldLineNum int64 `json:"old_position"`
	// if comment to new file line or 0
	NewLineNum int64 `json:"new_position"`
}

// SubmitPullReviewOptions are options to submit a pending pull review
type SubmitPullReviewOptions struct {
	State ReviewStateType `json:"event"`
	Body  string          `json:"body"`
}

// DismissPullReviewOptions are options to dismiss a pull review
type DismissPullReviewOptions struct {
	Message string `json:"message"`
}

// PullReviewRequestOptions are options to add or remove pull review requests
type PullReviewRequestOptions struct {
	Reviewers     []string `json:"reviewers"`
	TeamReviewers []string `json:"team_reviewers"`
}

// ListPullReviewsOptions options for listing PullReviews
type ListPullReviewsOptions struct {
	ListOptions
}

// Validate the CreatePullReviewOptions struct
func (opt CreatePullReviewOptions) Validate() error {
	if opt.State != ReviewStateApproved && len(opt.Comments) == 0 && len(strings.TrimSpace(opt.Body)) == 0 {
		return fmt.Errorf("body is empty")
	}
	for i := range opt.Comments {
		if err := opt.Comments[i].Validate(); err != nil {
			return err
		}
	}
	return nil
}

// Validate the SubmitPullReviewOptions struct
func (opt SubmitPullReviewOptions) Validate() error {
	if opt.State != ReviewStateApproved && len(strings.TrimSpace(opt.Body)) == 0 {
		return fmt.Errorf("body is empty")
	}
	return nil
}

// Validate the CreatePullReviewComment struct
func (opt CreatePullReviewComment) Validate() error {
	if len(strings.TrimSpace(opt.Body)) == 0 {
		return fmt.Errorf("body is empty")
	}
	if opt.NewLineNum != 0 && opt.OldLineNum != 0 {
		return fmt.Errorf("old and new line num are set, cant identify the code comment position")
	}
	return nil
}

// ListPullReviews lists all reviews of a pull request
func (c *Client) ListPullReviews(owner, repo string, index int64, opt ListPullReviewsOptions) ([]*PullReview, *Response, error) {
	if err := escapeValidatePathSegments(&owner, &repo); err != nil {
		return nil, nil, err
	}
	if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
		return nil, nil, err
	}
	opt.setDefaults()
	rs := make([]*PullReview, 0, opt.PageSize)

	link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", owner, repo, index))
	link.RawQuery = opt.ListOptions.getURLQuery().Encode()

	resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &rs)
	return rs, resp, err
}

// GetPullReview gets a specific review of a pull request
func (c *Client) GetPullReview(owner, repo string, index, id int64) (*PullReview, *Response, error) {
	if err := escapeValidatePathSegments(&owner, &repo); err != nil {
		return nil, nil, err
	}
	if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
		return nil, nil, err
	}

	r := new(PullReview)
	resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d", owner, repo, index, id), jsonHeader, nil, &r)
	return r, resp, err
}

// ListPullReviewComments lists all comments of a pull request review
func (c *Client) ListPullReviewComments(owner, repo string, index, id int64) ([]*PullReviewComment, *Response, error) {
	if err := escapeValidatePathSegments(&owner, &repo); err != nil {
		return nil, nil, err
	}
	if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
		return nil, nil, err
	}
	rcl := make([]*PullReviewComment, 0, 4)
	link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d/comments", owner, repo, index, id))

	resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &rcl)
	return rcl, resp, err
}

// DeletePullReview delete a specific review from a pull request
func (c *Client) DeletePullReview(owner, repo string, index, id int64) (*Response, error) {
	if err := escapeValidatePathSegments(&owner, &repo); err != nil {
		return nil, err
	}
	if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
		return nil, err
	}

	_, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d", owner, repo, index, id), jsonHeader, nil)
	return resp, err
}

// CreatePullReview create a review to an pull request
func (c *Client) CreatePullReview(owner, repo string, index int64, opt CreatePullReviewOptions) (*PullReview, *Response, error) {
	if err := escapeValidatePathSegments(&owner, &repo); err != nil {
		return nil, nil, err
	}
	if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
		return nil, nil, err
	}
	if err := opt.Validate(); err != nil {
		return nil, nil, err
	}
	body, err := json.Marshal(&opt)
	if err != nil {
		return nil, nil, err
	}

	r := new(PullReview)
	resp, err := c.getParsedResponse("POST",
		fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", owner, repo, index),
		jsonHeader, bytes.NewReader(body), r)
	return r, resp, err
}

// SubmitPullReview submit a pending review to an pull request
func (c *Client) SubmitPullReview(owner, repo string, index, id int64, opt SubmitPullReviewOptions) (*PullReview, *Response, error) {
	if err := escapeValidatePathSegments(&owner, &repo); err != nil {
		return nil, nil, err
	}
	if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
		return nil, nil, err
	}
	if err := opt.Validate(); err != nil {
		return nil, nil, err
	}
	body, err := json.Marshal(&opt)
	if err != nil {
		return nil, nil, err
	}

	r := new(PullReview)
	resp, err := c.getParsedResponse("POST",
		fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d", owner, repo, index, id),
		jsonHeader, bytes.NewReader(body), r)
	return r, resp, err
}

// CreateReviewRequests create review requests to an pull request
func (c *Client) CreateReviewRequests(owner, repo string, index int64, opt PullReviewRequestOptions) (*Response, error) {
	if err := escapeValidatePathSegments(&owner, &repo); err != nil {
		return nil, err
	}
	if err := c.checkServerVersionGreaterThanOrEqual(version1_14_0); err != nil {
		return nil, err
	}
	body, err := json.Marshal(&opt)
	if err != nil {
		return nil, err
	}

	_, resp, err := c.getResponse("POST",
		fmt.Sprintf("/repos/%s/%s/pulls/%d/requested_reviewers", owner, repo, index),
		jsonHeader, bytes.NewReader(body))
	return resp, err
}

// DeleteReviewRequests delete review requests to an pull request
func (c *Client) DeleteReviewRequests(owner, repo string, index int64, opt PullReviewRequestOptions) (*Response, error) {
	if err := escapeValidatePathSegments(&owner, &repo); err != nil {
		return nil, err
	}
	if err := c.checkServerVersionGreaterThanOrEqual(version1_14_0); err != nil {
		return nil, err
	}
	body, err := json.Marshal(&opt)
	if err != nil {
		return nil, err
	}

	_, resp, err := c.getResponse("DELETE",
		fmt.Sprintf("/repos/%s/%s/pulls/%d/requested_reviewers", owner, repo, index),
		jsonHeader, bytes.NewReader(body))
	return resp, err
}

// DismissPullReview dismiss a review for a pull request
func (c *Client) DismissPullReview(owner, repo string, index, id int64, opt DismissPullReviewOptions) (*Response, error) {
	if err := escapeValidatePathSegments(&owner, &repo); err != nil {
		return nil, err
	}
	if err := c.checkServerVersionGreaterThanOrEqual(version1_14_0); err != nil {
		return nil, err
	}
	body, err := json.Marshal(&opt)
	if err != nil {
		return nil, err
	}

	_, resp, err := c.getResponse("POST",
		fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d/dismissals", owner, repo, index, id),
		jsonHeader, bytes.NewReader(body))
	return resp, err
}

// UnDismissPullReview cancel to dismiss a review for a pull request
func (c *Client) UnDismissPullReview(owner, repo string, index, id int64) (*Response, error) {
	if err := escapeValidatePathSegments(&owner, &repo); err != nil {
		return nil, err
	}
	if err := c.checkServerVersionGreaterThanOrEqual(version1_14_0); err != nil {
		return nil, err
	}

	_, resp, err := c.getResponse("POST",
		fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d/undismissals", owner, repo, index, id),
		jsonHeader, nil)
	return resp, err
}
Thinking
Now I understand the SDK's review API. Let me check what kind of webhooks Forgejo sends for PR reviews. Forgejo has a `pull_request_review` webhook event with actions like `submitted`, `edited`, `dismissed`. Let me check the SDK's webhook types. Actually, Forgejo's webhook events include: - `pull_request` (opened, closed, reopened, edited, assigned, unassigned, labeled, unlabeled, synchronize, converted_to_draft, ready_for_review, approved, review_dismissed, review_request, review_request_removed) - `pull_request_review` (submitted, edited, dismissed) - `pull_request_review_comment` (created, edited, deleted) So, for "PR reviews", the following need to be handled: 1. New review submission (approved, changes requested, comment) → trigger an agent run to respond to the review 2. Possibly, review comments (inline comments) as well Let me check the SDK's webhook type definitions.
Tool 🔧 bash {"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && grep -rn \"pull_request_review\" --include=\"*.go\" | grep -v test | head -20; echo ---; grep -rn \"X-Gitea-Event\\|X-Forgejo-Event\" --include=\"*.go\" | head"}
{"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && grep -rn \"pull_request_review\" --include=\"*.go\" | grep -v test | head -20; echo ---; grep -rn \"X-Gitea-Event\\|X-Forgejo-Event\" --include=\"*.go\" | head"}
pull_review.go:63:	ReviewID int64  `json:"pull_request_review_id"`
---
Thinking
The SDK doesn't have a webhook type for pull_request_review. So we'll need to define the payload struct ourselves. Let's check what the `pull_request_review` webhook payload from Forgejo looks like. According to the Forgejo/Gitea documentation, the `pull_request_review` webhook payload looks like this: ```json { "action": "submitted", "review": { "id": 1, "user": { ... }, "state": "APPROVED", "body": "LGTM", "commit_id": "...", "submitted_at": "..." }, "pull_request": { ... }, "repository": { ... } } ``` And `pull_request_review_comment`: ```json { "action": "created", "comment": { ... PullReviewComment ... }, "pull_request": { ... }, "repository": { ... } } ``` Let's check the Gitea source to confirm. The Gitea webhook payload for pull_request_review is: ```go type PullRequestReviewPayload struct { Action string `json:"action"` Review *PullReview `json:"review"` PullRequest *PullRequest `json:"pull_request"` Repository *Repository `json:"repository"` } ``` And for pull_request_review_comment: ```go type PullRequestReviewCommentPayload struct { Action string `json:"action"` Comment *PullReviewComment `json:"comment"` PullRequest *PullRequest `json:"pull_request"` Repository *Repository `json:"repository"` } ``` Let's verify this by checking the Gitea source on the web... no, we can't access the web. Let's check if there's a local copy of gitea. Actually, let's think about what we know about the Gitea API: From the Gitea source code (models/asymkey, or services/webhook), the payload is defined in `models/webhook`... actually, in Gitea, the webhook payload is constructed in `services/webhook/`: - `pull_request_review.go`: ```go func (w *PullRequestReview) WebhookPayload() (string, error) { ``` Hmm, I remember the Gitea API documentation: https://docs.gitea.com/next/development/api/webhooks The `pull_request_review` event payload: ```json { "action": "submitted", "review": { "id": 1, "user": {}, "state": "APPROVED", "body": "", "commit_id": "", "submitted_at": "" }, "pull_request": {}, "repository": {} } ``` Yes, this matches. And `pull_request_review_comment`: ```json { "action": "created", "comment": {}, "pull_request": {}, "repository": {} } ``` Both are correct. Now, the issue says: "We need the agent to respond to and react to PR reviews. Define the workflow and implement the Zoo side." So the task is: 1. Define a workflow: when a PR review is submitted (or a review comment is created), Zoo triggers an agent run so that the agent can respond to the review (e.g., address the requested changes, reply to the review comment, push a fix, etc.). 2. Implement the Zoo side: - New event kinds: `pr:review` (and possibly `pr:review:comment`) - Webhook handling for the `pull_request_review` event (and possibly `pull_request_review_comment`) - Polling fallback: poll PR reviews since the watermark - Config: new event kinds in zoo.hcl - Seed message: include review details (state, body, inline comments) in the agent's briefing - Possibly new tools for the agent to react to reviews: e.g., `resolve_review_comment`, `submit_review`? Hmm. Let's think about what "respond to and react to PR reviews" means: - Respond: the agent posts a comment on the PR / review thread explaining how it addressed the feedback. - React: the agent makes code changes in response to the review feedback (pushes a new commit), and possibly resolves the review comment. So the Zoo-side implementation is: 1. New event kinds: - `pr:review` — when a review is submitted on a PR (approved/changes_requested/comment). - Possibly `pr:review:comment` — when an inline review comment is created. 2. Webhook: handle the `pull_request_review` event kind with action `submitted` (and possibly `edited`). Also handle `pull_request_review_comment` with action `created`. 3. Polling fallback: list PRs updated since the watermark, and for each PR, list reviews since the watermark, dispatching events for new reviews. 4. Event struct: add fields for review info: `ReviewState`, `ReviewBody`, `ReviewID`, and possibly review comments. 5. Seed message: for review events, include the review's state/body/inline comments in the briefing. 6. Config: add `pr:review` to staticEventKinds, and add a sample event block in zoo.hcl.example. 7. Tools: the agent needs to be able to "react" to the review. Options: - `resolve_review_comment` — resolve an inline review comment (once addressed). - `submit_review` — submit a review on the PR (approve/request changes/comment). - `dismiss_review`? Hmm, let's think about what's most useful and in scope. The issue says "respond to and react to PR reviews." The agent's main actions: - Make code changes (already possible: edit files, git commit, git push). - Respond with a comment (already possible: `comment` tool). - Resolve review comments (new tool: `resolve_review_comment`). - Possibly submit its own review (new tool: `submit_review`). Let's check the SDK for resolving review comments. Actually, let's check what the SDK provides for resolving review comments.
Tool 🔧 bash {"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && grep -rn \"Resolve\\|resolve\" --include=\"*.go\" | grep -v test | head -20"}
{"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && grep -rn \"Resolve\\|resolve\" --include=\"*.go\" | grep -v test | head -20"}
pull_review.go:64:	Resolver *User  `json:"resolver"`
issue_milestone.go:82:		m, resp, err := c.resolveMilestoneByName(owner, repo, name)
issue_milestone.go:173:		m, _, err := c.resolveMilestoneByName(owner, repo, name)
issue_milestone.go:207:		m, _, err := c.resolveMilestoneByName(owner, repo, name)
issue_milestone.go:220:// resolveMilestoneByName is a fallback method to find milestone id by name
issue_milestone.go:221:func (c *Client) resolveMilestoneByName(owner, repo, name string) (*Milestone, *Response, error) {
pull.go:369:// which stays available to resolve the commit sha. This is fixed for forgejo >= 1.14.0
pull.go:379:			return fmt.Errorf("unable to resolve PR ref '%s'", pr.Head.Ref)
repo_file.go:125:// it optional can resolve lfs pointers and server the file instead
repo_file.go:127:func (c *Client) GetFile(owner, repo, ref, filepath string, resolveLFS ...bool) ([]byte, *Response, error) {
repo_file.go:128:	reader, resp, err := c.GetFileReader(owner, repo, ref, filepath, resolveLFS...)
repo_file.go:143:// it optional can resolve lfs pointers and server the file instead
repo_file.go:145:func (c *Client) GetFileReader(owner, repo, ref, filepath string, resolveLFS ...bool) (io.ReadCloser, *Response, error) {
repo_file.go:150:	// resolve lfs
repo_file.go:151:	if len(resolveLFS) != 0 && resolveLFS[0] {
Thinking
The SDK doesn't have a method to resolve review comments. Gitea/Forgejo has an API endpoint for resolving review comments: `POST /repos/{owner}/{repo}/pulls/{index}/review_comments/{id}/resolve`. Since the SDK doesn't expose it, we'd need to use the raw HTTP API via the SDK's `getResponse`/`postParsedResponse` methods... but those are unexported. Hmm. Let me check what public methods the SDK's Client has for making arbitrary requests.
Tool 🔧 bash {"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && grep -n \"^func (c \\*Client)\" client.go | head -40; echo ---; grep -n \"func (c \\*Client) doRequest\\|func (c \\*Client) getResponse\\|func (c \\*Client) getParsedResponse\\|func (c \\*Client) postParsedResponse\\|func (c \\*Client) putParsedResponse\\|func (c \\*Client) deleteParsedResponse\" client.go"}
{"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && grep -n \"^func (c \\*Client)\" client.go | head -40; echo ---; grep -n \"func (c \\*Client) doRequest\\|func (c \\*Client) getResponse\\|func (c \\*Client) getParsedResponse\\|func (c \\*Client) postParsedResponse\\|func (c \\*Client) putParsedResponse\\|func (c \\*Client) deleteParsedResponse\" client.go"}
106:func (c *Client) SetHTTPClient(client *http.Client) {
177:func (c *Client) SetBasicAuth(username, password string) {
192:func (c *Client) SetOTP(otp string) {
207:func (c *Client) SetContext(ctx context.Context) {
222:func (c *Client) SetSudo(sudo string) {
237:func (c *Client) SetUserAgent(userAgent string) {
304:func (c *Client) getWebResponse(method, path string, body io.Reader) ([]byte, *Response, error) {
333:func (c *Client) doRequest(method, path string, header http.Header, body io.Reader) (*Response, error) {
428:func (c *Client) getResponseReader(method, path string, header http.Header, body io.Reader) (io.ReadCloser, *Response, error) {
443:func (c *Client) getResponse(method, path string, header http.Header, body io.Reader) ([]byte, *Response, error) {
465:func (c *Client) getParsedResponse(method, path string, header http.Header, body io.Reader, obj interface{}) (*Response, error) {
473:func (c *Client) getStatusCode(method, path string, header http.Header, body io.Reader) (int, *Response, error) {
---
333:func (c *Client) doRequest(method, path string, header http.Header, body io.Reader) (*Response, error) {
428:func (c *Client) getResponseReader(method, path string, header http.Header, body io.Reader) (io.ReadCloser, *Response, error) {
443:func (c *Client) getResponse(method, path string, header http.Header, body io.Reader) ([]byte, *Response, error) {
465:func (c *Client) getParsedResponse(method, path string, header http.Header, body io.Reader, obj interface{}) (*Response, error) {
Thinking
The SDK's low-level request methods are unexported. So for endpoints not in the SDK (like resolving review comments), we'd need to make a raw HTTP call using the client's baseURL and token. Let me check how the SDK's client stores the token and base URL, so we can make a raw call... Actually, zoo's `forgejo.Client` wrapper has `baseURL` and `token` fields, so we can make a raw HTTP request with `http.NewRequest` + Bearer token. OK, let me step back and think about scope. The issue is: "We need agents to respond to and react to PR reviews. Define a workflow and implement the Zoo side." The key deliverables: 1. **Workflow definition** (documented in the PR description / issue comment): - When a PR review is submitted (approve / request changes / comment), Zoo triggers an agent run (`pr:review` event). - The agent receives the review's state, body, and inline comments in its briefing. - The agent's job: address the feedback — make code changes, push to the PR branch, reply to the review (comment), resolve inline review comments that were addressed. - If the review is an approval, the agent might just thank / close out. - Loop prevention: the agent's own review submissions / comments shouldn't re-trigger itself (dedup + self-event filtering). 2. **Zoo-side implementation**: - New event kinds: `pr:review` (submitted review) and `pr:review:comment` (new inline review comment)? Or just `pr:review`? Let me think about what's cleanest and matches the existing architecture: Existing event kinds: - `issue:new` (static) - `issue:comment` (static) - `issue:assigned` (dynamic) - `pr:new` (static) New: - `pr:review` — a review was submitted on a PR (webhook: `pull_request_review` action `submitted`; polling: list PR reviews since watermark). - `pr:review:comment` — an inline review comment was created (webhook: `pull_request_review_comment` action `created`; polling: list review comments since watermark). Hmm, but do we need both? Inline review comments in Gitea/Forgejo are part of a review (a review can include comments). But you can also create a review comment without submitting a review (a pending review with comments). In Gitea, `pull_request_review_comment` fires when a comment is created on a PR diff — including as part of a review submission? Let me think... In Gitea, when a review with comments is submitted, both `pull_request_review` (submitted) and `pull_request_review_comment` (created, one per comment) webhooks fire. So if we handle both, the agent gets triggered twice for the same review. That's wasteful. Options: - Only handle `pull_request_review` (submitted). Inline comments are part of the review payload... but the review payload itself doesn't include the comments. We'd need to fetch them via `ListPullReviewComments(owner, repo, index, reviewID)`. - Only handle `pull_request_review_comment` (created). But then a review with just a body (no inline comments) wouldn't trigger anything. Best approach: handle `pull_request_review` (action `submitted`) as the primary event, and fetch the review's inline comments via the API when briefing the agent. This gives one run per review with full context. For `pull_request_review_comment` (created) — this fires for standalone comments too. Hmm, but if it fires for review comments as well, handling it would cause double triggering. Let me check Gitea's behavior more carefully. In Gitea's source (services/webhook/pull_review.go): ```go func (w *PullRequestReview) WebhookPayload() (string, error) { ``` And in `models/pull_review.go`, when a review is created with comments, `CreatePullReviewComments` is called, which... let me recall. In Gitea, `PullReviewComment` creation triggers a `pull_request_review_comment` webhook via `CreatePullReviewComments` → `m.notifyPullReviewComments`? Hmm. Actually, in Gitea, `CreatePullReviewComment` (for a single comment) triggers the webhook. When a review is submitted with comments, the comments are created via `CreatePullReviewComments` (plural), which does NOT trigger individual webhooks (I think). Let me check... From the Gitea source (models/pull_review.go): ```go // CreatePullReviewComments create a pull review comments func CreatePullReviewComments(ctx context.Context, pr *PullRequest, reviewID int64, comments []*PullReviewComment) error { ... } ``` And in `models/pull_review_comment.go`: ```go // CreatePullReviewComment creates a new pull review comment func CreatePullReviewComment(ctx context.Context, pr *PullRequest, reviewID int64, comment *PullReviewComment) error { ... if err := notifyPullReviewCommentCreated(ctx, pr, comment); ... } ``` Hmm, I'm not 100% sure whether review submission triggers per-comment webhooks. In Gitea, I believe `CreatePullReviewComments` (used at review submission) does not send webhooks, and only `CreatePullReviewComment` (single, used by the API `POST /pulls/{index}/comments`) sends the `pull_request_review_comment` webhook. Actually, let me look at the real Gitea code. From memory of gitea/models/pull_review.go: ```go // CreatePullReview creates a new pull review func CreatePullReview(ctx context.Context, pr *PullRequest, review *PullReview) error { ... } ``` And services/pull/review.go's `SubmitReview`: ```go func SubmitReview(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, review *issues_model.PullReview, form api.CreatePullReviewView) error { ... if len(form.Comments) > 0 { ... if err := issues_model.CreatePullReviewComments(ctx, pr, review.ID, comments); err != nil { ... } ... webhook.NotifyWatchers(ctx, asymkey_model.HookEventPullRequestReview, ...) } ``` And `CreatePullReviewComments`: ```go func CreatePullReviewComments(ctx context.Context, pr *PullRequest, reviewID int64, comments []*PullReviewComment) error { ... for _, comment := range comments { ... } // no individual webhooks } ``` Hmm, I really can't remember whether it sends webhooks. To be safe, the design should be robust to double triggering: dedup by event ID won't help (different IDs), but the scheduler's self-event filter won't help either (different authors... well, actually, if the agent itself is the reviewer, the self-filter catches it). The safest design: handle only `pull_request_review` (submitted) events. Don't handle `pull_request_review_comment` at all. This gives exactly one run per review, with the full review context (state + body + fetched inline comments). This is clean and avoids double triggering. But wait — what about standalone review comments (created via `POST /pulls/{index}/comments` without a review)? Those would be missed. That's a reasonable v1 tradeoff, and we can document it. Hmm, actually, let me reconsider. The issue says "respond to and react to PR reviews". The core workflow is: 1. Human (or another agent) submits a review on a PR. 2. Zoo triggers `pr:review` event → agent run. 3. Agent reviews the feedback, makes changes, pushes, responds to the review (comment), resolves addressed inline comments. For the agent to "react", it needs tools: - `resolve_review_comment` — resolve an inline review comment (needs a raw API call since the SDK doesn't have it). - `submit_review`? — the agent might want to submit a review on someone else's PR (e.g., when triggered by `pr:new`, review the PR). Hmm, that's actually a nice complement: the `pr:new` event currently says "Please review this pull request and respond appropriately." With a `submit_review` tool, the agent can submit an actual review (approve/request changes/comment) instead of just a comment. Wait, but let me be careful about scope creep. The issue is specifically about "responding to and reacting to PR reviews". Let me focus: **Core (must-have):** 1. `pr:review` event kind — triggered when a review is submitted on a PR. - Webhook: `pull_request_review` event, action `submitted`. - Polling: list PRs updated since watermark, list reviews, dispatch new ones. - Config: static event kind with agent + instructions. - Seed message: include review state, body, inline comments. 2. Tools for the agent to react: - `resolve_review_comment` — resolve an addressed inline review comment. - Possibly `submit_review` — submit a review (approve/request changes/comment) — useful for both reacting to a review (e.g., re-review after changes) and for `pr:new` reviews. Hmm wait, actually, let me reconsider whether `submit_review` is in scope. "We need agents to respond to and react to PR reviews." - "respond" → comment on the PR (existing `comment` tool) + resolve review comments (new tool). - "react" → make code changes (existing: edit + git push). I think the minimal, clean scope is: 1. `pr:review` event (webhook + polling + config + seed message). 2. `resolve_review_comment` tool. 3. `submit_review` tool (so agents can also submit reviews — this makes the review loop work between agents: agent A opens a PR, agent B reviews, agent A reacts, agent B re-reviews/approves). Actually, `submit_review` is important for the full workflow: after the agent addresses feedback, the reviewer (another agent or a human) re-reviews. If the reviewer is an agent, it needs to be able to submit a review. And when a review is approved, the PR author's agent might want to merge... but merging is out of scope (no merge tool exists; and the `pr:new` instructions say "review this pull request"). Hmm, let me also think about the "respond" side: when the agent receives a review, it should respond to the review. In Gitea, you can't "reply" to a review directly (no review reply API), but you can: - Comment on the PR (existing). - Resolve inline review comments (new tool). - Push changes (existing). OK, one more important consideration: **loop prevention**. If agent A is the PR author and agent B reviews, agent A's run responds (comments, pushes, resolves comments). Agent B's review submission triggered agent A. Agent A's response (comment on PR) — does that trigger anything? `issue:comment` events on PRs are currently ignored in the webhook path ("pr:comment is out of scope for v1"). In the polling path, `pollPulls` only dispatches `pr:new` for created PRs, and doesn't dispatch comments. So agent A's comment doesn't trigger a new run. Good. But wait — there's a subtlety with polling: when agent A pushes new commits to the PR branch, the PR is "synchronized" — `issue.Updated` changes. The polling path only dispatches `pr:new` if `issue.Created.After(since)`, so a sync doesn't re-trigger. Good. Now, what if agent B (reviewer) submits a review, and that review is... no wait, the review event triggers the PR author's agent (or the statically configured agent for `pr:review`). Let me think about who should handle `pr:review`. Option A: static mapping in zoo.hcl (like `pr:new` → greg). Simple, consistent with existing design. Option B: dynamic — trigger the agent that authored the PR (the PR author's agent). This is more "correct" for the workflow: the PR author should respond to the review. But the existing architecture only has dynamic resolution for `issue:assigned`. Hmm. The existing design uses static mappings for everything except `issue:assigned`. For consistency and simplicity, let's go with static mapping (Option A). The configured agent for `pr:review` handles all PR reviews. The agent can decide what to do based on the review content. Actually, wait. Let me reconsider. In the example zoo.hcl, `pr:new` → greg (senior dev/architect). For `pr:review`, it makes sense for the same agent to handle it, or a different one. Static mapping is the right answer for v1 — it's consistent, simple, and configurable. Now, let me also think about **self-triggering**: if the agent configured for `pr:review` submits a review (via the new `submit_review` tool), that would trigger a `pr:review` event... which would trigger itself. The scheduler's self-event filter checks `ev.Author == agentName`. For a review event, the author would be the reviewer. So if greg submits a review, the event's author is greg, and the resolved agent is greg (static) → dropped by the self-filter. But wait — there's a subtlety. The self-filter in `handle()`: ```go if ev.Author != "" && ev.Author == agentName { // drop } ``` This works if the resolved agent is the same as the author. For static `pr:review` → greg, if greg submits a review, author=greg, resolved=greg → dropped. Good. But if the configured agent for `pr:review` is leon, and greg submits a review, then author=greg, resolved=leon → not dropped → leon runs and "responds" to greg's review. That's... actually fine, that's the workflow working as intended (leon reacts to greg's review). OK, so the self-filter handles the pathological self-loop case. Good. Now, let me think about **event dedup IDs**: - For `pr:review`: the review has an ID. But a review can be edited (resubmitted). The `submitted` action fires on submission. If the same review is edited, does `submitted` fire again? No — editing a review fires the `edited` action, not `submitted`. So we only handle `submitted`, and the event ID can be `pr-review-{reviewID}`. But wait, what about polling? In polling, we list reviews and see their current state. A review that was submitted and then edited would still show up. We need to dedup by review ID so we don't re-trigger on edit. Hmm, but there's a problem: in polling, how do we know a review was *newly submitted* vs. already seen? We use the review's `Submitted` timestamp against the watermark. And dedup by review ID. But if a review is submitted, then its state changes (e.g., from PENDING to APPROVED), the `Submitted` timestamp... actually, `Submitted` is set when the review is submitted. A PENDING review has `Submitted` = zero time. Let me think about the polling approach more carefully: ```go func (w *Watcher) pollPulls(ctx context.Context) error { since, err := w.watermark(ctx, watermarkPulls) ... issues, _, err := w.client.sdk.ListIssues(sdk.ListIssueOption{ Type: sdk.IssueTypePull, State: sdk.StateAll, Since: since, }) ... for _, issue := range issues { ... if issue.Created.After(since) { w.dispatch(issueToPRNewEvent(issue, ...)) } // NEW: check for new reviews w.pollReviews(ctx, owner, repo, issue, since) } return w.store.SetWatermark(ctx, watermarkPulls, next.Format(time.RFC3339)) } ``` And `pollReviews`: ```go func (w *Watcher) pollReviews(ctx context.Context, owner, repo string, pr *sdk.Issue, since time.Time) { reviews, _, err := w.client.sdk.ListPullReviews(owner, repo, pr.Index, sdk.ListPullReviewsOptions{}) if err != nil { w.logger.Warn("poll reviews failed", ...) return } for _, review := range reviews { if review.Submitted.IsZero() { continue // pending, not submitted } if !review.Submitted.After(since) { continue } w.dispatch(reviewToEvent(pr, review, owner, repo)) } } ``` Wait, but there's a problem: `ListPullReviews` requires server version >= 1.12.0. That's fine, Forgejo is 1.12+. But there's a subtle issue with the watermark: the PR watermark advances to `next` (the max `issue.Updated`). But reviews have their own `Submitted` timestamp. If a PR is updated (e.g., a comment) and the watermark advances past a review's `Submitted` time, we might miss the review. Hmm. Actually, let me reconsider. The existing `pollIssues` has the same structure: it uses the issue's `Updated` for the watermark, and `pollNewComments` uses `ListIssueComments(Since: since)`. So comments are fetched with a `Since` filter. Is there a similar `Since` filter for reviews? `ListPullReviewsOptions` only has `ListOptions` (page, page size), no `Since`. So we can't filter reviews by time server-side. We have to fetch all reviews for each updated PR and filter client-side by `Submitted.After(since)`. The risk: the watermark advances based on `issue.Updated`, but a review's `Submitted` might be earlier than the current watermark (if the PR was updated for another reason after the review). In that case, we'd miss the review. Hmm, this is a real concern. Let me think... Actually, the same concern exists for comments: `pollNewComments` uses `ListIssueComments(Since: since)`, and `since` is the PR/issue watermark. If a comment was created at time T, and the issue was updated at time T+1 (advancing the watermark past T), then on the next poll, `since` = T+1 > T, so the comment is missed. But in practice, creating a comment updates the issue's `Updated` to roughly the same time, so the watermark and the comment time are close. There's a small race window, but it's acceptable for a fallback path. For reviews, the same logic applies: submitting a review updates the PR's `Updated` timestamp (roughly). So the watermark advances to roughly the review's `Submitted` time. On the next poll, `since` ≈ review's `Submitted`. We filter by `Submitted.After(since)`. If the review's `Submitted` is exactly equal to the watermark, `After` is false → missed. Hmm. Actually, wait. Let me re-read the existing code. In `pollIssues`: ```go next := since for _, issue := range issues { if issue.Updated.After(next) { next = issue.Updated } ... } return w.store.SetWatermark(ctx, watermarkIssues, next.Format(time.RFC3339)) ``` So the watermark becomes the max `Updated` across all issues. And `pollNewComments` is called with `since` = the old watermark (before advancing). So comments are fetched with `Since: oldWatermark`. A comment created at time T: the issue's `Updated` becomes T (roughly). On the poll where the issue is listed, `since` = oldWatermark < T, so the comment is fetched. Good. The watermark then advances to T. On the next poll, `since` = T, and the comment (created at T) is not re-fetched (because `Since: T` is exclusive? or inclusive?). Actually, the Gitea API's `since` parameter: "Since: only show notifications updated after this time". It's typically inclusive or exclusive depending on implementation. The existing code has `if !comment.Created.After(since) { continue }` as a client-side double-check, treating `since` as a lower bound (exclusive). So a comment created exactly at `since` is skipped. This is a known edge case (the comment must be created strictly after the watermark). In practice, timestamps have sub-second precision, so exact equality is rare. OK, so the existing pattern handles this "well enough" for a fallback. Let me follow the same pattern for reviews: fetch all reviews for updated PRs, filter client-side by `Submitted.After(since)`, and dedup by review ID (via the store's `MarkSeen`). The dedup is the real safety net — even if the timing is off, a review is only dispatched once (deduped by ID). The only failure mode is missing a review whose `Submitted` is before the watermark at the time the PR is first listed. But since submitting a review updates the PR's `Updated`, the PR will be listed in the same poll where the review was submitted (because `Updated` is after the old watermark), so `since` (old watermark) < `Submitted`, and the review is caught. Wait, but there's a subtle ordering issue. Let me trace through: - t0: watermark = W0. - t1: review submitted on PR. PR.Updated = t1, review.Submitted = t1. - t2 (poll): ListIssues(Since: W0) returns the PR (Updated=t1 > W0). pollReviews is called with since=W0. ListPullReviews returns the review (Submitted=t1). t1 > W0 → dispatch. Watermark advances to t1. - t3 (poll): ListIssues(Since: t1) — PR.Updated=t1 is not after t1, so the PR is not returned (unless updated again). So no re-dispatch. Good. But what if the PR was updated for another reason at t0.5 (between W0 and t1)? Then: - t0.5: PR.Updated = t0.5 (e.g., a comment). - t1 (poll): ListIssues(Since: W0) returns the PR (Updated=t0.5 > W0). pollReviews with since=W0. The review hasn't been submitted yet (submitted at t1.5). So no review. Watermark advances to t0.5. - t1.5: review submitted. PR.Updated = t1.5. - t2 (poll): ListIssues(Since: t0.5) returns the PR (Updated=t1.5 > t0.5). pollReviews with since=t0.5. Review.Submitted=t1.5 > t0.5 → dispatch. Good. So as long as submitting a review updates the PR's `Updated` to >= the review's `Submitted`, we're fine. And it does (submitting a review is an update to the PR). Good. Actually, I realize there might be an even simpler and more robust approach: use a separate watermark for reviews, like `watermarkReviews`. But reviews are tied to PRs, and we only want to check reviews for PRs that have changed. Using the PR watermark is fine and matches the existing pattern. Let me go with the PR watermark. Hmm, wait, actually there's a problem. `ListIssues(Since: W)` only returns issues updated after W. But what if a PR had a review submitted, and then the PR wasn't updated again? Then on subsequent polls, the PR is not returned (Updated is not after the watermark), so we don't re-check its reviews. That's fine, because we already dispatched the review in the poll where the PR was updated. But what if the review was submitted, and the PR's `Updated` was updated, but the poll happened to miss it (e.g., the poll interval is 10s, and the review was submitted and the PR was updated within the same 10s window)? No, that's fine — the poll will catch it on the next tick because the PR's `Updated` is after the watermark. OK, I'm confident the PR-watermark approach works. Let me also consider: should we use a dedicated review watermark to be safe? I think using the PR watermark is cleaner and matches the existing design. Let me go with that. Now, let me reconsider the event ID for dedup. For `pr:review`, the ID should be `pr-review-{reviewID}`. But wait — what if a review is submitted, and then the same review is... no, a review ID is unique and immutable. A review is submitted once. So `pr-review-{reviewID}` is a stable dedup key. Good. But actually, there's a subtlety with the webhook path. The `pull_request_review` webhook fires on `submitted`, `edited`, `dismissed` (and maybe others). We only want to handle `submitted`. So in the webhook decoder, we check `p.Action == "submitted"`. Good. Now, let me think about the **Event struct** changes. We need to carry review info: - `ReviewID int64` - `ReviewState string` (APPROVED, REQUEST_CHANGES, COMMENT) - `ReviewBody string` - `Reviewer string` (the reviewer's username) — actually, this is the `Author` field. Let me add these fields to the `Event` struct: ```go // Review fields, set for EventPRReview. ReviewID int64 ReviewState string ReviewBody string ``` And `Author` = reviewer's username. Now, for the **seed message**, we want to include the review's state, body, and inline comments. The inline comments need to be fetched via `ListPullReviewComments`. Where do we fetch them? In `agentrun.Run`, we already fetch `ListIssueComments`. We can add a fetch for review comments when the event kind is `pr:review`. Actually, let me reconsider. The seed message is built in `seedMessage(ev, branch, defaultBranch, instructions, comments)`. We can add a `review` parameter. But fetching the review comments requires a Forgejo API call, which happens in `Run`. Let me fetch the review comments in `Run` (next to the issue comments fetch) and pass them to `seedMessage`. Let me design the `seedMessage` changes: ```go func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, comments []forgejo.IssueComment, review *forgejo.ReviewDetail) string { ... var reviewSection string if review != nil { reviewSection = renderReviewSection(review) } ... } ``` Where `ReviewDetail` is a new type in the forgejo package: ```go type ReviewDetail struct { ID int64 State string Body string Reviewer string Comments []ReviewCommentDetail } type ReviewCommentDetail struct { Path string Line int Body string Author string } ``` Hmm, this is getting complex. Let me simplify. The `Event.Raw` already contains the full webhook payload (or a synthesized equivalent). For the webhook path, `Raw` is the `pull_request_review` payload, which includes the `review` object. For the polling path, we synthesize the raw from the review. Actually, the seed message already includes the full raw event payload as JSON. So the agent can already see the review details in the raw JSON. But it's nicer to have a human-readable section. Let me add a review section to the seed message for clarity. Let me reconsider the scope. The issue says "Define a workflow and implement the Zoo side." I think the key deliverables are: 1. New `pr:review` event (webhook + polling + config + dedup). 2. Seed message includes review context (state, body, inline comments). 3. New tools so the agent can react: `resolve_review_comment`, and possibly `submit_review`. 4. Workflow documentation (in the PR description and/or a comment on the issue). Let me focus on these. Let me also check: does the agent need to know the PR's head branch to push changes? The agent's working branch is `issue-{index}-{agent}`, which is branched from the default branch, not from the PR's head branch! This is a problem for "reacting" to a review — the agent needs to be on the PR's branch to push changes to the PR. Wait, let me re-read `run.go`: ```go branch := fmt.Sprintf("issue-%d-%s", ev.Index, agent.Name) projectDir := filepath.Join(workDir, "project") if err := cloneAndBranch(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, branch, projectDir); err != nil { ``` So the agent always branches from the default branch. For `pr:review`, the agent needs to be on the PR's head branch to make changes and push to the PR. Otherwise, the agent's changes go to a new branch `issue-{index}-{agent}`, not the PR. Hmm, this is a significant issue. Let me think about how to handle this. For `pr:review`, the agent should: 1. Check out the PR's head branch (not branch from default). 2. Make changes. 3. Push to the PR's head branch (so the PR updates). But the current `cloneAndBranch` always branches from the default branch. We need to modify it for `pr:review` to check out the PR's head branch instead. Let me look at `clone.go` to understand `cloneAndBranch`. Actually, this is getting complex. Let me reconsider the scope and what's realistic. Let me re-read the issue: "We need agents to respond to and react to PR reviews. Define a workflow and implement the Zoo side." "Implement the Zoo side" — this suggests there's a "non-Zoo side" too (maybe the agent behavior / prompt side, or the Forgejo webhook setup). The Zoo side is the daemon: event detection, dispatch, briefing, tools. I think the realistic, high-value scope is: 1. **Event detection**: `pr:review` event from webhook (`pull_request_review`/`submitted`) and polling. 2. **Config**: `pr:review` as a static event kind. 3. **Briefing**: seed message includes review state, body, inline comments, and the PR's head branch. 4. **Working tree**: for `pr:review`, check out the PR's head branch so the agent can push changes to the PR. (This is the "react" part.) 5. **Tools**: `resolve_review_comment` (to mark addressed comments as resolved). Possibly `submit_review` (to submit a review). 6. **Workflow documentation**. Let me check `clone.go` first.
Tool 🔧 read_file {"path": "internal/agentrun/clone.go", "start_line": 1, "end_line": 120}
{"path": "internal/agentrun/clone.go", "start_line": 1, "end_line": 120}
{"path":"internal/agentrun/clone.go","content":"package agentrun\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"encoding/base64\"\r\n\t\"fmt\"\r\n\t\"os/exec\"\r\n\t\"strings\"\r\n)\r\n\r\n// gitAuthArgs returns the `-c http.extraHeader=...` global git arguments\r\n// that authenticate a single invocation as the shared zoo Forgejo\r\n// identity. Unlike embedding the token in the remote URL, a `-c`\r\n// override lives only for the lifetime of that one process and is never\r\n// written to .git/config on disk — where the container bind-mounting\r\n// this working tree (and any command an agent runs inside it via the\r\n// bash tool) could otherwise read it straight back out.\r\nfunc gitAuthArgs(token string) []string {\r\n\tauth := base64.StdEncoding.EncodeToString([]byte(\"zoo:\" + token))\r\n\r\n\treturn []string{\"-c\", \"http.extraHeader=Authorization: Basic \" + auth}\r\n}\r\n\r\n// cloneAndBranch clones cloneURL into dir, authenticating the clone\r\n// itself with token (see gitAuthArgs), and checks out a new branch off\r\n// of defaultBranch. token never ends up in the resulting working tree:\r\n// the origin remote is left with the plain cloneURL.\r\nfunc cloneAndBranch(ctx context.Context, cloneURL, token, defaultBranch, branch, dir string) error {\r\n\targs := append(gitAuthArgs(token), \"clone\", \"--branch\", defaultBranch, \"--single-branch\", cloneURL, dir)\r\n\tif out, err := runGit(ctx, \"\", args...); err != nil {\r\n\t\treturn fmt.Errorf(\"clone: %w: %s\", err, out)\r\n\t}\r\n\r\n\tif out, err := runGit(ctx, dir, \"checkout\", \"-b\", branch); err != nil {\r\n\t\treturn fmt.Errorf(\"checkout branch %q: %w: %s\", branch, err, out)\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// runGit runs a git subcommand on the host (not inside any container)\r\n// with no Forgejo credential attached.\r\nfunc runGit(ctx context.Context, dir string, args ...string) (string, error) {\r\n\tcmd := exec.CommandContext(ctx, \"git\", args...)\r\n\tif dir != \"\" {\r\n\t\tcmd.Dir = dir\r\n\t}\r\n\r\n\tout, err := cmd.CombinedOutput()\r\n\r\n\treturn strings.TrimSpace(string(out)), err\r\n}\r\n\r\n// runGitAuthed is runGit with token attached via gitAuthArgs, for the\r\n// one subcommand (push) that needs to authenticate against Forgejo.\r\nfunc runGitAuthed(ctx context.Context, dir, token string, args ...string) (string, error) {\r\n\treturn runGit(ctx, dir, append(gitAuthArgs(token), args...)...)\r\n}\r","start_line":1,"end_line":58,"total_lines":58,"truncated":false}
Tool 🔧 read_file {"path": "internal/agentrun/system.md", "start_line": 1, "end_line": 120}
{"path": "internal/agentrun/system.md", "start_line": 1, "end_line": 120}
{"path":"internal/agentrun/system.md","content":"You are an autonomous coding agent working inside `zoo`, a system that\r\ntriggers you from Forgejo (a Gitea-family forge) issue/PR events.\r\n\r\n**Your environment**\r\n\r\n- Your working tree is at `/project`, checked out on a fresh branch off\r\n  the repository's default branch. Only `/project` persists; nothing\r\n  outside it survives between tool calls.\r\n- The event that triggered you (issue or pull request JSON) is available\r\n  at `/event` inside the container, and is also included below.\r\n- You have a real git remote configured with push access. When you're\r\n  done, `git add`/`git commit`/`git push` your branch — that's how your\r\n  work gets saved. Nothing is persisted automatically.\r\n\r\n**Tools**\r\n\r\n- `bash`, `git`, `read_file`, `write_file`, `list_files`, `grep_search`,\r\n  `move_file`, `remove_file` operate on the project container.\r\n- `comment`, `open_pull_request`, `request_review`, `add_label`,\r\n  `remove_label`, `close_issue`, `reopen_issue`, `assign_issue` act\r\n  directly on the Forgejo issue/PR that triggered this run — use\r\n  `comment` to report back to the person who filed it, and\r\n  `open_pull_request` once you've pushed a branch with your changes.\r\n\r\nBelow, in \"Your identity\" and \"Other agents\", you'll find your own role\r\n(from your Forgejo profile) and a roster of the other agents zoo runs,\r\neach with their own role. If a task in front of you clearly belongs to\r\nsomeone else's role, use `assign_issue` to hand it off rather than\r\ndoing it yourself.\r\n\r\n**Git**\r\n\r\nYou may use normal git commands to manage your working tree. For remote\r\n(pull, fetch, push, etc.) git operations, you can use the `git` tool.\r\n\r\n**Guidelines**\r\n\r\n- Make surgical changes: modify only what's necessary for the task in\r\n  the triggering event.\r\n- Before finishing, verify your changes (re-read the file, run tests or\r\n  a build if the project has them).\r\n- Always leave a `comment` summarizing what you did (or why you couldn't\r\n  finish), and use `open_pull_request` when you have a change ready for\r\n  review. Don't leave the issue without a response.\r","start_line":1,"end_line":44,"total_lines":44,"truncated":false}
Thinking
Alright, let's look at the existing tests to understand the test patterns, and then check the web package (the dashboard) to see if events are displayed.
Tool 🔧 read_file {"path": "internal/forgejo/webhook_test.go", "start_line": 1, "end_line": 200}
{"path": "internal/forgejo/webhook_test.go", "start_line": 1, "end_line": 200}
{"path":"internal/forgejo/webhook_test.go","content":"package forgejo\r\n\r\nimport (\r\n\t\"testing\"\r\n\t\"time\"\r\n)\r\n\r\nconst issueOpenedPayload = `{\r\n  \"action\": \"opened\",\r\n  \"issue\": {\r\n    \"id\": 101,\r\n    \"number\": 5,\r\n    \"title\": \"Something is broken\",\r\n    \"body\": \"Steps to reproduce...\",\r\n    \"user\": {\"login\": \"alice\"},\r\n    \"assignees\": []\r\n  },\r\n  \"repository\": {\r\n    \"name\": \"widgets\",\r\n    \"owner\": {\"login\": \"acme\"}\r\n  }\r\n}`\r\n\r\nconst issueAssignedPayload = `{\r\n  \"action\": \"assigned\",\r\n  \"issue\": {\r\n    \"id\": 101,\r\n    \"number\": 5,\r\n    \"title\": \"Something is broken\",\r\n    \"updated_at\": \"2026-08-20T10:00:00Z\",\r\n    \"user\": {\"login\": \"alice\"},\r\n    \"assignees\": [{\"login\": \"leon\"}]\r\n  },\r\n  \"repository\": {\r\n    \"name\": \"widgets\",\r\n    \"owner\": {\"login\": \"acme\"}\r\n  }\r\n}`\r\n\r\n// The same issue assigned to the same agent a second time, later.\r\nconst issueReassignedPayload = `{\r\n  \"action\": \"assigned\",\r\n  \"issue\": {\r\n    \"id\": 101,\r\n    \"number\": 5,\r\n    \"title\": \"Something is broken\",\r\n    \"updated_at\": \"2026-08-20T11:30:00Z\",\r\n    \"user\": {\"login\": \"alice\"},\r\n    \"assignees\": [{\"login\": \"leon\"}]\r\n  },\r\n  \"repository\": {\r\n    \"name\": \"widgets\",\r\n    \"owner\": {\"login\": \"acme\"}\r\n  }\r\n}`\r\n\r\nconst issueCommentCreatedPayload = `{\r\n  \"action\": \"created\",\r\n  \"issue\": {\r\n    \"id\": 101,\r\n    \"number\": 5,\r\n    \"title\": \"Something is broken\",\r\n    \"user\": {\"login\": \"alice\"}\r\n  },\r\n  \"comment\": {\r\n    \"id\": 55,\r\n    \"body\": \"any update?\",\r\n    \"user\": {\"login\": \"bob\"}\r\n  },\r\n  \"repository\": {\r\n    \"name\": \"widgets\",\r\n    \"owner\": {\"login\": \"acme\"}\r\n  }\r\n}`\r\n\r\nconst pullRequestOpenedPayload = `{\r\n  \"action\": \"opened\",\r\n  \"pull_request\": {\r\n    \"id\": 202,\r\n    \"number\": 9,\r\n    \"title\": \"Fix the thing\",\r\n    \"body\": \"This fixes it\",\r\n    \"user\": {\"login\": \"greg\"}\r\n  },\r\n  \"repository\": {\r\n    \"name\": \"widgets\",\r\n    \"owner\": {\"login\": \"acme\"}\r\n  }\r\n}`\r\n\r\nfunc TestDecodeIssueOpened(t *testing.T) {\r\n\tev, ok, err := decodeWebhookEvent(\"issues\", []byte(issueOpenedPayload))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif !ok {\r\n\t\tt.Fatal(\"expected an event\")\r\n\t}\r\n\tif ev.Kind != EventIssueNew || ev.Owner != \"acme\" || ev.Repo != \"widgets\" || ev.Index != 5 || ev.Author != \"alice\" {\r\n\t\tt.Fatalf(\"unexpected event: %+v\", ev)\r\n\t}\r\n\tif ev.ID != \"issue-new-101\" {\r\n\t\tt.Fatalf(\"unexpected dedup id: %q\", ev.ID)\r\n\t}\r\n}\r\n\r\nfunc TestDecodeIssueAssigned(t *testing.T) {\r\n\tev, ok, err := decodeWebhookEvent(\"issues\", []byte(issueAssignedPayload))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif !ok {\r\n\t\tt.Fatal(\"expected an event\")\r\n\t}\r\n\tif ev.Kind != EventIssueAssigned || ev.Assignee != \"leon\" {\r\n\t\tt.Fatalf(\"unexpected event: %+v\", ev)\r\n\t}\r\n}\r\n\r\n// Assigning an issue to an agent that already held it once has to\r\n// produce a distinct dedup id, or the watcher swallows it as already\r\n// seen and the agent never picks the issue up again.\r\nfunc TestDecodeIssueReassignedToSameAgentIsNotDeduped(t *testing.T) {\r\n\tfirst, _, err := decodeWebhookEvent(\"issues\", []byte(issueAssignedPayload))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\tsecond, _, err := decodeWebhookEvent(\"issues\", []byte(issueReassignedPayload))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\tif second.Assignee != \"leon\" {\r\n\t\tt.Fatalf(\"unexpected assignee: %q\", second.Assignee)\r\n\t}\r\n\tif first.ID == second.ID {\r\n\t\tt.Fatalf(\"re-assignment reused the dedup id %q, so it would be dropped\", first.ID)\r\n\t}\r\n}\r\n\r\n// The webhook delivery and the poller's later sighting of that one\r\n// assignment must still collapse onto a single id, so it runs once.\r\nfunc TestAssignedIDMatchesAcrossWebhookAndPoll(t *testing.T) {\r\n\tev, _, err := decodeWebhookEvent(\"issues\", []byte(issueAssignedPayload))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\tupdated, err := time.Parse(time.RFC3339, \"2026-08-20T10:00:00Z\")\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\tif polled := issueAssignedID(101, \"leon\", updated); polled != ev.ID {\r\n\t\tt.Fatalf(\"poll id %q does not match webhook id %q\", polled, ev.ID)\r\n\t}\r\n}\r\n\r\nfunc TestDecodeIssueCommentCreated(t *testing.T) {\r\n\tev, ok, err := decodeWebhookEvent(\"issue_comment\", []byte(issueCommentCreatedPayload))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif !ok {\r\n\t\tt.Fatal(\"expected an event\")\r\n\t}\r\n\tif ev.Kind != EventIssueComment || ev.Comment != \"any update?\" || ev.Author != \"bob\" {\r\n\t\tt.Fatalf(\"unexpected event: %+v\", ev)\r\n\t}\r\n}\r\n\r\nfunc TestDecodePullRequestOpened(t *testing.T) {\r\n\tev, ok, err := decodeWebhookEvent(\"pull_request\", []byte(pullRequestOpenedPayload))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif !ok {\r\n\t\tt.Fatal(\"expected an event\")\r\n\t}\r\n\tif ev.Kind != EventPRNew || ev.Index != 9 || ev.Author != \"greg\" {\r\n\t\tt.Fatalf(\"unexpected event: %+v\", ev)\r\n\t}\r\n}\r\n\r\nfunc TestDecodeIgnoresUnknownAction(t *testing.T) {\r\n\t_, ok, err := decodeWebhookEvent(\"issues\", []byte(`{\"action\": \"closed\", \"issue\": {\"id\": 1}, \"repository\": {\"name\": \"x\", \"owner\": {\"login\": \"y\"}}}`))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif ok {\r\n\t\tt.Fatal(\"expected closed action to be ignored\")\r\n\t}\r\n}\r\n\r\nfunc TestDecodeIgnoresUnknownEventKind(t *testing.T) {\r\n\t_, ok, err := decodeWebhookEvent(\"release\", []byte(`{}`))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r","start_line":1,"end_line":200,"total_lines":204,"truncated":true}
Thinking
Let's check the remaining tests (seed_test.go, config_test.go, scheduler_test.go, client_test.go, tools_test.go) to understand the testing conventions.
Tool 🔧 read_file {"path": "internal/agentrun/seed_test.go", "start_line": 1, "end_line": 150}
{"path": "internal/agentrun/seed_test.go", "start_line": 1, "end_line": 150}
{"path":"internal/agentrun/seed_test.go","content":"package agentrun\r\n\r\nimport (\r\n\t\"strings\"\r\n\t\"testing\"\r\n\t\"time\"\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.\", nil)\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\", \"\", nil)\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\n\r\nfunc TestSeedMessageIncludesAllComments(t *testing.T) {\r\n\tev := forgejo.Event{Kind: \"issue:assigned\", Owner: \"acme\", Repo: \"widgets\", Index: 5, Title: \"t\", Body: \"b\", Raw: []byte(`{}`)}\r\n\r\n\tcomments := []forgejo.IssueComment{\r\n\t\t{Author: \"alice\", Body: \"Please also handle the edge case.\", Created: time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC)},\r\n\t\t{Author: \"bob\", Body: \"And add a test for it.\", Created: time.Date(2026, 8, 24, 9, 5, 0, 0, time.UTC)},\r\n\t}\r\n\r\n\tmsg := seedMessage(ev, \"issue-5-greg\", \"main\", \"\", comments)\r\n\r\n\tfor _, want := range []string{\r\n\t\t\"Comments (2):\",\r\n\t\t\"1. alice (2026-08-24T09:00:00Z):\\nPlease also handle the edge case.\",\r\n\t\t\"2. bob (2026-08-24T09:05:00Z):\\nAnd add a test for it.\",\r\n\t} {\r\n\t\tif !strings.Contains(msg, want) {\r\n\t\t\tt.Fatalf(\"expected %q in seed message, got:\\n%s\", want, msg)\r\n\t\t}\r\n\t}\r\n\r\n\t// Comments come before the raw payload, so the agent reads them\r\n\t// as part of the briefing, not buried in JSON.\r\n\tif !strings.Contains(msg, \"Comments (2):\") ||\r\n\t\tstrings.Index(msg, \"Comments (2):\") \u003e strings.Index(msg, \"Full event payload:\") {\r\n\t\tt.Fatalf(\"expected comments section before the raw payload, got:\\n%s\", msg)\r\n\t}\r\n}\r\n\r\nfunc TestSeedMessageOmitsEmptyComments(t *testing.T) {\r\n\tev := forgejo.Event{Kind: \"issue:new\", Owner: \"acme\", Repo: \"widgets\", Index: 5, Title: \"t\", Body: \"b\", Raw: []byte(`{}`)}\r\n\r\n\tmsg := seedMessage(ev, \"issue-5-greg\", \"main\", \"\", nil)\r\n\r\n\tif strings.Contains(msg, \"Comments\") {\r\n\t\tt.Fatalf(\"expected no comments section, got: %s\", msg)\r\n\t}\r\n}\r","start_line":1,"end_line":67,"total_lines":67,"truncated":false}
Tool 🔧 read_file {"path": "internal/config/config_test.go", "start_line": 1, "end_line": 120}
{"path": "internal/config/config_test.go", "start_line": 1, "end_line": 120}
{"path":"internal/config/config_test.go","content":"package config\r\n\r\nimport (\r\n\t\"os\"\r\n\t\"path/filepath\"\r\n\t\"testing\"\r\n)\r\n\r\nfunc load(t *testing.T, hcl string) (*Config, error) {\r\n\tt.Helper()\r\n\r\n\tpath := filepath.Join(t.TempDir(), \"zoo.hcl\")\r\n\tif err := os.WriteFile(path, []byte(hcl), 0o600); err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\treturn Load(path)\r\n}\r\n\r\nconst validConfig = `\r\nllm \"qwen\" {\r\n    openai = \"https://example.com\"\r\n    token  = \"tok\"\r\n    model  = \"qwen3\"\r\n}\r\n\r\nforgejo {\r\n    url   = \"https://example.com\"\r\n    token = \"tok\"\r\n}\r\n\r\nenvironment {\r\n    docker_image   = \"debian:unstable\"\r\n    max_live_agents = 2\r\n}\r\n\r\nagent \"leon\" {\r\n    llm = \"qwen\"\r\n}\r\n\r\nevent \"issue:new\" {\r\n    agent = \"leon\"\r\n}\r\n\r\nevent \"issue:assigned\" {\r\n    instructions = \"Please handle this issue.\"\r\n}\r\n\r\nevent \"issue:comment\" {\r\n    agent = \"leon\"\r\n    instructions = \"Please review the comment and respond appropriately.\"\r\n}\r\n\r\nevent \"pr:new\" {\r\n    agent = \"leon\"\r\n}\r\n`\r\n\r\nfunc TestLoadValid(t *testing.T) {\r\n\tcfg, err := load(t, validConfig)\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"unexpected error: %v\", err)\r\n\t}\r\n\r\n\tif len(cfg.LLMs) != 1 || cfg.LLMs[0].Name != \"qwen\" {\r\n\t\tt.Fatalf(\"unexpected llms: %+v\", cfg.LLMs)\r\n\t}\r\n\r\n\tif agent, ok := cfg.EventAgent(EventIssueNew); !ok || agent != \"leon\" {\r\n\t\tt.Fatalf(\"expected issue:new -\u003e leon, got %q, %v\", agent, ok)\r\n\t}\r\n\r\n\tif _, ok := cfg.EventAgent(EventIssueAssigned); ok {\r\n\t\tt.Fatalf(\"issue:assigned should have no static agent\")\r\n\t}\r\n\r\n\tif got := cfg.EventInstructions(EventIssueAssigned); got != \"Please handle this issue.\" {\r\n\t\tt.Fatalf(\"unexpected issue:assigned instructions: %q\", got)\r\n\t}\r\n\tif got := cfg.EventInstructions(EventIssueComment); got != \"Please review the comment and respond appropriately.\" {\r\n\t\tt.Fatalf(\"unexpected issue:comment instructions: %q\", got)\r\n\t}\r\n\tif got := cfg.EventInstructions(EventPRNew); got != \"\" {\r\n\t\tt.Fatalf(\"expected no instructions for pr:new, got %q\", got)\r\n\t}\r\n}\r\n\r\nfunc TestValidateUndeclaredLLM(t *testing.T) {\r\n\t_, err := load(t, `\r\nforgejo { url = \"https://example.com\" token = \"tok\" }\r\nenvironment { docker_image = \"debian:unstable\" max_live_agents = 1 }\r\nagent \"leon\" { llm = \"missing\" }\r\n`)\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error for undeclared llm reference\")\r\n\t}\r\n}\r\n\r\nfunc TestValidateUndeclaredEventAgent(t *testing.T) {\r\n\t_, err := load(t, `\r\nllm \"qwen\" { openai = \"https://example.com\" token = \"tok\" model = \"qwen3\" }\r\nforgejo { url = \"https://example.com\" token = \"tok\" }\r\nenvironment { docker_image = \"debian:unstable\" max_live_agents = 1 }\r\nagent \"leon\" { llm = \"qwen\" }\r\nevent \"issue:new\" { agent = \"missing\" }\r\n`)\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error for undeclared agent reference\")\r\n\t}\r\n}\r\n\r\nfunc TestValidateIssueAssignedRejectsAgent(t *testing.T) {\r\n\t_, err := load(t, `\r\nllm \"qwen\" { openai = \"https://example.com\" token = \"tok\" model = \"qwen3\" }\r\nforgejo { url = \"https://example.com\" token = \"tok\" }\r\nenvironment { docker_image = \"debian:unstable\" max_live_agents = 1 }\r\nagent \"leon\" { llm = \"qwen\" }\r\nevent \"issue:assigned\" { agent = \"leon\" }\r\n`)\r\n\tif err == nil {\r","start_line":1,"end_line":120,"total_lines":158,"truncated":true}
Thinking
Let's check the remaining tests: the rest of config_test, scheduler_test, client_test, tools_test, and the web package.
Tool 🔧 read_file {"path": "internal/config/config_test.go", "start_line": 120, "end_line": 158}
{"path": "internal/config/config_test.go", "start_line": 120, "end_line": 158}
{"path":"internal/config/config_test.go","content":"\tif err == nil {\r\n\t\tt.Fatal(\"expected error: issue:assigned must not set agent\")\r\n\t}\r\n}\r\n\r\nfunc TestValidateMaxLiveAgents(t *testing.T) {\r\n\t_, err := load(t, `\r\nllm \"qwen\" { openai = \"https://example.com\" token = \"tok\" model = \"qwen3\" }\r\nforgejo { url = \"https://example.com\" token = \"tok\" }\r\nenvironment { docker_image = \"debian:unstable\" max_live_agents = 0 }\r\n`)\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error for max_live_agents \u003c 1\")\r\n\t}\r\n}\r\n\r\nfunc TestValidateMissingDockerImage(t *testing.T) {\r\n\t_, err := load(t, `\r\nllm \"qwen\" { openai = \"https://example.com\" token = \"tok\" model = \"qwen3\" }\r\nforgejo { url = \"https://example.com\" token = \"tok\" }\r\nenvironment { docker_image = \"\" max_live_agents = 1 }\r\n`)\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error for missing docker_image\")\r\n\t}\r\n}\r\n\r\nfunc TestValidateUnknownEventKind(t *testing.T) {\r\n\t_, err := load(t, `\r\nllm \"qwen\" { openai = \"https://example.com\" token = \"tok\" model = \"qwen3\" }\r\nforgejo { url = \"https://example.com\" token = \"tok\" }\r\nenvironment { docker_image = \"debian:unstable\" max_live_agents = 1 }\r\nagent \"leon\" { llm = \"qwen\" }\r\nevent \"pr:sync\" { agent = \"leon\" }\r\n`)\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error for unknown event kind\")\r\n\t}\r\n}\r","start_line":120,"end_line":158,"total_lines":158,"truncated":false}
Tool 🔧 bash {"command": "cd /project && ls internal/scheduler/ && grep -n \"func Test\" internal/scheduler/scheduler_test.go internal/forgejo/client_test.go internal/tools/tools_test.go internal/web/web_test.go internal/store/store_test.go"}
{"command": "cd /project && ls internal/scheduler/ && grep -n \"func Test\" internal/scheduler/scheduler_test.go internal/forgejo/client_test.go internal/tools/tools_test.go internal/web/web_test.go internal/store/store_test.go"}
scheduler.go  scheduler_test.go
internal/scheduler/scheduler_test.go:36:func TestResolveAgentStatic(t *testing.T) {
internal/scheduler/scheduler_test.go:45:func TestResolveAgentAssignedMatch(t *testing.T) {
internal/scheduler/scheduler_test.go:54:func TestResolveAgentAssignedNoMatch(t *testing.T) {
internal/scheduler/scheduler_test.go:127:func TestSchedulerDispatchAndSucceed(t *testing.T) {
internal/scheduler/scheduler_test.go:158:func TestSchedulerReportsFailure(t *testing.T) {
internal/scheduler/scheduler_test.go:189:func TestSchedulerClassifiesTimeout(t *testing.T) {
internal/scheduler/scheduler_test.go:217:func TestSchedulerDropsUnresolvedEvent(t *testing.T) {
internal/scheduler/scheduler_test.go:245:func TestSchedulerDropsEventAuthoredByTargetAgent(t *testing.T) {
internal/scheduler/scheduler_test.go:273:func TestSchedulerDispatchesCommentFromSomeoneElse(t *testing.T) {
internal/scheduler/scheduler_test.go:301:func TestSchedulerBackpressure(t *testing.T) {
internal/forgejo/client_test.go:47:func TestListIssueCommentsFetchesAllPages(t *testing.T) {
internal/forgejo/client_test.go:102:func TestListIssueCommentsEmpty(t *testing.T) {
internal/tools/tools_test.go:55:func TestShellQuote(t *testing.T) {
internal/tools/tools_test.go:67:func TestReadFileParsesMetaAndContent(t *testing.T) {
internal/tools/tools_test.go:86:func TestGitRejectsDisallowedSubcommand(t *testing.T) {
internal/tools/tools_test.go:95:func TestGitPassesSubcommandAndArgsThrough(t *testing.T) {
internal/tools/tools_test.go:111:func TestCommentToolCallsForgejo(t *testing.T) {
internal/tools/tools_test.go:124:func TestAssignIssueToolCallsForgejo(t *testing.T) {
internal/tools/tools_test.go:140:func TestGrepSearchUsesExtendedRegex(t *testing.T) {
internal/tools/tools_test.go:155:func TestGrepSearchNoMatches(t *testing.T) {
internal/tools/tools_test.go:167:func TestRegistryRoundTrip(t *testing.T) {
internal/web/web_test.go:39:func TestIndexRenders(t *testing.T) {
internal/web/web_test.go:53:func TestJobsAndDetail(t *testing.T) {
internal/web/web_test.go:107:func TestAuthGate(t *testing.T) {
internal/web/web_test.go:135:func TestJobsShowAgentAvatars(t *testing.T) {
internal/store/store_test.go:21:func TestMarkSeenDedup(t *testing.T) {
internal/store/store_test.go:42:func TestWatermark(t *testing.T) {
internal/store/store_test.go:79:func TestJobLifecycle(t *testing.T) {
internal/store/store_test.go:137:func TestJobLogs(t *testing.T) {
internal/store/store_test.go:171:func TestSyncAssignees(t *testing.T) {
internal/store/store_test.go:207:func TestSyncAssigneesTracksIssuesIndependently(t *testing.T) {
Thinking
Let's check the scheduler tests to understand the test patterns for event resolution, and also take a look at the fake forgejoActions.
Tool 🔧 read_file {"path": "internal/scheduler/scheduler_test.go", "start_line": 1, "end_line": 130}
{"path": "internal/scheduler/scheduler_test.go", "start_line": 1, "end_line": 130}
{"path":"internal/scheduler/scheduler_test.go","content":"package scheduler\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"fmt\"\r\n\t\"log/slog\"\r\n\t\"path/filepath\"\r\n\t\"sync\"\r\n\t\"testing\"\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/store\"\r\n)\r\n\r\nfunc testConfig() *config.Config {\r\n\treturn \u0026config.Config{\r\n\t\tLLMs: []config.LLM{{Name: \"qwen\", OpenAI: \"https://example.com\", Token: \"tok\", Model: \"qwen3\"}},\r\n\t\tForgejo:     config.Forgejo{URL: \"https://example.com\", Token: \"tok\"},\r\n\t\tEnvironment: config.Environment{DockerImage: \"debian:unstable\", MaxLive: 1},\r\n\t\tAgents: []config.Agent{\r\n\t\t\t{Name: \"leon\", LLM: \"qwen\"},\r\n\t\t\t{Name: \"greg\", LLM: \"qwen\"},\r\n\t\t},\r\n\t\tEvents: []config.Event{\r\n\t\t\t{Kind: config.EventIssueNew, Agent: \"leon\"},\r\n\t\t\t{Kind: config.EventIssueComment, Agent: \"leon\"},\r\n\t\t\t{Kind: config.EventIssueAssigned},\r\n\t\t\t{Kind: config.EventPRNew, Agent: \"greg\"},\r\n\t\t},\r\n\t}\r\n}\r\n\r\nfunc TestResolveAgentStatic(t *testing.T) {\r\n\tcfg := testConfig()\r\n\r\n\tname, ok := resolveAgent(cfg, forgejo.Event{Kind: config.EventIssueNew})\r\n\tif !ok || name != \"leon\" {\r\n\t\tt.Fatalf(\"expected leon, got %q, %v\", name, ok)\r\n\t}\r\n}\r\n\r\nfunc TestResolveAgentAssignedMatch(t *testing.T) {\r\n\tcfg := testConfig()\r\n\r\n\tname, ok := resolveAgent(cfg, forgejo.Event{Kind: config.EventIssueAssigned, Assignee: \"greg\"})\r\n\tif !ok || name != \"greg\" {\r\n\t\tt.Fatalf(\"expected greg, got %q, %v\", name, ok)\r\n\t}\r\n}\r\n\r\nfunc TestResolveAgentAssignedNoMatch(t *testing.T) {\r\n\tcfg := testConfig()\r\n\r\n\t_, ok := resolveAgent(cfg, forgejo.Event{Kind: config.EventIssueAssigned, Assignee: \"not-an-agent\"})\r\n\tif ok {\r\n\t\tt.Fatal(\"expected no agent to resolve for a non-agent assignee\")\r\n\t}\r\n}\r\n\r\ntype fakeForgejo struct {\r\n\tmu       sync.Mutex\r\n\tcomments []string\r\n\tlabels   []string\r\n}\r\n\r\nfunc (f *fakeForgejo) CreateIssueComment(owner, repo string, index int64, body string) error {\r\n\tf.mu.Lock()\r\n\tdefer f.mu.Unlock()\r\n\tf.comments = append(f.comments, body)\r\n\treturn nil\r\n}\r\n\r\nfunc (f *fakeForgejo) AddLabel(owner, repo string, index int64, name string) error {\r\n\tf.mu.Lock()\r\n\tdefer f.mu.Unlock()\r\n\tf.labels = append(f.labels, name)\r\n\treturn nil\r\n}\r\n\r\ntype fakeRunner struct {\r\n\tmu         sync.Mutex\r\n\trunning    int\r\n\tmaxSeen    int\r\n\tfail       bool\r\n\ttimeoutErr bool\r\n\tblockUntil chan struct{}\r\n}\r\n\r\nfunc (f *fakeRunner) Run(ctx context.Context, jobID string, agent config.AgentConfig, llm config.LLM, dockerImage string, ev forgejo.Event) error {\r\n\tf.mu.Lock()\r\n\tf.running++\r\n\tif f.running \u003e f.maxSeen {\r\n\t\tf.maxSeen = f.running\r\n\t}\r\n\tf.mu.Unlock()\r\n\r\n\tif f.blockUntil != nil {\r\n\t\t\u003c-f.blockUntil\r\n\t}\r\n\r\n\tf.mu.Lock()\r\n\tf.running--\r\n\tf.mu.Unlock()\r\n\r\n\tif f.timeoutErr {\r\n\t\treturn fmt.Errorf(\"agent loop: %w\", context.DeadlineExceeded)\r\n\t}\r\n\tif f.fail {\r\n\t\treturn fmt.Errorf(\"boom\")\r\n\t}\r\n\treturn nil\r\n}\r\n\r\nfunc newStore(t *testing.T) *store.Store {\r\n\tt.Helper()\r\n\ts, err := store.Open(filepath.Join(t.TempDir(), \"zoo.db\"))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tt.Cleanup(func() { s.Close() })\r\n\treturn s\r\n}\r\n\r\nfunc TestSchedulerDispatchAndSucceed(t *testing.T) {\r\n\tcfg := testConfig()\r\n\tst := newStore(t)\r\n\tfg := \u0026fakeForgejo{}\r","start_line":1,"end_line":130,"total_lines":347,"truncated":true}
Thinking
Now I have a solid understanding of the codebase. Let me also check the `sdk.PullRequest` type to figure out what fields are available (Head.Ref for the PR branch).
Tool 🔧 bash {"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n '1,120p' pull.go"}
{"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n '1,120p' pull.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.
// Copyright 2019 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 (
	"bytes"
	"encoding/json"
	"fmt"
	"net/url"
	"strings"
	"time"
)

// PRBranchInfo information about a branch
type PRBranchInfo struct {
	Name       string      `json:"label"`
	Ref        string      `json:"ref"`
	Sha        string      `json:"sha"`
	RepoID     int64       `json:"repo_id"`
	Repository *Repository `json:"repo"`
}

// PullRequest represents a pull request
type PullRequest struct {
	ID        int64      `json:"id"`
	URL       string     `json:"url"`
	Index     int64      `json:"number"`
	Poster    *User      `json:"user"`
	Title     string     `json:"title"`
	Body      string     `json:"body"`
	Labels    []*Label   `json:"labels"`
	Milestone *Milestone `json:"milestone"`
	Assignee  *User      `json:"assignee"`
	Assignees []*User    `json:"assignees"`
	State     StateType  `json:"state"`
	IsLocked  bool       `json:"is_locked"`
	Comments  int        `json:"comments"`

	HTMLURL  string `json:"html_url"`
	DiffURL  string `json:"diff_url"`
	PatchURL string `json:"patch_url"`

	Mergeable           bool       `json:"mergeable"`
	HasMerged           bool       `json:"merged"`
	Merged              *time.Time `json:"merged_at"`
	MergedCommitID      *string    `json:"merge_commit_sha"`
	MergedBy            *User      `json:"merged_by"`
	AllowMaintainerEdit bool       `json:"allow_maintainer_edit"`

	Base      *PRBranchInfo `json:"base"`
	Head      *PRBranchInfo `json:"head"`
	MergeBase string        `json:"merge_base"`

	Deadline *time.Time `json:"due_date"`
	Created  *time.Time `json:"created_at"`
	Updated  *time.Time `json:"updated_at"`
	Closed   *time.Time `json:"closed_at"`
}

// ChangedFile is a changed file in a diff
type ChangedFile struct {
	Filename         string `json:"filename"`
	PreviousFilename string `json:"previous_filename"`
	Status           string `json:"status"`
	Additions        int    `json:"additions"`
	Deletions        int    `json:"deletions"`
	Changes          int    `json:"changes"`
	HTMLURL          string `json:"html_url"`
	ContentsURL      string `json:"contents_url"`
	RawURL           string `json:"raw_url"`
}

// ListPullRequestsOptions options for listing pull requests
type ListPullRequestsOptions struct {
	ListOptions
	State StateType `json:"state"`
	// oldest, recentupdate, leastupdate, mostcomment, leastcomment, priority
	Sort      string
	Milestone int64
}

// MergeStyle is used specify how a pull is merged
type MergeStyle string

const (
	// MergeStyleMerge merge pull as usual
	MergeStyleMerge MergeStyle = "merge"
	// MergeStyleRebase rebase pull
	MergeStyleRebase MergeStyle = "rebase"
	// MergeStyleRebaseMerge rebase and merge pull
	MergeStyleRebaseMerge MergeStyle = "rebase-merge"
	// MergeStyleSquash squash and merge pull
	MergeStyleSquash MergeStyle = "squash"
)

// QueryEncode turns options into querystring argument
func (opt *ListPullRequestsOptions) QueryEncode() string {
	query := opt.getURLQuery()
	if len(opt.State) > 0 {
		query.Add("state", string(opt.State))
	}
	if len(opt.Sort) > 0 {
		query.Add("sort", opt.Sort)
	}
	if opt.Milestone > 0 {
		query.Add("milestone", fmt.Sprintf("%d", opt.Milestone))
	}
	return query.Encode()
}

// ListRepoPullRequests list PRs of one repository
func (c *Client) ListRepoPullRequests(owner, repo string, opt ListPullRequestsOptions) ([]*PullRequest, *Response, error) {
	if err := escapeValidatePathSegments(&owner, &repo); err != nil {
Thinking
Now I have a grasp of the whole picture. Let me design the implementation. ## Workflow Definition **PR Review Workflow:** 1. **Review submitted** → A human or agent submits a review on a PR (approve / request changes / comment, with optional inline comments). 2. **Zoo detects** → Via the `pull_request_review` webhook (action `submitted`), or via the polling fallback (list reviews on updated PRs). 3. **Zoo dispatches** → A `pr:review` event is dispatched to the statically configured agent (zoo.hcl: `event "pr:review" { agent = "..." }`). 4. **Agent run** → The agent is triggered with the following briefing: - The PR's title/body, - The review's state (approved / changes requested / comment), - The review's body, - All inline review comments (file, line, body, author), - The full comment thread, - The raw event payload. - **Working tree**: The PR's head branch is checked out (not a new branch from default), so the agent can push fixes directly to the PR. 5. **Agent reacts**: - Makes code changes addressing the feedback, commits, and pushes to the PR branch (updates the PR). - Replies to the review with a `comment` explaining what was changed / why. - Resolves addressed inline review comments with the `resolve_review_comment` tool. - If the review is an approval: thanks / wraps up (and possibly merges — out of scope). 6. **Loop prevention**: - The agent's own actions (comments, pushes, review submissions) don't re-trigger itself: the scheduler drops events whose author is the resolved agent. - The agent's own review submissions on the same PR → `pr:review` event with author == agent → dropped (if the resolved agent is the same). - Dedup by event ID ensures webhook + polling converge to a single run. **New tools:** - `resolve_review_comment(comment_id)` — Marks an inline review comment as resolved (for when the feedback has been addressed). - `submit_review(state, body)` — Submits a review on the PR (approve / request changes / comment). This lets agents participate in the review loop (e.g., re-review after a fix, or review a PR when triggered by `pr:new`). Wait, should I add `submit_review`? Let me think about the workflow: agent A opens a PR. Agent B (or a human) reviews → `pr:review` triggers agent A (or the configured agent). Agent A fixes and pushes. Then the reviewer needs to re-review. If the reviewer is an agent, how does it get triggered? A push to the PR is a `pull_request` `synchronize` action — that's not handled. Hmm. For the full loop to work between agents, we'd need a `pr:sync` event (new commits pushed to a PR) to trigger the reviewer. But that's scope creep. The issue says "respond to and react to PR reviews" — the core is reacting to reviews. Let me keep the scope: - `pr:review` event (webhook + polling). - `resolve_review_comment` tool. - `submit_review` tool — I'll include this. It's a natural part of "reacting to PR reviews" (an agent responding to a review can submit its own review, e.g., approving after a fix, or requesting changes). It also enables the `pr:new` workflow ("review this PR") to produce actual reviews, which then flow back into `pr:review` for the author's agent. This closes the loop: pr:new → agent reviews (submit_review) → pr:review → author's agent reacts (fixes, resolves comments, responds) → ... Hmm, wait, but there's a subtlety with `submit_review` + self-triggering: if greg is the author of a PR, and greg is also the statically configured agent for `pr:review`, and greg submits a review on his own PR (weird, but possible), the event is dropped (author == resolved agent). Fine. But here's a more realistic scenario: greg opens a PR (pr:new → greg reviews it? no wait, pr:new → greg per the example config, and greg is the author... the self-filter drops greg's own pr:new? no wait — for pr:new, the event's author is the PR author. If greg opens a PR, author=greg, resolved agent for pr:new=greg → dropped by the self-filter! Hmm, that's existing behavior. Interesting — so in the example config, when greg opens a PR, greg doesn't review it. When leon opens a PR, greg reviews it. OK, that's existing behavior and not my concern. Let me focus on `pr:review`. Now, about the working tree for `pr:review`: the agent needs to be on the PR's head branch. Let me think about how to implement this cleanly. Current flow in `Run`: ```go branch := fmt.Sprintf("issue-%d-%s", ev.Index, agent.Name) projectDir := filepath.Join(workDir, "project") if err := cloneAndBranch(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, branch, projectDir); err != nil { ``` For `pr:review`, I want to check out the PR's head branch. The PR's head branch is `pr.Head.Ref`. I can get it from the event's raw payload, or fetch it via the API. Option 1: Add a `HeadRef` field to `Event`, set from the webhook payload / polling. Option 2: In `Run`, if `ev.Kind == pr:review`, fetch the PR via the API to get the head ref. Option 1 is cleaner (no extra API call, and the data is already in the payload). Let me add a `PRHeadRef` field... hmm, actually let me think about naming. The event already has `Index` (PR number). Let me add: ```go // HeadRef is set for PR events (pr:new, pr:review): the PR's head // branch, so a run reacting to the PR can work on the PR's own branch // instead of a fresh one off the default branch. HeadRef string ``` Wait, but for `pr:new`, the agent is reviewing the PR — it doesn't necessarily need to be on the head branch. But it might be useful. Let me set `HeadRef` for both `pr:new` and `pr:review` (it's available in both payloads). But only use it for the working tree in `pr:review` (where the agent needs to push to the PR). Hmm, actually, let me reconsider. For `pr:review`, the agent should work on the PR's head branch. But the head branch might be in a fork (if the PR is from a fork). In that case, pushing to the head branch requires push access to the fork, which the zoo token might not have. For v1, let me assume the PR is from the same repo (which is the common case for agent workflows). I'll note this limitation. Let me design the clone logic: - For `pr:review` (and any event with `HeadRef` set): clone the repo (full, not single-branch, so we can access the head branch), and check out the head branch. Actually, we need to fetch the head branch. Let me do: ``` git clone --single-branch --branch <headRef> <url> <dir> ``` This clones just the head branch. Then the agent is on the head branch. Then push goes to the head branch. But wait, the current `cloneAndBranch` clones the default branch and creates a new branch. For `pr:review`, I want to clone the head branch directly and stay on it. Let me refactor `cloneAndBranch` to support both modes. Actually, let me add a new function `cloneBranch` that clones a specific existing branch (the PR head) and checks it out, vs. `cloneAndBranch` which clones default and creates a new branch. Hmm, but there's a subtlety: the agent's working branch name. Currently it's `issue-{index}-{agent}`. For `pr:review`, the working branch is the PR's head branch (e.g., `feature-xyz`). The seed message says "Your working branch is X, checked out from the default branch Y." For `pr:review`, it should say "Your working branch is the PR's head branch X." Let me restructure. In `Run`: ```go var branch string if ev.HeadRef != "" && ev.Kind == forgejo.EventPRReview { // work on the PR's own head branch so fixes push straight to the PR branch = ev.HeadRef if err := cloneBranch(ctx, repoInfo.CloneURL, r.forgejo.Token(), branch, projectDir); err != nil { return fmt.Errorf("prepare git working tree: %w", err) } } else { branch = fmt.Sprintf("issue-%d-%s", ev.Index, agent.Name) if err := cloneAndBranch(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, branch, projectDir); err != nil { return fmt.Errorf("prepare git working tree: %w", err) } } ``` Wait, but I need to be careful. Let me reconsider whether to gate on `ev.Kind == pr:review` or just `ev.HeadRef != ""`. If I set `HeadRef` for `pr:new` too, then `pr:new` runs would also check out the head branch. Is that desirable? For `pr:new`, the agent is reviewing the PR. Checking out the head branch is actually useful (it can see the PR's code). But the current behavior for `pr:new` is to branch from default. Changing `pr:new` behavior is a bigger change. Let me be conservative: only use `HeadRef` for the working tree when `ev.Kind == pr:review`. And set `HeadRef` for both `pr:new` and `pr:review` (harmless, and useful for the briefing). Actually, let me reconsider. Let me keep it simple and focused: - Set `HeadRef` on the event for `pr:review` (and `pr:new` since it's available). - In `Run`, if `ev.Kind == pr:review && ev.HeadRef != ""`, check out the head branch. Otherwise, the existing behavior. This is clean and surgical. Now, let me also think about the **seed message** for `pr:review`. I want to include: - The review's state (approved / changes requested / comment). - The review's body. - The inline review comments. - A note that the working branch is the PR's head branch. Let me add a `Review` field to the seed message. I'll fetch the review details in `Run` and pass them in. Let me define a `ReviewDetail` type in the forgejo package: ```go // ReviewDetail is the review context zoo briefs an agent with when a // pr:review event fires: the review's verdict and body, plus its inline // comments (file, line, text). type ReviewDetail struct { ID int64 State string // APPROVED, REQUEST_CHANGES, COMMENT Body string Reviewer string Comments []ReviewCommentDetail } type ReviewCommentDetail struct { Path string Line int Body string Author string } ``` And a client method: ```go func (c *Client) ReviewDetail(owner, repo string, index, reviewID int64) (*ReviewDetail, error) { review, _, err := c.sdk.GetPullReview(owner, repo, index, reviewID) if err != nil { return nil, fmt.Errorf("get review %d on %s/%s#%d: %w", reviewID, owner, repo, index, err) } comments, _, err := c.sdk.ListPullReviewComments(owner, repo, index, reviewID) if err != nil { return nil, fmt.Errorf("list comments on review %d: %w", reviewID, err) } ... } ``` Wait, but `GetPullReview` and `ListPullReviewComments` require server version >= 1.12.0. That's fine. Actually, for the webhook path, we already have the review in the payload. But for consistency (and because the polling path needs to fetch anyway), let me always fetch the review details in `Run` via `ReviewDetail`. That way the briefing is consistent regardless of source. And it fetches the inline comments, which the webhook payload doesn't include. Hmm, but that's an extra API call. That's fine — we already make several API calls per run (RepositoryInfo, ListIssueComments, AgentProfile per agent). Actually, let me reconsider. For the webhook path, the raw payload already has the review (state, body). But not the inline comments. So I need to fetch the inline comments anyway. Let me just fetch the full `ReviewDetail` (review + comments) in `Run` for `pr:review` events. Clean and consistent. Now, let me think about the **Event struct** changes. I'll add: ```go // Review fields, set for EventPRReview: the submitted review's id, // verdict, and body. Inline comments are fetched separately when // briefing the agent (they're not in the webhook payload). ReviewID int64 ReviewState string ReviewBody string // HeadRef is set for PR events: the PR's head branch name. HeadRef string ``` Now, let me think about the **webhook payload** for `pull_request_review`. I need to define the struct: ```go type pullRequestReviewPayload struct { Action string `json:"action"` Review *sdk.PullReview `json:"review"` PullRequest *sdk.PullRequest `json:"pull_request"` Repository *sdk.Repository `json:"repository"` } ``` And the decoder: ```go case "pull_request_review": var p pullRequestReviewPayload if err := json.Unmarshal(body, &p); err != nil { return Event{}, false, err } return pullRequestReviewEvent(p, body) ``` ```go func pullRequestReviewEvent(p pullRequestReviewPayload, raw []byte) (Event, bool, error) { if p.Action != "submitted" || p.Review == nil || p.PullRequest == nil || p.Repository == nil { return Event{}, false, nil } headRef := "" if p.PullRequest.Head != nil { headRef = p.PullRequest.Head.Ref } return Event{ Kind: EventPRReview, ID: prReviewID(p.Review.ID), Owner: repoOwner(p.Repository), Repo: p.Repository.Name, Index: p.PullRequest.Index, Title: p.PullRequest.Title, Body: p.PullRequest.Body, Author: posterName(p.Review.Reviewer), ReviewID: p.Review.ID, ReviewState: string(p.Review.State), ReviewBody: p.Review.Body, HeadRef: headRef, Raw: raw, }, true, nil } ``` Wait, `p.Review.Reviewer` is a `*User`. Let me check the field name. In the SDK, `PullReview.Reviewer *User`. Yes. So `posterName(p.Review.Reviewer)`. Now, the **polling path**. In `pollPulls`, after dispatching `pr:new` for created PRs, I'll also check for new reviews: ```go for _, issue := range issues { if issue.Repository == nil { continue } if issue.Updated.After(next) { next = issue.Updated } owner, repo := issue.Repository.Owner, issue.Repository.Name if issue.Created.After(since) { w.dispatch(issueToPRNewEvent(issue, owner, repo)) } w.pollReviews(ctx, owner, repo, issue, since) } ``` And: ```go func (w *Watcher) pollReviews(ctx context.Context, owner, repo string, pr *sdk.Issue, since time.Time) { reviews, _, err := w.client.sdk.ListPullReviews(owner, repo, pr.Index, sdk.ListPullReviewsOptions{}) if err != nil { w.logger.Warn("poll reviews failed", "owner", owner, "repo", repo, "pr", pr.Index, "error", err) return } for _, review := range reviews { if review.Reviewer == nil { continue } if review.Submitted.IsZero() || !review.Submitted.After(since) { continue } w.dispatch(reviewToEvent(pr, review, owner, repo)) } } ``` Wait, but `pr` is a `*sdk.Issue` (since `ListIssues` returns issues, and PRs are issues). But I need the PR's head ref. `sdk.Issue` — does it have `Head`? Let me check. `sdk.Issue` is the issue type. PRs are issues with a `PullRequest` field. Let me check the `Issue` struct. Actually, `ListIssues` with `Type: IssueTypePull` returns `[]*Issue`, and each `Issue` has a `PullRequest *PullRequest` field (for PRs). Let me verify. Let me check the SDK's `Issue` struct.
Tool 🔧 bash {"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n '/^type Issue struct/,/^}/p' issue.go"}
{"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n '/^type Issue struct/,/^}/p' issue.go"}
type Issue struct {
	ID               int64      `json:"id"`
	URL              string     `json:"url"`
	HTMLURL          string     `json:"html_url"`
	Index            int64      `json:"number"`
	Poster           *User      `json:"user"`
	OriginalAuthor   string     `json:"original_author"`
	OriginalAuthorID int64      `json:"original_author_id"`
	Title            string     `json:"title"`
	Body             string     `json:"body"`
	Ref              string     `json:"ref"`
	Labels           []*Label   `json:"labels"`
	Milestone        *Milestone `json:"milestone"`
	Assignees        []*User    `json:"assignees"`
	// Whether the issue is open or closed
	State       StateType        `json:"state"`
	IsLocked    bool             `json:"is_locked"`
	Comments    int              `json:"comments"`
	Created     time.Time        `json:"created_at"`
	Updated     time.Time        `json:"updated_at"`
	Closed      *time.Time       `json:"closed_at"`
	Deadline    *time.Time       `json:"due_date"`
	PullRequest *PullRequestMeta `json:"pull_request"`
	Repository  *RepositoryMeta  `json:"repository"`
}
Thinking
`Issue.PullRequest` is `*PullRequestMeta`. Let me check `PullRequestMeta`:
Tool 🔧 bash {"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n '/^type PullRequestMeta struct/,/^}/p' issue.go; echo ---; sed -n '/^type RepositoryMeta struct/,/^}/p' issue.go"}
{"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n '/^type PullRequestMeta struct/,/^}/p' issue.go; echo ---; sed -n '/^type RepositoryMeta struct/,/^}/p' issue.go"}
type PullRequestMeta struct {
	HasMerged bool       `json:"merged"`
	Merged    *time.Time `json:"merged_at"`
}
---
type RepositoryMeta struct {
	ID       int64  `json:"id"`
	Name     string `json:"name"`
	Owner    string `json:"owner"`
	FullName string `json:"full_name"`
}
Thinking
So `Issue.PullRequest` is just `PullRequestMeta` (HasMerged, Merged) — no head ref. Which means on the polling path, we don't get the head ref from the issue list. We'd need to fetch the PR via `GetPullRequest(owner, repo, index)` to get the head ref. Hmm. So on the polling path, to get the head ref, we need to call `GetPullRequest`. That's an extra API call per updated PR. But we're already calling `ListPullReviews` per updated PR. Let me check if `ListPullReviews` or the review itself has the head ref... no, `PullReview` doesn't have the head ref. Options: 1. In `pollReviews`, for each new review, fetch the PR via `GetPullRequest` to get the head ref. This is an extra call, but only for new reviews (rare). 2. Don't set HeadRef on the polling path; set it only on the webhook path. But then the working-tree behavior differs between webhook and polling. Not great. 3. In `Run`, if `ev.Kind == pr:review && ev.HeadRef == ""`, fetch the PR to get the head ref. This centralizes the fetch in `Run` and works for both paths. Option 3 is cleanest: the event carries HeadRef if it's available (webhook), and `Run` fills it in from the API if not. Actually, even simpler: in `Run`, for `pr:review`, always fetch the PR to get the current head ref (via `GetPullRequest`). That's the most reliable (the head ref could have changed). But that's an extra API call. Hmm, let me reconsider. Actually, the cleanest is: in `Run`, for `pr:review`, fetch the PR via a new client method `PullRequestInfo(owner, repo, index)` that returns the head ref (and maybe the base ref). Use that for the working tree. This is reliable and works for both webhook and polling. The event's HeadRef (from webhook) can be used as a fallback or for the briefing, but the authoritative source is the API fetch in `Run`. Wait, but that means we're fetching the PR in `Run` anyway. So do we even need HeadRef on the Event? For the briefing, we can include the head ref from the fetch. Let me simplify: - Don't add HeadRef to Event. - In `Run`, for `pr:review`, fetch the PR's head ref via the API. - Use it for the working tree and the briefing. But wait, that means the webhook path doesn't need to extract HeadRef either. Let me keep the Event minimal: just ReviewID, ReviewState, ReviewBody. And fetch the PR head ref in `Run`. Hmm, but actually, fetching the PR in `Run` is fine. Let me add a client method: ```go // PullRequestInfo returns the PR's head and base branch refs. type PullRequestInfo struct { HeadRef string BaseRef string } func (c *Client) PullRequestInfo(owner, repo string, index int64) (PullRequestInfo, error) { pr, _, err := c.sdk.GetPullRequest(owner, repo, index) if err != nil { return PullRequestInfo{}, fmt.Errorf("get pull request %s/%s#%d: %w", owner, repo, index, err) } info := PullRequestInfo{} if pr.Head != nil { info.HeadRef = pr.Head.Ref } if pr.Base != nil { info.BaseRef = pr.Base.Ref } return info, nil } ``` Let me verify `GetPullRequest` exists in the SDK. Actually, I realize I should double-check: does the agent really need to be on the PR head branch? Let me reconsider the workflow. When a review is submitted on a PR, the "PR author" should react. In the agent workflow, the PR author is typically an agent (e.g., greg opened a PR via `open_pull_request`). The PR's head branch is `issue-{index}-{author-agent}` (e.g., `issue-9-greg`). When the review comes in, the agent handling `pr:review` needs to check out `issue-9-greg`, make fixes, and push to `issue-9-greg`. That updates the PR. So yes, the agent needs to be on the PR head branch. And the head branch is `issue-{index}-{original-author}`. But here's a subtlety: the agent handling `pr:review` might be a different agent than the PR author. E.g., greg opened the PR (head branch `issue-9-greg`), and leon is configured for `pr:review`. Then leon checks out `issue-9-greg`, makes fixes, and pushes to `issue-9-greg`. That works (leon has push access via the zoo token). Good. So the working tree for `pr:review` = the PR head branch. Let me implement that. Now, one concern: `cloneAndBranch` uses `--single-branch` on the default branch. For the head branch, I need to clone that specific branch. Let me write `cloneBranch`: ```go func cloneBranch(ctx context.Context, cloneURL, token, branch, dir string) error { args := append(gitAuthArgs(token), "clone", "--branch", branch, "--single-branch", cloneURL, dir) if out, err := runGit(ctx, "", args...); err != nil { return fmt.Errorf("clone branch %q: %w: %s", branch, err, out) } return nil } ``` This clones the head branch and checks it out (git clone --branch checks out that branch). Good. But wait, there's a problem: the head branch might be a PR from a fork. In that case, the branch doesn't exist in the main repo, and `git clone --branch <headRef>` fails. For v1, I'll assume same-repo PRs. If it fails, the run fails with a clear error. I'll note this limitation in the workflow doc. Actually, let me reconsider. To be more robust, I could clone the default branch (as now) and then fetch the PR head via the PR ref (`refs/pull/{index}/head`). Forgejo/Gitea exposes PR heads as `refs/pull/{index}/head`. So: ``` git clone --single-branch --branch <default> <url> <dir> git fetch origin refs/pull/{index}/head git checkout -B <headRef> FETCH_HEAD ``` This works even for fork PRs (the PR ref is in the main repo). And it's more robust. But it's more complex. Hmm, let me keep it simpler for v1: `git clone --branch <headRef> --single-branch`. If the PR is from a fork, it fails with a clear error. Actually, let me use the more robust PR-ref approach, since it's not much more code and handles the common fork case. Wait, but `refs/pull/{index}/head` — is that the right ref for Forgejo? Gitea uses `refs/pull/{index}/head`. Let me verify... Gitea exposes PRs as `refs/pull/{number}/head` and `refs/pull/{number}/merge`. Yes. Actually, let me step back. I'm overcomplicating this. Let me reconsider the scope and what's truly necessary. The issue is: "We need agents to respond to and react to PR reviews. Define a workflow and implement the Zoo side." The core "Zoo side" is: 1. Detect PR reviews (webhook + polling) → `pr:review` event. 2. Dispatch to the configured agent. 3. Brief the agent with the review context (state, body, inline comments). 4. Give the agent the tools to react (resolve_review_comment, submit_review). 5. Set up the working tree so the agent can push fixes to the PR (check out the head branch). I think checking out the head branch is important for "react" (making changes). Let me implement it with the simple `clone --branch <headRef>` approach, and note the fork limitation. Actually, let me use the PR-ref approach for robustness — it's only a few more lines and handles forks. Wait, let me reconsider once more. Let me look at what's cleanest and most testable. The `cloneAndBranch` / `cloneBranch` functions run real git commands, so they're hard to unit test (they need a real git repo). The existing tests don't test `cloneAndBranch` directly (it's integration-level). So I have freedom here. Let me go with the PR-ref approach for robustness: ```go // clonePRHead clones cloneURL (default branch) and checks out the PR's // head as a local branch named headRef, via the PR's refs/pull/<index>/head // ref. This works for PRs from forks too, where the head branch doesn't // exist in the base repo. func clonePRHead(ctx context.Context, cloneURL, token string, index int64, headRef, dir string) error { // clone default branch (we don't know it here, so clone all? no...) } ``` Hmm, the problem is I don't know the default branch in this function. `cloneAndBranch` takes `defaultBranch`. Let me pass it in. Actually, `repoInfo.DefaultBranch` is available in `Run`. Let me pass both. Actually, the simplest robust approach: ```go func clonePRHead(ctx context.Context, cloneURL, token, defaultBranch, headRef string, index int64, dir string) error { // Clone the default branch first (guaranteed to exist). args := append(gitAuthArgs(token), "clone", "--branch", defaultBranch, "--single-branch", cloneURL, dir) if out, err := runGit(ctx, "", args...); err != nil { return fmt.Errorf("clone: %w: %s", err, out) } // Fetch the PR head and check it out as a local branch. if out, err := runGit(ctx, dir, "fetch", "origin", fmt.Sprintf("refs/pull/%d/head", index)); err != nil { return fmt.Errorf("fetch PR head: %w: %s", err, out) } if out, err := runGit(ctx, dir, "checkout", "-B", headRef, "FETCH_HEAD"); err != nil { return fmt.Errorf("checkout PR head: %w: %s", err, out) } return nil } ``` Wait, but `git fetch origin refs/pull/N/head` without auth — the fetch needs to authenticate. Let me add auth args to the fetch. Actually, `gitAuthArgs` uses `http.extraHeader`, which applies to the whole git invocation. Let me add it to the fetch too. Hmm, this is getting complicated. Let me simplify: since the clone already authenticated and set up the remote, subsequent fetches to the same remote need auth again (git doesn't persist the extra header). Let me add `gitAuthArgs` to the fetch. Actually, you know what, let me step back and reconsider whether checking out the head branch is in scope, or if I'm overengineering. Let me re-read the issue: "We need agents to respond to and react to PR reviews. Define a workflow and implement the Zoo side." I think "react" primarily means the agent takes action in response to the review — which includes making code changes. For the agent to make code changes that land on the PR, it needs to be on the PR head branch. So yes, it's in scope and important. But let me reconsider: maybe I'm overcomplicating the git part. Let me use the simple `clone --branch <headRef>` approach for same-repo PRs, which is the common case in agent workflows (agents open PRs from their own branches in the same repo). I'll note the fork limitation. This is simpler and cleaner. Actually, let me do the robust PR-ref approach but keep it clean. It's the right thing to do and not that much more code. Let me write it carefully. Wait, I realize there's an even simpler and more robust option. Since the agent's PR head branch is typically `issue-{index}-{author}`, and the agent that reacts might be different, let me just fetch the PR head ref. Let me go with: ```go func clonePRHead(ctx context.Context, cloneURL, token, defaultBranch, headRef string, index int64, dir string) error { args := append(gitAuthArgs(token), "clone", "--branch", defaultBranch, "--single-branch", cloneURL, dir) if out, err := runGit(ctx, "", args...); err != nil { return fmt.Errorf("clone: %w: %s", err, out) } fetchArgs := append(gitAuthArgs(token), "fetch", "origin", fmt.Sprintf("refs/pull/%d/head:refs/remotes/origin/pr-%d", index, index)) if out, err := runGit(ctx, dir, fetchArgs...); err != nil { return fmt.Errorf("fetch PR head: %w: %s", err, out) } if out, err := runGit(ctx, dir, "checkout", "-B", headRef, fmt.Sprintf("refs/remotes/origin/pr-%d", index)); err != nil { return fmt.Errorf("checkout PR head: %w: %s", err, out) } return nil } ``` Hmm, `git fetch origin <src>:<dst>` — the dst is a local ref. `refs/remotes/origin/pr-N` is a bit unusual but works. Then `checkout -B headRef <that ref>`. This creates a local branch `headRef` pointing at the PR head. Good. Actually, simpler: `git fetch origin refs/pull/N/head` (no dst), then `git checkout -B headRef FETCH_HEAD`. FETCH_HEAD is set by the fetch. Let me use that. ```go func clonePRHead(ctx context.Context, cloneURL, token, defaultBranch, headRef string, index int64, dir string) error { args := append(gitAuthArgs(token), "clone", "--branch", defaultBranch, "--single-branch", cloneURL, dir) if out, err := runGit(ctx, "", args...); err != nil { return fmt.Errorf("clone: %w: %s", err, out) } fetchArgs := append(gitAuthArgs(token), "fetch", "--no-tags", "origin", fmt.Sprintf("refs/pull/%d/head", index)) if out, err := runGit(ctx, dir, fetchArgs...); err != nil { return fmt.Errorf("fetch PR head: %w: %s", err, out) } if out, err := runGit(ctx, dir, "checkout", "-B", headRef, "FETCH_HEAD"); err != nil { return fmt.Errorf("checkout PR head: %w: %s", err, out) } return nil } ``` This is clean. Let me go with this. Now, let me also reconsider: do I even need to change the working tree? What if the agent just makes changes and opens a new PR, or pushes to a new branch? No — the whole point is to update the existing PR in response to the review. So the agent must push to the PR head branch. So yes, check out the head branch. OK let me now also reconsider the `submit_review` tool. Is it necessary? Let me think about the workflow again: - Agent A opens a PR. - Reviewer (human or agent B) submits a review → `pr:review` → agent A reacts. - Agent A fixes, pushes, resolves comments, comments. - Reviewer re-reviews. If the reviewer is agent B, how does B get triggered to re-review? For B to re-review, we'd need a `pr:sync` event (new commits pushed). That's out of scope. So the re-review step is manual (human) or requires a future `pr:sync` event. But `submit_review` is still useful: - When agent A is triggered by `pr:review` and the review is "changes requested", A fixes and... no, A doesn't submit a review on its own PR. - When an agent is triggered by `pr:new` (review this PR), it can submit an actual review (approve/request changes/comment) via `submit_review`. That's a big improvement over just commenting. And that review then triggers `pr:review` for the author's agent. So `submit_review` closes the loop between `pr:new` and `pr:review`. So `submit_review` is valuable and in scope ("react to PR reviews" includes the ability to submit reviews as part of the review workflow). Let me include it. Actually, wait. Let me reconsider the scope more carefully. The issue is specifically about "respond to and react to PR reviews." Let me focus on the react side (the agent that receives a review). The `submit_review` tool is more about the review side (the agent that reviews). But it's a natural complement and makes the workflow complete. And it's a small addition. Let me include it, but keep the focus on the react side. Hmm, let me reconsider. Adding `submit_review` means the agent can approve/request-changes. That's a significant capability. But it's directly related to "PR reviews." I'll include it. It makes the review workflow actually work end-to-end between agents. Let me also add `resolve_review_comment` — that's clearly in scope (reacting to a review = resolving the inline comments you've addressed). OK, let me now also think about whether I need a `pr:review:comment` event (for standalone review comments). I decided to skip it to avoid double-triggering (review submission already includes inline comments). I'll document this. Now, let me also reconsider: should `pr:review` be a static event (configured agent) or dynamic (PR author's agent)? I decided static, for consistency. But let me reconsider dynamic. The "correct" agent to react to a review is the PR author. But the existing architecture only supports dynamic resolution for `issue:assigned` (by assignee username). For `pr:review`, dynamic resolution would be "the agent whose name matches the PR author's username." That's actually a nice fit! The PR author's username is in the event (the `Author` field... no wait, for `pr:review`, the `Author` is the reviewer, not the PR author). Hmm. Let me reconsider. For `pr:review`: - `Author` = the reviewer (who submitted the review). - The PR author = who opened the PR. If I want dynamic resolution to the PR author, I need the PR author's username. That's in the payload (`pull_request.user.login`). I could add a `PRAuthor` field to the event. But dynamic resolution adds complexity and deviates from the existing static pattern. And the self-filter: if the PR author's agent is triggered, and that agent is also the reviewer... no, the reviewer and author are different. Let me keep it simple and go with static resolution (configured agent for `pr:review`). This is consistent with `pr:new`, `issue:new`, `issue:comment`. The operator decides which agent reacts to reviews. This is the least surprising and most consistent choice. Actually, wait. Let me reconsider. There's a strong argument for dynamic (PR author) resolution: the PR author is the one who should fix the code. If I statically assign `pr:review` to, say, leon, then when greg's PR gets a review, leon fixes greg's code. That might be fine (leon is a senior dev), but it's not "correct." But the existing design philosophy is static mappings (except `issue:assigned`). And the operator can configure `pr:review` to the most capable agent. Let me go static for consistency and simplicity. I'll document that the operator should configure `pr:review` to an agent that can handle PR feedback. Hmm, but actually, let me reconsider once more. Let me look at how `issue:assigned` works — it's dynamic because the assignee IS the agent (the issue is assigned to an agent's username). For `pr:review`, there's no analogous "the review is assigned to X." So static is the right choice. OK, decision: static resolution for `pr:review`. Now, let me also think about the **config** changes: - Add `EventPRReview = "pr:review"` to the config package constants. - Add it to `staticEventKinds`. - Update `zoo.hcl.example` with an `event "pr:review"` block. And the **forgejo** package: - Add `EventPRReview = "pr:review"` to the event kinds. - Add the webhook decoder for `pull_request_review`. - Add the polling for reviews. - Add `ReviewDetail` type + `ReviewDetail` client method. - Add `PullRequestInfo` type + method (for the head ref). - Add `ResolveReviewComment` and `SubmitReview` client methods. And the **tools** package: - Add `resolve_review_comment` tool. - Add `submit_review` tool. - Add these to the `ForgejoActions` interface. And the **agentrun** package: - Update `Run` to fetch review details + PR head ref for `pr:review`, check out the head branch, and brief the agent. - Update `seedMessage` to include the review section. - Update `runForgejoActions` to implement the new methods. Let me look at `runForgejoActions` to see how it's structured. Let me search for `runForgejoActions`.
Tool 🔧 grep_search {"query": "runForgejoActions", "context_after": 30}
{"query": "runForgejoActions", "context_after": 30}
/project/internal/agentrun/forgejo.go:9:// runForgejoActions implements tools.ForgejoActions, binding the
/project/internal/agentrun/forgejo.go-10-// generic forgejo.Client to the single issue/PR that triggered this run
/project/internal/agentrun/forgejo.go-11-// so agent tool calls don't need to specify owner/repo/index themselves.
/project/internal/agentrun/forgejo.go:12:type runForgejoActions struct {
/project/internal/agentrun/forgejo.go-13-	client      *forgejo.Client
/project/internal/agentrun/forgejo.go-14-	owner, repo string
/project/internal/agentrun/forgejo.go-15-	index       int64
/project/internal/agentrun/forgejo.go-16-	logger      *slog.Logger
/project/internal/agentrun/forgejo.go-17-}
/project/internal/agentrun/forgejo.go-18-
/project/internal/agentrun/forgejo.go-19-// logResult logs a forge action once it's known to have succeeded or
/project/internal/agentrun/forgejo.go-20-// failed, so every comment, PR, label, or issue-state change an agent
/project/internal/agentrun/forgejo.go-21-// makes is traceable in zoo's own logs, not just in Forgejo's history.
/project/internal/agentrun/forgejo.go:22:func (a *runForgejoActions) logResult(action string, err error, args ...any) {
/project/internal/agentrun/forgejo.go-23-	fields := append([]any{"owner", a.owner, "repo", a.repo, "index", a.index}, args...)
/project/internal/agentrun/forgejo.go-24-
/project/internal/agentrun/forgejo.go-25-	if err != nil {
/project/internal/agentrun/forgejo.go-26-		a.logger.Warn("forge action failed", append(append([]any{"action", action}, fields...), "error", err)...)
/project/internal/agentrun/forgejo.go-27-		return
/project/internal/agentrun/forgejo.go-28-	}
/project/internal/agentrun/forgejo.go-29-
/project/internal/agentrun/forgejo.go-30-	a.logger.Info("forge action", append([]any{"action", action}, fields...)...)
/project/internal/agentrun/forgejo.go-31-}
/project/internal/agentrun/forgejo.go-32-
/project/internal/agentrun/forgejo.go:33:func (a *runForgejoActions) Comment(body string) error {
/project/internal/agentrun/forgejo.go-34-	err := a.client.CreateIssueComment(a.owner, a.repo, a.index, body)
/project/internal/agentrun/forgejo.go-35-	a.logResult("comment", err)
/project/internal/agentrun/forgejo.go-36-
/project/internal/agentrun/forgejo.go-37-	return err
/project/internal/agentrun/forgejo.go-38-}
/project/internal/agentrun/forgejo.go-39-
/project/internal/agentrun/forgejo.go:40:func (a *runForgejoActions) OpenPullRequest(head, base, title, body string) error {
/project/internal/agentrun/forgejo.go-41-	err := a.client.CreatePullRequest(a.owner, a.repo, head, base, title, body)
/project/internal/agentrun/forgejo.go-42-	a.logResult("open_pull_request", err, "head", head, "base", base, "title", title)
/project/internal/agentrun/forgejo.go-43-
/project/internal/agentrun/forgejo.go-44-	return err
/project/internal/agentrun/forgejo.go-45-}
/project/internal/agentrun/forgejo.go-46-
/project/internal/agentrun/forgejo.go:47:func (a *runForgejoActions) RequestReview(reviewers []string) error {
/project/internal/agentrun/forgejo.go-48-	err := a.client.RequestReview(a.owner, a.repo, a.index, reviewers)
/project/internal/agentrun/forgejo.go-49-	a.logResult("request_review", err, "reviewers", reviewers)
/project/internal/agentrun/forgejo.go-50-
/project/internal/agentrun/forgejo.go-51-	return err
/project/internal/agentrun/forgejo.go-52-}
/project/internal/agentrun/forgejo.go-53-
/project/internal/agentrun/forgejo.go:54:func (a *runForgejoActions) AddLabel(name string) error {
/project/internal/agentrun/forgejo.go-55-	err := a.client.AddLabel(a.owner, a.repo, a.index, name)
/project/internal/agentrun/forgejo.go-56-	a.logResult("add_label", err, "label", name)
/project/internal/agentrun/forgejo.go-57-
/project/internal/agentrun/forgejo.go-58-	return err
/project/internal/agentrun/forgejo.go-59-}
/project/internal/agentrun/forgejo.go-60-
/project/internal/agentrun/forgejo.go:61:func (a *runForgejoActions) RemoveLabel(name string) error {
/project/internal/agentrun/forgejo.go-62-	err := a.client.RemoveLabel(a.owner, a.repo, a.index, name)
/project/internal/agentrun/forgejo.go-63-	a.logResult("remove_label", err, "label", name)
/project/internal/agentrun/forgejo.go-64-
/project/internal/agentrun/forgejo.go-65-	return err
/project/internal/agentrun/forgejo.go-66-}
/project/internal/agentrun/forgejo.go-67-
/project/internal/agentrun/forgejo.go:68:func (a *runForgejoActions) CloseIssue() error {
/project/internal/agentrun/forgejo.go-69-	err := a.client.CloseIssue(a.owner, a.repo, a.index)
/project/internal/agentrun/forgejo.go-70-	a.logResult("close_issue", err)
/project/internal/agentrun/forgejo.go-71-
/project/internal/agentrun/forgejo.go-72-	return err
/project/internal/agentrun/forgejo.go-73-}
/project/internal/agentrun/forgejo.go-74-
/project/internal/agentrun/forgejo.go:75:func (a *runForgejoActions) ReopenIssue() error {
/project/internal/agentrun/forgejo.go-76-	err := a.client.ReopenIssue(a.owner, a.repo, a.index)
/project/internal/agentrun/forgejo.go-77-	a.logResult("reopen_issue", err)
/project/internal/agentrun/forgejo.go-78-
/project/internal/agentrun/forgejo.go-79-	return err
/project/internal/agentrun/forgejo.go-80-}
/project/internal/agentrun/forgejo.go-81-
/project/internal/agentrun/forgejo.go:82:func (a *runForgejoActions) AssignIssue(agentName string) error {
/project/internal/agentrun/forgejo.go-83-	err := a.client.AssignIssue(a.owner, a.repo, a.index, agentName)
/project/internal/agentrun/forgejo.go-84-	a.logResult("assign_issue", err, "assignee", agentName)
/project/internal/agentrun/forgejo.go-85-
/project/internal/agentrun/forgejo.go-86-	return err
/project/internal/agentrun/forgejo.go-87-}
--
/project/internal/agentrun/run.go:196:		forgejo: &runForgejoActions{
/project/internal/agentrun/run.go-197-			client: r.forgejoAs(agent.Name, agent.Token),
/project/internal/agentrun/run.go-198-			owner:  ev.Owner,
/project/internal/agentrun/run.go-199-			repo:   ev.Repo,
/project/internal/agentrun/run.go-200-			index:  ev.Index,
/project/internal/agentrun/run.go-201-			logger: logger,
/project/internal/agentrun/run.go-202-		},
/project/internal/agentrun/run.go-203-	}
/project/internal/agentrun/run.go-204-
/project/internal/agentrun/run.go-205-	llmClient := llm.NewClient(llmCfg)
/project/internal/agentrun/run.go-206-
/project/internal/agentrun/run.go-207-	systemPrompt := defaultSystemPrompt + identitySection(agent.Name, roster)
/project/internal/agentrun/run.go-208-
/project/internal/agentrun/run.go-209-	instructions := r.cfg.EventInstructions(ev.Kind)
/project/internal/agentrun/run.go-210-
/project/internal/agentrun/run.go-211-	// Fetch the full comment thread so the agent sees everything that's
/project/internal/agentrun/run.go-212-	// been said on the issue/PR, not just the triggering event (which
/project/internal/agentrun/run.go-213-	// only carries the latest comment, if any). A failure degrades to
/project/internal/agentrun/run.go-214-	// no comments rather than failing the run: the agent can still do
/project/internal/agentrun/run.go-215-	// its job, just without prior context.
Thinking
Now I have a grasp of the overall picture. Let me check the rest of the scheduler tests and the web package to see if there's anything else that needs updating (e.g., the dashboard displaying event types). Let me quickly check the web package to see if event types are referenced anywhere.
Tool 🔧 grep_search {"query": "EventPRNew|pr:new|EventIssue", "glob": "*.go", "context_after": 2}
{"query": "EventPRNew|pr:new|EventIssue", "glob": "*.go", "context_after": 2}
/project/internal/config/config.go:14:	EventIssueNew      = "issue:new"
/project/internal/config/config.go:15:	EventIssueComment  = "issue:comment"
/project/internal/config/config.go:16:	EventIssueAssigned = "issue:assigned"
/project/internal/config/config.go:17:	EventPRNew         = "pr:new"
/project/internal/config/config.go-18-)
/project/internal/config/config.go-19-
--
/project/internal/config/config.go:21:	EventIssueNew:     true,
/project/internal/config/config.go:22:	EventIssueComment: true,
/project/internal/config/config.go:23:	EventPRNew:        true,
/project/internal/config/config.go-24-}
/project/internal/config/config.go-25-
--
/project/internal/config/config.go:126:		case e.Kind == EventIssueAssigned:
/project/internal/config/config.go-127-			if e.Agent != "" {
/project/internal/config/config.go-128-				return fmt.Errorf("event %q: must not set agent; the agent whose name matches the Forgejo assignee's username is triggered dynamically", e.Kind)
--
/project/internal/config/config_test.go:54:event "pr:new" {
/project/internal/config/config_test.go-55-    agent = "leon"
/project/internal/config/config_test.go-56-}
--
/project/internal/config/config_test.go:69:	if agent, ok := cfg.EventAgent(EventIssueNew); !ok || agent != "leon" {
/project/internal/config/config_test.go-70-		t.Fatalf("expected issue:new -> leon, got %q, %v", agent, ok)
/project/internal/config/config_test.go-71-	}
--
/project/internal/config/config_test.go:73:	if _, ok := cfg.EventAgent(EventIssueAssigned); ok {
/project/internal/config/config_test.go-74-		t.Fatalf("issue:assigned should have no static agent")
/project/internal/config/config_test.go-75-	}
--
/project/internal/config/config_test.go:77:	if got := cfg.EventInstructions(EventIssueAssigned); got != "Please handle this issue." {
/project/internal/config/config_test.go-78-		t.Fatalf("unexpected issue:assigned instructions: %q", got)
/project/internal/config/config_test.go-79-	}
/project/internal/config/config_test.go:80:	if got := cfg.EventInstructions(EventIssueComment); got != "Please review the comment and respond appropriately." {
/project/internal/config/config_test.go-81-		t.Fatalf("unexpected issue:comment instructions: %q", got)
/project/internal/config/config_test.go-82-	}
/project/internal/config/config_test.go:83:	if got := cfg.EventInstructions(EventPRNew); got != "" {
/project/internal/config/config_test.go:84:		t.Fatalf("expected no instructions for pr:new, got %q", got)
/project/internal/config/config_test.go-85-	}
/project/internal/config/config_test.go-86-}
--
/project/internal/web/web_test.go:31:		Events:      []config.Event{{Kind: config.EventIssueNew, Agent: "leon"}},
/project/internal/web/web_test.go-32-		Environment: config.Environment{DockerImage: "debian:unstable", MaxLive: 2},
/project/internal/web/web_test.go-33-	}
--
/project/internal/forgejo/poll.go:178:		Kind:   EventIssueNew,
/project/internal/forgejo/poll.go-179-		ID:     issueNewID(issue.ID),
/project/internal/forgejo/poll.go-180-		Owner:  owner,
--
/project/internal/forgejo/poll.go:194:		Kind:     EventIssueAssigned,
/project/internal/forgejo/poll.go-195-		ID:       issueAssignedID(issue.ID, assignee, issue.Updated),
/project/internal/forgejo/poll.go-196-		Owner:    owner,
--
/project/internal/forgejo/poll.go:211:		Kind:    EventIssueComment,
/project/internal/forgejo/poll.go-212-		ID:      issueCommentID(comment.ID),
/project/internal/forgejo/poll.go-213-		Owner:   owner,
--
/project/internal/forgejo/poll.go:228:		Kind:   EventPRNew,
/project/internal/forgejo/poll.go-229-		ID:     prNewID(issue.ID),
/project/internal/forgejo/poll.go-230-		Owner:  owner,
--
/project/internal/forgejo/webhook_test.go:99:	if ev.Kind != EventIssueNew || ev.Owner != "acme" || ev.Repo != "widgets" || ev.Index != 5 || ev.Author != "alice" {
/project/internal/forgejo/webhook_test.go-100-		t.Fatalf("unexpected event: %+v", ev)
/project/internal/forgejo/webhook_test.go-101-	}
--
/project/internal/forgejo/webhook_test.go:115:	if ev.Kind != EventIssueAssigned || ev.Assignee != "leon" {
/project/internal/forgejo/webhook_test.go-116-		t.Fatalf("unexpected event: %+v", ev)
/project/internal/forgejo/webhook_test.go-117-	}
--
/project/internal/forgejo/webhook_test.go:168:	if ev.Kind != EventIssueComment || ev.Comment != "any update?" || ev.Author != "bob" {
/project/internal/forgejo/webhook_test.go-169-		t.Fatalf("unexpected event: %+v", ev)
/project/internal/forgejo/webhook_test.go-170-	}
--
/project/internal/forgejo/webhook_test.go:181:	if ev.Kind != EventPRNew || ev.Index != 9 || ev.Author != "greg" {
/project/internal/forgejo/webhook_test.go-182-		t.Fatalf("unexpected event: %+v", ev)
/project/internal/forgejo/webhook_test.go-183-	}
--
/project/internal/forgejo/webhook.go:117:			Kind:   EventIssueNew,
/project/internal/forgejo/webhook.go-118-			ID:     issueNewID(p.Issue.ID),
/project/internal/forgejo/webhook.go-119-			Owner:  owner,
--
/project/internal/forgejo/webhook.go:140:			Kind:     EventIssueAssigned,
/project/internal/forgejo/webhook.go-141-			ID:       issueAssignedID(p.Issue.ID, assignee.UserName, p.Issue.Updated),
/project/internal/forgejo/webhook.go-142-			Owner:    owner,
--
/project/internal/forgejo/webhook.go:170:		Kind:    EventIssueComment,
/project/internal/forgejo/webhook.go-171-		ID:      issueCommentID(p.Comment.ID),
/project/internal/forgejo/webhook.go-172-		Owner:   repoOwner(p.Repository),
--
/project/internal/forgejo/webhook.go:189:		Kind:   EventPRNew,
/project/internal/forgejo/webhook.go-190-		ID:     prNewID(p.PullRequest.ID),
/project/internal/forgejo/webhook.go-191-		Owner:  repoOwner(p.Repository),
--
/project/internal/forgejo/event.go:9:	EventIssueNew      = "issue:new"
/project/internal/forgejo/event.go:10:	EventIssueComment  = "issue:comment"
/project/internal/forgejo/event.go:11:	EventIssueAssigned = "issue:assigned"
/project/internal/forgejo/event.go:12:	EventPRNew         = "pr:new"
/project/internal/forgejo/event.go-13-)
/project/internal/forgejo/event.go-14-
--
/project/internal/forgejo/event.go:30:	// Assignee is set for EventIssueAssigned: the Forgejo username the
Thinking
Now I have a grasp of the overall picture. Let me plan the implementation in detail. ## Implementation Plan ### 1. `internal/forgejo/event.go` - Add `EventPRReview = "pr:review"`. - Add fields to `Event`: - `ReviewID int64` — set for EventPRReview. - `ReviewState string` — set for EventPRReview (APPROVED / REQUEST_CHANGES / COMMENT). - `ReviewBody string` — set for EventPRReview. - `HeadRef string` — set for PR events: the PR's head branch. ### 2. `internal/forgejo/webhook.go` - Add `pullRequestReviewPayload` struct. - Add `case "pull_request_review"` to `decodeWebhookEvent`. - Add `pullRequestReviewEvent` function (action `submitted` only). - Add `prReviewID` helper. ### 3. `internal/forgejo/poll.go` - In `pollPulls`, call `w.pollReviews(ctx, owner, repo, issue, since)` for each updated PR. - Add `pollReviews` function: list reviews, filter by `Submitted.After(since)` and non-zero Submitted, and dispatch. - Add `reviewToEvent` function. Wait, there's one problem: `pollReviews` needs the PR's head ref for the event. But `issue` (from ListIssues) is `*sdk.Issue`, which doesn't have the head ref. So in `reviewToEvent`, HeadRef will be empty. And in `Run`, if HeadRef is empty, fetch it from the API. Hmm, wait, let me actually reconsider. Should the polling path fetch the PR to get the head ref? That would be an extra API call per new review. Or, `Run` fetches it. Let's have `Run` fetch it (only when needed, i.e., for `pr:review` where HeadRef is empty). Actually, for reliability, let's have `Run` always fetch the PR info for `pr:review` (to get the current head ref). That way we don't rely on the (possibly stale) webhook payload. Hmm, but that means the webhook path also fetches. That's fine — one extra API call per pr:review run, and it's reliable. Actually, let me reconsider. Let's have the event carry HeadRef when it's available (webhook path), and have `Run` fetch it when it's not (polling path). That minimizes API calls while maintaining correctness. But there's a subtle issue: the webhook payload's head ref could be stale if the PR's head branch was renamed. That's rare. Let's go with: use the event's HeadRef if set, otherwise fetch. Wait, actually, for simplicity and reliability, let's have `Run` fetch the PR info for `pr:review` events and use that head ref (ignoring the event's HeadRef for the working tree). But use the event's HeadRef for... hmm, no. Let's keep it simple: - The event carries HeadRef (set from webhook; empty from polling). - In `Run`, for `pr:review`: `headRef := ev.HeadRef; if headRef == "" { fetch from API }`. This is clean and minimizes API calls. Let's go with this. Actually, wait. Let me reconsider whether we should fetch the PR info in Run at all. The polling path doesn't have the head ref. So we need to fetch it there. Let's fetch it in `Run` when `ev.HeadRef == ""`. Good. ### 4. `internal/forgejo/client.go` - Add `ReviewDetail` and `ReviewCommentDetail` types. - Add `ReviewDetail(owner, repo string, index, reviewID int64) (*ReviewDetail, error)` method. - Add `PullRequestInfo` type + `PullRequestInfo(owner, repo string, index int64) (PullRequestInfo, error)` method. - Add `ResolveReviewComment(owner, repo string, index, commentID int64) error` method (raw HTTP since the SDK doesn't have it). - Add `SubmitReview(owner, repo string, index int64, state string, body string) error` method (wraps `CreatePullReview`). Let me verify the SDK's `CreatePullReview` signature: `CreatePullReview(owner, repo string, index int64, opt CreatePullReviewOptions)`. And `CreatePullReviewOptions{State, Body, CommitID, Comments}`. For a body-only review, `Comments` is empty. But `Validate()` requires a non-empty body unless state is APPROVED. So for APPROVED with empty body, it's OK. For REQUEST_CHANGES/COMMENT, the body must be non-empty. Let's enforce that in the tool (require body unless approving). For `ResolveReviewComment`, the SDK doesn't have it. Gitea/Forgejo API: `POST /repos/{owner}/{repo}/pulls/{index}/review_comments/{id}/resolve`. Let's make a raw HTTP call. But the SDK's low-level methods are unexported. So we need to make a raw `http.NewRequest` call using the client's baseURL and token. Let me check how the SDK's client stores baseURL and token, so we can replicate the auth. Actually, zoo's `forgejo.Client` wrapper has `baseURL` and `token` fields. So we can do: ```go func (c *Client) ResolveReviewComment(owner, repo string, index, commentID int64) error { path := fmt.Sprintf("/repos/%s/%s/pulls/%d/review_comments/%d/resolve", owner, repo, index, commentID) req, err := http.NewRequest("POST", c.baseURL+path, nil) if err != nil { return err } req.Header.Set("Authorization", "token "+c.token) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ... } ``` Wait, how does the SDK authenticate? Let me check. The SDK uses `sdk.SetToken(token)`. Let me see how it sets the auth header. Let me check the SDK's client.go for how the token is used.
Tool 🔧 bash {"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && grep -n \"SetToken\\|Authorization\\|token\" client.go | head -30"}
{"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && grep -n \"SetToken\\|Authorization\\|token\" client.go | head -30"}
112:// SetToken is an option for NewClient to set token
113:func SetToken(token string) ClientOption {
116:		client.accessToken = token
351:		req.Header.Set("Authorization", "token "+c.accessToken)
Thinking
The SDK uses `Authorization: token <token>`. So my raw HTTP call should use `token ` + c.token. Let me look at the SDK's doRequest to see exactly how it constructs the request (base URL handling, etc.).
Tool 🔧 bash {"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n '333,430p' client.go"}
{"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n '333,430p' client.go"}
func (c *Client) doRequest(method, path string, header http.Header, body io.Reader) (*Response, error) {
	c.mutex.RLock()
	debug := c.debug
	if debug {
		var bodyStr string
		if body != nil {
			bs, _ := io.ReadAll(body)
			body = bytes.NewReader(bs)
			bodyStr = string(bs)
		}
		fmt.Printf("%s: %s\nHeader: %v\nBody: %s\n", method, c.url+"/api/v1"+path, header, bodyStr)
	}
	req, err := http.NewRequestWithContext(c.ctx, method, c.url+"/api/v1"+path, body)
	if err != nil {
		c.mutex.RUnlock()
		return nil, err
	}
	if len(c.accessToken) != 0 {
		req.Header.Set("Authorization", "token "+c.accessToken)
	}
	if len(c.otp) != 0 {
		req.Header.Set("X-FORGEJO-OTP", c.otp)
	}
	if len(c.username) != 0 {
		req.SetBasicAuth(c.username, c.password)
	}
	if len(c.sudo) != 0 {
		req.Header.Set("Sudo", c.sudo)
	}
	if len(c.userAgent) != 0 {
		req.Header.Set("User-Agent", c.userAgent)
	}

	client := c.client // client ref can change from this point on so safe it
	c.mutex.RUnlock()

	for k, v := range header {
		req.Header[k] = v
	}

	if c.httpsigner != nil {
		err = c.SignRequest(req)
		if err != nil {
			return nil, err
		}
	}

	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	if debug {
		fmt.Printf("Response: %v\n\n", resp)
	}

	return newResponse(resp), nil
}

// Converts a response for a HTTP status code indicating an error condition
// (non-2XX) to a well-known error value and response body. For non-problematic
// (2XX) status codes nil will be returned. Note that on a non-2XX response, the
// response body stream will have been read and, hence, is closed on return.
func statusCodeToErr(resp *Response) (body []byte, err error) {
	// no error
	if resp.StatusCode/100 == 2 {
		return nil, nil
	}

	//
	// error: body will be read for details
	//
	defer resp.Body.Close()
	data, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("body read on HTTP error %d: %v", resp.StatusCode, err)
	}

	// Try to unmarshal and get an error message
	errMap := make(map[string]interface{})
	if err = json.Unmarshal(data, &errMap); err != nil {
		// when the JSON can't be parsed, data was probably empty or a
		// plain string, so we try to return a helpful error anyway
		path := resp.Request.URL.Path
		method := resp.Request.Method
		return data, fmt.Errorf("unknown API Error: %d\nRequest: '%s' with '%s' method and '%s' body", resp.StatusCode, path, method, string(data))
	}

	if msg, ok := errMap["message"]; ok {
		return data, fmt.Errorf("%v", msg)
	}

	// If no error message, at least give status and data
	return data, fmt.Errorf("%s: %s", resp.Status, string(data))
}

func (c *Client) getResponseReader(method, path string, header http.Header, body io.Reader) (io.ReadCloser, *Response, error) {
	resp, err := c.doRequest(method, path, header, body)
	if err != nil {
Thinking
The SDK's API path is `c.url + "/api/v1" + path`. So the full URL for resolving a review comment would be `c.baseURL + "/api/v1/repos/{owner}/{repo}/pulls/{index}/review_comments/{id}/resolve"`. I'll write a raw HTTP call for `ResolveReviewComment`. Let's make it robust: ```go // ResolveReviewComment marks the inline review comment with the given id // as resolved. The SDK doesn't expose this endpoint, so it's a direct // call to the API. func (c *Client) ResolveReviewComment(owner, repo string, index, commentID int64) error { path := fmt.Sprintf("/repos/%s/%s/pulls/%d/review_comments/%d/resolve", owner, repo, index, commentID) req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/v1"+path, nil) if err != nil { return fmt.Errorf("resolve review comment: %w", err) } req.Header.Set("Authorization", "token "+c.token) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("resolve review comment %d: %w", commentID, err) } defer resp.Body.Close() if resp.StatusCode/100 != 2 { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("resolve review comment %d: %s: %s", commentID, resp.Status, strings.TrimSpace(string(body))) } return nil } ``` I need to import `io`, `net/http`, `strings` in client.go. Let me check the current imports. Actually, I realize there might be a cleaner way. Let me check if the SDK has a generic method. No, it doesn't. So raw HTTP it is. Now let me also reconsider: do I want to add `SubmitReview`? Let me reconsider the scope once more. The issue: "We need agents to respond to and react to PR reviews. Define a workflow and implement the Zoo side." I'll implement: 1. `pr:review` event (webhook + polling + config + dedup). 2. Briefing with review context (state, body, inline comments). 3. Working tree on the PR head branch (so the agent can push fixes). 4. `resolve_review_comment` tool (react: mark addressed comments as resolved). 5. `submit_review` tool (react/review: submit a review — closes the loop with pr:new and enables agent-to-agent review). I think this is a complete, coherent "Zoo side" for PR reviews. Let me also write the workflow documentation (in the PR description and a comment on the issue). Now let me also think about the **seed message** changes. I'll add a review section. Let me design it: ``` Review (submitted by bob): State: REQUEST_CHANGES (changes requested) Body: <review body> Inline comments: 1. src/foo.go:42 (bob): <comment body> 2. src/bar.go:10 (bob): <comment body> ``` And a note about the working branch. Let me update the seed message format. Current seed message: ``` You were triggered by a %q event on %s/%s. %sInstructions for this event, from zoo.hcl: %s Your working branch is %q, checked out from the default branch %q. Title: %s Body: %s %sComments (%d): 1. alice (2026-08-24T09:00:00Z): ... Full event payload: ```json ... ``` ``` For `pr:review`, I want: - "Your working branch is %q (the pull request's head branch)." instead of "checked out from the default branch %q." - A review section. Let me restructure `seedMessage` to take a `review *forgejo.ReviewDetail` and a `headRef`/`onPRBranch bool`. Actually, let me pass the review detail and let the function decide the branch wording based on the event kind. Let me design the new `seedMessage` signature: ```go func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, comments []forgejo.IssueComment, review *forgejo.ReviewDetail) string ``` And inside: - If `ev.Kind == forgejo.EventPRReview`, the branch line is: `Your working branch is %q, the pull request's head branch — commits you push here update the pull request.` - Else: `Your working branch is %q, checked out from the default branch %q.` - If review != nil, add a review section. Let me write the review section renderer: ```go func reviewSection(review *forgejo.ReviewDetail) string { var b strings.Builder fmt.Fprintf(&b, "Review submitted by %s:\n", review.Reviewer) fmt.Fprintf(&b, " Verdict: %s\n", humanReviewState(review.State)) if review.Body != "" { fmt.Fprintf(&b, " Review body:\n%s\n", indent(review.Body)) } if len(review.Comments) > 0 { fmt.Fprintf(&b, " Inline comments (%d):\n", len(review.Comments)) for i, c := range review.Comments { fmt.Fprintf(&b, " %d. %s:%d (%s):\n", i+1, c.Path, c.Line, c.Author) fmt.Fprintf(&b, "%s\n", indent(c.Body)) } } return b.String() } ``` Hmm, this is getting complex. Let me simplify the formatting. Let me make it clean and readable. Actually, let me reconsider. The raw event payload already contains the review (state, body) for the webhook path. And I'm fetching the inline comments. So the seed message should present the review clearly. Let me write a clean renderer. Let me define `humanReviewState`: ```go func humanReviewState(state string) string { switch state { case "APPROVED": return "approved" case "REQUEST_CHANGES": return "changes requested" case "COMMENT": return "comment" default: return state } } ``` OK. Now let me also think about the **tools** for `submit_review` and `resolve_review_comment`. `submit_review`: ```go type submitReviewParams struct { State string `json:"state"` Body string `json:"body"` } func init() { tool := llm.NewTool( "submit_review", "Submit a review on the pull request that triggered this run: approve it, request changes, or leave a comment. Use 'approved' to approve, 'changes_requested' to request changes, or 'comment' to leave review feedback without a verdict.") tool.AddStringProperty("state", "The review verdict: 'approved', 'changes_requested', or 'comment'", true) tool.AddStringProperty("body", "The review text, in Markdown. Required unless state is 'approved'.", true) Register(tool, submitReview) } func submitReview(ctx Context, params submitReviewParams) (string, error) { state := mapReviewState(params.State) if state == "" { return "", fmt.Errorf("state must be 'approved', 'changes_requested', or 'comment'") } if err := ctx.Forgejo().SubmitReview(state, params.Body); err != nil { return "", err } return "Review submitted", nil } ``` Wait, the SDK's `CreatePullReview` uses `ReviewStateType` which is "APPROVED", "REQUEST_CHANGES", "COMMENT". Let me map the friendly names to these. `mapReviewState`: ```go func mapReviewState(s string) string { switch strings.ToLower(s) { case "approved", "approve": return "APPROVED" case "changes_requested", "request_changes", "request changes": return "REQUEST_CHANGES" case "comment": return "COMMENT" default: return "" } } ``` Hmm, but the `ForgejoActions` interface method should take the SDK's state type or a friendly one? Let me have the tool do the mapping and pass the SDK state ("APPROVED", etc.) to the interface. Actually, let me keep the interface clean and have it take the friendly state, and do the mapping in the client. Hmm. Let me decide: the `ForgejoActions` interface is the boundary between tools and agentrun. Let me have it take the SDK-compatible state string ("APPROVED", "REQUEST_CHANGES", "COMMENT"), and have the tool map the LLM's friendly input to that. Actually, that couples the tool to the SDK's state names. Alternatively, the interface takes the friendly name and the client maps. Let me have the interface take a `state string` that is one of "approved", "changes_requested", "comment" (friendly, lowercase), and the client maps to the SDK's `ReviewStateType`. That keeps the tool clean and the mapping in one place (the client). Actually, let me reconsider. Let me look at how the existing tools handle enums. The `add_label` tool just passes a name. There's no enum mapping. So there's no strong precedent. Let me keep it simple: the tool validates and passes the friendly state; the client maps to the SDK state. Let me define in the forgejo client: ```go // Review states an agent can submit, in the friendly names the tools // expose. Mapped to the SDK's ReviewStateType in SubmitReview. const ( ReviewStateApproved = "approved" ReviewStateChangesRequest = "changes_requested" ReviewStateComment = "comment" ) func (c *Client) SubmitReview(owner, repo string, index int64, state, body string) error { var sdkState sdk.ReviewStateType switch state { case ReviewStateApproved: sdkState = sdk.ReviewStateApproved case ReviewStateChangesRequest: sdkState = sdk.ReviewStateRequestChanges case ReviewStateComment: sdkState = sdk.ReviewStateComment default: return fmt.Errorf("unknown review state %q", state) } _, _, err := c.sdk.CreatePullReview(owner, repo, index, sdk.CreatePullReviewOptions{State: sdkState, Body: body}) if err != nil { return fmt.Errorf("submit review on %s/%s#%d: %w", owner, repo, index, err) } return nil } ``` Wait, but `CreatePullReviewOptions.Validate()` requires a non-empty body unless state is APPROVED. So if the agent submits "changes_requested" or "comment" with an empty body, the SDK will error. That's fine — the error will surface. But I should validate in the tool for a better error message. Let me validate in the tool: if state != approved and body is empty, error. Actually, the SDK's Validate is called in CreatePullReview, so it'll catch it. But a clearer error from the tool is nicer. Let me validate in the tool. Now, `resolve_review_comment`: ```go type resolveReviewCommentParams struct { CommentID int64 `json:"comment_id"` } func init() { tool := llm.NewTool( "resolve_review_comment", "Mark an inline review comment on the pull request as resolved, once you've addressed the feedback it contains.") tool.AddIntProperty("comment_id", "The id of the inline review comment to resolve", true) Register(tool, resolveReviewComment) } func resolveReviewComment(ctx Context, params resolveReviewCommentParams) (string, error) { if err := ctx.Forgejo().ResolveReviewComment(params.CommentID); err != nil { return "", err } return "Review comment resolved", nil } ``` Wait, does `llm.NewTool` have `AddIntProperty`? Let me check the llm package's tool definition. Let me look at the llm package's tool helpers.
Tool 🔧 read_file {"path": "internal/llm/types.go", "start_line": 1, "end_line": 200}
{"path": "internal/llm/types.go", "start_line": 1, "end_line": 200}
{"path":"internal/llm/types.go","content":"// Package llm is an OpenAI-compatible chat completions client with\r\n// streaming and tool-call support, ported from ../a's llamacpp package.\r\npackage llm\r\n\r\nimport \"fmt\"\r\n\r\ntype Message struct {\r\n\tContent          string     `json:\"content\"`\r\n\tRole             string     `json:\"role\"`\r\n\tReasoningContent string     `json:\"reasoning_content,omitempty\"`\r\n\tToolCalls        []ToolCall `json:\"tool_calls,omitempty\"`\r\n\tToolCallID       string     `json:\"tool_call_id,omitempty\"`\r\n\tName             string     `json:\"name,omitempty\"`\r\n}\r\n\r\nfunc (m *Message) String() string {\r\n\tstr := fmt.Sprintf(\"Role: %s, Content: %s ReasoningContent: %s\", m.Role, m.Content, m.ReasoningContent)\r\n\r\n\tfor _, call := range m.ToolCalls {\r\n\t\tstr += fmt.Sprintf(\"\\n  ToolCall: %s\", call.String())\r\n\t}\r\n\r\n\treturn str\r\n}\r\n\r\n// Merge folds a streamed delta into the accumulated message.\r\nfunc (m *Message) Merge(next *Message) {\r\n\tm.Content += next.Content\r\n\r\n\tif next.Role != \"\" {\r\n\t\tm.Role = next.Role\r\n\t}\r\n\r\n\tm.ReasoningContent += next.ReasoningContent\r\n\r\n\tminLength := 0\r\n\tfor _, choice := range next.ToolCalls {\r\n\t\tif choice.Index+1 \u003e minLength {\r\n\t\t\tminLength = choice.Index + 1\r\n\t\t}\r\n\t}\r\n\r\n\tif len(m.ToolCalls) \u003c minLength {\r\n\t\tnewToolCalls := make([]ToolCall, minLength)\r\n\t\tcopy(newToolCalls, m.ToolCalls)\r\n\t\tm.ToolCalls = newToolCalls\r\n\t}\r\n\r\n\tfor _, nextChoice := range next.ToolCalls {\r\n\t\tm.ToolCalls[nextChoice.Index].Merge(\u0026nextChoice)\r\n\t}\r\n}\r\n\r\ntype FunctionCall struct {\r\n\tName      string `json:\"name\"`\r\n\tArguments string `json:\"arguments\"`\r\n}\r\n\r\ntype ToolCall struct {\r\n\tType         string       `json:\"type\"`\r\n\tId           string       `json:\"id\"`\r\n\tIndex        int          `json:\"index\"`\r\n\tName         string       `json:\"name\"`\r\n\tFunctionCall FunctionCall `json:\"function\"`\r\n}\r\n\r\nfunc (t *ToolCall) String() string {\r\n\treturn fmt.Sprintf(\"Type: %s, Id: %s, Index: %d, Name: %s, FunctionCall: {Name: %s, Arguments: %s}\",\r\n\t\tt.Type, t.Id, t.Index, t.Name, t.FunctionCall.Name, t.FunctionCall.Arguments)\r\n}\r\n\r\nfunc (t *ToolCall) Merge(next *ToolCall) {\r\n\tif next.Type != \"\" {\r\n\t\tt.Type = next.Type\r\n\t}\r\n\r\n\tif next.Id != \"\" {\r\n\t\tt.Id = next.Id\r\n\t}\r\n\r\n\tif next.Name != \"\" {\r\n\t\tt.Name = next.Name\r\n\t}\r\n\r\n\tt.Index = next.Index\r\n\r\n\tif next.FunctionCall.Name != \"\" {\r\n\t\tt.FunctionCall.Name = next.FunctionCall.Name\r\n\t}\r\n\r\n\tt.FunctionCall.Arguments += next.FunctionCall.Arguments\r\n}\r\n\r\ntype Property struct {\r\n\tType        string    `json:\"type\"`\r\n\tDescription string    `json:\"description,omitempty\"`\r\n\tEnum        []string  `json:\"enum,omitempty\"`\r\n\tItems       *Property `json:\"items,omitempty\"`\r\n}\r\n\r\ntype Parameters struct {\r\n\tType       string              `json:\"type\"` // object\r\n\tProperties map[string]Property `json:\"properties,omitempty\"`\r\n\tRequired   []string            `json:\"required,omitempty\"`\r\n}\r\n\r\ntype Function struct {\r\n\tName        string     `json:\"name\"`\r\n\tDescription string     `json:\"description,omitempty\"`\r\n\tParameters  Parameters `json:\"parameters,omitempty\"`\r\n\tStrict      bool       `json:\"strict\"`\r\n}\r\n\r\ntype Tool struct {\r\n\tType     string   `json:\"type\"`\r\n\tFunction Function `json:\"function\"`\r\n}\r\n\r\nfunc NewTool(name, description string) Tool {\r\n\treturn Tool{\r\n\t\tType: \"function\",\r\n\t\tFunction: Function{\r\n\t\t\tName:        name,\r\n\t\t\tDescription: description,\r\n\t\t\tStrict:      true,\r\n\t\t},\r\n\t}\r\n}\r\n\r\nfunc (t *Tool) AddProperty(name string, property Property, required bool) *Tool {\r\n\tif t.Function.Parameters.Properties == nil {\r\n\t\tt.Function.Parameters.Properties = make(map[string]Property)\r\n\t} else if _, exists := t.Function.Parameters.Properties[name]; exists {\r\n\t\tpanic(\"property already exists: \" + name)\r\n\t}\r\n\r\n\tt.Function.Parameters.Properties[name] = property\r\n\r\n\tif required {\r\n\t\tt.Function.Parameters.Required = append(t.Function.Parameters.Required, name)\r\n\t}\r\n\r\n\treturn t\r\n}\r\n\r\nfunc (t *Tool) AddStringProperty(name, description string, required bool) *Tool {\r\n\treturn t.AddProperty(name, Property{Type: \"string\", Description: description}, required)\r\n}\r\n\r\nfunc (t *Tool) AddBooleanProperty(name, description string, required bool) *Tool {\r\n\treturn t.AddProperty(name, Property{Type: \"boolean\", Description: description}, required)\r\n}\r\n\r\nfunc (t *Tool) AddIntegerProperty(name, description string, required bool) *Tool {\r\n\treturn t.AddProperty(name, Property{Type: \"integer\", Description: description}, required)\r\n}\r\n\r\nfunc (t *Tool) AddEnumProperty(name, description string, values []string, required bool) *Tool {\r\n\treturn t.AddProperty(name, Property{Type: \"string\", Description: description, Enum: values}, required)\r\n}\r\n\r\nfunc (t *Tool) AddArrayProperty(name, itemType, description string, required bool) *Tool {\r\n\treturn t.AddProperty(name, Property{\r\n\t\tType:        \"array\",\r\n\t\tDescription: description,\r\n\t\tItems:       \u0026Property{Type: itemType},\r\n\t}, required)\r\n}\r\n\r\nfunc (t *Tool) AddStringArrayProperty(name, description string, required bool) *Tool {\r\n\treturn t.AddArrayProperty(name, \"string\", description, required)\r\n}\r\n\r\ntype ChatCompletionRequest struct {\r\n\tModel       string    `json:\"model\"`\r\n\tMessages    []Message `json:\"messages,omitempty\"`\r\n\tStream      bool      `json:\"stream\"`\r\n\tTools       []Tool    `json:\"tools,omitempty\"`\r\n\tSeed        int       `json:\"seed,omitempty\"`\r\n\tTemperature float64   `json:\"temperature,omitempty\"`\r\n\tTopP        float64   `json:\"top_p,omitempty\"`\r\n}\r\n\r\ntype Choice struct {\r\n\tDelta        Message `json:\"delta\"`\r\n\tFinishReason string  `json:\"finish_reason,omitempty\"`\r\n\tIndex        int     `json:\"index\"`\r\n\tMessage      Message `json:\"message\"`\r\n\tRole         string  `json:\"role\"`\r\n}\r\n\r\nfunc (c *Choice) String() string {\r\n\treturn fmt.Sprintf(\"Index: %d, Role: %s, FinishReason: %v, Delta: %s, Message: %s\",\r\n\t\tc.Index, c.Role, c.FinishReason, c.Delta.String(), c.Message.String())\r\n}\r\n\r\nfunc (c *Choice) Merge(next *Choice) {\r\n\tc.Message.Merge(\u0026next.Delta)\r\n\r\n\tif next.FinishReason != \"\" {\r","start_line":1,"end_line":200,"total_lines":267,"truncated":true}
Thinking
There's `AddIntegerProperty` and `AddEnumProperty`. For `submit_review`, we can use `AddEnumProperty` for state. That's cleaner. Now let me look at the tools registry and how tools are registered, and the `tools.Call` function.
Tool 🔧 read_file {"path": "internal/tools/registry.go", "start_line": 1, "end_line": 120}
{"path": "internal/tools/registry.go", "start_line": 1, "end_line": 120}
{"path":"internal/tools/registry.go","content":"package tools\r\n\r\nimport (\r\n\t\"encoding/json\"\r\n\t\"fmt\"\r\n\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n)\r\n\r\ntype toolFunc[P any] func(Context, P) (string, error)\r\n\r\ntype tool struct {\r\n\ttool llm.Tool\r\n\tcall func(ctx Context, args string) (string, error)\r\n}\r\n\r\nvar registry = map[string]tool{}\r\n\r\n// All lists all known tools.\r\nfunc All() []llm.Tool {\r\n\tts := make([]llm.Tool, 0, len(registry))\r\n\r\n\tfor _, t := range registry {\r\n\t\tts = append(ts, t.tool)\r\n\t}\r\n\r\n\treturn ts\r\n}\r\n\r\n// Register registers a tool function with the given definition. The\r\n// registry is a stateless name-\u003efunc map shared across concurrent runs;\r\n// all per-run state lives in the Context passed to Call.\r\nfunc Register[P any](def llm.Tool, fun toolFunc[P]) {\r\n\tif def.Type != \"function\" {\r\n\t\tpanic(\"tool must be a function\")\r\n\t}\r\n\r\n\tif def.Function.Name == \"\" {\r\n\t\tpanic(\"tool function must have a name\")\r\n\t}\r\n\r\n\tif _, found := registry[def.Function.Name]; found {\r\n\t\tpanic(fmt.Sprintf(\"tool with name %q already registered\", def.Function.Name))\r\n\t}\r\n\r\n\tregistry[def.Function.Name] = tool{\r\n\t\ttool: def,\r\n\t\tcall: func(ctx Context, args string) (string, error) {\r\n\t\t\tvar params P\r\n\r\n\t\t\tif err := json.Unmarshal([]byte(args), \u0026params); err != nil {\r\n\t\t\t\treturn \"\", err\r\n\t\t\t}\r\n\r\n\t\t\treturn fun(ctx, params)\r\n\t\t},\r\n\t}\r\n}\r\n\r\n// Call invokes a registered tool by name with the given (raw JSON)\r\n// arguments, against the given run's Context.\r\nfunc Call(ctx Context, fcall llm.FunctionCall) (string, error) {\r\n\tt, found := registry[fcall.Name]\r\n\tif !found {\r\n\t\treturn \"\", fmt.Errorf(\"unknown function: %s\", fcall.Name)\r\n\t}\r\n\r\n\treturn t.call(ctx, fcall.Arguments)\r\n}\r","start_line":1,"end_line":69,"total_lines":69,"truncated":false}
Thinking
Alright, I've got everything I need now. Let me also check the web package to see if the event kind is displayed somewhere (the dashboard). I'll do a quick check of web.go.
Tool 🔧 grep_search {"query": "EventKind|event_kind|Kind", "glob": "internal/web/*.go", "context_after": 1}
{"query": "EventKind|event_kind|Kind", "glob": "internal/web/*.go", "context_after": 1}
No matches found.
Thinking
The web package doesn't reference the event kind directly (it uses the Job struct's `EventKind` field, which is just a string). So no changes are needed there. Now let me check the rest of the scheduler tests to understand the test patterns, and then start implementing. Let me read the rest of scheduler_test.go.
Tool 🔧 read_file {"path": "internal/scheduler/scheduler_test.go", "start_line": 130, "end_line": 347}
{"path": "internal/scheduler/scheduler_test.go", "start_line": 130, "end_line": 347}
{"path":"internal/scheduler/scheduler_test.go","content":"\tfg := \u0026fakeForgejo{}\r\n\trunner := \u0026fakeRunner{}\r\n\tlogger := slog.New(slog.DiscardHandler)\r\n\r\n\tsched := New(cfg, st, fg, runner, livelog.NewHub(), logger)\r\n\r\n\tevents := make(chan forgejo.Event, 1)\r\n\tevents \u003c- forgejo.Event{Kind: config.EventIssueNew, Owner: \"acme\", Repo: \"widgets\", Index: 5}\r\n\tclose(events)\r\n\r\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\r\n\tdefer cancel()\r\n\r\n\tsched.Run(ctx, events)\r\n\tsched.Shutdown(ctx)\r\n\r\n\tjobs, err := st.ListJobs(ctx, 10)\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif len(jobs) != 1 || jobs[0].Status != store.JobSucceeded {\r\n\t\tt.Fatalf(\"unexpected jobs: %+v\", jobs)\r\n\t}\r\n\tif len(fg.comments) != 0 || len(fg.labels) != 0 {\r\n\t\tt.Fatalf(\"expected no failure reporting on success, got comments=%v labels=%v\", fg.comments, fg.labels)\r\n\t}\r\n}\r\n\r\nfunc TestSchedulerReportsFailure(t *testing.T) {\r\n\tcfg := testConfig()\r\n\tst := newStore(t)\r\n\tfg := \u0026fakeForgejo{}\r\n\trunner := \u0026fakeRunner{fail: true}\r\n\tlogger := slog.New(slog.DiscardHandler)\r\n\r\n\tsched := New(cfg, st, fg, runner, livelog.NewHub(), logger)\r\n\r\n\tevents := make(chan forgejo.Event, 1)\r\n\tevents \u003c- forgejo.Event{Kind: config.EventPRNew, Owner: \"acme\", Repo: \"widgets\", Index: 9}\r\n\tclose(events)\r\n\r\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\r\n\tdefer cancel()\r\n\r\n\tsched.Run(ctx, events)\r\n\tsched.Shutdown(ctx)\r\n\r\n\tjobs, err := st.ListJobs(ctx, 10)\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif len(jobs) != 1 || jobs[0].Status != store.JobFailed {\r\n\t\tt.Fatalf(\"unexpected jobs: %+v\", jobs)\r\n\t}\r\n\tif len(fg.comments) != 1 || len(fg.labels) != 1 || fg.labels[0] != FailureLabel {\r\n\t\tt.Fatalf(\"expected failure comment+label, got comments=%v labels=%v\", fg.comments, fg.labels)\r\n\t}\r\n}\r\n\r\nfunc TestSchedulerClassifiesTimeout(t *testing.T) {\r\n\tcfg := testConfig()\r\n\tst := newStore(t)\r\n\tfg := \u0026fakeForgejo{}\r\n\trunner := \u0026fakeRunner{timeoutErr: true}\r\n\tlogger := slog.New(slog.DiscardHandler)\r\n\r\n\tsched := New(cfg, st, fg, runner, livelog.NewHub(), logger)\r\n\r\n\tevents := make(chan forgejo.Event, 1)\r\n\tevents \u003c- forgejo.Event{Kind: config.EventIssueNew, Owner: \"acme\", Repo: \"widgets\", Index: 3}\r\n\tclose(events)\r\n\r\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\r\n\tdefer cancel()\r\n\r\n\tsched.Run(ctx, events)\r\n\tsched.Shutdown(ctx)\r\n\r\n\tjobs, err := st.ListJobs(ctx, 10)\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif len(jobs) != 1 || jobs[0].Status != store.JobTimedOut {\r\n\t\tt.Fatalf(\"expected timed_out status, got %+v\", jobs)\r\n\t}\r\n}\r\n\r\nfunc TestSchedulerDropsUnresolvedEvent(t *testing.T) {\r\n\tcfg := testConfig()\r\n\tst := newStore(t)\r\n\tfg := \u0026fakeForgejo{}\r\n\trunner := \u0026fakeRunner{}\r\n\tlogger := slog.New(slog.DiscardHandler)\r\n\r\n\tsched := New(cfg, st, fg, runner, livelog.NewHub(), logger)\r\n\r\n\tevents := make(chan forgejo.Event, 1)\r\n\tevents \u003c- forgejo.Event{Kind: config.EventIssueAssigned, Assignee: \"nobody\", Owner: \"acme\", Repo: \"widgets\", Index: 1}\r\n\tclose(events)\r\n\r\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\r\n\tdefer cancel()\r\n\r\n\tsched.Run(ctx, events)\r\n\tsched.Shutdown(ctx)\r\n\r\n\tjobs, err := st.ListJobs(ctx, 10)\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif len(jobs) != 0 {\r\n\t\tt.Fatalf(\"expected no job for unresolved event, got %+v\", jobs)\r\n\t}\r\n}\r\n\r\nfunc TestSchedulerDropsEventAuthoredByTargetAgent(t *testing.T) {\r\n\tcfg := testConfig()\r\n\tst := newStore(t)\r\n\tfg := \u0026fakeForgejo{}\r\n\trunner := \u0026fakeRunner{}\r\n\tlogger := slog.New(slog.DiscardHandler)\r\n\r\n\tsched := New(cfg, st, fg, runner, livelog.NewHub(), logger)\r\n\r\n\tevents := make(chan forgejo.Event, 1)\r\n\tevents \u003c- forgejo.Event{Kind: config.EventIssueComment, Author: \"leon\", Owner: \"acme\", Repo: \"widgets\", Index: 1}\r\n\tclose(events)\r\n\r\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\r\n\tdefer cancel()\r\n\r\n\tsched.Run(ctx, events)\r\n\tsched.Shutdown(ctx)\r\n\r\n\tjobs, err := st.ListJobs(ctx, 10)\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif len(jobs) != 0 {\r\n\t\tt.Fatalf(\"expected leon's own comment not to trigger leon, got %+v\", jobs)\r\n\t}\r\n}\r\n\r\nfunc TestSchedulerDispatchesCommentFromSomeoneElse(t *testing.T) {\r\n\tcfg := testConfig()\r\n\tst := newStore(t)\r\n\tfg := \u0026fakeForgejo{}\r\n\trunner := \u0026fakeRunner{}\r\n\tlogger := slog.New(slog.DiscardHandler)\r\n\r\n\tsched := New(cfg, st, fg, runner, livelog.NewHub(), logger)\r\n\r\n\tevents := make(chan forgejo.Event, 1)\r\n\tevents \u003c- forgejo.Event{Kind: config.EventIssueComment, Author: \"greg\", Owner: \"acme\", Repo: \"widgets\", Index: 1}\r\n\tclose(events)\r\n\r\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\r\n\tdefer cancel()\r\n\r\n\tsched.Run(ctx, events)\r\n\tsched.Shutdown(ctx)\r\n\r\n\tjobs, err := st.ListJobs(ctx, 10)\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif len(jobs) != 1 || jobs[0].Agent != \"leon\" {\r\n\t\tt.Fatalf(\"expected a comment from a different author to still dispatch to leon, got %+v\", jobs)\r\n\t}\r\n}\r\n\r\nfunc TestSchedulerBackpressure(t *testing.T) {\r\n\tcfg := testConfig()\r\n\tcfg.Environment.MaxLive = 1\r\n\tst := newStore(t)\r\n\tfg := \u0026fakeForgejo{}\r\n\tblock := make(chan struct{})\r\n\trunner := \u0026fakeRunner{blockUntil: block}\r\n\tlogger := slog.New(slog.DiscardHandler)\r\n\r\n\tsched := New(cfg, st, fg, runner, livelog.NewHub(), logger)\r\n\r\n\tevents := make(chan forgejo.Event, 2)\r\n\tevents \u003c- forgejo.Event{Kind: config.EventIssueNew, Owner: \"acme\", Repo: \"widgets\", Index: 1}\r\n\tevents \u003c- forgejo.Event{Kind: config.EventIssueNew, Owner: \"acme\", Repo: \"widgets\", Index: 2}\r\n\tclose(events)\r\n\r\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\r\n\tdefer cancel()\r\n\r\n\tdone := make(chan struct{})\r\n\tgo func() {\r\n\t\tsched.Run(ctx, events)\r\n\t\tclose(done)\r\n\t}()\r\n\r\n\t// Give the scheduler a moment to pick up both events; with\r\n\t// max_live_agents=1 only one Run should be in flight at a time.\r\n\ttime.Sleep(200 * time.Millisecond)\r\n\trunner.mu.Lock()\r\n\tmaxSeen := runner.maxSeen\r\n\trunner.mu.Unlock()\r\n\tif maxSeen \u003e 1 {\r\n\t\tt.Fatalf(\"expected at most 1 concurrent run, saw %d\", maxSeen)\r\n\t}\r\n\r\n\tclose(block)\r\n\t\u003c-done\r\n\tsched.Shutdown(ctx)\r\n\r\n\tjobs, err := st.ListJobs(ctx, 10)\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif len(jobs) != 2 {\r\n\t\tt.Fatalf(\"expected 2 jobs, got %d\", len(jobs))\r\n\t}\r\n}\r","start_line":130,"end_line":347,"total_lines":347,"truncated":false}
Thinking
Now I have a grasp of the overall picture of the codebase and testing conventions. Let me lay out the complete implementation plan: ## Implementation Plan ### 1. `internal/forgejo/event.go` - Add `EventPRReview = "pr:review"`. - Add to `Event`: `ReviewID int64`, `ReviewState string`, `ReviewBody string`, `HeadRef string`. ### 2. `internal/forgejo/webhook.go` - Add `pullRequestReviewPayload` struct. - Add `case "pull_request_review"` to `decodeWebhookEvent`. - Add `pullRequestReviewEvent` (action `submitted`). - Add `prReviewID(reviewID)` helper. ### 3. `internal/forgejo/poll.go` - `pollPulls`: call `w.pollReviews(ctx, owner, repo, issue, since)` for each PR. - Add `pollReviews` + `reviewToEvent`. ### 4. `internal/forgejo/client.go` - Add `ReviewDetail`/`ReviewCommentDetail` types + `ReviewDetail()` method. - Add `PullRequestInfo` type + `PullRequestInfo()` method. - Add `ResolveReviewComment()` method (raw HTTP). - Add `SubmitReview()` method. ### 5. `internal/config/config.go` - Add `EventPRReview = "pr:review"` constant. - Add to `staticEventKinds`. ### 6. `zoo.hcl.example` - Add `event "pr:review"` block. ### 7. `internal/tools/context.go` - Add `SubmitReview(state, body string) error` and `ResolveReviewComment(commentID int64) error` to `ForgejoActions`. ### 8. `internal/tools/forgejo.go` - Add `submit_review` tool. - Add `resolve_review_comment` tool. ### 9. `internal/agentrun/forgejo.go` - Implement `SubmitReview` and `ResolveReviewComment` on `runForgejoActions`. ### 10. `internal/agentrun/run.go` - For `pr:review`: fetch the review details + PR head ref, check out the head branch, and pass the review to seedMessage. ### 11. `internal/agentrun/clone.go` - Add `clonePRHead`. ### 12. `internal/agentrun/run.go` (seedMessage) - Add a review section + PR branch wording. ### 13. Tests - `webhook_test.go`: test decoding of `pull_request_review`. - `poll_test.go`? There's no existing poll_test.go. Let me check… the file list only has `client_test.go`, `webhook_test.go` in the forgejo package. There's no poll test. Hmm, the polling logic is hard to test without a fake SDK client. The SDK client is a concrete type (`*sdk.Client`), so it can't easily be faked. Let me check how `Client` is structured — `w.client.sdk` is `*sdk.Client`. To test polling, I'd need an httptest server that mimics the Forgejo API. That's doable: create an httptest server, point the SDK client at it, and serve the JSON for `/api/v1/issues` and `/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews`. Let me check `client_test.go` to see how it tests `ListIssueComments` with pagination — it's probably using an httptest server. Let me read client_test.go.
Tool 🔧 read_file {"path": "internal/forgejo/client_test.go", "start_line": 1, "end_line": 130}
{"path": "internal/forgejo/client_test.go", "start_line": 1, "end_line": 130}
{"path":"internal/forgejo/client_test.go","content":"package forgejo\r\n\r\nimport (\r\n\t\"encoding/json\"\r\n\t\"fmt\"\r\n\t\"net/http\"\r\n\t\"net/http/httptest\"\r\n\t\"strconv\"\r\n\t\"testing\"\r\n\t\"time\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n)\r\n\r\n// Wire shapes matching the SDK's Comment type, as Forgejo serves the\r\n// /repos/{owner}/{repo}/issues/{index}/comments endpoint.\r\ntype wireUser struct {\r\n\tLogin string `json:\"login\"`\r\n}\r\n\r\ntype wireComment struct {\r\n\tID      int64     `json:\"id\"`\r\n\tPoster  wireUser  `json:\"user\"`\r\n\tBody    string    `json:\"body\"`\r\n\tCreated time.Time `json:\"created_at\"`\r\n}\r\n\r\n// newTestServer returns an httptest server that answers the SDK's\r\n// /api/v1/version probe (made by NewClient) plus the routes registered\r\n// on the returned mux.\r\nfunc newTestServer(t *testing.T) (*httptest.Server, *http.ServeMux) {\r\n\tt.Helper()\r\n\r\n\tmux := http.NewServeMux()\r\n\r\n\tmux.HandleFunc(\"/api/v1/version\", func(w http.ResponseWriter, r *http.Request) {\r\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\r\n\t\t_, _ = w.Write([]byte(`{\"version\":\"1.23.0\"}`))\r\n\t})\r\n\r\n\tserver := httptest.NewServer(mux)\r\n\tt.Cleanup(server.Close)\r\n\r\n\treturn server, mux\r\n}\r\n\r\nfunc TestListIssueCommentsFetchesAllPages(t *testing.T) {\r\n\tconst total = 120 // 3 pages at the client's page size of 50\r\n\r\n\tserver, mux := newTestServer(t)\r\n\r\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/issues/5/comments\", func(w http.ResponseWriter, r *http.Request) {\r\n\t\tpage, _ := strconv.ParseInt(r.URL.Query().Get(\"page\"), 10, 64)\r\n\t\tlimit, _ := strconv.ParseInt(r.URL.Query().Get(\"limit\"), 10, 64)\r\n\r\n\t\tstart := (page - 1) * limit\r\n\t\tend := start + limit\r\n\t\tif end \u003e total {\r\n\t\t\tend = total\r\n\t\t}\r\n\r\n\t\tbody := make([]wireComment, 0, end-start)\r\n\t\tfor i := start; i \u003c end; i++ {\r\n\t\t\tbody = append(body, wireComment{\r\n\t\t\t\tID:      i + 1,\r\n\t\t\t\tPoster:  wireUser{Login: fmt.Sprintf(\"user-%d\", i+1)},\r\n\t\t\t\tBody:    fmt.Sprintf(\"comment %d\", i+1),\r\n\t\t\t\tCreated: time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Minute),\r\n\t\t\t})\r\n\t\t}\r\n\r\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\r\n\t\t_ = json.NewEncoder(w).Encode(body)\r\n\t})\r\n\r\n\tclient, err := NewClient(config.Forgejo{URL: server.URL, Token: \"test\"})\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"new client: %v\", err)\r\n\t}\r\n\r\n\tcomments, err := client.ListIssueComments(\"acme\", \"widgets\", 5)\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"list comments: %v\", err)\r\n\t}\r\n\r\n\tif len(comments) != total {\r\n\t\tt.Fatalf(\"expected %d comments, got %d\", total, len(comments))\r\n\t}\r\n\r\n\tfor i, c := range comments {\r\n\t\tif c.Author != fmt.Sprintf(\"user-%d\", i+1) || c.Body != fmt.Sprintf(\"comment %d\", i+1) {\r\n\t\t\tt.Fatalf(\"comment %d: got author %q body %q\", i, c.Author, c.Body)\r\n\t\t}\r\n\r\n\t\twant := time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Minute)\r\n\t\tif !c.Created.Equal(want) {\r\n\t\t\tt.Fatalf(\"comment %d: got created %s, want %s\", i, c.Created, want)\r\n\t\t}\r\n\t}\r\n}\r\n\r\nfunc TestListIssueCommentsEmpty(t *testing.T) {\r\n\tserver, mux := newTestServer(t)\r\n\r\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/issues/7/comments\", func(w http.ResponseWriter, r *http.Request) {\r\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\r\n\t\t_, _ = w.Write([]byte(`[]`))\r\n\t})\r\n\r\n\tclient, err := NewClient(config.Forgejo{URL: server.URL, Token: \"test\"})\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"new client: %v\", err)\r\n\t}\r\n\r\n\tcomments, err := client.ListIssueComments(\"acme\", \"widgets\", 7)\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"list comments: %v\", err)\r\n\t}\r\n\r\n\tif len(comments) != 0 {\r\n\t\tt.Fatalf(\"expected no comments, got %d\", len(comments))\r\n\t}\r\n}\r","start_line":1,"end_line":123,"total_lines":123,"truncated":false}
Thinking
The test pattern uses an httptest server. I can write similar tests for the new client methods (`ReviewDetail`, `PullRequestInfo`, `ResolveReviewComment`, `SubmitReview`). Now, regarding the polling tests, I need to test `pollReviews`. The Watcher has `client *Client` and `store *store.Store`. I can create a real `*store.Store` (in-memory or temp file), create a `Client` pointing at the httptest server, and call `w.pollPulls(ctx)` or `w.pollOnce(ctx)`. The test server needs to respond to: - `/api/v1/version` (already in newTestServer) - `/api/v1/issues?type=pulls&state=all&since=...` (ListIssues) - `/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews` (ListPullReviews) And I need to capture the dispatched events. The Watcher dispatches to `w.events` channel (buffered, 64). I can read from `w.Events()`. Wait, but `pollOnce` calls both `pollIssues` and `pollPulls`. For a focused test, I could call `pollPulls` directly (it's lowercase but the test is in the same package `forgejo`). Yes, the test is in package `forgejo` (internal test). So I can call `w.pollPulls(ctx)` directly. Let me design the poll test: ```go func TestPollPullsDispatchesNewReviews(t *testing.T) { server, mux := newTestServer(t) // serve /api/v1/issues?type=pulls with one PR // serve /api/v1/repos/acme/widgets/pulls/9/reviews with one submitted review ... st := newStore(t) // need a store; but newStore is in scheduler package. Let me create one here. client, _ := NewClient(config.Forgejo{URL: server.URL, Token: "test"}) w := NewWatcher(client, st, "", slog.New(slog.DiscardHandler)) w.pollPulls(context.Background()) // read from w.Events() } ``` I need a store. Let me create a helper in the forgejo test package, or just open a store directly. Let me open a store with `store.Open(filepath.Join(t.TempDir(), "zoo.db"))`. Actually, the `dispatch` method uses `w.store.MarkSeen`. So I need a real store. Let me import store in the forgejo test. Wait, but there's a circular import concern: does `store` import `forgejo`? No. `forgejo` imports `store` (watcher.go imports store). So `forgejo` test importing `store` is fine (no cycle). Let me verify: `internal/forgejo/watcher.go` imports `github.com/abrander/zoo/internal/store`. And `internal/store` imports only stdlib + sqlite. So no cycle. Good. Now, the `ListIssues` endpoint. The SDK's `ListIssues` calls `/api/v1/issues` with query params. Let me check the exact path and params. Let me check the SDK's ListIssues.
Tool 🔧 bash {"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n '/^func (c \\*Client) ListIssues/,/^}/p' issue.go; echo \"=== ListPullReviews path ===\"; grep -n \"pulls/%d/reviews\\|/api/v1/issues\\|listIssues\" issue.go pull_review.go | head"}
{"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n '/^func (c \\*Client) ListIssues/,/^}/p' issue.go; echo \"=== ListPullReviews path ===\"; grep -n \"pulls/%d/reviews\\|/api/v1/issues\\|listIssues\" issue.go pull_review.go | head"}
func (c *Client) ListIssues(opt ListIssueOption) ([]*Issue, *Response, error) {
	opt.setDefaults()
	issues := make([]*Issue, 0, opt.PageSize)

	link, _ := url.Parse("/repos/issues/search")
	link.RawQuery = opt.QueryEncode()
	resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &issues)
	if e := c.checkServerVersionGreaterThanOrEqual(version1_12_0); e != nil {
		for i := 0; i < len(issues); i++ {
			if issues[i].Repository != nil {
				issues[i].Repository.Owner = strings.Split(issues[i].Repository.FullName, "/")[0]
			}
		}
	}
	for i := range issues {
		c.issueBackwardsCompatibility(issues[i])
	}
	return issues, resp, err
}
=== ListPullReviews path ===
pull_review.go:164:	link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", owner, repo, index))
pull_review.go:181:	resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d", owner, repo, index, id), jsonHeader, nil, &r)
pull_review.go:194:	link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d/comments", owner, repo, index, id))
pull_review.go:209:	_, resp, err := c.getResponse("DELETE", fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d", owner, repo, index, id), jsonHeader, nil)
pull_review.go:231:		fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", owner, repo, index),
pull_review.go:254:		fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d", owner, repo, index, id),
pull_review.go:311:		fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d/dismissals", owner, repo, index, id),
pull_review.go:326:		fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d/undismissals", owner, repo, index, id),
Thinking
`ListIssues` uses `/repos/issues/search`. Let me check `ListIssueOption.QueryEncode` to understand the query parameters. Actually, for the polling test, I need to serve `/repos/issues/search`. Let me check what query parameters it sends. `ListIssueOption` has `Type`, `State`, `Since`, and `ListOptions`. Let me check `QueryEncode`. Actually, this is getting complicated. Let me simplify the polling test: instead of testing the full `pollPulls` (which requires serving `/repos/issues/search`), I could test `pollReviews` directly. `pollReviews` takes `(ctx, owner, repo, pr *sdk.Issue, since time.Time)` and calls `ListPullReviews` (which hits `/repos/{owner}/{repo}/pulls/{index}/reviews`). That's a simpler endpoint to serve. So I'll write a test that: 1. Creates a store, client (pointing at httptest server), and watcher. 2. Serves `/api/v1/repos/acme/widgets/pulls/9/reviews` with a submitted review. 3. Calls `w.pollReviews(ctx, "acme", "widgets", &sdk.Issue{Index: 9}, since)`. 4. Reads from `w.Events()` and asserts the event. That's clean and focused. Let me do that. But wait, `pollReviews` needs a `*sdk.Issue`. I can construct one with `Index: 9`. Good. Now, let me also reconsider: the `dispatch` method sends to `w.events` channel. In the test, I read from `w.Events()`. But `dispatch` is called synchronously within `pollReviews`, and it does a `select` with a default (drops if full). The channel has buffer 64, so it won't drop. Good. I can read from the channel after `pollReviews` returns. Let me now also think about the `ReviewDetail` test. I'll serve: - `/api/v1/repos/acme/widgets/pulls/9/reviews/42` (GetPullReview) - `/api/v1/repos/acme/widgets/pulls/9/reviews/42/comments` (ListPullReviewComments) And assert the returned `ReviewDetail`. And `PullRequestInfo` test: serve `/api/v1/repos/acme/widgets/pulls/9` (GetPullRequest). Let me check the GetPullRequest path. Let me check GetPullRequest.
Tool 🔧 bash {"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n '/^func (c \\*Client) GetPullRequest(/,/^}/p' pull.go; echo \"=== ListIssueOption ===\"; sed -n '/^type ListIssueOption struct/,/^}/p' issue.go; sed -n '/func (opt ListIssueOption) QueryEncode/,/^}/p' issue.go"}
{"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n '/^func (c \\*Client) GetPullRequest(/,/^}/p' pull.go; echo \"=== ListIssueOption ===\"; sed -n '/^type ListIssueOption struct/,/^}/p' issue.go; sed -n '/func (opt ListIssueOption) QueryEncode/,/^}/p' issue.go"}
func (c *Client) GetPullRequest(owner, repo string, index int64) (*PullRequest, *Response, error) {
	if err := escapeValidatePathSegments(&owner, &repo); err != nil {
		return nil, nil, err
	}
	pr := new(PullRequest)
	resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, repo, index), nil, nil, pr)
	if c.checkServerVersionGreaterThanOrEqual(version1_14_0) != nil {
		if err := fixPullHeadSha(c, pr); err != nil {
			return pr, resp, err
		}
	}
	return pr, resp, err
}
=== ListIssueOption ===
type ListIssueOption struct {
	ListOptions
	State      StateType
	Type       IssueType
	Labels     []string
	Milestones []string
	KeyWord    string
	Since      time.Time
	Before     time.Time
	// filter by created by username
	CreatedBy string
	// filter by assigned to username
	AssignedBy string
	// filter by username mentioned
	MentionedBy string
	// filter by owner (only works on ListIssues on User)
	Owner string
	// filter by team (requires organization owner parameter to be provided and only works on ListIssues on User)
	Team string
}
Thinking
`GetPullRequest` uses `/repos/{owner}/{repo}/pulls/{index}`. Good. Note: `GetPullRequest` calls `fixPullHeadSha` if server version >= 1.14.0. My test server reports version 1.23.0, so `fixPullHeadSha` will be called. Let me check what `fixPullHeadSha` does — it might make an extra API call to resolve the head SHA. Let me check.
Tool 🔧 bash {"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n '/func fixPullHeadSha/,/^}/p' pull.go"}
{"command": "cd $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n '/func fixPullHeadSha/,/^}/p' pull.go"}
func fixPullHeadSha(client *Client, pr *PullRequest) error {
	if pr.Base != nil && pr.Base.Repository != nil && pr.Base.Repository.Owner != nil &&
		pr.Head != nil && pr.Head.Ref != "" && pr.Head.Sha == "" {
		owner := pr.Base.Repository.Owner.UserName
		repo := pr.Base.Repository.Name
		refs, _, err := client.GetRepoRefs(owner, repo, pr.Head.Ref)
		if err != nil {
			return err
		} else if len(refs) == 0 {
			return fmt.Errorf("unable to resolve PR ref '%s'", pr.Head.Ref)
		}
		pr.Head.Sha = refs[0].Object.SHA
	}
	return nil
}
Thinking
`fixPullHeadSha` only makes an extra call when `pr.Head.Sha == ""` and the base repository is set. In my test, I'll set `Head.Sha` to a non-empty value to avoid the extra call. Good. Now I have all the info I need. Let me start implementing. I'll work through the files in order. Let me start with the forgejo package (event.go, webhook.go, poll.go, client.go). ### Step 1: `internal/forgejo/event.go` Add `EventPRReview` and new Event fields.
Now I have a thorough understanding of the codebase. Let me look at a couple more pieces before implementing — the `llm` client test and the `web` package to make sure I don't miss anything.
Tool 🔧 read_file {"path": "internal/forgejo/webhook_test.go", "start_line": 200, "end_line": 204}
{"path": "internal/forgejo/webhook_test.go", "start_line": 200, "end_line": 204}
{"path":"internal/forgejo/webhook_test.go","content":"\t}\r\n\tif ok {\r\n\t\tt.Fatal(\"expected unknown event kind to be ignored\")\r\n\t}\r\n}\r","start_line":200,"end_line":204,"total_lines":204,"truncated":false}
Tool 🔧 read_file {"path": "internal/tools/tools_test.go", "start_line": 1, "end_line": 55}
{"path": "internal/tools/tools_test.go", "start_line": 1, "end_line": 55}
{"path":"internal/tools/tools_test.go","content":"package tools\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"fmt\"\r\n\t\"strings\"\r\n\t\"testing\"\r\n)\r\n\r\ntype fakeForgejoActions struct {\r\n\tcomments []string\r\n\tassigned []string\r\n}\r\n\r\nfunc (f *fakeForgejoActions) Comment(body string) error {\r\n\tf.comments = append(f.comments, body)\r\n\treturn nil\r\n}\r\nfunc (f *fakeForgejoActions) OpenPullRequest(head, base, title, body string) error { return nil }\r\nfunc (f *fakeForgejoActions) RequestReview(reviewers []string) error               { return nil }\r\nfunc (f *fakeForgejoActions) AddLabel(name string) error                           { return nil }\r\nfunc (f *fakeForgejoActions) RemoveLabel(name string) error                        { return nil }\r\nfunc (f *fakeForgejoActions) CloseIssue() error                                    { return nil }\r\nfunc (f *fakeForgejoActions) ReopenIssue() error                                   { return nil }\r\nfunc (f *fakeForgejoActions) AssignIssue(agentName string) error {\r\n\tf.assigned = append(f.assigned, agentName)\r\n\treturn nil\r\n}\r\n\r\ntype fakeContext struct {\r\n\tlastCmd string\r\n\toutput  string\r\n\terr     error\r\n\tfg      *fakeForgejoActions\r\n\r\n\tlastGitSubcommand string\r\n\tlastGitArgs       []string\r\n}\r\n\r\nfunc (f *fakeContext) Exec(ctx context.Context, command string) (string, error) {\r\n\tf.lastCmd = command\r\n\treturn f.output, f.err\r\n}\r\n\r\nfunc (f *fakeContext) Git(ctx context.Context, subcommand string, args ...string) (string, error) {\r\n\tf.lastGitSubcommand = subcommand\r\n\tf.lastGitArgs = args\r\n\treturn f.output, f.err\r\n}\r\n\r\nfunc (f *fakeContext) Forgejo() ForgejoActions {\r\n\treturn f.fg\r\n}\r\n\r\nfunc TestShellQuote(t *testing.T) {\r","start_line":1,"end_line":55,"total_lines":181,"truncated":true}
Thinking
I've grasped the overall picture. Now let's start the implementation. I'll proceed file by file. **Design overview (workflow):** **PR review workflow:** 1. A review is submitted on a PR (by a human or an agent) → Forgejo fires a `pull_request_review` webhook (action `submitted`). If there's no webhook, zoo's poller detects the new review on an updated PR. 2. Zoo normalizes this into a `pr:review` event (deduplicated by review ID, so webhook and polling converge to a single run). 3. The scheduler resolves the statically configured `pr:review` agent (zoo.hcl) and starts a run. Events created by the agent itself (e.g., a review submitted by the agent itself) are discarded, so no self-triggering loop occurs. 4. The run is prepared: - The working tree is checked out on the PR's **head branch** (via `refs/pull/N/head`), so the agent's commits and pushes update the PR directly. - The briefing includes the review's verdict (approved / changes requested / comment), the review body, all inline review comments (file:line + text), the full PR comment thread, and the raw payload. 5. The agent reacts: - Fixes the code, commits, and pushes to the PR branch. - Replies with a `comment` explaining what was addressed. - Marks addressed inline comments as resolved with `resolve_review_comment`. - Can also submit its own review with `submit_review` (approve / request changes / comment) — this closes the loop: a review submitted by an agent (e.g., when reacting to `pr:new`) fires `pr:review` for the PR's author agent. **Implementation files:** 1. `internal/forgejo/event.go` — `EventPRReview` + Event fields (ReviewID, ReviewState, ReviewBody, HeadRef). 2. `internal/forgejo/webhook.go` — decoding of `pull_request_review`. 3. `internal/forgejo/poll.go` — polling of reviews. 4. `internal/forgejo/client.go` — `ReviewDetail`, `PullRequestInfo`, `ResolveReviewComment`, `SubmitReview`. 5. `internal/config/config.go` — `EventPRReview` + staticEventKinds. 6. `zoo.hcl.example` — event block. 7. `internal/tools/context.go` — interface methods. 8. `internal/tools/forgejo.go` — tools. 9. `internal/agentrun/forgejo.go` — action implementation. 10. `internal/agentrun/clone.go` — `clonePRHead`. 11. `internal/agentrun/run.go` — wiring + seedMessage. 12. Tests: webhook_test.go, poll_test.go (new), client_test.go, config_test.go, tools_test.go, seed_test.go, scheduler_test.go. Alright, let's start writing code. First, `event.go`:
I now have a complete picture. Let me lay out my plan and start implementing. **Workflow design** (the "definition" part of the issue): 1. A review is submitted on a PR → Forgejo `pull_request_review` webhook (action `submitted`), with the poller as fallback. 2. Zoo normalizes it to a new `pr:review` event, deduped by review ID (webhook + poll collapse to one run). 3. The statically configured `pr:review` agent runs, with the working tree checked out on the **PR's head branch** (so pushes update the PR) and a briefing containing the verdict, review body, and inline comments. 4. The agent reacts: fixes code + pushes, replies via `comment`, resolves addressed inline comments via a new `resolve_review_comment` tool, and can submit its own review via a new `submit_review` tool (which closes the loop back into `pr:review`). Self-authored events are dropped by the existing self-trigger guard. Let me start with the forgejo package:
Tool 🔧 edit_file {"path": "internal/forgejo/event.go", "find": "const (\n\tEventIssueNew = \"issue:new\"\n\tEventIssueComment = \"issue:comment\"\n\tEventIssueAssigned = \"issue:assigned\"\n\tEventPRNew = \"pr:new\"\n)", "replace": "const (\n\tEventIssueNew = \"issue:new\"\n\tEventIssueComment = \"issue:comment\"\n\tEventIssueAssigned = \"issue:assigned\"\n\tEventPRNew = \"pr:new\"\n\tEventPRReview = \"pr:review\"\n)"}
{"path": "internal/forgejo/event.go", "find": "const (\n\tEventIssueNew      = \"issue:new\"\n\tEventIssueComment  = \"issue:comment\"\n\tEventIssueAssigned = \"issue:assigned\"\n\tEventPRNew         = \"pr:new\"\n)", "replace": "const (\n\tEventIssueNew      = \"issue:new\"\n\tEventIssueComment  = \"issue:comment\"\n\tEventIssueAssigned = \"issue:assigned\"\n\tEventPRNew         = \"pr:new\"\n\tEventPRReview      = \"pr:review\"\n)"}
1 occurrences replaced
Thinking
Next, I'll add new fields to the Event struct.
Tool 🔧 edit_file {"path": "internal/forgejo/event.go", "find": "\t// Comment is set for EventIssueComment.\n\tComment string\n\n\t// Raw is the full source payload (webhook body, or a synthesized\n\t// equivalent when polling), written to /event in the agent container.\n\tRaw json.RawMessage\n}", "replace": "\t// Comment is set for EventIssueComment.\n\tComment string\n\n\t// Review fields, set for EventPRReview: the submitted review's id,\n\t// verdict, and body. Inline review comments are not part of the\n\t// webhook payload, so they're fetched separately when briefing the\n\t// agent (see Client.ReviewDetail).\n\tReviewID int64\n\tReviewState string\n\tReviewBody string\n\n\t// HeadRef is set for PR events when the source payload carries the\n\t// PR's head branch: the branch the PR's changes live on. A run\n\t// reacting to the PR checks it out so its pushes update the PR\n\t// directly.\n\tHeadRef string\n\n\t// Raw is the full source payload (webhook body, or a synthesized\n\t// equivalent when polling), written to /event in the agent container.\n\tRaw json.RawMessage\n}"}
{"path": "internal/forgejo/event.go", "find": "\t// Comment is set for EventIssueComment.\n\tComment string\n\n\t// Raw is the full source payload (webhook body, or a synthesized\n\t// equivalent when polling), written to /event in the agent container.\n\tRaw json.RawMessage\n}", "replace": "\t// Comment is set for EventIssueComment.\n\tComment string\n\n\t// Review fields, set for EventPRReview: the submitted review's id,\n\t// verdict, and body. Inline review comments are not part of the\n\t// webhook payload, so they're fetched separately when briefing the\n\t// agent (see Client.ReviewDetail).\n\tReviewID    int64\n\tReviewState string\n\tReviewBody  string\n\n\t// HeadRef is set for PR events when the source payload carries the\n\t// PR's head branch: the branch the PR's changes live on. A run\n\t// reacting to the PR checks it out so its pushes update the PR\n\t// directly.\n\tHeadRef string\n\n\t// Raw is the full source payload (webhook body, or a synthesized\n\t// equivalent when polling), written to /event in the agent container.\n\tRaw json.RawMessage\n}"}
1 occurrences replaced
Thinking
Next, the webhook decoder. Add the payload struct, the case inside decodeWebhookEvent, the event builder, and the ID helper.
Now the webhook decoder:
Tool 🔧 edit_file {"path": "internal/forgejo/webhook.go", "find": "type pullRequestPayload struct {\n\tAction string `json:\"action\"`\n\tPullRequest *sdk.PullRequest `json:\"pull_request\"`\n\tRepository *sdk.Repository `json:\"repository\"`\n}", "replace": "type pullRequestPayload struct {\n\tAction string `json:\"action\"`\n\tPullRequest *sdk.PullRequest `json:\"pull_request\"`\n\tRepository *sdk.Repository `json:\"repository\"`\n}\n\ntype pullRequestReviewPayload struct {\n\tAction string `json:\"action\"`\n\tReview *sdk.PullReview `json:\"review\"`\n\tPullRequest *sdk.PullRequest `json:\"pull_request\"`\n\tRepository *sdk.Repository `json:\"repository\"`\n}"}
{"path": "internal/forgejo/webhook.go", "find": "type pullRequestPayload struct {\n\tAction      string           `json:\"action\"`\n\tPullRequest *sdk.PullRequest `json:\"pull_request\"`\n\tRepository  *sdk.Repository  `json:\"repository\"`\n}", "replace": "type pullRequestPayload struct {\n\tAction      string           `json:\"action\"`\n\tPullRequest *sdk.PullRequest `json:\"pull_request\"`\n\tRepository  *sdk.Repository  `json:\"repository\"`\n}\n\ntype pullRequestReviewPayload struct {\n\tAction      string           `json:\"action\"`\n\tReview      *sdk.PullReview  `json:\"review\"`\n\tPullRequest *sdk.PullRequest `json:\"pull_request\"`\n\tRepository  *sdk.Repository  `json:\"repository\"`\n}"}
1 occurrences replaced
Thinking
Next, let's add a case to `decodeWebhookEvent` and add the `pullRequestReviewEvent` function.
Tool 🔧 edit_file {"path": "internal/forgejo/webhook.go", "find": "\tcase \"pull_request\":\n\t\tvar p pullRequestPayload\n\n\t\tif err := json.Unmarshal(body, &p); err != nil {\n\t\t\treturn Event{}, false, err\n\t\t}\n\n\t\treturn pullRequestEvent(p, body)\n\n\tdefault:\n\t\treturn Event{}, false, nil\n\t}\n}", "replace": "\tcase \"pull_request\":\n\t\tvar p pullRequestPayload\n\n\t\tif err := json.Unmarshal(body, &p); err != nil {\n\t\t\treturn Event{}, false, err\n\t\t}\n\n\t\treturn pullRequestEvent(p, body)\n\n\tcase \"pull_request_review\":\n\t\tvar p pullRequestReviewPayload\n\n\t\tif err := json.Unmarshal(body, &p); err != nil {\n\t\t\treturn Event{}, false, err\n\t\t}\n\n\t\treturn pullRequestReviewEvent(p, body)\n\n\tdefault:\n\t\treturn Event{}, false, nil\n\t}\n}"}
{"path": "internal/forgejo/webhook.go", "find": "\tcase \"pull_request\":\n\t\tvar p pullRequestPayload\n\n\t\tif err := json.Unmarshal(body, &p); err != nil {\n\t\t\treturn Event{}, false, err\n\t\t}\n\n\t\treturn pullRequestEvent(p, body)\n\n\tdefault:\n\t\treturn Event{}, false, nil\n\t}\n}", "replace": "\tcase \"pull_request\":\n\t\tvar p pullRequestPayload\n\n\t\tif err := json.Unmarshal(body, &p); err != nil {\n\t\t\treturn Event{}, false, err\n\t\t}\n\n\t\treturn pullRequestEvent(p, body)\n\n\tcase \"pull_request_review\":\n\t\tvar p pullRequestReviewPayload\n\n\t\tif err := json.Unmarshal(body, &p); err != nil {\n\t\t\treturn Event{}, false, err\n\t\t}\n\n\t\treturn pullRequestReviewEvent(p, body)\n\n\tdefault:\n\t\treturn Event{}, false, nil\n\t}\n}"}
1 occurrences replaced
Thinking
Next, I'll add the `pullRequestReviewEvent` function after `pullRequestEvent`, and also add the `prReviewID` helper. Let's add it after the `pullRequestEvent` function.
Tool 🔧 edit_file {"path": "internal/forgejo/webhook.go", "find": "func pullRequestEvent(p pullRequestPayload, raw []byte) (Event, bool, error) {\n\tif p.Action != \"opened\" || p.PullRequest == nil || p.Repository == nil {\n\t\treturn Event{}, false, nil\n\t}\n\n\treturn Event{\n\t\tKind: EventPRNew,\n\t\tID: prNewID(p.PullRequest.ID),\n\t\tOwner: repoOwner(p.Repository),\n\t\tRepo: p.Repository.Name,\n\t\tIndex: p.PullRequest.Index,\n\t\tTitle: p.PullRequest.Title,\n\t\tBody: p.PullRequest.Body,\n\t\tAuthor: posterName(p.PullRequest.Poster),\n\t\tRaw: raw,\n\t}, true, nil\n}", "replace": "func pullRequestEvent(p pullRequestPayload, raw []byte) (Event, bool, error) {\n\tif p.Action != \"opened\" || p.PullRequest == nil || p.Repository == nil {\n\t\treturn Event{}, false, nil\n\t}\n\n\treturn Event{\n\t\tKind: EventPRNew,\n\t\tID: prNewID(p.PullRequest.ID),\n\t\tOwner: repoOwner(p.Repository),\n\t\tRepo: p.Repository.Name,\n\t\tIndex: p.PullRequest.Index,\n\t\tTitle: p.PullRequest.Title,\n\t\tBody: p.PullRequest.Body,\n\t\tAuthor: posterName(p.PullRequest.Poster),\n\t\tRaw: raw,\n\t}, true, nil\n}\n\n// pullRequestReviewEvent turns a pull_request_review delivery into a\n// pr:review event. Only \"submitted\" is interesting: it's the moment a\n// review lands with a verdict. \"edited\" and \"dismissed\" are follow-ups\n// to a review that already triggered a run, and \"pending\" reviews have\n// no verdict to react to yet.\nfunc pullRequestReviewEvent(p pullRequestReviewPayload, raw []byte) (Event, bool, error) {\n\tif p.Action != \"submitted\" || p.Review == nil || p.PullRequest == nil || p.Repository == nil {\n\t\treturn Event{}, false, nil\n\t}\n\n\theadRef := \"\"\n\tif p.PullRequest.Head != nil {\n\t\theadRef = p.PullRequest.Head.Ref\n\t}\n\n\treturn Event{\n\t\tKind: EventPRReview,\n\t\tID: prReviewID(p.Review.ID),\n\t\tOwner: repoOwner(p.Repository),\n\t\tRepo: p.Repository.Name,\n\t\tIndex: p.PullRequest.Index,\n\t\tTitle: p.PullRequest.Title,\n\t\tBody: p.PullRequest.Body,\n\t\tAuthor: posterName(p.Review.Reviewer),\n\t\tReviewID: p.Review.ID,\n\t\tReviewState: string(p.Review.State),\n\t\tReviewBody: p.Review.Body,\n\t\tHeadRef: headRef,\n\t\tRaw: raw,\n\t}, true, nil\n}"}
{"path": "internal/forgejo/webhook.go", "find": "func pullRequestEvent(p pullRequestPayload, raw []byte) (Event, bool, error) {\n\tif p.Action != \"opened\" || p.PullRequest == nil || p.Repository == nil {\n\t\treturn Event{}, false, nil\n\t}\n\n\treturn Event{\n\t\tKind:   EventPRNew,\n\t\tID:     prNewID(p.PullRequest.ID),\n\t\tOwner:  repoOwner(p.Repository),\n\t\tRepo:   p.Repository.Name,\n\t\tIndex:  p.PullRequest.Index,\n\t\tTitle:  p.PullRequest.Title,\n\t\tBody:   p.PullRequest.Body,\n\t\tAuthor: posterName(p.PullRequest.Poster),\n\t\tRaw:    raw,\n\t}, true, nil\n}", "replace": "func pullRequestEvent(p pullRequestPayload, raw []byte) (Event, bool, error) {\n\tif p.Action != \"opened\" || p.PullRequest == nil || p.Repository == nil {\n\t\treturn Event{}, false, nil\n\t}\n\n\treturn Event{\n\t\tKind:   EventPRNew,\n\t\tID:     prNewID(p.PullRequest.ID),\n\t\tOwner:  repoOwner(p.Repository),\n\t\tRepo:   p.Repository.Name,\n\t\tIndex:  p.PullRequest.Index,\n\t\tTitle:  p.PullRequest.Title,\n\t\tBody:   p.PullRequest.Body,\n\t\tAuthor: posterName(p.PullRequest.Poster),\n\t\tRaw:    raw,\n\t}, true, nil\n}\n\n// pullRequestReviewEvent turns a pull_request_review delivery into a\n// pr:review event. Only \"submitted\" is interesting: it's the moment a\n// review lands with a verdict. \"edited\" and \"dismissed\" are follow-ups\n// to a review that already triggered a run, and \"pending\" reviews have\n// no verdict to react to yet.\nfunc pullRequestReviewEvent(p pullRequestReviewPayload, raw []byte) (Event, bool, error) {\n\tif p.Action != \"submitted\" || p.Review == nil || p.PullRequest == nil || p.Repository == nil {\n\t\treturn Event{}, false, nil\n\t}\n\n\theadRef := \"\"\n\tif p.PullRequest.Head != nil {\n\t\theadRef = p.PullRequest.Head.Ref\n\t}\n\n\treturn Event{\n\t\tKind:        EventPRReview,\n\t\tID:          prReviewID(p.Review.ID),\n\t\tOwner:       repoOwner(p.Repository),\n\t\tRepo:        p.Repository.Name,\n\t\tIndex:       p.PullRequest.Index,\n\t\tTitle:       p.PullRequest.Title,\n\t\tBody:        p.PullRequest.Body,\n\t\tAuthor:      posterName(p.Review.Reviewer),\n\t\tReviewID:    p.Review.ID,\n\t\tReviewState: string(p.Review.State),\n\t\tReviewBody:  p.Review.Body,\n\t\tHeadRef:     headRef,\n\t\tRaw:         raw,\n\t}, true, nil\n}"}
1 occurrences replaced
Thinking
Next, I'll add the `prReviewID` helper next to `prNewID`.
Tool 🔧 edit_file {"path": "internal/forgejo/webhook.go", "find": "func prNewID(prID int64) string {\n\treturn fmt.Sprintf(\"pr-new-%d\", prID)\n}", "replace": "func prNewID(prID int64) string {\n\treturn fmt.Sprintf(\"pr-new-%d\", prID)\n}\n\n// prReviewID keys the review itself: a review is submitted once, so its\n// id is a stable dedup key across the webhook delivery and the poller's\n// later sighting of the same review.\nfunc prReviewID(reviewID int64) string {\n\treturn fmt.Sprintf(\"pr-review-%d\", reviewID)\n}"}
{"path": "internal/forgejo/webhook.go", "find": "func prNewID(prID int64) string {\n\treturn fmt.Sprintf(\"pr-new-%d\", prID)\n}", "replace": "func prNewID(prID int64) string {\n\treturn fmt.Sprintf(\"pr-new-%d\", prID)\n}\n\n// prReviewID keys the review itself: a review is submitted once, so its\n// id is a stable dedup key across the webhook delivery and the poller's\n// later sighting of the same review.\nfunc prReviewID(reviewID int64) string {\n\treturn fmt.Sprintf(\"pr-review-%d\", reviewID)\n}"}
1 occurrences replaced
Thinking
Next, the polling path in `poll.go`. Do the following: 1. Inside `pollPulls`, call `w.pollReviews(ctx, owner, repo, issue, since)` for each PR. 2. Add a `pollReviews` function. 3. Add a `reviewToEvent` function. Current `pollPulls`: ```go func (w *Watcher) pollPulls(ctx context.Context) error { since, err := w.watermark(ctx, watermarkPulls) if err != nil { return err } issues, _, err := w.client.sdk.ListIssues(sdk.ListIssueOption{ Type: sdk.IssueTypePull, State: sdk.StateAll, Since: since, }) if err != nil { return fmt.Errorf("list pull requests: %w", err) } next := since for _, issue := range issues { if issue.Repository == nil { continue } if issue.Updated.After(next) { next = issue.Updated } if issue.Created.After(since) { w.dispatch(issueToPRNewEvent(issue, issue.Repository.Owner, issue.Repository.Name)) } } return w.store.SetWatermark(ctx, watermarkPulls, next.Format(time.RFC3339)) } ``` I'll add the `w.pollReviews(...)` call. Note that `issue.Repository.Owner` is a string (RepositoryMeta.Owner is a string). Let me double-check: `RepositoryMeta` has `Owner string`. Yes, it's a string. So: ```go for _, issue := range issues { if issue.Repository == nil { continue } if issue.Updated.After(next) { next = issue.Updated } owner, repo := issue.Repository.Owner, issue.Repository.Name if issue.Created.After(since) { w.dispatch(issueToPRNewEvent(issue, owner, repo)) } w.pollReviews(ctx, owner, repo, issue, since) } ``` Wait, the current code uses `issue.Repository.Owner, issue.Repository.Name` inline. Let me refactor to use owner/repo variables for clarity. Next, `pollReviews`: ```go // pollReviews dispatches a pr:review event for each review submitted on // the PR since the last poll. Listing reviews has no server-side "since" // filter, so every review on an updated PR is fetched and filtered // client-side by submitted time. Submitting a review bumps the PR's // updated timestamp, so the PR is listed in the same poll that sees the // review, and the watermark can't run past it. Dedup by review id keeps // a review from being dispatched twice. func (w *Watcher) pollReviews(ctx context.Context, owner, repo string, pr *sdk.Issue, since time.Time) { reviews, _, err := w.client.sdk.ListPullReviews(owner, repo, pr.Index, sdk.ListPullReviewsOptions{}) if err != nil { w.logger.Warn("poll reviews failed", "owner", owner, "repo", repo, "pr", pr.Index, "error", err) return } for _, review := range reviews { if review.Reviewer == nil || review.Submitted.IsZero() { continue } if !review.Submitted.After(since) { continue } w.dispatch(reviewToEvent(pr, review, owner, repo)) } } ``` Wait, `ListPullReviews` requires server version >= 1.12.0. If the server is older, it returns an error. That's fine — it'll be logged as a warning. Next, `reviewToEvent`: ```go func reviewToEvent(pr *sdk.Issue, review *sdk.PullReview, owner, repo string) Event { raw, _ := json.Marshal(review) return Event{ Kind: EventPRReview, ID: prReviewID(review.ID), Owner: owner, Repo: repo, Index: pr.Index, Title: pr.Title, Body: pr.Body, Author: posterName(review.Reviewer), ReviewID: review.ID, ReviewState: string(review.State), ReviewBody: review.Body, Raw: raw, } } ``` Note: HeadRef is not set here (the issue list doesn't include the head ref). It'll be fetched in `Run`. Wait, but there's a subtle issue: the webhook path sets `HeadRef`, but the polling path doesn't. In `Run`, I'll fetch the head ref if it's empty. Good. Also, `reviewToEvent` uses `pr.Title` and `pr.Body` — these come from the issue (which is the PR). Good. Alright, let me write these edits.
Now the polling fallback in `poll.go`:
Tool 🔧 edit_file {"path": "internal/forgejo/poll.go", "find": "\tnext := since\n\n\tfor _, issue := range issues {\n\t\tif issue.Repository == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tif issue.Updated.After(next) {\n\t\t\tnext = issue.Updated\n\t\t}\n\n\t\tif issue.Created.After(since) {\n\t\t\tw.dispatch(issueToPRNewEvent(issue, issue.Repository.Owner, issue.Repository.Name))\n\t\t}\n\t}\n\n\treturn w.store.SetWatermark(ctx, watermarkPulls, next.Format(time.RFC3339))\n}", "replace": "\tnext := since\n\n\tfor _, issue := range issues {\n\t\tif issue.Repository == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tif issue.Updated.After(next) {\n\t\t\tnext = issue.Updated\n\t\t}\n\n\t\towner, repo := issue.Repository.Owner, issue.Repository.Name\n\n\t\tif issue.Created.After(since) {\n\t\t\tw.dispatch(issueToPRNewEvent(issue, owner, repo))\n\t\t}\n\n\t\tw.pollReviews(ctx, owner, repo, issue, since)\n\t}\n\n\treturn w.store.SetWatermark(ctx, watermarkPulls, next.Format(time.RFC3339))\n}\n\n// pollReviews dispatches a pr:review event for each review submitted on\n// the PR since the last poll. The reviews listing has no server-side\n// \"since\" filter, so every review on an updated PR is fetched and\n// filtered client-side by submitted time. Submitting a review bumps the\n// PR's updated timestamp, so the PR is listed in the same poll that sees\n// the review and the watermark can't run past it; dedup by review id\n// keeps a review from being dispatched twice.\nfunc (w *Watcher) pollReviews(ctx context.Context, owner, repo string, pr *sdk.Issue, since time.Time) {\n\treviews, _, err := w.client.sdk.ListPullReviews(owner, repo, pr.Index, sdk.ListPullReviewsOptions{})\n\tif err != nil {\n\t\tw.logger.Warn(\"poll reviews failed\", \"owner\", owner, \"repo\", repo, \"pr\", pr.Index, \"error\", err)\n\t\treturn\n\t}\n\n\tfor _, review := range reviews {\n\t\tif review.Reviewer == nil || review.Submitted.IsZero() {\n\t\t\tcontinue\n\t\t}\n\n\t\tif !review.Submitted.After(since) {\n\t\t\tcontinue\n\t\t}\n\n\t\tw.dispatch(reviewToEvent(pr, review, owner, repo))\n\t}\n}"}
{"path": "internal/forgejo/poll.go", "find": "\tnext := since\n\n\tfor _, issue := range issues {\n\t\tif issue.Repository == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tif issue.Updated.After(next) {\n\t\t\tnext = issue.Updated\n\t\t}\n\n\t\tif issue.Created.After(since) {\n\t\t\tw.dispatch(issueToPRNewEvent(issue, issue.Repository.Owner, issue.Repository.Name))\n\t\t}\n\t}\n\n\treturn w.store.SetWatermark(ctx, watermarkPulls, next.Format(time.RFC3339))\n}", "replace": "\tnext := since\n\n\tfor _, issue := range issues {\n\t\tif issue.Repository == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tif issue.Updated.After(next) {\n\t\t\tnext = issue.Updated\n\t\t}\n\n\t\towner, repo := issue.Repository.Owner, issue.Repository.Name\n\n\t\tif issue.Created.After(since) {\n\t\t\tw.dispatch(issueToPRNewEvent(issue, owner, repo))\n\t\t}\n\n\t\tw.pollReviews(ctx, owner, repo, issue, since)\n\t}\n\n\treturn w.store.SetWatermark(ctx, watermarkPulls, next.Format(time.RFC3339))\n}\n\n// pollReviews dispatches a pr:review event for each review submitted on\n// the PR since the last poll. The reviews listing has no server-side\n// \"since\" filter, so every review on an updated PR is fetched and\n// filtered client-side by submitted time. Submitting a review bumps the\n// PR's updated timestamp, so the PR is listed in the same poll that sees\n// the review and the watermark can't run past it; dedup by review id\n// keeps a review from being dispatched twice.\nfunc (w *Watcher) pollReviews(ctx context.Context, owner, repo string, pr *sdk.Issue, since time.Time) {\n\treviews, _, err := w.client.sdk.ListPullReviews(owner, repo, pr.Index, sdk.ListPullReviewsOptions{})\n\tif err != nil {\n\t\tw.logger.Warn(\"poll reviews failed\", \"owner\", owner, \"repo\", repo, \"pr\", pr.Index, \"error\", err)\n\t\treturn\n\t}\n\n\tfor _, review := range reviews {\n\t\tif review.Reviewer == nil || review.Submitted.IsZero() {\n\t\t\tcontinue\n\t\t}\n\n\t\tif !review.Submitted.After(since) {\n\t\t\tcontinue\n\t\t}\n\n\t\tw.dispatch(reviewToEvent(pr, review, owner, repo))\n\t}\n}"}
1 occurrences replaced
Thinking
Next, I'll add the `reviewToEvent` function at the end of poll.go, after `issueToPRNewEvent`.
Tool 🔧 edit_file {"path": "internal/forgejo/poll.go", "find": "func issueToPRNewEvent(issue *sdk.Issue, owner, repo string) Event {\n\traw, _ := json.Marshal(issue)\n\n\treturn Event{\n\t\tKind: EventPRNew,\n\t\tID: prNewID(issue.ID),\n\t\tOwner: owner,\n\t\tRepo: repo,\n\t\tIndex: issue.Index,\n\t\tTitle: issue.Title,\n\t\tBody: issue.Body,\n\t\tAuthor: posterName(issue.Poster),\n\t\tRaw: raw,\n\t}\n}", "replace": "func issueToPRNewEvent(issue *sdk.Issue, owner, repo string) Event {\n\traw, _ := json.Marshal(issue)\n\n\treturn Event{\n\t\tKind: EventPRNew,\n\t\tID: prNewID(issue.ID),\n\t\tOwner: owner,\n\t\tRepo: repo,\n\t\tIndex: issue.Index,\n\t\tTitle: issue.Title,\n\t\tBody: issue.Body,\n\t\tAuthor: posterName(issue.Poster),\n\t\tRaw: raw,\n\t}\n}\n\nfunc reviewToEvent(pr *sdk.Issue, review *sdk.PullReview, owner, repo string) Event {\n\traw, _ := json.Marshal(review)\n\n\treturn Event{\n\t\tKind: EventPRReview,\n\t\tID: prReviewID(review.ID),\n\t\tOwner: owner,\n\t\tRepo: repo,\n\t\tIndex: pr.Index,\n\t\tTitle: pr.Title,\n\t\tBody: pr.Body,\n\t\tAuthor: posterName(review.Reviewer),\n\t\tReviewID: review.ID,\n\t\tReviewState: string(review.State),\n\t\tReviewBody: review.Body,\n\t\tRaw: raw,\n\t}\n}"}
{"path": "internal/forgejo/poll.go", "find": "func issueToPRNewEvent(issue *sdk.Issue, owner, repo string) Event {\n\traw, _ := json.Marshal(issue)\n\n\treturn Event{\n\t\tKind:   EventPRNew,\n\t\tID:     prNewID(issue.ID),\n\t\tOwner:  owner,\n\t\tRepo:   repo,\n\t\tIndex:  issue.Index,\n\t\tTitle:  issue.Title,\n\t\tBody:   issue.Body,\n\t\tAuthor: posterName(issue.Poster),\n\t\tRaw:    raw,\n\t}\n}", "replace": "func issueToPRNewEvent(issue *sdk.Issue, owner, repo string) Event {\n\traw, _ := json.Marshal(issue)\n\n\treturn Event{\n\t\tKind:   EventPRNew,\n\t\tID:     prNewID(issue.ID),\n\t\tOwner:  owner,\n\t\tRepo:   repo,\n\t\tIndex:  issue.Index,\n\t\tTitle:  issue.Title,\n\t\tBody:   issue.Body,\n\t\tAuthor: posterName(issue.Poster),\n\t\tRaw:    raw,\n\t}\n}\n\nfunc reviewToEvent(pr *sdk.Issue, review *sdk.PullReview, owner, repo string) Event {\n\traw, _ := json.Marshal(review)\n\n\treturn Event{\n\t\tKind:        EventPRReview,\n\t\tID:          prReviewID(review.ID),\n\t\tOwner:       owner,\n\t\tRepo:        repo,\n\t\tIndex:       pr.Index,\n\t\tTitle:       pr.Title,\n\t\tBody:        pr.Body,\n\t\tAuthor:      posterName(review.Reviewer),\n\t\tReviewID:    review.ID,\n\t\tReviewState: string(review.State),\n\t\tReviewBody:  review.Body,\n\t\tRaw:         raw,\n\t}\n}"}
1 occurrences replaced
Thinking
Next, the additions to the client: `ReviewDetail`, `PullRequestInfo`, `ResolveReviewComment`, `SubmitReview`. First, let's check the current imports in client.go.
Now the client additions. Let me check the current imports in `client.go`:
Tool 🔧 read_file {"path": "internal/forgejo/client.go", "start_line": 1, "end_line": 15}
{"path": "internal/forgejo/client.go", "start_line": 1, "end_line": 15}
{"path":"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\t\"time\"\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","start_line":1,"end_line":15,"total_lines":305,"truncated":true}
Thinking
I need to add `io`, `net/http`, and `strings` to the imports for the raw HTTP calls inside `ResolveReviewComment`. Let's update the imports and add the new methods. I'll add the new methods after `AssignIssue` (before `IssueTitle`), or at a logical location. Let's add them after `RequestReview`, which is review-related. Actually, I'll decide to add the review-related methods together after `RequestReview`. First, let's update the imports.
Tool 🔧 edit_file {"path": "internal/forgejo/client.go", "find": "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)", "replace": "import (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n)"}
{"path": "internal/forgejo/client.go", "find": "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)", "replace": "import (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\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
Thinking
Next, I'll add the review-related client methods. I'll add them after the `RequestReview` method. Let me find that spot and insert after it.
Tool 🔧 edit_file {"path": "internal/forgejo/client.go", "find": "// RequestReview asks the given users to review the pull request.\nfunc (c *Client) RequestReview(owner, repo string, index int64, reviewers []string) error {\n\t_, err := c.sdk.CreateReviewRequests(owner, repo, index, sdk.PullReviewRequestOptions{Reviewers: reviewers})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"request review on %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\treturn nil\n}", "replace": "// RequestReview asks the given users to review the pull request.\nfunc (c *Client) RequestReview(owner, repo string, index int64, reviewers []string) error {\n\t_, err := c.sdk.CreateReviewRequests(owner, repo, index, sdk.PullReviewRequestOptions{Reviewers: reviewers})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"request review on %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\treturn nil\n}\n\n// Review states an agent can submit, in the friendly names the tools\n// expose. SubmitReview maps them onto the SDK's ReviewStateType.\nconst (\n\tReviewStateApproved = \"approved\"\n\tReviewStateChangesRequest = \"changes_requested\"\n\tReviewStateComment = \"comment\"\n)\n\n// SubmitReview submits a review on the pull request with the given\n// verdict and body. state is one of ReviewStateApproved,\n// ReviewStateChangesRequest, or ReviewStateComment. A body is required\n// for anything other than an approval (Forgejo enforces this too).\nfunc (c *Client) SubmitReview(owner, repo string, index int64, state, body string) error {\n\tvar sdkState sdk.ReviewStateType\n\n\tswitch state {\n\tcase ReviewStateApproved:\n\t\tsdkState = sdk.ReviewStateApproved\n\tcase ReviewStateChangesRequest:\n\t\tsdkState = sdk.ReviewStateRequestChanges\n\tcase ReviewStateComment:\n\t\tsdkState = sdk.ReviewStateComment\n\tdefault:\n\t\treturn fmt.Errorf(\"submit review on %s/%s#%d: unknown review state %q\", owner, repo, index, state)\n\t}\n\n\tif _, _, err := c.sdk.CreatePullReview(owner, repo, index, sdk.CreatePullReviewOptions{State: sdkState, Body: body}); err != nil {\n\t\treturn fmt.Errorf(\"submit review on %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\treturn nil\n}\n\n// ReviewCommentDetail is one inline comment on a pull request review,\n// in the shape zoo needs when briefing an agent: where it points and\n// what it says.\ntype ReviewCommentDetail struct {\n\tPath string\n\tLine int\n\tBody string\n\tAuthor string\n}\n\n// ReviewDetail is the review context zoo briefs an agent with when a\n// pr:review event fires: the review's verdict and body, plus its inline\n// comments.\ntype ReviewDetail struct {\n\tID int64\n\tState string\n\tBody string\n\tReviewer string\n\tComments []ReviewCommentDetail\n}\n\n// ReviewDetail fetches a pull request review and its inline comments.\n// The webhook payload carries the review but not its inline comments,\n// so this is how a reacting agent gets the full feedback.\nfunc (c *Client) ReviewDetail(owner, repo string, index, reviewID int64) (*ReviewDetail, error) {\n\treview, _, err := c.sdk.GetPullReview(owner, repo, index, reviewID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get review %d on %s/%s#%d: %w\", reviewID, owner, repo, index, err)\n\t}\n\n\tcomments, _, err := c.sdk.ListPullReviewComments(owner, repo, index, reviewID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"list comments on review %d of %s/%s#%d: %w\", reviewID, owner, repo, index, err)\n\t}\n\n\tdetail := &ReviewDetail{\n\t\tID: review.ID,\n\t\tState: string(review.State),\n\t\tBody: review.Body,\n\t}\n\n\tif review.Reviewer != nil {\n\t\tdetail.Reviewer = review.Reviewer.UserName\n\t}\n\n\tfor _, cm := range comments {\n\t\tauthor := \"\"\n\t\tif cm.Reviewer != nil {\n\t\t\tauthor = cm.Reviewer.UserName\n\t\t}\n\n\t\tline := int(cm.LineNum)\n\t\tif cm.OldLineNum != 0 && cm.LineNum == 0 {\n\t\t\tline = int(cm.OldLineNum)\n\t\t}\n\n\t\tdetail.Comments = append(detail.Comments, ReviewCommentDetail{\n\t\t\tPath: cm.Path,\n\t\t\tLine: line,\n\t\t\tBody: cm.Body,\n\t\t\tAuthor: author,\n\t\t})\n\t}\n\n\treturn detail, nil\n}\n\n// ResolveReviewComment marks the inline review comment with the given id\n// as resolved, i.e. the feedback it contains has been addressed. The SDK\n// doesn't expose this endpoint, so it's a direct API call.\nfunc (c *Client) ResolveReviewComment(owner, repo string, index, commentID int64) error {\n\tpath := fmt.Sprintf(\"/repos/%s/%s/pulls/%d/review_comments/%d/resolve\", owner, repo, index, commentID)\n\n\treq, err := http.NewRequest(http.MethodPost, c.baseURL+\"/api/v1\"+path, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"resolve review comment %d: %w\", commentID, err)\n\t}\n\n\treq.Header.Set(\"Authorization\", \"token \"+c.token)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"resolve review comment %d: %w\", commentID, err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode/100 != 2 {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\n\t\treturn fmt.Errorf(\"resolve review comment %d: %s: %s\", commentID, resp.Status, strings.TrimSpace(string(body)))\n\t}\n\n\treturn nil\n}\n\n// PullRequestInfo is the branch metadata agentrun needs to check out a\n// pull request's head.\ntype PullRequestInfo struct {\n\tHeadRef string\n\tBaseRef string\n}\n\n// PullRequestInfo returns the pull request's head and base branch refs.\nfunc (c *Client) PullRequestInfo(owner, repo string, index int64) (PullRequestInfo, error) {\n\tpr, _, err := c.sdk.GetPullRequest(owner, repo, index)\n\tif err != nil {\n\t\treturn PullRequestInfo{}, fmt.Errorf(\"get pull request %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\tinfo := PullRequestInfo{}\n\tif pr.Head != nil {\n\t\tinfo.HeadRef = pr.Head.Ref\n\t}\n\tif pr.Base != nil {\n\t\tinfo.BaseRef = pr.Base.Ref\n\t}\n\n\treturn info, nil\n}"}
{"path": "internal/forgejo/client.go", "find": "// RequestReview asks the given users to review the pull request.\nfunc (c *Client) RequestReview(owner, repo string, index int64, reviewers []string) error {\n\t_, err := c.sdk.CreateReviewRequests(owner, repo, index, sdk.PullReviewRequestOptions{Reviewers: reviewers})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"request review on %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\treturn nil\n}", "replace": "// RequestReview asks the given users to review the pull request.\nfunc (c *Client) RequestReview(owner, repo string, index int64, reviewers []string) error {\n\t_, err := c.sdk.CreateReviewRequests(owner, repo, index, sdk.PullReviewRequestOptions{Reviewers: reviewers})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"request review on %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\treturn nil\n}\n\n// Review states an agent can submit, in the friendly names the tools\n// expose. SubmitReview maps them onto the SDK's ReviewStateType.\nconst (\n\tReviewStateApproved       = \"approved\"\n\tReviewStateChangesRequest = \"changes_requested\"\n\tReviewStateComment        = \"comment\"\n)\n\n// SubmitReview submits a review on the pull request with the given\n// verdict and body. state is one of ReviewStateApproved,\n// ReviewStateChangesRequest, or ReviewStateComment. A body is required\n// for anything other than an approval (Forgejo enforces this too).\nfunc (c *Client) SubmitReview(owner, repo string, index int64, state, body string) error {\n\tvar sdkState sdk.ReviewStateType\n\n\tswitch state {\n\tcase ReviewStateApproved:\n\t\tsdkState = sdk.ReviewStateApproved\n\tcase ReviewStateChangesRequest:\n\t\tsdkState = sdk.ReviewStateRequestChanges\n\tcase ReviewStateComment:\n\t\tsdkState = sdk.ReviewStateComment\n\tdefault:\n\t\treturn fmt.Errorf(\"submit review on %s/%s#%d: unknown review state %q\", owner, repo, index, state)\n\t}\n\n\tif _, _, err := c.sdk.CreatePullReview(owner, repo, index, sdk.CreatePullReviewOptions{State: sdkState, Body: body}); err != nil {\n\t\treturn fmt.Errorf(\"submit review on %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\treturn nil\n}\n\n// ReviewCommentDetail is one inline comment on a pull request review,\n// in the shape zoo needs when briefing an agent: where it points and\n// what it says.\ntype ReviewCommentDetail struct {\n\tPath   string\n\tLine   int\n\tBody   string\n\tAuthor string\n}\n\n// ReviewDetail is the review context zoo briefs an agent with when a\n// pr:review event fires: the review's verdict and body, plus its inline\n// comments.\ntype ReviewDetail struct {\n\tID       int64\n\tState    string\n\tBody     string\n\tReviewer string\n\tComments []ReviewCommentDetail\n}\n\n// ReviewDetail fetches a pull request review and its inline comments.\n// The webhook payload carries the review but not its inline comments,\n// so this is how a reacting agent gets the full feedback.\nfunc (c *Client) ReviewDetail(owner, repo string, index, reviewID int64) (*ReviewDetail, error) {\n\treview, _, err := c.sdk.GetPullReview(owner, repo, index, reviewID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"get review %d on %s/%s#%d: %w\", reviewID, owner, repo, index, err)\n\t}\n\n\tcomments, _, err := c.sdk.ListPullReviewComments(owner, repo, index, reviewID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"list comments on review %d of %s/%s#%d: %w\", reviewID, owner, repo, index, err)\n\t}\n\n\tdetail := &ReviewDetail{\n\t\tID:   review.ID,\n\t\tState: string(review.State),\n\t\tBody: review.Body,\n\t}\n\n\tif review.Reviewer != nil {\n\t\tdetail.Reviewer = review.Reviewer.UserName\n\t}\n\n\tfor _, cm := range comments {\n\t\tauthor := \"\"\n\t\tif cm.Reviewer != nil {\n\t\t\tauthor = cm.Reviewer.UserName\n\t\t}\n\n\t\tline := int(cm.LineNum)\n\t\tif cm.OldLineNum != 0 && cm.LineNum == 0 {\n\t\t\tline = int(cm.OldLineNum)\n\t\t}\n\n\t\tdetail.Comments = append(detail.Comments, ReviewCommentDetail{\n\t\t\tPath:   cm.Path,\n\t\t\tLine:   line,\n\t\t\tBody:   cm.Body,\n\t\t\tAuthor: author,\n\t\t})\n\t}\n\n\treturn detail, nil\n}\n\n// ResolveReviewComment marks the inline review comment with the given id\n// as resolved, i.e. the feedback it contains has been addressed. The SDK\n// doesn't expose this endpoint, so it's a direct API call.\nfunc (c *Client) ResolveReviewComment(owner, repo string, index, commentID int64) error {\n\tpath := fmt.Sprintf(\"/repos/%s/%s/pulls/%d/review_comments/%d/resolve\", owner, repo, index, commentID)\n\n\treq, err := http.NewRequest(http.MethodPost, c.baseURL+\"/api/v1\"+path, nil)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"resolve review comment %d: %w\", commentID, err)\n\t}\n\n\treq.Header.Set(\"Authorization\", \"token \"+c.token)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"resolve review comment %d: %w\", commentID, err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode/100 != 2 {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\n\t\treturn fmt.Errorf(\"resolve review comment %d: %s: %s\", commentID, resp.Status, strings.TrimSpace(string(body)))\n\t}\n\n\treturn nil\n}\n\n// PullRequestInfo is the branch metadata agentrun needs to check out a\n// pull request's head.\ntype PullRequestInfo struct {\n\tHeadRef string\n\tBaseRef string\n}\n\n// PullRequestInfo returns the pull request's head and base branch refs.\nfunc (c *Client) PullRequestInfo(owner, repo string, index int64) (PullRequestInfo, error) {\n\tpr, _, err := c.sdk.GetPullRequest(owner, repo, index)\n\tif err != nil {\n\t\treturn PullRequestInfo{}, fmt.Errorf(\"get pull request %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\tinfo := PullRequestInfo{}\n\tif pr.Head != nil {\n\t\tinfo.HeadRef = pr.Head.Ref\n\t}\n\tif pr.Base != nil {\n\t\tinfo.BaseRef = pr.Base.Ref\n\t}\n\n\treturn info, nil\n}"}
1 occurrences replaced
Thinking
Next, the config package. Add the `EventPRReview` constant and add it to `staticEventKinds`.
Now the config package:
Tool 🔧 edit_file {"path": "internal/config/config.go", "find": "const (\n\tEventIssueNew = \"issue:new\"\n\tEventIssueComment = \"issue:comment\"\n\tEventIssueAssigned = \"issue:assigned\"\n\tEventPRNew = \"pr:new\"\n)\n\nvar staticEventKinds = map[string]bool{\n\tEventIssueNew: true,\n\tEventIssueComment: true,\n\tEventPRNew: true,\n}", "replace": "const (\n\tEventIssueNew = \"issue:new\"\n\tEventIssueComment = \"issue:comment\"\n\tEventIssueAssigned = \"issue:assigned\"\n\tEventPRNew = \"pr:new\"\n\tEventPRReview = \"pr:review\"\n)\n\nvar staticEventKinds = map[string]bool{\n\tEventIssueNew: true,\n\tEventIssueComment: true,\n\tEventPRNew: true,\n\tEventPRReview: true,\n}"}
{"path": "internal/config/config.go", "find": "const (\n\tEventIssueNew      = \"issue:new\"\n\tEventIssueComment  = \"issue:comment\"\n\tEventIssueAssigned = \"issue:assigned\"\n\tEventPRNew         = \"pr:new\"\n)\n\nvar staticEventKinds = map[string]bool{\n\tEventIssueNew:     true,\n\tEventIssueComment: true,\n\tEventPRNew:        true,\n}", "replace": "const (\n\tEventIssueNew      = \"issue:new\"\n\tEventIssueComment  = \"issue:comment\"\n\tEventIssueAssigned = \"issue:assigned\"\n\tEventPRNew         = \"pr:new\"\n\tEventPRReview      = \"pr:review\"\n)\n\nvar staticEventKinds = map[string]bool{\n\tEventIssueNew:     true,\n\tEventIssueComment: true,\n\tEventPRNew:        true,\n\tEventPRReview:     true,\n}"}
1 occurrences replaced
Thinking
Next, I'll update `zoo.hcl.example` to add the `pr:review` event block.
Now the example config:
Tool 🔧 edit_file {"path": "zoo.hcl.example", "find": "event \"pr:new\" {\n agent = \"greg\"\n instructions = \"Please review this pull request and respond appropriately.\"\n}", "replace": "event \"pr:new\" {\n agent = \"greg\"\n instructions = \"Please review this pull request and respond appropriately.\"\n}\n\n# A review was submitted on a pull request (approved, changes requested,\n# or comment). The agent's working tree is checked out on the PR's head\n# branch, so changes it pushes update the PR directly. It should address\n# the feedback, reply to the review, and resolve the inline comments it\n# has addressed.\nevent \"pr:review\" {\n agent = \"greg\"\n instructions = \"A review was submitted on this pull request. Address the feedback, push your changes to the PR branch, reply to the review, and resolve the inline comments you have addressed.\"\n}"}
{"path": "zoo.hcl.example", "find": "event \"pr:new\" {\n    agent        = \"greg\"\n    instructions = \"Please review this pull request and respond appropriately.\"\n}", "replace": "event \"pr:new\" {\n    agent        = \"greg\"\n    instructions = \"Please review this pull request and respond appropriately.\"\n}\n\n# A review was submitted on a pull request (approved, changes requested,\n# or comment). The agent's working tree is checked out on the PR's head\n# branch, so changes it pushes update the PR directly. It should address\n# the feedback, reply to the review, and resolve the inline comments it\n# has addressed.\nevent \"pr:review\" {\n    agent        = \"greg\"\n    instructions = \"A review was submitted on this pull request. Address the feedback, push your changes to the PR branch, reply to the review, and resolve the inline comments you have addressed.\"\n}"}
1 occurrences replaced
Thinking
Next, the tools package. First, update the `ForgejoActions` interface in `context.go` to add `SubmitReview` and `ResolveReviewComment`.
Now the tools package — first the `ForgejoActions` interface:
Tool 🔧 edit_file {"path": "internal/tools/context.go", "find": "// ForgejoActions are the actions available to an agent against the\n// triggering issue/PR. Implemented by internal/agentrun, backed by\n// internal/forgejo.Client plus the run's owner/repo/issue.\ntype ForgejoActions interface {\n\tComment(body string) error\n\tOpenPullRequest(head, base, title, body string) error\n\tRequestReview(reviewers []string) error\n\tAddLabel(name string) error\n\tRemoveLabel(name string) error\n\tCloseIssue() error\n\tReopenIssue() error\n\n\t// AssignIssue hands the triggering issue/PR off to another agent by\n\t// their name (which doubles as their Forgejo username); the\n\t// resulting issue:assigned event triggers that agent's run.\n\tAssignIssue(agentName string) error\n}", "replace": "// ForgejoActions are the actions available to an agent against the\n// triggering issue/PR. Implemented by internal/agentrun, backed by\n// internal/forgejo.Client plus the run's owner/repo/issue.\ntype ForgejoActions interface {\n\tComment(body string) error\n\tOpenPullRequest(head, base, title, body string) error\n\tRequestReview(reviewers []string) error\n\tAddLabel(name string) error\n\tRemoveLabel(name string) error\n\tCloseIssue() error\n\tReopenIssue() error\n\n\t// AssignIssue hands the triggering issue/PR off to another agent by\n\t// their name (which doubles as their Forgejo username); the\n\t// resulting issue:assigned event triggers that agent's run.\n\tAssignIssue(agentName string) error\n\n\t// SubmitReview submits a review on the triggering pull request with\n\t// the given verdict (forgejo.ReviewStateApproved,\n\t// ReviewStateChangesRequest, or ReviewStateComment) and body.\n\tSubmitReview(state, body string) error\n\n\t// ResolveReviewComment marks the inline review comment with the\n\t// given id as resolved, once its feedback has been addressed.\n\tResolveReviewComment(commentID int64) error\n}"}
{"path": "internal/tools/context.go", "find": "// ForgejoActions are the actions available to an agent against the\n// triggering issue/PR. Implemented by internal/agentrun, backed by\n// internal/forgejo.Client plus the run's owner/repo/issue.\ntype ForgejoActions interface {\n\tComment(body string) error\n\tOpenPullRequest(head, base, title, body string) error\n\tRequestReview(reviewers []string) error\n\tAddLabel(name string) error\n\tRemoveLabel(name string) error\n\tCloseIssue() error\n\tReopenIssue() error\n\n\t// AssignIssue hands the triggering issue/PR off to another agent by\n\t// their name (which doubles as their Forgejo username); the\n\t// resulting issue:assigned event triggers that agent's run.\n\tAssignIssue(agentName string) error\n}", "replace": "// ForgejoActions are the actions available to an agent against the\n// triggering issue/PR. Implemented by internal/agentrun, backed by\n// internal/forgejo.Client plus the run's owner/repo/issue.\ntype ForgejoActions interface {\n\tComment(body string) error\n\tOpenPullRequest(head, base, title, body string) error\n\tRequestReview(reviewers []string) error\n\tAddLabel(name string) error\n\tRemoveLabel(name string) error\n\tCloseIssue() error\n\tReopenIssue() error\n\n\t// AssignIssue hands the triggering issue/PR off to another agent by\n\t// their name (which doubles as their Forgejo username); the\n\t// resulting issue:assigned event triggers that agent's run.\n\tAssignIssue(agentName string) error\n\n\t// SubmitReview submits a review on the triggering pull request with\n\t// the given verdict (forgejo.ReviewStateApproved,\n\t// ReviewStateChangesRequest, or ReviewStateComment) and body.\n\tSubmitReview(state, body string) error\n\n\t// ResolveReviewComment marks the inline review comment with the\n\t// given id as resolved, once its feedback has been addressed.\n\tResolveReviewComment(commentID int64) error\n}"}
1 occurrences replaced
Thinking
Next, I'll add two new tools to `internal/tools/forgejo.go`. I'll add them after `requestReview`.
Now the two new tools in `internal/tools/forgejo.go`:
Tool 🔧 edit_file {"path": "internal/tools/forgejo.go", "find": "func requestReview(ctx Context, params requestReviewParams) (string, error) {\n\tif err := ctx.Forgejo().RequestReview(params.Reviewers); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn \"Review requested\", nil\n}", "replace": "func requestReview(ctx Context, params requestReviewParams) (string, error) {\n\tif err := ctx.Forgejo().RequestReview(params.Reviewers); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn \"Review requested\", nil\n}\n\ntype submitReviewParams struct {\n\tState string `json:\"state\"`\n\tBody string `json:\"body\"`\n}\n\nfunc init() {\n\ttool := llm.NewTool(\n\t\t\"submit_review\",\n\t\t\"Submit a review on the pull request that triggered this run: approve it, request changes, or leave review feedback. A submitted review triggers the PR author's agent, so use it to react to a PR (e.g. after addressing earlier feedback) or to review someone else's PR.\")\n\n\ttool.AddEnumProperty(\"state\", \"The review verdict\", []string{\"approved\", \"changes_requested\", \"comment\"}, true)\n\ttool.AddStringProperty(\"body\", \"The review text, in Markdown. Required unless state is 'approved'.\", true)\n\n\tRegister(tool, submitReview)\n}\n\nfunc submitReview(ctx Context, params submitReviewParams) (string, error) {\n\tif params.State != \"approved\" && strings.TrimSpace(params.Body) == \"\" {\n\t\treturn \"\", fmt.Errorf(\"body is required unless state is 'approved'\")\n\t}\n\n\tif err := ctx.Forgejo().SubmitReview(params.State, params.Body); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn \"Review submitted\", nil\n}\n\ntype resolveReviewCommentParams struct {\n\tCommentID int64 `json:\"comment_id\"`\n}\n\nfunc init() {\n\ttool := llm.NewTool(\n\t\t\"resolve_review_comment\",\n\t\t\"Mark an inline review comment on the pull request as resolved, once you have addressed the feedback it contains. The comment's id is in the review briefing (and the raw event payload).\")\n\n\ttool.AddIntegerProperty(\"comment_id\", \"The id of the inline review comment to resolve\", true)\n\n\tRegister(tool, resolveReviewComment)\n}\n\nfunc resolveReviewComment(ctx Context, params resolveReviewCommentParams) (string, error) {\n\tif err := ctx.Forgejo().ResolveReviewComment(params.CommentID); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn \"Review comment resolved\", nil\n}"}
{"path": "internal/tools/forgejo.go", "find": "func requestReview(ctx Context, params requestReviewParams) (string, error) {\n\tif err := ctx.Forgejo().RequestReview(params.Reviewers); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn \"Review requested\", nil\n}", "replace": "func requestReview(ctx Context, params requestReviewParams) (string, error) {\n\tif err := ctx.Forgejo().RequestReview(params.Reviewers); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn \"Review requested\", nil\n}\n\ntype submitReviewParams struct {\n\tState string `json:\"state\"`\n\tBody  string `json:\"body\"`\n}\n\nfunc init() {\n\ttool := llm.NewTool(\n\t\t\"submit_review\",\n\t\t\"Submit a review on the pull request that triggered this run: approve it, request changes, or leave review feedback. A submitted review triggers the PR author's agent, so use it to react to a PR (e.g. after addressing earlier feedback) or to review someone else's PR.\")\n\n\ttool.AddEnumProperty(\"state\", \"The review verdict\", []string{\"approved\", \"changes_requested\", \"comment\"}, true)\n\ttool.AddStringProperty(\"body\", \"The review text, in Markdown. Required unless state is 'approved'.\", true)\n\n\tRegister(tool, submitReview)\n}\n\nfunc submitReview(ctx Context, params submitReviewParams) (string, error) {\n\tif params.State != \"approved\" && strings.TrimSpace(params.Body) == \"\" {\n\t\treturn \"\", fmt.Errorf(\"body is required unless state is 'approved'\")\n\t}\n\n\tif err := ctx.Forgejo().SubmitReview(params.State, params.Body); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn \"Review submitted\", nil\n}\n\ntype resolveReviewCommentParams struct {\n\tCommentID int64 `json:\"comment_id\"`\n}\n\nfunc init() {\n\ttool := llm.NewTool(\n\t\t\"resolve_review_comment\",\n\t\t\"Mark an inline review comment on the pull request as resolved, once you have addressed the feedback it contains. The comment's id is in the review briefing (and the raw event payload).\")\n\n\ttool.AddIntegerProperty(\"comment_id\", \"The id of the inline review comment to resolve\", true)\n\n\tRegister(tool, resolveReviewComment)\n}\n\nfunc resolveReviewComment(ctx Context, params resolveReviewCommentParams) (string, error) {\n\tif err := ctx.Forgejo().ResolveReviewComment(params.CommentID); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn \"Review comment resolved\", nil\n}"}
1 occurrences replaced
Thinking
I need to add `strings` to the imports in `internal/tools/forgejo.go`. Let me check the current imports.
Tool 🔧 read_file {"path": "internal/tools/forgejo.go", "start_line": 1, "end_line": 10}
{"path": "internal/tools/forgejo.go", "start_line": 1, "end_line": 10}
{"path":"internal/tools/forgejo.go","content":"package tools\r\n\r\nimport (\r\n\t\"fmt\"\r\n\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n)\r\n\r\ntype commentParams struct {\r\n\tBody string `json:\"body\"`\r","start_line":1,"end_line":10,"total_lines":225,"truncated":true}
Thinking
I need to add `strings` to the imports.
Tool 🔧 edit_file {"path": "internal/tools/forgejo.go", "find": "import (\n\t\"fmt\"\n\n\t\"github.com/abrander/zoo/internal/llm\"\n)", "replace": "import (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/abrander/zoo/internal/llm\"\n)"}
{"path": "internal/tools/forgejo.go", "find": "import (\n\t\"fmt\"\n\n\t\"github.com/abrander/zoo/internal/llm\"\n)", "replace": "import (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/abrander/zoo/internal/llm\"\n)"}
1 occurrences replaced
Thinking
Next, I'll update `internal/agentrun/forgejo.go` to implement the two new methods on `runForgejoActions`. Let's add them after `AssignIssue`.
Now implement the new actions in `internal/agentrun/forgejo.go`:
Tool 🔧 edit_file {"path": "internal/agentrun/forgejo.go", "find": "func (a *runForgejoActions) AssignIssue(agentName string) error {\n\terr := a.client.AssignIssue(a.owner, a.repo, a.index, agentName)\n\ta.logResult(\"assign_issue\", err, \"assignee\", agentName)\n\n\treturn err\n}", "replace": "func (a *runForgejoActions) AssignIssue(agentName string) error {\n\terr := a.client.AssignIssue(a.owner, a.repo, a.index, agentName)\n\ta.logResult(\"assign_issue\", err, \"assignee\", agentName)\n\n\treturn err\n}\n\nfunc (a *runForgejoActions) SubmitReview(state, body string) error {\n\terr := a.client.SubmitReview(a.owner, a.repo, a.index, state, body)\n\ta.logResult(\"submit_review\", err, \"state\", state)\n\n\treturn err\n}\n\nfunc (a *runForgejoActions) ResolveReviewComment(commentID int64) error {\n\terr := a.client.ResolveReviewComment(a.owner, a.repo, a.index, commentID)\n\ta.logResult(\"resolve_review_comment\", err, \"comment_id\", commentID)\n\n\treturn err\n}"}
{"path": "internal/agentrun/forgejo.go", "find": "func (a *runForgejoActions) AssignIssue(agentName string) error {\n\terr := a.client.AssignIssue(a.owner, a.repo, a.index, agentName)\n\ta.logResult(\"assign_issue\", err, \"assignee\", agentName)\n\n\treturn err\n}", "replace": "func (a *runForgejoActions) AssignIssue(agentName string) error {\n\terr := a.client.AssignIssue(a.owner, a.repo, a.index, agentName)\n\ta.logResult(\"assign_issue\", err, \"assignee\", agentName)\n\n\treturn err\n}\n\nfunc (a *runForgejoActions) SubmitReview(state, body string) error {\n\terr := a.client.SubmitReview(a.owner, a.repo, a.index, state, body)\n\ta.logResult(\"submit_review\", err, \"state\", state)\n\n\treturn err\n}\n\nfunc (a *runForgejoActions) ResolveReviewComment(commentID int64) error {\n\terr := a.client.ResolveReviewComment(a.owner, a.repo, a.index, commentID)\n\ta.logResult(\"resolve_review_comment\", err, \"comment_id\", commentID)\n\n\treturn err\n}"}
1 occurrences replaced
Thinking
Next, I'll update `internal/agentrun/clone.go` to add `clonePRHead`.
Now add `clonePRHead` to `internal/agentrun/clone.go`:
Tool 🔧 edit_file {"path": "internal/agentrun/clone.go", "find": "// cloneAndBranch clones cloneURL into dir, authenticating the clone\n// itself with token (see gitAuthArgs), and checks out a new branch off\n// of defaultBranch. token never ends up in the resulting working tree:\n// the origin remote is left with the plain cloneURL.\nfunc cloneAndBranch(ctx context.Context, cloneURL, token, defaultBranch, branch, dir string) error {\n\targs := append(gitAuthArgs(token), \"clone\", \"--branch\", defaultBranch, \"--single-branch\", cloneURL, dir)\n\tif out, err := runGit(ctx, \"\", args...); err != nil {\n\t\treturn fmt.Errorf(\"clone: %w: %s\", err, out)\n\t}\n\n\tif out, err := runGit(ctx, dir, \"checkout\", \"-b\", branch); err != nil {\n\t\treturn fmt.Errorf(\"checkout branch %q: %w: %s\", branch, err, out)\n\t}\n\n\treturn nil\n}", "replace": "// cloneAndBranch clones cloneURL into dir, authenticating the clone\n// itself with token (see gitAuthArgs), and checks out a new branch off\n// of defaultBranch. token never ends up in the resulting working tree:\n// the origin remote is left with the plain cloneURL.\nfunc cloneAndBranch(ctx context.Context, cloneURL, token, defaultBranch, branch, dir string) error {\n\targs := append(gitAuthArgs(token), \"clone\", \"--branch\", defaultBranch, \"--single-branch\", cloneURL, dir)\n\tif out, err := runGit(ctx, \"\", args...); err != nil {\n\t\treturn fmt.Errorf(\"clone: %w: %s\", err, out)\n\t}\n\n\tif out, err := runGit(ctx, dir, \"checkout\", \"-b\", branch); err != nil {\n\t\treturn fmt.Errorf(\"checkout branch %q: %w: %s\", branch, err, out)\n\t}\n\n\treturn nil\n}\n\n// clonePRHead clones cloneURL into dir and checks out the pull request's\n// head as a local branch named headRef, so commits the agent makes push\n// straight to the PR. The head is fetched via the PR's refs/pull/<n>/head\n// ref rather than by branch name, which also works for PRs opened from a\n// fork, where the head branch doesn't exist in the base repo.\nfunc clonePRHead(ctx context.Context, cloneURL, token, defaultBranch, headRef string, index int64, dir string) error {\n\targs := append(gitAuthArgs(token), \"clone\", \"--branch\", defaultBranch, \"--single-branch\", cloneURL, dir)\n\tif out, err := runGit(ctx, \"\", args...); err != nil {\n\t\treturn fmt.Errorf(\"clone: %w: %s\", err, out)\n\t}\n\n\tfetchArgs := append(gitAuthArgs(token), \"fetch\", \"--no-tags\", \"origin\", fmt.Sprintf(\"refs/pull/%d/head\", index))\n\tif out, err := runGit(ctx, dir, fetchArgs...); err != nil {\n\t\treturn fmt.Errorf(\"fetch PR %d head: %w: %s\", index, err, out)\n\t}\n\n\tif out, err := runGit(ctx, dir, \"checkout\", \"-B\", headRef, \"FETCH_HEAD\"); err != nil {\n\t\treturn fmt.Errorf(\"checkout PR head %q: %w: %s\", headRef, err, out)\n\t}\n\n\treturn nil\n}"}
{"path": "internal/agentrun/clone.go", "find": "// cloneAndBranch clones cloneURL into dir, authenticating the clone\n// itself with token (see gitAuthArgs), and checks out a new branch off\n// of defaultBranch. token never ends up in the resulting working tree:\n// the origin remote is left with the plain cloneURL.\nfunc cloneAndBranch(ctx context.Context, cloneURL, token, defaultBranch, branch, dir string) error {\n\targs := append(gitAuthArgs(token), \"clone\", \"--branch\", defaultBranch, \"--single-branch\", cloneURL, dir)\n\tif out, err := runGit(ctx, \"\", args...); err != nil {\n\t\treturn fmt.Errorf(\"clone: %w: %s\", err, out)\n\t}\n\n\tif out, err := runGit(ctx, dir, \"checkout\", \"-b\", branch); err != nil {\n\t\treturn fmt.Errorf(\"checkout branch %q: %w: %s\", branch, err, out)\n\t}\n\n\treturn nil\n}", "replace": "// cloneAndBranch clones cloneURL into dir, authenticating the clone\n// itself with token (see gitAuthArgs), and checks out a new branch off\n// of defaultBranch. token never ends up in the resulting working tree:\n// the origin remote is left with the plain cloneURL.\nfunc cloneAndBranch(ctx context.Context, cloneURL, token, defaultBranch, branch, dir string) error {\n\targs := append(gitAuthArgs(token), \"clone\", \"--branch\", defaultBranch, \"--single-branch\", cloneURL, dir)\n\tif out, err := runGit(ctx, \"\", args...); err != nil {\n\t\treturn fmt.Errorf(\"clone: %w: %s\", err, out)\n\t}\n\n\tif out, err := runGit(ctx, dir, \"checkout\", \"-b\", branch); err != nil {\n\t\treturn fmt.Errorf(\"checkout branch %q: %w: %s\", branch, err, out)\n\t}\n\n\treturn nil\n}\n\n// clonePRHead clones cloneURL into dir and checks out the pull request's\n// head as a local branch named headRef, so commits the agent makes push\n// straight to the PR. The head is fetched via the PR's refs/pull/<n>/head\n// ref rather than by branch name, which also works for PRs opened from a\n// fork, where the head branch doesn't exist in the base repo.\nfunc clonePRHead(ctx context.Context, cloneURL, token, defaultBranch, headRef string, index int64, dir string) error {\n\targs := append(gitAuthArgs(token), \"clone\", \"--branch\", defaultBranch, \"--single-branch\", cloneURL, dir)\n\tif out, err := runGit(ctx, \"\", args...); err != nil {\n\t\treturn fmt.Errorf(\"clone: %w: %s\", err, out)\n\t}\n\n\tfetchArgs := append(gitAuthArgs(token), \"fetch\", \"--no-tags\", \"origin\", fmt.Sprintf(\"refs/pull/%d/head\", index))\n\tif out, err := runGit(ctx, dir, fetchArgs...); err != nil {\n\t\treturn fmt.Errorf(\"fetch PR %d head: %w: %s\", index, err, out)\n\t}\n\n\tif out, err := runGit(ctx, dir, \"checkout\", \"-B\", headRef, \"FETCH_HEAD\"); err != nil {\n\t\treturn fmt.Errorf(\"checkout PR head %q: %w: %s\", headRef, err, out)\n\t}\n\n\treturn nil\n}"}
1 occurrences replaced
Thinking
Next, the main wiring in `internal/agentrun/run.go`. What needs to be done: 1. For `pr:review` events: fetch the PR's head ref (if not in the event), check out the head branch, fetch the review details, and pass them to seedMessage. 2. Update `seedMessage` to accept the review details and render the review section and the PR branch wording. Let me re-read the relevant part of run.go to make the edits precisely.
Now the main wiring in `internal/agentrun/run.go`. Let me re-read the relevant section:
Tool 🔧 read_file {"path": "internal/agentrun/run.go", "start_line": 95, "end_line": 235}
{"path": "internal/agentrun/run.go", "start_line": 95, "end_line": 235}
{"path":"internal/agentrun/run.go","content":"\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\t// Fetch the full comment thread so the agent sees everything that's\r\n\t// been said on the issue/PR, not just the triggering event (which\r\n\t// only carries the latest comment, if any). A failure degrades to\r\n\t// no comments rather than failing the run: the agent can still do\r\n\t// its job, just without prior context.\r\n\tcomments, err := r.forgejo.ListIssueComments(ev.Owner, ev.Repo, ev.Index)\r\n\tif err != nil {\r\n\t\tlogger.Warn(\"fetch issue comments failed; agent will not see prior comments\", \"error\", err)\r\n\t\tcomments = nil\r\n\t}\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, comments)},\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","start_line":95,"end_line":235,"total_lines":333,"truncated":true}
Thinking
Now I'll make edits to `Run`: 1. Replace the branch/clone block with logic that handles `pr:review`: ```go branch := fmt.Sprintf("issue-%d-%s", ev.Index, agent.Name) projectDir := filepath.Join(workDir, "project") if err := cloneAndBranch(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, branch, projectDir); err != nil { return fmt.Errorf("prepare git working tree: %w", err) } ``` Replace this with the following: ```go projectDir := filepath.Join(workDir, "project") // For pr:review, work directly on the PR's head branch so the // agent's commits push straight to the PR. For everything else, // branch off the default branch as usual. var review *forgejo.ReviewDetail if ev.Kind == forgejo.EventPRReview { headRef := ev.HeadRef if headRef == "" { // The polling path doesn't carry the head ref; fetch it. prInfo, err := r.forgejo.PullRequestInfo(ev.Owner, ev.Repo, ev.Index) if err != nil { return fmt.Errorf("look up pull request head: %w", err) } headRef = prInfo.HeadRef } if headRef == "" { return fmt.Errorf("pr:review event has no PR head branch to check out") } if err := clonePRHead(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, headRef, ev.Index, projectDir); err != nil { return fmt.Errorf("prepare git working tree: %w", err) } branch = headRef // Fetch the full review (verdict, body, inline comments) so the // agent sees all the feedback, not just the triggering event. // A failure degrades to no review detail rather than failing the // run: the agent can still do its job, just without the inline // comments. review, err = r.forgejo.ReviewDetail(ev.Owner, ev.Repo, ev.Index, ev.ReviewID) if err != nil { logger.Warn("fetch review detail failed; agent will not see inline review comments", "error", err) review = nil } } else { branch = fmt.Sprintf("issue-%d-%s", ev.Index, agent.Name) if err := cloneAndBranch(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, branch, projectDir); err != nil { return fmt.Errorf("prepare git working tree: %w", err) } } ``` Wait a sec, but there's a subtle point: the `branch` variable is used in the seed message. For pr:review, branch = headRef. Good. 2. Update the seedMessage call to pass review: ```go {Role: "user", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions, comments, review)}, ``` 3. Update the `seedMessage` function to accept `review *forgejo.ReviewDetail` and render it. Let me write the seedMessage changes. Current: ```go func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, comments []forgejo.IssueComment) string { raw, _ := json.MarshalIndent(json.RawMessage(ev.Raw), "", " ") var instructionsSection string if instructions != "" { instructionsSection = fmt.Sprintf("Instructions for this event, from zoo.hcl:\n%s\n\n", instructions) } 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\n%sFull event payload:\n```json\n%s\n```", ev.Kind, ev.Owner, ev.Repo, instructionsSection, branch, defaultBranch, ev.Title, ev.Body, commentsSection, raw) } ``` New version: ```go func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, comments []forgejo.IssueComment, review *forgejo.ReviewDetail) string { raw, _ := json.MarshalIndent(json.RawMessage(ev.Raw), "", " ") var instructionsSection string if instructions != "" { instructionsSection = fmt.Sprintf("Instructions for this event, from zoo.hcl:\n%s\n\n", instructions) } // On a pr:review run the working branch is the PR's own head branch, // not a fresh branch off the default branch. branchLine := fmt.Sprintf("Your working branch is %q, checked out from the default branch %q.\n\n", branch, defaultBranch) if ev.Kind == forgejo.EventPRReview { branchLine = fmt.Sprintf("Your working branch is %q, the pull request's head branch — commits you push here update the pull request directly.\n\n", branch) } var reviewSection string if review != nil { reviewSection = renderReviewSection(review) } 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"+ "%s%s"+ "Title: %s\n\nBody:\n%s\n\n%s%sFull event payload:\n```json\n%s\n```", ev.Kind, ev.Owner, ev.Repo, instructionsSection, branchLine, ev.Title, ev.Body, reviewSection, commentsSection, raw) } ``` Wait, the original format string was: ``` "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\n%sFull event payload:\n```json\n%s\n```", ev.Kind, ev.Owner, ev.Repo, instructionsSection, branch, defaultBranch, ev.Title, ev.Body, commentsSection, raw ``` So the order is: instructions, branch line, title, body, comments, raw. I want to insert the review section. Where? Logically, the review is the crux of the event, so it should come right after the branch line (before title/body) or after body. Let me put it after body and before comments. No, actually, the review is the most important context for a pr:review. Let me put it right after the branch line, before Title/Body. Hmm, but Title/Body is the PR's title/body. The review is feedback on that PR. Let me think about the ideal briefing order for a pr:review: 1. "You were triggered by a pr:review event on acme/widgets." 2. Instructions (from zoo.hcl). 3. Branch line (you're on the PR head branch). 4. **Review section** (verdict, body, inline comments) — this is the core of what to react to. 5. Title + Body (the PR itself). 6. Comments (the PR's comment thread). 7. Raw payload. That makes sense. Let me structure it that way. So the review section comes after the branch line and before Title. Let me rewrite the format string: ```go return fmt.Sprintf( "You were triggered by a %q event on %s/%s.\n\n"+ "%s%s"+ "%sTitle: %s\n\nBody:\n%s\n\n%sFull event payload:\n```json\n%s\n```", ev.Kind, ev.Owner, ev.Repo, instructionsSection, branchLine, reviewSection, ev.Title, ev.Body, commentsSection, raw) ``` Here reviewSection ends with "\n\n" so it flows into "Title:". And commentsSection also ends with "\n\n" (each comment ends with "\n\n"). Let me make sure reviewSection ends with a blank line. Let me write `renderReviewSection`: ```go func renderReviewSection(review *forgejo.ReviewDetail) string { var b strings.Builder fmt.Fprintf(&b, "Review submitted by %s:\n", review.Reviewer) fmt.Fprintf(&b, " Verdict: %s\n", humanReviewState(review.State)) if review.Body != "" { fmt.Fprintf(&b, " Review body:\n") for _, line := range strings.Split(review.Body, "\n") { fmt.Fprintf(&b, " %s\n", line) } } if len(review.Comments) > 0 { fmt.Fprintf(&b, " Inline comments (%d):\n", len(review.Comments)) for i, c := range review.Comments { where := c.Path if c.Line > 0 { where = fmt.Sprintf("%s:%d", c.Path, c.Line) } fmt.Fprintf(&b, " %d. [%s] %s (id %d):\n", i+1, where, c.Author, ???) for _, line := range strings.Split(c.Body, "\n") { fmt.Fprintf(&b, " %s\n", line) } } } return b.String() + "\n" } ``` Wait a sec — the agent needs the comment ID to call `resolve_review_comment`. The `ReviewCommentDetail` I defined doesn't have an ID! Let me add an `ID` field to `ReviewCommentDetail`. Let me update the client's `ReviewDetail` to include the comment ID. Let me update `ReviewCommentDetail` to include `ID int64`. Let me go back and fix the client. I'll add `ID` to `ReviewCommentDetail` and set it in `ReviewDetail`. Also, the `humanReviewState` function. Let me add it. Let me first fix the client to add the comment ID.
I need the inline comment IDs in the briefing (the agent needs them to call `resolve_review_comment`). Let me update the client's `ReviewCommentDetail`:
Tool 🔧 edit_file {"path": "internal/forgejo/client.go", "find": "// ReviewCommentDetail is one inline comment on a pull request review,\n// in the shape zoo needs when briefing an agent: where it points and\n// what it says.\ntype ReviewCommentDetail struct {\n\tPath string\n\tLine int\n\tBody string\n\tAuthor string\n}", "replace": "// ReviewCommentDetail is one inline comment on a pull request review,\n// in the shape zoo needs when briefing an agent: where it points, what\n// it says, and its id (so the agent can resolve it once addressed).\ntype ReviewCommentDetail struct {\n\tID int64\n\tPath string\n\tLine int\n\tBody string\n\tAuthor string\n}"}
{"path": "internal/forgejo/client.go", "find": "// ReviewCommentDetail is one inline comment on a pull request review,\n// in the shape zoo needs when briefing an agent: where it points and\n// what it says.\ntype ReviewCommentDetail struct {\n\tPath   string\n\tLine   int\n\tBody   string\n\tAuthor string\n}", "replace": "// ReviewCommentDetail is one inline comment on a pull request review,\n// in the shape zoo needs when briefing an agent: where it points, what\n// it says, and its id (so the agent can resolve it once addressed).\ntype ReviewCommentDetail struct {\n\tID     int64\n\tPath   string\n\tLine   int\n\tBody   string\n\tAuthor string\n}"}
1 occurrences replaced
Thinking
Next, I'll update the `ReviewDetail` method to set the comment ID.
Tool 🔧 edit_file {"path": "internal/forgejo/client.go", "find": "\t\tdetail.Comments = append(detail.Comments, ReviewCommentDetail{\n\t\t\tPath: cm.Path,\n\t\t\tLine: line,\n\t\t\tBody: cm.Body,\n\t\t\tAuthor: author,\n\t\t})", "replace": "\t\tdetail.Comments = append(detail.Comments, ReviewCommentDetail{\n\t\t\tID: cm.ID,\n\t\t\tPath: cm.Path,\n\t\t\tLine: line,\n\t\t\tBody: cm.Body,\n\t\t\tAuthor: author,\n\t\t})"}
{"path": "internal/forgejo/client.go", "find": "\t\tdetail.Comments = append(detail.Comments, ReviewCommentDetail{\n\t\t\tPath:   cm.Path,\n\t\t\tLine:   line,\n\t\t\tBody:   cm.Body,\n\t\t\tAuthor: author,\n\t\t})", "replace": "\t\tdetail.Comments = append(detail.Comments, ReviewCommentDetail{\n\t\t\tID:     cm.ID,\n\t\t\tPath:   cm.Path,\n\t\t\tLine:   line,\n\t\t\tBody:   cm.Body,\n\t\t\tAuthor: author,\n\t\t})"}
1 occurrences replaced
Thinking
Next, I'll update the `Run` function in run.go. I'll replace the branch/clone block and the seedMessage call.
Now update `Run` in `run.go` — the working-tree setup:
Tool 🔧 edit_file {"path": "internal/agentrun/run.go", "find": "\tbranch := fmt.Sprintf(\"issue-%d-%s\", ev.Index, agent.Name)\n\tprojectDir := filepath.Join(workDir, \"project\")\n\n\tif err := cloneAndBranch(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, branch, projectDir); err != nil {\n\t\treturn fmt.Errorf(\"prepare git working tree: %w\", err)\n\t}", "replace": "\tprojectDir := filepath.Join(workDir, \"project\")\n\n\t// A pr:review run works on the PR's own head branch, so the agent's\n\t// commits push straight to the PR. Every other event kind branches\n\t// off the default branch as usual.\n\tvar review *forgejo.ReviewDetail\n\tbranch := fmt.Sprintf(\"issue-%d-%s\", ev.Index, agent.Name)\n\n\tif ev.Kind == forgejo.EventPRReview {\n\t\theadRef := ev.HeadRef\n\t\tif headRef == \"\" {\n\t\t\t// The polling path doesn't carry the head ref; fetch it.\n\t\t\tprInfo, err := r.forgejo.PullRequestInfo(ev.Owner, ev.Repo, ev.Index)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"look up pull request head: %w\", err)\n\t\t\t}\n\t\t\theadRef = prInfo.HeadRef\n\t\t}\n\n\t\tif headRef == \"\" {\n\t\t\treturn fmt.Errorf(\"pr:review event has no pull request head branch to check out\")\n\t\t}\n\n\t\tif err := clonePRHead(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, headRef, ev.Index, projectDir); err != nil {\n\t\t\treturn fmt.Errorf(\"prepare git working tree: %w\", err)\n\t\t}\n\n\t\tbranch = headRef\n\n\t\t// Fetch the full review (verdict, body, inline comments) so the\n\t\t// agent sees all the feedback, not just the triggering event. A\n\t\t// failure degrades to no review detail rather than failing the\n\t\t// run: the agent can still do its job, just without the inline\n\t\t// comments.\n\t\treview, err = r.forgejo.ReviewDetail(ev.Owner, ev.Repo, ev.Index, ev.ReviewID)\n\t\tif err != nil {\n\t\t\tlogger.Warn(\"fetch review detail failed; agent will not see inline review comments\", \"error\", err)\n\t\t\treview = nil\n\t\t}\n\t} else {\n\t\tif err := cloneAndBranch(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, branch, projectDir); err != nil {\n\t\t\treturn fmt.Errorf(\"prepare git working tree: %w\", err)\n\t\t}\n\t}"}
{"path": "internal/agentrun/run.go", "find": "\tbranch := fmt.Sprintf(\"issue-%d-%s\", ev.Index, agent.Name)\n\tprojectDir := filepath.Join(workDir, \"project\")\n\n\tif err := cloneAndBranch(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, branch, projectDir); err != nil {\n\t\treturn fmt.Errorf(\"prepare git working tree: %w\", err)\n\t}", "replace": "\tprojectDir := filepath.Join(workDir, \"project\")\n\n\t// A pr:review run works on the PR's own head branch, so the agent's\n\t// commits push straight to the PR. Every other event kind branches\n\t// off the default branch as usual.\n\tvar review *forgejo.ReviewDetail\n\tbranch := fmt.Sprintf(\"issue-%d-%s\", ev.Index, agent.Name)\n\n\tif ev.Kind == forgejo.EventPRReview {\n\t\theadRef := ev.HeadRef\n\t\tif headRef == \"\" {\n\t\t\t// The polling path doesn't carry the head ref; fetch it.\n\t\t\tprInfo, err := r.forgejo.PullRequestInfo(ev.Owner, ev.Repo, ev.Index)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"look up pull request head: %w\", err)\n\t\t\t}\n\t\t\theadRef = prInfo.HeadRef\n\t\t}\n\n\t\tif headRef == \"\" {\n\t\t\treturn fmt.Errorf(\"pr:review event has no pull request head branch to check out\")\n\t\t}\n\n\t\tif err := clonePRHead(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, headRef, ev.Index, projectDir); err != nil {\n\t\t\treturn fmt.Errorf(\"prepare git working tree: %w\", err)\n\t\t}\n\n\t\tbranch = headRef\n\n\t\t// Fetch the full review (verdict, body, inline comments) so the\n\t\t// agent sees all the feedback, not just the triggering event. A\n\t\t// failure degrades to no review detail rather than failing the\n\t\t// run: the agent can still do its job, just without the inline\n\t\t// comments.\n\t\treview, err = r.forgejo.ReviewDetail(ev.Owner, ev.Repo, ev.Index, ev.ReviewID)\n\t\tif err != nil {\n\t\t\tlogger.Warn(\"fetch review detail failed; agent will not see inline review comments\", \"error\", err)\n\t\t\treview = nil\n\t\t}\n\t} else {\n\t\tif err := cloneAndBranch(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, branch, projectDir); err != nil {\n\t\t\treturn fmt.Errorf(\"prepare git working tree: %w\", err)\n\t\t}\n\t}"}
1 occurrences replaced
Thinking
Next, I'll update the seedMessage call to pass `review`.
Tool 🔧 edit_file {"path": "internal/agentrun/run.go", "find": "\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}", "replace": "\tmessages := []llm.Message{\n\t\t{Role: \"system\", Content: systemPrompt},\n\t\t{Role: \"user\", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions, comments, review)},\n\t}"}
{"path": "internal/agentrun/run.go", "find": "\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}", "replace": "\tmessages := []llm.Message{\n\t\t{Role: \"system\", Content: systemPrompt},\n\t\t{Role: \"user\", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions, comments, review)},\n\t}"}
1 occurrences replaced
Thinking
Next, I'll update the `seedMessage` function and add `renderReviewSection` and `humanReviewState`. Let's replace the entire `seedMessage` function.
Now update `seedMessage` and add the review-section renderer:
Tool 🔧 edit_file {"path": "internal/agentrun/run.go", "find": "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}", "replace": "func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, comments []forgejo.IssueComment, review *forgejo.ReviewDetail) 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\t// A pr:review run works on the PR's own head branch, not a fresh\n\t// branch off the default branch.\n\tbranchLine := fmt.Sprintf(\"Your working branch is %q, checked out from the default branch %q.\\n\\n\", branch, defaultBranch)\n\tif ev.Kind == forgejo.EventPRReview {\n\t\tbranchLine = fmt.Sprintf(\"Your working branch is %q, the pull request's head branch — commits you push here update the pull request directly.\\n\\n\", branch)\n\t}\n\n\tvar reviewSection string\n\tif review != nil {\n\t\treviewSection = renderReviewSection(review)\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\"%s%s\"+\n\t\t\t\"%sTitle: %s\\n\\nBody:\\n%s\\n\\n%sFull event payload:\\n```json\\n%s\\n```\",\n\t\tev.Kind, ev.Owner, ev.Repo, instructionsSection, branchLine, reviewSection, ev.Title, ev.Body, commentsSection, raw)\n}\n\n// renderReviewSection renders the submitted review as a briefing\n// section: the verdict, the review body, and each inline comment with\n// its location and id (the id is what the agent passes to\n// resolve_review_comment once it has addressed the feedback).\nfunc renderReviewSection(review *forgejo.ReviewDetail) string {\n\tvar b strings.Builder\n\n\tfmt.Fprintf(&b, \"Review submitted by %s:\\n\", review.Reviewer)\n\tfmt.Fprintf(&b, \" Verdict: %s\\n\", humanReviewState(review.State))\n\n\tif review.Body != \"\" {\n\t\tb.WriteString(\" Review body:\\n\")\n\t\tfor _, line := range strings.Split(review.Body, \"\\n\") {\n\t\t\tfmt.Fprintf(&b, \" %s\\n\", line)\n\t\t}\n\t}\n\n\tif len(review.Comments) > 0 {\n\t\tfmt.Fprintf(&b, \" Inline comments (%d):\\n\", len(review.Comments))\n\n\t\tfor i, c := range review.Comments {\n\t\t\twhere := c.Path\n\t\t\tif c.Line > 0 {\n\t\t\t\twhere = fmt.Sprintf(\"%s:%d\", c.Path, c.Line)\n\t\t\t}\n\n\t\t\tfmt.Fprintf(&b, \" %d. [%s] %s (comment id %d):\\n\", i+1, where, c.Author, c.ID)\n\t\t\tfor _, line := range strings.Split(c.Body, \"\\n\") {\n\t\t\t\tfmt.Fprintf(&b, \" %s\\n\", line)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn b.String() + \"\\n\"\n}\n\n// humanReviewState maps a Forgejo review state to a readable verdict.\nfunc humanReviewState(state string) string {\n\tswitch state {\n\tcase \"APPROVED\":\n\t\treturn \"approved\"\n\tcase \"REQUEST_CHANGES\":\n\t\treturn \"changes requested\"\n\tcase \"COMMENT\":\n\t\treturn \"comment\"\n\tdefault:\n\t\treturn state\n\t}\n}"}
{"path": "internal/agentrun/run.go", "find": "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}", "replace": "func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, comments []forgejo.IssueComment, review *forgejo.ReviewDetail) 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\t// A pr:review run works on the PR's own head branch, not a fresh\n\t// branch off the default branch.\n\tbranchLine := fmt.Sprintf(\"Your working branch is %q, checked out from the default branch %q.\\n\\n\", branch, defaultBranch)\n\tif ev.Kind == forgejo.EventPRReview {\n\t\tbranchLine = fmt.Sprintf(\"Your working branch is %q, the pull request's head branch — commits you push here update the pull request directly.\\n\\n\", branch)\n\t}\n\n\tvar reviewSection string\n\tif review != nil {\n\t\treviewSection = renderReviewSection(review)\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\"%s%s\"+\n\t\t\t\"%sTitle: %s\\n\\nBody:\\n%s\\n\\n%sFull event payload:\\n```json\\n%s\\n```\",\n\t\tev.Kind, ev.Owner, ev.Repo, instructionsSection, branchLine, reviewSection, ev.Title, ev.Body, commentsSection, raw)\n}\n\n// renderReviewSection renders the submitted review as a briefing\n// section: the verdict, the review body, and each inline comment with\n// its location and id (the id is what the agent passes to\n// resolve_review_comment once it has addressed the feedback).\nfunc renderReviewSection(review *forgejo.ReviewDetail) string {\n\tvar b strings.Builder\n\n\tfmt.Fprintf(&b, \"Review submitted by %s:\\n\", review.Reviewer)\n\tfmt.Fprintf(&b, \"  Verdict: %s\\n\", humanReviewState(review.State))\n\n\tif review.Body != \"\" {\n\t\tb.WriteString(\"  Review body:\\n\")\n\t\tfor _, line := range strings.Split(review.Body, \"\\n\") {\n\t\t\tfmt.Fprintf(&b, \"  %s\\n\", line)\n\t\t}\n\t}\n\n\tif len(review.Comments) > 0 {\n\t\tfmt.Fprintf(&b, \"  Inline comments (%d):\\n\", len(review.Comments))\n\n\t\tfor i, c := range review.Comments {\n\t\t\twhere := c.Path\n\t\t\tif c.Line > 0 {\n\t\t\t\twhere = fmt.Sprintf(\"%s:%d\", c.Path, c.Line)\n\t\t\t}\n\n\t\t\tfmt.Fprintf(&b, \"  %d. [%s] %s (comment id %d):\\n\", i+1, where, c.Author, c.ID)\n\t\t\tfor _, line := range strings.Split(c.Body, \"\\n\") {\n\t\t\t\tfmt.Fprintf(&b, \"     %s\\n\", line)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn b.String() + \"\\n\"\n}\n\n// humanReviewState maps a Forgejo review state to a readable verdict.\nfunc humanReviewState(state string) string {\n\tswitch state {\n\tcase \"APPROVED\":\n\t\treturn \"approved\"\n\tcase \"REQUEST_CHANGES\":\n\t\treturn \"changes requested\"\n\tcase \"COMMENT\":\n\t\treturn \"comment\"\n\tdefault:\n\t\treturn state\n\t}\n}"}
1 occurrences replaced
Thinking
Alright, let's build it to check for compile errors.
Let me build to check for compile errors:
Tool 🔧 bash {"command": "cd /project && go build ./... 2>&1 | head -40", "timeout": 300}
{"command": "cd /project && go build ./... 2>&1 | head -40", "timeout": 300}

        
Thinking
The build succeeded. Next, let's run the existing tests to make sure nothing is broken.
Build passes. Now let me run the existing tests to check for breakage:
Tool 🔧 bash {"command": "cd /project && go test ./... 2>&1 | tail -30", "timeout": 300}
{"command": "cd /project && go test ./... 2>&1 | tail -30", "timeout": 300}
# github.com/abrander/zoo/internal/agentrun [github.com/abrander/zoo/internal/agentrun.test]
internal/agentrun/seed_test.go:14:105: not enough arguments in call to seedMessage
	have ("github.com/abrander/zoo/internal/forgejo".Event, string, string, string, nil)
	want ("github.com/abrander/zoo/internal/forgejo".Event, string, string, string, []"github.com/abrander/zoo/internal/forgejo".IssueComment, *"github.com/abrander/zoo/internal/forgejo".ReviewDetail)
internal/agentrun/seed_test.go:24:53: not enough arguments in call to seedMessage
	have ("github.com/abrander/zoo/internal/forgejo".Event, string, string, string, nil)
	want ("github.com/abrander/zoo/internal/forgejo".Event, string, string, string, []"github.com/abrander/zoo/internal/forgejo".IssueComment, *"github.com/abrander/zoo/internal/forgejo".ReviewDetail)
internal/agentrun/seed_test.go:39:53: not enough arguments in call to seedMessage
	have ("github.com/abrander/zoo/internal/forgejo".Event, string, string, string, []"github.com/abrander/zoo/internal/forgejo".IssueComment)
	want ("github.com/abrander/zoo/internal/forgejo".Event, string, string, string, []"github.com/abrander/zoo/internal/forgejo".IssueComment, *"github.com/abrander/zoo/internal/forgejo".ReviewDetail)
internal/agentrun/seed_test.go:62:53: not enough arguments in call to seedMessage
	have ("github.com/abrander/zoo/internal/forgejo".Event, string, string, string, nil)
	want ("github.com/abrander/zoo/internal/forgejo".Event, string, string, string, []"github.com/abrander/zoo/internal/forgejo".IssueComment, *"github.com/abrander/zoo/internal/forgejo".ReviewDetail)
?   	github.com/abrander/zoo/cmd/zoo	[no test files]
FAIL	github.com/abrander/zoo/internal/agentrun [build failed]
ok  	github.com/abrander/zoo/internal/config	0.004s
ok  	github.com/abrander/zoo/internal/forgejo	0.005s
ok  	github.com/abrander/zoo/internal/livelog	0.052s
ok  	github.com/abrander/zoo/internal/llm	0.003s
# github.com/abrander/zoo/internal/tools [github.com/abrander/zoo/internal/tools.test]
internal/tools/tools_test.go:52:9: cannot use f.fg (variable of type *fakeForgejoActions) as ForgejoActions value in return statement: *fakeForgejoActions does not implement ForgejoActions (missing method ResolveReviewComment)
ok  	github.com/abrander/zoo/internal/scheduler	2.038s
ok  	github.com/abrander/zoo/internal/store	1.738s
FAIL	github.com/abrander/zoo/internal/tools [build failed]
ok  	github.com/abrander/zoo/internal/web	0.810s
FAIL
Thinking
As expected, I need to update the tests: 1. `seed_test.go` — Add the `review` argument to the `seedMessage` call. 2. `tools_test.go` — Add the new method to `fakeForgejoActions`. Let's update these. Starting with `seed_test.go`.
Expected — the test call sites need updating. Let me fix `seed_test.go`:
Tool 🔧 edit_file {"path": "internal/agentrun/seed_test.go", "find": "func 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}", "replace": "func 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, 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, 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}"}
{"path": "internal/agentrun/seed_test.go", "find": "func 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}", "replace": "func 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, 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, 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}"}
1 occurrences replaced
Thinking
Next, I'll update the remaining two seedMessage calls in seed_test.go (TestSeedMessageIncludesAllComments and TestSeedMessageOmitsEmptyComments).
Tool 🔧 edit_file {"path": "internal/agentrun/seed_test.go", "find": "\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} {", "replace": "\tmsg := seedMessage(ev, \"issue-5-greg\", \"main\", \"\", comments, nil)\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} {"}
{"path": "internal/agentrun/seed_test.go", "find": "\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} {", "replace": "\tmsg := seedMessage(ev, \"issue-5-greg\", \"main\", \"\", comments, nil)\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} {"}
1 occurrences replaced
Thinking
Next, I'll update the last seedMessage call inside TestSeedMessageOmitsEmptyComments.
Tool 🔧 edit_file {"path": "internal/agentrun/seed_test.go", "find": "func 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}", "replace": "func 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, nil)\n\n\tif strings.Contains(msg, \"Comments\") {\n\t\tt.Fatalf(\"expected no comments section, got: %s\", msg)\n\t}\n}\n\nfunc TestSeedMessageIncludesReview(t *testing.T) {\n\tev := forgejo.Event{\n\t\tKind: forgejo.EventPRReview,\n\t\tOwner: \"acme\",\n\t\tRepo: \"widgets\",\n\t\tIndex: 9,\n\t\tTitle: \"Fix the thing\",\n\t\tBody: \"This fixes it\",\n\t\tReviewID: 42,\n\t\tReviewState: \"REQUEST_CHANGES\",\n\t\tReviewBody: \"Please address the inline comments.\",\n\t\tRaw: []byte(`{}`),\n\t}\n\n\treview := &forgejo.ReviewDetail{\n\t\tID: 42,\n\t\tState: \"REQUEST_CHANGES\",\n\t\tBody: \"Please address the inline comments.\",\n\t\tReviewer: \"leon\",\n\t\tComments: []forgejo.ReviewCommentDetail{\n\t\t\t{ID: 7, Path: \"src/foo.go\", Line: 42, Body: \"This is wrong.\", Author: \"leon\"},\n\t\t\t{ID: 8, Path: \"src/bar.go\", Line: 0, Body: \"And this.\", Author: \"leon\"},\n\t\t},\n\t}\n\n\tmsg := seedMessage(ev, \"feature-branch\", \"main\", \"\", nil, review)\n\n\tfor _, want := range []string{\n\t\t\"the pull request's head branch\",\n\t\t\"Review submitted by leon:\",\n\t\t\"Verdict: changes requested\",\n\t\t\"Review body:\",\n\t\t\"Please address the inline comments.\",\n\t\t\"Inline comments (2):\",\n\t\t\"[src/foo.go:42] leon (comment id 7):\",\n\t\t\"This is wrong.\",\n\t\t\"[src/bar.go] leon (comment id 8):\",\n\t\t\"And this.\",\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// The review section comes before the raw payload, so the agent\n\t// reads it as part of the briefing, not buried in JSON.\n\tif strings.Index(msg, \"Review submitted by\") > strings.Index(msg, \"Full event payload:\") {\n\t\tt.Fatalf(\"expected review section before the raw payload, got:\\n%s\", msg)\n\t}\n}\n\nfunc TestSeedMessageOmitsNilReview(t *testing.T) {\n\tev := forgejo.Event{Kind: forgejo.EventPRReview, Owner: \"acme\", Repo: \"widgets\", Index: 9, Raw: []byte(`{}`)}\n\n\tmsg := seedMessage(ev, \"feature-branch\", \"main\", \"\", nil, nil)\n\n\tif strings.Contains(msg, \"Review submitted by\") {\n\t\tt.Fatalf(\"expected no review section, got: %s\", msg)\n\t}\n}"}
{"path": "internal/agentrun/seed_test.go", "find": "func 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}", "replace": "func 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, nil)\n\n\tif strings.Contains(msg, \"Comments\") {\n\t\tt.Fatalf(\"expected no comments section, got: %s\", msg)\n\t}\n}\n\nfunc TestSeedMessageIncludesReview(t *testing.T) {\n\tev := forgejo.Event{\n\t\tKind:        forgejo.EventPRReview,\n\t\tOwner:       \"acme\",\n\t\tRepo:        \"widgets\",\n\t\tIndex:       9,\n\t\tTitle:       \"Fix the thing\",\n\t\tBody:        \"This fixes it\",\n\t\tReviewID:    42,\n\t\tReviewState: \"REQUEST_CHANGES\",\n\t\tReviewBody:  \"Please address the inline comments.\",\n\t\tRaw:         []byte(`{}`),\n\t}\n\n\treview := &forgejo.ReviewDetail{\n\t\tID:       42,\n\t\tState:    \"REQUEST_CHANGES\",\n\t\tBody:     \"Please address the inline comments.\",\n\t\tReviewer: \"leon\",\n\t\tComments: []forgejo.ReviewCommentDetail{\n\t\t\t{ID: 7, Path: \"src/foo.go\", Line: 42, Body: \"This is wrong.\", Author: \"leon\"},\n\t\t\t{ID: 8, Path: \"src/bar.go\", Line: 0, Body: \"And this.\", Author: \"leon\"},\n\t\t},\n\t}\n\n\tmsg := seedMessage(ev, \"feature-branch\", \"main\", \"\", nil, review)\n\n\tfor _, want := range []string{\n\t\t\"the pull request's head branch\",\n\t\t\"Review submitted by leon:\",\n\t\t\"Verdict: changes requested\",\n\t\t\"Review body:\",\n\t\t\"Please address the inline comments.\",\n\t\t\"Inline comments (2):\",\n\t\t\"[src/foo.go:42] leon (comment id 7):\",\n\t\t\"This is wrong.\",\n\t\t\"[src/bar.go] leon (comment id 8):\",\n\t\t\"And this.\",\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// The review section comes before the raw payload, so the agent\n\t// reads it as part of the briefing, not buried in JSON.\n\tif strings.Index(msg, \"Review submitted by\") > strings.Index(msg, \"Full event payload:\") {\n\t\tt.Fatalf(\"expected review section before the raw payload, got:\\n%s\", msg)\n\t}\n}\n\nfunc TestSeedMessageOmitsNilReview(t *testing.T) {\n\tev := forgejo.Event{Kind: forgejo.EventPRReview, Owner: \"acme\", Repo: \"widgets\", Index: 9, Raw: []byte(`{}`)}\n\n\tmsg := seedMessage(ev, \"feature-branch\", \"main\", \"\", nil, nil)\n\n\tif strings.Contains(msg, \"Review submitted by\") {\n\t\tt.Fatalf(\"expected no review section, got: %s\", msg)\n\t}\n}"}
1 occurrences replaced
Thinking
Next, I'll update `tools_test.go` to add the new methods to `fakeForgejoActions`.
Now update `tools_test.go` — the fake needs the two new methods:
Tool 🔧 edit_file {"path": "internal/tools/tools_test.go", "find": "type fakeForgejoActions struct {\n\tcomments []string\n\tassigned []string\n}\n\nfunc (f *fakeForgejoActions) Comment(body string) error {\n\tf.comments = append(f.comments, body)\n\treturn nil\n}\nfunc (f *fakeForgejoActions) OpenPullRequest(head, base, title, body string) error { return nil }\nfunc (f *fakeForgejoActions) RequestReview(reviewers []string) error { return nil }\nfunc (f *fakeForgejoActions) AddLabel(name string) error { return nil }\nfunc (f *fakeForgejoActions) RemoveLabel(name string) error { return nil }\nfunc (f *fakeForgejoActions) CloseIssue() error { return nil }\nfunc (f *fakeForgejoActions) ReopenIssue() error { return nil }\nfunc (f *fakeForgejoActions) AssignIssue(agentName string) error {\n\tf.assigned = append(f.assigned, agentName)\n\treturn nil\n}", "replace": "type fakeForgejoActions struct {\n\tcomments []string\n\tassigned []string\n\treviews []string\n\tresolvedComments []int64\n}\n\nfunc (f *fakeForgejoActions) Comment(body string) error {\n\tf.comments = append(f.comments, body)\n\treturn nil\n}\nfunc (f *fakeForgejoActions) OpenPullRequest(head, base, title, body string) error { return nil }\nfunc (f *fakeForgejoActions) RequestReview(reviewers []string) error { return nil }\nfunc (f *fakeForgejoActions) AddLabel(name string) error { return nil }\nfunc (f *fakeForgejoActions) RemoveLabel(name string) error { return nil }\nfunc (f *fakeForgejoActions) CloseIssue() error { return nil }\nfunc (f *fakeForgejoActions) ReopenIssue() error { return nil }\nfunc (f *fakeForgejoActions) AssignIssue(agentName string) error {\n\tf.assigned = append(f.assigned, agentName)\n\treturn nil\n}\nfunc (f *fakeForgejoActions) SubmitReview(state, body string) error {\n\tf.reviews = append(f.reviews, state)\n\treturn nil\n}\nfunc (f *fakeForgejoActions) ResolveReviewComment(commentID int64) error {\n\tf.resolvedComments = append(f.resolvedComments, commentID)\n\treturn nil\n}"}
{"path": "internal/tools/tools_test.go", "find": "type fakeForgejoActions struct {\n\tcomments []string\n\tassigned []string\n}\n\nfunc (f *fakeForgejoActions) Comment(body string) error {\n\tf.comments = append(f.comments, body)\n\treturn nil\n}\nfunc (f *fakeForgejoActions) OpenPullRequest(head, base, title, body string) error { return nil }\nfunc (f *fakeForgejoActions) RequestReview(reviewers []string) error               { return nil }\nfunc (f *fakeForgejoActions) AddLabel(name string) error                           { return nil }\nfunc (f *fakeForgejoActions) RemoveLabel(name string) error                        { return nil }\nfunc (f *fakeForgejoActions) CloseIssue() error                                    { return nil }\nfunc (f *fakeForgejoActions) ReopenIssue() error                                   { return nil }\nfunc (f *fakeForgejoActions) AssignIssue(agentName string) error {\n\tf.assigned = append(f.assigned, agentName)\n\treturn nil\n}", "replace": "type fakeForgejoActions struct {\n\tcomments          []string\n\tassigned          []string\n\treviews           []string\n\tresolvedComments  []int64\n}\n\nfunc (f *fakeForgejoActions) Comment(body string) error {\n\tf.comments = append(f.comments, body)\n\treturn nil\n}\nfunc (f *fakeForgejoActions) OpenPullRequest(head, base, title, body string) error { return nil }\nfunc (f *fakeForgejoActions) RequestReview(reviewers []string) error               { return nil }\nfunc (f *fakeForgejoActions) AddLabel(name string) error                           { return nil }\nfunc (f *fakeForgejoActions) RemoveLabel(name string) error                        { return nil }\nfunc (f *fakeForgejoActions) CloseIssue() error                                    { return nil }\nfunc (f *fakeForgejoActions) ReopenIssue() error                                   { return nil }\nfunc (f *fakeForgejoActions) AssignIssue(agentName string) error {\n\tf.assigned = append(f.assigned, agentName)\n\treturn nil\n}\nfunc (f *fakeForgejoActions) SubmitReview(state, body string) error {\n\tf.reviews = append(f.reviews, state)\n\treturn nil\n}\nfunc (f *fakeForgejoActions) ResolveReviewComment(commentID int64) error {\n\tf.resolvedComments = append(f.resolvedComments, commentID)\n\treturn nil\n}"}
1 occurrences replaced
Thinking
Alright, let's run the tests one more time.
Tool 🔧 bash {"command": "cd /project && go test ./... 2>&1 | tail -20", "timeout": 300}
{"command": "cd /project && 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.005s
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	0.003s
ok  	github.com/abrander/zoo/internal/web	(cached)
Thinking
All existing tests pass. Next, let's add new tests for the new functionality: 1. **Webhook decoding test** (`webhook_test.go`): - `TestDecodePullRequestReviewSubmitted` — decode a `pull_request_review` payload with action `submitted`, and verify the event fields (Kind, ID, ReviewID, ReviewState, ReviewBody, HeadRef, Author). - `TestDecodePullRequestReviewIgnoresNonSubmitted` — action `edited`/`dismissed` are ignored. - `TestReviewIDMatchesAcrossWebhookAndPoll` — the webhook's ID matches `prReviewID`. 2. **Polling test** (new file `poll_test.go`): - `TestPollReviewsDispatchesNewReview` — serve the reviews endpoint, call `pollReviews`, and verify the dispatched event. - `TestPollReviewsSkipsOldAndPending` — reviews before `since` and pending (zero Submitted) are skipped. 3. **Client tests** (`client_test.go`): - `TestReviewDetail` — serve the review + review comments endpoints and verify the details. - `TestPullRequestInfo` — serve the PR endpoint and verify head/base refs. - `TestResolveReviewComment` — serve the resolve endpoint, verify method/path, and confirm that errors are returned on non-2xx. - `TestSubmitReview` — serve the reviews POST endpoint and verify the state mapping. 4. **Config test** (`config_test.go`): - Add `event "pr:review"` to validConfig and verify EventAgent. - Possibly a test that pr:review requires an agent. 5. **Scheduler test** (`scheduler_test.go`): - Add `pr:review` to testConfig and add a test that it resolves statically. - A test that events created by the agent itself (Author == resolved agent) are discarded — this is already covered by the generic self-filter test, but let's add one for pr:review. 6. **Tools test** (`tools_test.go`): - `TestSubmitReviewToolCallsForgejo` — verify that the tool calls Forgejo().SubmitReview. - `TestResolveReviewCommentToolCallsForgejo` — verify that the tool calls Forgejo().ResolveReviewComment. - `TestSubmitReviewRequiresBodyUnlessApproved`. Let's write these tests. Starting with the webhook tests. First, let's add the webhook test payload and test functions. Let's add them after `TestDecodePullRequestOpened`.
All green. Now let me add tests for the new functionality. First, webhook decoding tests:
Tool 🔧 edit_file {"path": "internal/forgejo/webhook_test.go", "find": "const pullRequestOpenedPayload = `{\n \"action\": \"opened\",\n \"pull_request\": {\n \"id\": 202,\n \"number\": 9,\n \"title\": \"Fix the thing\",\n \"body\": \"This fixes it\",\n \"user\": {\"login\": \"greg\"}\n },\n \"repository\": {\n \"name\": \"widgets\",\n \"owner\": {\"login\": \"acme\"}\n }\n}`", "replace": "const pullRequestOpenedPayload = `{\n \"action\": \"opened\",\n \"pull_request\": {\n \"id\": 202,\n \"number\": 9,\n \"title\": \"Fix the thing\",\n \"body\": \"This fixes it\",\n \"user\": {\"login\": \"greg\"}\n },\n \"repository\": {\n \"name\": \"widgets\",\n \"owner\": {\"login\": \"acme\"}\n }\n}`\n\nconst pullRequestReviewSubmittedPayload = `{\n \"action\": \"submitted\",\n \"review\": {\n \"id\": 42,\n \"user\": {\"login\": \"leon\"},\n \"state\": \"REQUEST_CHANGES\",\n \"body\": \"Please address the inline comments.\",\n \"submitted_at\": \"2026-08-24T10:00:00Z\"\n },\n \"pull_request\": {\n \"id\": 202,\n \"number\": 9,\n \"title\": \"Fix the thing\",\n \"body\": \"This fixes it\",\n \"user\": {\"login\": \"greg\"},\n \"head\": {\"ref\": \"issue-9-greg\"}\n },\n \"repository\": {\n \"name\": \"widgets\",\n \"owner\": {\"login\": \"acme\"}\n }\n}`"}
{"path": "internal/forgejo/webhook_test.go", "find": "const pullRequestOpenedPayload = `{\n  \"action\": \"opened\",\n  \"pull_request\": {\n    \"id\": 202,\n    \"number\": 9,\n    \"title\": \"Fix the thing\",\n    \"body\": \"This fixes it\",\n    \"user\": {\"login\": \"greg\"}\n  },\n  \"repository\": {\n    \"name\": \"widgets\",\n    \"owner\": {\"login\": \"acme\"}\n  }\n}`", "replace": "const pullRequestOpenedPayload = `{\n  \"action\": \"opened\",\n  \"pull_request\": {\n    \"id\": 202,\n    \"number\": 9,\n    \"title\": \"Fix the thing\",\n    \"body\": \"This fixes it\",\n    \"user\": {\"login\": \"greg\"}\n  },\n  \"repository\": {\n    \"name\": \"widgets\",\n    \"owner\": {\"login\": \"acme\"}\n  }\n}`\n\nconst pullRequestReviewSubmittedPayload = `{\n  \"action\": \"submitted\",\n  \"review\": {\n    \"id\": 42,\n    \"user\": {\"login\": \"leon\"},\n    \"state\": \"REQUEST_CHANGES\",\n    \"body\": \"Please address the inline comments.\",\n    \"submitted_at\": \"2026-08-24T10:00:00Z\"\n  },\n  \"pull_request\": {\n    \"id\": 202,\n    \"number\": 9,\n    \"title\": \"Fix the thing\",\n    \"body\": \"This fixes it\",\n    \"user\": {\"login\": \"greg\"},\n    \"head\": {\"ref\": \"issue-9-greg\"}\n  },\n  \"repository\": {\n    \"name\": \"widgets\",\n    \"owner\": {\"login\": \"acme\"}\n  }\n}`"}
1 occurrences replaced
Thinking
Next, I'll add a test function after `TestDecodePullRequestOpened`.
Tool 🔧 edit_file {"path": "internal/forgejo/webhook_test.go", "find": "func TestDecodePullRequestOpened(t *testing.T) {\n\tev, ok, err := decodeWebhookEvent(\"pull_request\", []byte(pullRequestOpenedPayload))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !ok {\n\t\tt.Fatal(\"expected an event\")\n\t}\n\tif ev.Kind != EventPRNew || ev.Index != 9 || ev.Author != \"greg\" {\n\t\tt.Fatalf(\"unexpected event: %+v\", ev)\n\t}\n}", "replace": "func TestDecodePullRequestOpened(t *testing.T) {\n\tev, ok, err := decodeWebhookEvent(\"pull_request\", []byte(pullRequestOpenedPayload))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !ok {\n\t\tt.Fatal(\"expected an event\")\n\t}\n\tif ev.Kind != EventPRNew || ev.Index != 9 || ev.Author != \"greg\" {\n\t\tt.Fatalf(\"unexpected event: %+v\", ev)\n\t}\n}\n\nfunc TestDecodePullRequestReviewSubmitted(t *testing.T) {\n\tev, ok, err := decodeWebhookEvent(\"pull_request_review\", []byte(pullRequestReviewSubmittedPayload))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !ok {\n\t\tt.Fatal(\"expected an event\")\n\t}\n\tif ev.Kind != EventPRReview {\n\t\tt.Fatalf(\"expected pr:review, got %q\", ev.Kind)\n\t}\n\tif ev.Owner != \"acme\" || ev.Repo != \"widgets\" || ev.Index != 9 {\n\t\tt.Fatalf(\"unexpected owner/repo/index: %+v\", ev)\n\t}\n\t// The author is the reviewer, not the PR author.\n\tif ev.Author != \"leon\" {\n\t\tt.Fatalf(\"expected reviewer as author, got %q\", ev.Author)\n\t}\n\tif ev.ReviewID != 42 || ev.ReviewState != \"REQUEST_CHANGES\" || ev.ReviewBody != \"Please address the inline comments.\" {\n\t\tt.Fatalf(\"unexpected review fields: %+v\", ev)\n\t}\n\tif ev.HeadRef != \"issue-9-greg\" {\n\t\tt.Fatalf(\"expected head ref issue-9-greg, got %q\", ev.HeadRef)\n\t}\n\tif ev.ID != \"pr-review-42\" {\n\t\tt.Fatalf(\"unexpected dedup id: %q\", ev.ID)\n\t}\n}\n\n// Only a submitted review triggers a run: an edit or dismissal of a\n// review that already triggered one is a follow-up, not new feedback.\nfunc TestDecodePullRequestReviewIgnoresNonSubmitted(t *testing.T) {\n\tfor _, action := range []string{\"edited\", \"dismissed\", \"pending\"} {\n\t\tpayload := strings.Replace(pullRequestReviewSubmittedPayload, `\"action\": \"submitted\"`, `\"action\": \"`+action+`\"`, 1)\n\n\t\tif _, ok, err := decodeWebhookEvent(\"pull_request_review\", []byte(payload)); err != nil {\n\t\t\tt.Fatalf(\"action %s: unexpected error: %v\", action, err)\n\t\t} else if ok {\n\t\t\tt.Fatalf(\"expected action %s to be ignored\", action)\n\t\t}\n\t}\n}\n\n// The webhook delivery and the poller's later sighting of the same\n// review must collapse onto a single dedup id, so it runs once.\nfunc TestReviewIDMatchesAcrossWebhookAndPoll(t *testing.T) {\n\tev, _, err := decodeWebhookEvent(\"pull_request_review\", []byte(pullRequestReviewSubmittedPayload))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif polled := prReviewID(42); polled != ev.ID {\n\t\tt.Fatalf(\"poll id %q does not match webhook id %q\", polled, ev.ID)\n\t}\n}"}
{"path": "internal/forgejo/webhook_test.go", "find": "func TestDecodePullRequestOpened(t *testing.T) {\n\tev, ok, err := decodeWebhookEvent(\"pull_request\", []byte(pullRequestOpenedPayload))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !ok {\n\t\tt.Fatal(\"expected an event\")\n\t}\n\tif ev.Kind != EventPRNew || ev.Index != 9 || ev.Author != \"greg\" {\n\t\tt.Fatalf(\"unexpected event: %+v\", ev)\n\t}\n}", "replace": "func TestDecodePullRequestOpened(t *testing.T) {\n\tev, ok, err := decodeWebhookEvent(\"pull_request\", []byte(pullRequestOpenedPayload))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !ok {\n\t\tt.Fatal(\"expected an event\")\n\t}\n\tif ev.Kind != EventPRNew || ev.Index != 9 || ev.Author != \"greg\" {\n\t\tt.Fatalf(\"unexpected event: %+v\", ev)\n\t}\n}\n\nfunc TestDecodePullRequestReviewSubmitted(t *testing.T) {\n\tev, ok, err := decodeWebhookEvent(\"pull_request_review\", []byte(pullRequestReviewSubmittedPayload))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !ok {\n\t\tt.Fatal(\"expected an event\")\n\t}\n\tif ev.Kind != EventPRReview {\n\t\tt.Fatalf(\"expected pr:review, got %q\", ev.Kind)\n\t}\n\tif ev.Owner != \"acme\" || ev.Repo != \"widgets\" || ev.Index != 9 {\n\t\tt.Fatalf(\"unexpected owner/repo/index: %+v\", ev)\n\t}\n\t// The author is the reviewer, not the PR author.\n\tif ev.Author != \"leon\" {\n\t\tt.Fatalf(\"expected reviewer as author, got %q\", ev.Author)\n\t}\n\tif ev.ReviewID != 42 || ev.ReviewState != \"REQUEST_CHANGES\" || ev.ReviewBody != \"Please address the inline comments.\" {\n\t\tt.Fatalf(\"unexpected review fields: %+v\", ev)\n\t}\n\tif ev.HeadRef != \"issue-9-greg\" {\n\t\tt.Fatalf(\"expected head ref issue-9-greg, got %q\", ev.HeadRef)\n\t}\n\tif ev.ID != \"pr-review-42\" {\n\t\tt.Fatalf(\"unexpected dedup id: %q\", ev.ID)\n\t}\n}\n\n// Only a submitted review triggers a run: an edit or dismissal of a\n// review that already triggered one is a follow-up, not new feedback.\nfunc TestDecodePullRequestReviewIgnoresNonSubmitted(t *testing.T) {\n\tfor _, action := range []string{\"edited\", \"dismissed\", \"pending\"} {\n\t\tpayload := strings.Replace(pullRequestReviewSubmittedPayload, `\"action\": \"submitted\"`, `\"action\": \"`+action+`\"`, 1)\n\n\t\tif _, ok, err := decodeWebhookEvent(\"pull_request_review\", []byte(payload)); err != nil {\n\t\t\tt.Fatalf(\"action %s: unexpected error: %v\", action, err)\n\t\t} else if ok {\n\t\t\tt.Fatalf(\"expected action %s to be ignored\", action)\n\t\t}\n\t}\n}\n\n// The webhook delivery and the poller's later sighting of the same\n// review must collapse onto a single dedup id, so it runs once.\nfunc TestReviewIDMatchesAcrossWebhookAndPoll(t *testing.T) {\n\tev, _, err := decodeWebhookEvent(\"pull_request_review\", []byte(pullRequestReviewSubmittedPayload))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif polled := prReviewID(42); polled != ev.ID {\n\t\tt.Fatalf(\"poll id %q does not match webhook id %q\", polled, ev.ID)\n\t}\n}"}
1 occurrences replaced
Thinking
I used `strings.Replace` in the webhook tests, so I need to add the `strings` import. Let me check the current imports in webhook_test.go.
Tool 🔧 read_file {"path": "internal/forgejo/webhook_test.go", "start_line": 1, "end_line": 8}
{"path": "internal/forgejo/webhook_test.go", "start_line": 1, "end_line": 8}
{"path":"internal/forgejo/webhook_test.go","content":"package forgejo\r\n\r\nimport (\r\n\t\"testing\"\r\n\t\"time\"\r\n)\r\n\r\nconst issueOpenedPayload = `{\r","start_line":1,"end_line":8,"total_lines":283,"truncated":true}
Thinking
I need to add `strings` to the imports.
Tool 🔧 edit_file {"path": "internal/forgejo/webhook_test.go", "find": "import (\n\t\"testing\"\n\t\"time\"\n)", "replace": "import (\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)"}
{"path": "internal/forgejo/webhook_test.go", "find": "import (\n\t\"testing\"\n\t\"time\"\n)", "replace": "import (\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)"}
1 occurrences replaced
Thinking
Next, let's create a polling test file `poll_test.go`. We need to test `pollReviews`. Let's create a test that does the following: 1. Create an httptest server that serves `/api/v1/repos/acme/widgets/pulls/9/reviews`. 2. Create a store, client, and watcher. 3. Call `w.pollReviews(ctx, "acme", "widgets", &sdk.Issue{Index: 9}, since)`. 4. Read from `w.Events()` and assert. We need to import `sdk` for `sdk.Issue` and `sdk.PullReview`. Let's write the test. Actually, `pollReviews` takes `pr *sdk.Issue`. We can construct `&sdk.Issue{Index: 9, Title: "Fix the thing", Body: "This fixes it"}`. The reviews endpoint returns `[]*sdk.PullReview`. Let's serve JSON matching the PullReview wire format: ```json [ { "id": 42, "user": {"login": "leon"}, "state": "REQUEST_CHANGES", "body": "Please address the inline comments.", "submitted_at": "2026-08-24T10:00:00Z" } ] ``` Let's write the test. We need a store. Let's open one with `store.Open`. Let's import store. Wait, `NewWatcher` takes `(client *Client, st *store.Store, webhookSecret string, logger *slog.Logger)`. Let's construct it. Let's write poll_test.go.
Now a polling test. Let me create `poll_test.go`:
Tool 🔧 write_file {"path": "internal/forgejo/poll_test.go", "content": "package forgejo\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"log/slog\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\nfunc newTestStore(t *testing.T) *store.Store {\n\tt.Helper()\n\n\tst, err := store.Open(filepath.Join(t.TempDir(), \"zoo.db\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tt.Cleanup(func() { st.Close() })\n\n\treturn st\n}\n\n// newTestWatcher returns a Watcher whose client talks to the test\n// server and whose events can be read from Events().\nfunc newTestWatcher(t *testing.T, serverURL string) *Watcher {\n\tt.Helper()\n\n\tclient, err := NewClient(config.Forgejo{URL: serverURL, Token: \"test\"})\n\tif err != nil {\n\t\tt.Fatalf(\"new client: %v\", err)\n\t}\n\n\treturn NewWatcher(client, newTestStore(t), \"\", slog.New(slog.DiscardHandler))\n}\n\nfunc TestPollReviewsDispatchesNewReview(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/pulls/9/reviews\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode([]map[string]any{\n\t\t\t{\n\t\t\t\t\"id\": 42,\n\t\t\t\t\"user\": map[string]string{\"login\": \"leon\"},\n\t\t\t\t\"state\": \"REQUEST_CHANGES\",\n\t\t\t\t\"body\": \"Please address the inline comments.\",\n\t\t\t\t\"submitted_at\": \"2026-08-24T10:00:00Z\",\n\t\t\t},\n\t\t})\n\t})\n\n\tw := newTestWatcher(t, server.URL)\n\tsince := time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC)\n\n\tw.pollReviews(context.Background(), \"acme\", \"widgets\", &sdk.Issue{Index: 9, Title: \"Fix the thing\", Body: \"This fixes it\"}, since)\n\n\tselect {\n\tcase ev := <-w.Events():\n\t\tif ev.Kind != EventPRReview {\n\t\t\tt.Fatalf(\"expected pr:review, got %q\", ev.Kind)\n\t\t}\n\t\tif ev.Owner != \"acme\" || ev.Repo != \"widgets\" || ev.Index != 9 {\n\t\t\tt.Fatalf(\"unexpected owner/repo/index: %+v\", ev)\n\t\t}\n\t\tif ev.Author != \"leon\" {\n\t\t\tt.Fatalf(\"expected reviewer as author, got %q\", ev.Author)\n\t\t}\n\t\tif ev.ReviewID != 42 || ev.ReviewState != \"REQUEST_CHANGES\" || ev.ReviewBody != \"Please address the inline comments.\" {\n\t\t\tt.Fatalf(\"unexpected review fields: %+v\", ev)\n\t\t}\n\t\tif ev.ID != \"pr-review-42\" {\n\t\t\tt.Fatalf(\"unexpected dedup id: %q\", ev.ID)\n\t\t}\n\tdefault:\n\t\tt.Fatal(\"expected a dispatched review event, got none\")\n\t}\n}\n\n// A review submitted before the watermark, and a pending (unsubmitted)\n// review, must not be dispatched.\nfunc TestPollReviewsSkipsOldAndPending(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/pulls/9/reviews\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode([]map[string]any{\n\t\t\t{\n\t\t\t\t// Submitted before `since` — already seen.\n\t\t\t\t\"id\": 41,\n\t\t\t\t\"user\": map[string]string{\"login\": \"leon\"},\n\t\t\t\t\"state\": \"APPROVED\",\n\t\t\t\t\"body\": \"lgtm\",\n\t\t\t\t\"submitted_at\": \"2026-08-24T08:00:00Z\",\n\t\t\t},\n\t\t\t{\n\t\t\t\t// Pending — no submitted_at, no verdict yet.\n\t\t\t\t\"id\": 43,\n\t\t\t\t\"user\": map[string]string{\"login\": \"greg\"},\n\t\t\t\t\"state\": \"PENDING\",\n\t\t\t\t\"body\": \"\",\n\t\t\t},\n\t\t})\n\t})\n\n\tw := newTestWatcher(t, server.URL)\n\tsince := time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC)\n\n\tw.pollReviews(context.Background(), \"acme\", \"widgets\", &sdk.Issue{Index: 9}, since)\n\n\tselect {\n\tcase ev := <-w.Events():\n\t\tt.Fatalf(\"expected no event, got %+v\", ev)\n\tdefault:\n\t}\n}\n\n// A review the poller dispatches must carry the same dedup id the\n// webhook path would, so a review seen by both runs once.\nfunc TestPollReviewIDMatchesWebhook(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/pulls/9/reviews\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode([]map[string]any{\n\t\t\t{\n\t\t\t\t\"id\": 42,\n\t\t\t\t\"user\": map[string]string{\"login\": \"leon\"},\n\t\t\t\t\"state\": \"COMMENT\",\n\t\t\t\t\"body\": \"a thought\",\n\t\t\t\t\"submitted_at\": \"2026-08-24T10:00:00Z\",\n\t\t\t},\n\t\t})\n\t})\n\n\tw := newTestWatcher(t, server.URL)\n\tsince := time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC)\n\n\tw.pollReviews(context.Background(), \"acme\", \"widgets\", &sdk.Issue{Index: 9}, since)\n\n\tselect {\n\tcase ev := <-w.Events():\n\t\tif ev.ID != prReviewID(42) {\n\t\t\tt.Fatalf(\"poll id %q does not match webhook id %q\", ev.ID, prReviewID(42))\n\t\t}\n\tdefault:\n\t\tt.Fatal(\"expected a dispatched review event, got none\")\n\t}\n}\n"}
{"path": "internal/forgejo/poll_test.go", "content": "package forgejo\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"log/slog\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\nfunc newTestStore(t *testing.T) *store.Store {\n\tt.Helper()\n\n\tst, err := store.Open(filepath.Join(t.TempDir(), \"zoo.db\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tt.Cleanup(func() { st.Close() })\n\n\treturn st\n}\n\n// newTestWatcher returns a Watcher whose client talks to the test\n// server and whose events can be read from Events().\nfunc newTestWatcher(t *testing.T, serverURL string) *Watcher {\n\tt.Helper()\n\n\tclient, err := NewClient(config.Forgejo{URL: serverURL, Token: \"test\"})\n\tif err != nil {\n\t\tt.Fatalf(\"new client: %v\", err)\n\t}\n\n\treturn NewWatcher(client, newTestStore(t), \"\", slog.New(slog.DiscardHandler))\n}\n\nfunc TestPollReviewsDispatchesNewReview(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/pulls/9/reviews\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode([]map[string]any{\n\t\t\t{\n\t\t\t\t\"id\":           42,\n\t\t\t\t\"user\":         map[string]string{\"login\": \"leon\"},\n\t\t\t\t\"state\":        \"REQUEST_CHANGES\",\n\t\t\t\t\"body\":         \"Please address the inline comments.\",\n\t\t\t\t\"submitted_at\": \"2026-08-24T10:00:00Z\",\n\t\t\t},\n\t\t})\n\t})\n\n\tw := newTestWatcher(t, server.URL)\n\tsince := time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC)\n\n\tw.pollReviews(context.Background(), \"acme\", \"widgets\", &sdk.Issue{Index: 9, Title: \"Fix the thing\", Body: \"This fixes it\"}, since)\n\n\tselect {\n\tcase ev := <-w.Events():\n\t\tif ev.Kind != EventPRReview {\n\t\t\tt.Fatalf(\"expected pr:review, got %q\", ev.Kind)\n\t\t}\n\t\tif ev.Owner != \"acme\" || ev.Repo != \"widgets\" || ev.Index != 9 {\n\t\t\tt.Fatalf(\"unexpected owner/repo/index: %+v\", ev)\n\t\t}\n\t\tif ev.Author != \"leon\" {\n\t\t\tt.Fatalf(\"expected reviewer as author, got %q\", ev.Author)\n\t\t}\n\t\tif ev.ReviewID != 42 || ev.ReviewState != \"REQUEST_CHANGES\" || ev.ReviewBody != \"Please address the inline comments.\" {\n\t\t\tt.Fatalf(\"unexpected review fields: %+v\", ev)\n\t\t}\n\t\tif ev.ID != \"pr-review-42\" {\n\t\t\tt.Fatalf(\"unexpected dedup id: %q\", ev.ID)\n\t\t}\n\tdefault:\n\t\tt.Fatal(\"expected a dispatched review event, got none\")\n\t}\n}\n\n// A review submitted before the watermark, and a pending (unsubmitted)\n// review, must not be dispatched.\nfunc TestPollReviewsSkipsOldAndPending(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/pulls/9/reviews\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode([]map[string]any{\n\t\t\t{\n\t\t\t\t// Submitted before `since` — already seen.\n\t\t\t\t\"id\":           41,\n\t\t\t\t\"user\":         map[string]string{\"login\": \"leon\"},\n\t\t\t\t\"state\":        \"APPROVED\",\n\t\t\t\t\"body\":         \"lgtm\",\n\t\t\t\t\"submitted_at\": \"2026-08-24T08:00:00Z\",\n\t\t\t},\n\t\t\t{\n\t\t\t\t// Pending — no submitted_at, no verdict yet.\n\t\t\t\t\"id\":   43,\n\t\t\t\t\"user\": map[string]string{\"login\": \"greg\"},\n\t\t\t\t\"state\": \"PENDING\",\n\t\t\t\t\"body\":  \"\",\n\t\t\t},\n\t\t})\n\t})\n\n\tw := newTestWatcher(t, server.URL)\n\tsince := time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC)\n\n\tw.pollReviews(context.Background(), \"acme\", \"widgets\", &sdk.Issue{Index: 9}, since)\n\n\tselect {\n\tcase ev := <-w.Events():\n\t\tt.Fatalf(\"expected no event, got %+v\", ev)\n\tdefault:\n\t}\n}\n\n// A review the poller dispatches must carry the same dedup id the\n// webhook path would, so a review seen by both runs once.\nfunc TestPollReviewIDMatchesWebhook(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/pulls/9/reviews\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode([]map[string]any{\n\t\t\t{\n\t\t\t\t\"id\":           42,\n\t\t\t\t\"user\":         map[string]string{\"login\": \"leon\"},\n\t\t\t\t\"state\":        \"COMMENT\",\n\t\t\t\t\"body\":         \"a thought\",\n\t\t\t\t\"submitted_at\": \"2026-08-24T10:00:00Z\",\n\t\t\t},\n\t\t})\n\t})\n\n\tw := newTestWatcher(t, server.URL)\n\tsince := time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC)\n\n\tw.pollReviews(context.Background(), \"acme\", \"widgets\", &sdk.Issue{Index: 9}, since)\n\n\tselect {\n\tcase ev := <-w.Events():\n\t\tif ev.ID != prReviewID(42) {\n\t\t\tt.Fatalf(\"poll id %q does not match webhook id %q\", ev.ID, prReviewID(42))\n\t\t}\n\tdefault:\n\t\tt.Fatal(\"expected a dispatched review event, got none\")\n\t}\n}\n"}
OK
Thinking
I used `http.ResponseWriter` in poll_test.go, but I didn't import `net/http`. Let me add the import.
Tool 🔧 edit_file {"path": "internal/forgejo/poll_test.go", "find": "import (\n\t\"context\"\n\t\"encoding/json\"\n\t\"log/slog\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/store\"\n)", "replace": "import (\n\t\"context\"\n\t\"encoding/json\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/store\"\n)"}
{"path": "internal/forgejo/poll_test.go", "find": "import (\n\t\"context\"\n\t\"encoding/json\"\n\t\"log/slog\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/store\"\n)", "replace": "import (\n\t\"context\"\n\t\"encoding/json\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/store\"\n)"}
1 occurrences replaced
Thinking
Next, let's add client tests for the new client methods. I'll add them to `client_test.go`: - `TestReviewDetail` - `TestPullRequestInfo` - `TestResolveReviewComment` - `TestSubmitReview` Let me append these to client_test.go. I'll add them after `TestListIssueCommentsEmpty`. For `TestReviewDetail`, I'll serve the following: - `/api/v1/repos/acme/widgets/pulls/9/reviews/42` → review JSON - `/api/v1/repos/acme/widgets/pulls/9/reviews/42/comments` → comments JSON The review JSON needs to match the fields of the `sdk.PullReview` struct: id, user, state, body, submitted_at. The comment JSON needs to match `sdk.PullReviewComment`: id, body, user, pull_request_review_id, path, position, original_position, created_at. Let me write these. For `TestPullRequestInfo`, I'll serve `/api/v1/repos/acme/widgets/pulls/9` → PR JSON containing head.ref and base.ref. Note that `GetPullRequest` calls `fixPullHeadSha` when the version is 1.14 or higher. To avoid the extra ref call, I'll set `head.sha` to a non-empty value. For `TestResolveReviewComment`, I'll serve `/api/v1/repos/acme/widgets/pulls/9/review_comments/7/resolve` and verify that the method is POST and that it returns 204. I'll also test the error case (404). For `TestSubmitReview`, I'll serve `POST /api/v1/repos/acme/widgets/pulls/9/reviews` and verify that the request body contains the mapped state. I'll decode the request body and check the "event" field. Let me write these tests.
Now client tests for the new methods. Let me append to `client_test.go`:
Tool 🔧 read_file {"path": "internal/forgejo/client_test.go", "start_line": 100, "end_line": 123}
{"path": "internal/forgejo/client_test.go", "start_line": 100, "end_line": 123}
{"path":"internal/forgejo/client_test.go","content":"}\r\n\r\nfunc TestListIssueCommentsEmpty(t *testing.T) {\r\n\tserver, mux := newTestServer(t)\r\n\r\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/issues/7/comments\", func(w http.ResponseWriter, r *http.Request) {\r\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\r\n\t\t_, _ = w.Write([]byte(`[]`))\r\n\t})\r\n\r\n\tclient, err := NewClient(config.Forgejo{URL: server.URL, Token: \"test\"})\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"new client: %v\", err)\r\n\t}\r\n\r\n\tcomments, err := client.ListIssueComments(\"acme\", \"widgets\", 7)\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"list comments: %v\", err)\r\n\t}\r\n\r\n\tif len(comments) != 0 {\r\n\t\tt.Fatalf(\"expected no comments, got %d\", len(comments))\r\n\t}\r\n}\r","start_line":100,"end_line":123,"total_lines":123,"truncated":false}
Thinking
Let's add a new client test at the end of client_test.go.
Tool 🔧 edit_file {"path": "internal/forgejo/client_test.go", "find": "func TestListIssueCommentsEmpty(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tmux.HandleFunc(\"/api/v1/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\"})\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}", "replace": "func TestListIssueCommentsEmpty(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tmux.HandleFunc(\"/api/v1/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\"})\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\nfunc TestReviewDetail(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/pulls/9/reviews/42\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`{\n\t\t\t\"id\": 42,\n\t\t\t\"user\": {\"login\": \"leon\"},\n\t\t\t\"state\": \"REQUEST_CHANGES\",\n\t\t\t\"body\": \"Please address the inline comments.\",\n\t\t\t\"submitted_at\": \"2026-08-24T10:00:00Z\"\n\t\t}`))\n\t})\n\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/pulls/9/reviews/42/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\t\t{\"id\": 7, \"body\": \"This is wrong.\", \"user\": {\"login\": \"leon\"}, \"path\": \"src/foo.go\", \"position\": 42},\n\t\t\t{\"id\": 8, \"body\": \"And this.\", \"user\": {\"login\": \"leon\"}, \"path\": \"src/bar.go\", \"original_position\": 10}\n\t\t]`))\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\tdetail, err := client.ReviewDetail(\"acme\", \"widgets\", 9, 42)\n\tif err != nil {\n\t\tt.Fatalf(\"review detail: %v\", err)\n\t}\n\n\tif detail.ID != 42 || detail.State != \"REQUEST_CHANGES\" || detail.Reviewer != \"leon\" {\n\t\tt.Fatalf(\"unexpected review: %+v\", detail)\n\t}\n\tif detail.Body != \"Please address the inline comments.\" {\n\t\tt.Fatalf(\"unexpected body: %q\", detail.Body)\n\t}\n\tif len(detail.Comments) != 2 {\n\t\tt.Fatalf(\"expected 2 comments, got %d\", len(detail.Comments))\n\t}\n\tif detail.Comments[0].ID != 7 || detail.Comments[0].Path != \"src/foo.go\" || detail.Comments[0].Line != 42 || detail.Comments[0].Author != \"leon\" {\n\t\tt.Fatalf(\"unexpected comment 0: %+v\", detail.Comments[0])\n\t}\n\t// A comment on a deleted line reports its original position.\n\tif detail.Comments[1].ID != 8 || detail.Comments[1].Line != 10 {\n\t\tt.Fatalf(\"unexpected comment 1: %+v\", detail.Comments[1])\n\t}\n}\n\nfunc TestPullRequestInfo(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/pulls/9\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`{\n\t\t\t\"id\": 202,\n\t\t\t\"number\": 9,\n\t\t\t\"head\": {\"ref\": \"issue-9-greg\", \"sha\": \"abc123\"},\n\t\t\t\"base\": {\"ref\": \"main\"}\n\t\t}`))\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\tinfo, err := client.PullRequestInfo(\"acme\", \"widgets\", 9)\n\tif err != nil {\n\t\tt.Fatalf(\"pull request info: %v\", err)\n\t}\n\n\tif info.HeadRef != \"issue-9-greg\" || info.BaseRef != \"main\" {\n\t\tt.Fatalf(\"unexpected info: %+v\", info)\n\t}\n}\n\nfunc TestResolveReviewComment(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tvar method, auth string\n\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/pulls/9/review_comments/7/resolve\", func(w http.ResponseWriter, r *http.Request) {\n\t\tmethod = r.Method\n\t\tauth = r.Header.Get(\"Authorization\")\n\t\tw.WriteHeader(http.StatusNoContent)\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\tif err := client.ResolveReviewComment(\"acme\", \"widgets\", 9, 7); err != nil {\n\t\tt.Fatalf(\"resolve: %v\", err)\n\t}\n\n\tif method != http.MethodPost {\n\t\tt.Fatalf(\"expected POST, got %s\", method)\n\t}\n\tif auth != \"token test\" {\n\t\tt.Fatalf(\"expected bearer token auth, got %q\", auth)\n\t}\n}\n\nfunc TestResolveReviewCommentError(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/pulls/9/review_comments/99/resolve\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusNotFound)\n\t\t_, _ = w.Write([]byte(`{\"message\": \"not found\"}`))\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\tif err := client.ResolveReviewComment(\"acme\", \"widgets\", 9, 99); err == nil {\n\t\tt.Fatal(\"expected an error for a missing review comment\")\n\t}\n}\n\nfunc TestSubmitReview(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tvar gotEvent, gotBody string\n\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/pulls/9/reviews\", func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPost {\n\t\t\tt.Fatalf(\"expected POST, got %s\", r.Method)\n\t\t}\n\n\t\tvar payload struct {\n\t\t\tEvent string `json:\"event\"`\n\t\t\tBody string `json:\"body\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&payload); err != nil {\n\t\t\tt.Fatalf(\"decode review payload: %v\", err)\n\t\t}\n\t\tgotEvent, gotBody = payload.Event, payload.Body\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`{\"id\": 44}`))\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\tif err := client.SubmitReview(\"acme\", \"widgets\", 9, ReviewStateChangesRequest, \"please fix\"); err != nil {\n\t\tt.Fatalf(\"submit review: %v\", err)\n\t}\n\n\tif gotEvent != \"REQUEST_CHANGES\" {\n\t\tt.Fatalf(\"expected REQUEST_CHANGES, got %q\", gotEvent)\n\t}\n\tif gotBody != \"please fix\" {\n\t\tt.Fatalf(\"expected body %q, got %q\", \"please fix\", gotBody)\n\t}\n}\n\nfunc TestSubmitReviewUnknownState(t *testing.T) {\n\tserver, _ := newTestServer(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\tif err := client.SubmitReview(\"acme\", \"widgets\", 9, \"bogus\", \"body\"); err == nil {\n\t\tt.Fatal(\"expected an error for an unknown review state\")\n\t}\n}"}
{"path": "internal/forgejo/client_test.go", "find": "func TestListIssueCommentsEmpty(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tmux.HandleFunc(\"/api/v1/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\"})\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}", "replace": "func TestListIssueCommentsEmpty(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tmux.HandleFunc(\"/api/v1/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\"})\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\nfunc TestReviewDetail(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/pulls/9/reviews/42\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`{\n\t\t\t\"id\": 42,\n\t\t\t\"user\": {\"login\": \"leon\"},\n\t\t\t\"state\": \"REQUEST_CHANGES\",\n\t\t\t\"body\": \"Please address the inline comments.\",\n\t\t\t\"submitted_at\": \"2026-08-24T10:00:00Z\"\n\t\t}`))\n\t})\n\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/pulls/9/reviews/42/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\t\t{\"id\": 7, \"body\": \"This is wrong.\", \"user\": {\"login\": \"leon\"}, \"path\": \"src/foo.go\", \"position\": 42},\n\t\t\t{\"id\": 8, \"body\": \"And this.\", \"user\": {\"login\": \"leon\"}, \"path\": \"src/bar.go\", \"original_position\": 10}\n\t\t]`))\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\tdetail, err := client.ReviewDetail(\"acme\", \"widgets\", 9, 42)\n\tif err != nil {\n\t\tt.Fatalf(\"review detail: %v\", err)\n\t}\n\n\tif detail.ID != 42 || detail.State != \"REQUEST_CHANGES\" || detail.Reviewer != \"leon\" {\n\t\tt.Fatalf(\"unexpected review: %+v\", detail)\n\t}\n\tif detail.Body != \"Please address the inline comments.\" {\n\t\tt.Fatalf(\"unexpected body: %q\", detail.Body)\n\t}\n\tif len(detail.Comments) != 2 {\n\t\tt.Fatalf(\"expected 2 comments, got %d\", len(detail.Comments))\n\t}\n\tif detail.Comments[0].ID != 7 || detail.Comments[0].Path != \"src/foo.go\" || detail.Comments[0].Line != 42 || detail.Comments[0].Author != \"leon\" {\n\t\tt.Fatalf(\"unexpected comment 0: %+v\", detail.Comments[0])\n\t}\n\t// A comment on a deleted line reports its original position.\n\tif detail.Comments[1].ID != 8 || detail.Comments[1].Line != 10 {\n\t\tt.Fatalf(\"unexpected comment 1: %+v\", detail.Comments[1])\n\t}\n}\n\nfunc TestPullRequestInfo(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/pulls/9\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`{\n\t\t\t\"id\": 202,\n\t\t\t\"number\": 9,\n\t\t\t\"head\": {\"ref\": \"issue-9-greg\", \"sha\": \"abc123\"},\n\t\t\t\"base\": {\"ref\": \"main\"}\n\t\t}`))\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\tinfo, err := client.PullRequestInfo(\"acme\", \"widgets\", 9)\n\tif err != nil {\n\t\tt.Fatalf(\"pull request info: %v\", err)\n\t}\n\n\tif info.HeadRef != \"issue-9-greg\" || info.BaseRef != \"main\" {\n\t\tt.Fatalf(\"unexpected info: %+v\", info)\n\t}\n}\n\nfunc TestResolveReviewComment(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tvar method, auth string\n\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/pulls/9/review_comments/7/resolve\", func(w http.ResponseWriter, r *http.Request) {\n\t\tmethod = r.Method\n\t\tauth = r.Header.Get(\"Authorization\")\n\t\tw.WriteHeader(http.StatusNoContent)\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\tif err := client.ResolveReviewComment(\"acme\", \"widgets\", 9, 7); err != nil {\n\t\tt.Fatalf(\"resolve: %v\", err)\n\t}\n\n\tif method != http.MethodPost {\n\t\tt.Fatalf(\"expected POST, got %s\", method)\n\t}\n\tif auth != \"token test\" {\n\t\tt.Fatalf(\"expected bearer token auth, got %q\", auth)\n\t}\n}\n\nfunc TestResolveReviewCommentError(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/pulls/9/review_comments/99/resolve\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusNotFound)\n\t\t_, _ = w.Write([]byte(`{\"message\": \"not found\"}`))\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\tif err := client.ResolveReviewComment(\"acme\", \"widgets\", 9, 99); err == nil {\n\t\tt.Fatal(\"expected an error for a missing review comment\")\n\t}\n}\n\nfunc TestSubmitReview(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tvar gotEvent, gotBody string\n\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/pulls/9/reviews\", func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPost {\n\t\t\tt.Fatalf(\"expected POST, got %s\", r.Method)\n\t\t}\n\n\t\tvar payload struct {\n\t\t\tEvent string `json:\"event\"`\n\t\t\tBody  string `json:\"body\"`\n\t\t}\n\t\tif err := json.NewDecoder(r.Body).Decode(&payload); err != nil {\n\t\t\tt.Fatalf(\"decode review payload: %v\", err)\n\t\t}\n\t\tgotEvent, gotBody = payload.Event, payload.Body\n\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`{\"id\": 44}`))\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\tif err := client.SubmitReview(\"acme\", \"widgets\", 9, ReviewStateChangesRequest, \"please fix\"); err != nil {\n\t\tt.Fatalf(\"submit review: %v\", err)\n\t}\n\n\tif gotEvent != \"REQUEST_CHANGES\" {\n\t\tt.Fatalf(\"expected REQUEST_CHANGES, got %q\", gotEvent)\n\t}\n\tif gotBody != \"please fix\" {\n\t\tt.Fatalf(\"expected body %q, got %q\", \"please fix\", gotBody)\n\t}\n}\n\nfunc TestSubmitReviewUnknownState(t *testing.T) {\n\tserver, _ := newTestServer(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\tif err := client.SubmitReview(\"acme\", \"widgets\", 9, \"bogus\", \"body\"); err == nil {\n\t\tt.Fatal(\"expected an error for an unknown review state\")\n\t}\n}"}
1 occurrences replaced
Thinking
Next, let's update the config tests to include `pr:review`. I'll update `validConfig` and add assertions. Also, I'll add a test that `pr:review` requires an agent. Let's update `validConfig` in config_test.go.
Now update the config test to cover `pr:review`:
Tool 🔧 edit_file {"path": "internal/config/config_test.go", "find": "event \"pr:new\" {\n agent = \"leon\"\n}\n`", "replace": "event \"pr:new\" {\n agent = \"leon\"\n}\n\nevent \"pr:review\" {\n agent = \"leon\"\n instructions = \"Address the review feedback and respond.\"\n}\n`"}
{"path": "internal/config/config_test.go", "find": "event \"pr:new\" {\n    agent = \"leon\"\n}\n`", "replace": "event \"pr:new\" {\n    agent = \"leon\"\n}\n\nevent \"pr:review\" {\n    agent        = \"leon\"\n    instructions = \"Address the review feedback and respond.\"\n}\n`"}
1 occurrences replaced
Thinking
Next, I'll add assertions to `TestLoadValid` for `pr:review`. Let's update the test.
Tool 🔧 edit_file {"path": "internal/config/config_test.go", "find": "\tif got := cfg.EventInstructions(EventPRNew); got != \"\" {\n\t\tt.Fatalf(\"expected no instructions for pr:new, got %q\", got)\n\t}\n}", "replace": "\tif got := cfg.EventInstructions(EventPRNew); got != \"\" {\n\t\tt.Fatalf(\"expected no instructions for pr:new, got %q\", got)\n\t}\n\n\tif agent, ok := cfg.EventAgent(EventPRReview); !ok || agent != \"leon\" {\n\t\tt.Fatalf(\"expected pr:review -> leon, got %q, %v\", agent, ok)\n\t}\n\tif got := cfg.EventInstructions(EventPRReview); got != \"Address the review feedback and respond.\" {\n\t\tt.Fatalf(\"unexpected pr:review instructions: %q\", got)\n\t}\n}\n\nfunc TestValidatePRReviewRequiresAgent(t *testing.T) {\n\t_, err := load(t, `\nllm \"qwen\" { openai = \"https://example.com\" token = \"tok\" model = \"qwen3\" }\nforgejo { url = \"https://example.com\" token = \"tok\" }\nenvironment { docker_image = \"debian:unstable\" max_live_agents = 1 }\nagent \"leon\" { llm = \"qwen\" }\nevent \"pr:review\" { instructions = \"no agent set\" }\n`)\n\tif err == nil {\n\t\tt.Fatal(\"expected error for pr:review without an agent\")\n\t}\n}"}
{"path": "internal/config/config_test.go", "find": "\tif got := cfg.EventInstructions(EventPRNew); got != \"\" {\n\t\tt.Fatalf(\"expected no instructions for pr:new, got %q\", got)\n\t}\n}", "replace": "\tif got := cfg.EventInstructions(EventPRNew); got != \"\" {\n\t\tt.Fatalf(\"expected no instructions for pr:new, got %q\", got)\n\t}\n\n\tif agent, ok := cfg.EventAgent(EventPRReview); !ok || agent != \"leon\" {\n\t\tt.Fatalf(\"expected pr:review -> leon, got %q, %v\", agent, ok)\n\t}\n\tif got := cfg.EventInstructions(EventPRReview); got != \"Address the review feedback and respond.\" {\n\t\tt.Fatalf(\"unexpected pr:review instructions: %q\", got)\n\t}\n}\n\nfunc TestValidatePRReviewRequiresAgent(t *testing.T) {\n\t_, err := load(t, `\nllm \"qwen\" { openai = \"https://example.com\" token = \"tok\" model = \"qwen3\" }\nforgejo { url = \"https://example.com\" token = \"tok\" }\nenvironment { docker_image = \"debian:unstable\" max_live_agents = 1 }\nagent \"leon\" { llm = \"qwen\" }\nevent \"pr:review\" { instructions = \"no agent set\" }\n`)\n\tif err == nil {\n\t\tt.Fatal(\"expected error for pr:review without an agent\")\n\t}\n}"}
1 occurrences replaced
Thinking
Next, let's update the scheduler tests. I'll add `pr:review` to `testConfig` and add a test that it resolves statically. Also, I'll add a test that pr:review events created by the agent itself are discarded. Let's update `testConfig` in scheduler_test.go.
Now update the scheduler test config and add resolution tests:
Tool 🔧 edit_file {"path": "internal/scheduler/scheduler_test.go", "find": "\t\tEvents: []config.Event{\n\t\t\t{Kind: config.EventIssueNew, Agent: \"leon\"},\n\t\t\t{Kind: config.EventIssueComment, Agent: \"leon\"},\n\t\t\t{Kind: config.EventIssueAssigned},\n\t\t\t{Kind: config.EventPRNew, Agent: \"greg\"},\n\t\t},\n\t}\n}", "replace": "\t\tEvents: []config.Event{\n\t\t\t{Kind: config.EventIssueNew, Agent: \"leon\"},\n\t\t\t{Kind: config.EventIssueComment, Agent: \"leon\"},\n\t\t\t{Kind: config.EventIssueAssigned},\n\t\t\t{Kind: config.EventPRNew, Agent: \"greg\"},\n\t\t\t{Kind: config.EventPRReview, Agent: \"greg\"},\n\t\t},\n\t}\n}"}
{"path": "internal/scheduler/scheduler_test.go", "find": "\t\tEvents: []config.Event{\n\t\t\t{Kind: config.EventIssueNew, Agent: \"leon\"},\n\t\t\t{Kind: config.EventIssueComment, Agent: \"leon\"},\n\t\t\t{Kind: config.EventIssueAssigned},\n\t\t\t{Kind: config.EventPRNew, Agent: \"greg\"},\n\t\t},\n\t}\n}", "replace": "\t\tEvents: []config.Event{\n\t\t\t{Kind: config.EventIssueNew, Agent: \"leon\"},\n\t\t\t{Kind: config.EventIssueComment, Agent: \"leon\"},\n\t\t\t{Kind: config.EventIssueAssigned},\n\t\t\t{Kind: config.EventPRNew, Agent: \"greg\"},\n\t\t\t{Kind: config.EventPRReview, Agent: \"greg\"},\n\t\t},\n\t}\n}"}
1 occurrences replaced
Thinking
Next, I'll add a test that pr:review is resolved statically to greg, and a test that pr:review events created by greg (the resolved agent) are discarded. I'll add these after `TestResolveAgentStatic`.
Tool 🔧 edit_file {"path": "internal/scheduler/scheduler_test.go", "find": "func TestResolveAgentStatic(t *testing.T) {\n\tcfg := testConfig()\n\n\tname, ok := resolveAgent(cfg, forgejo.Event{Kind: config.EventIssueNew})\n\tif !ok || name != \"leon\" {\n\t\tt.Fatalf(\"expected leon, got %q, %v\", name, ok)\n\t}\n}", "replace": "func TestResolveAgentStatic(t *testing.T) {\n\tcfg := testConfig()\n\n\tname, ok := resolveAgent(cfg, forgejo.Event{Kind: config.EventIssueNew})\n\tif !ok || name != \"leon\" {\n\t\tt.Fatalf(\"expected leon, got %q, %v\", name, ok)\n\t}\n}\n\nfunc TestResolveAgentPRReviewStatic(t *testing.T) {\n\tcfg := testConfig()\n\n\tname, ok := resolveAgent(cfg, forgejo.Event{Kind: config.EventPRReview, ReviewID: 42})\n\tif !ok || name != \"greg\" {\n\t\tt.Fatalf(\"expected greg, got %q, %v\", name, ok)\n\t}\n}"}
{"path": "internal/scheduler/scheduler_test.go", "find": "func TestResolveAgentStatic(t *testing.T) {\n\tcfg := testConfig()\n\n\tname, ok := resolveAgent(cfg, forgejo.Event{Kind: config.EventIssueNew})\n\tif !ok || name != \"leon\" {\n\t\tt.Fatalf(\"expected leon, got %q, %v\", name, ok)\n\t}\n}", "replace": "func TestResolveAgentStatic(t *testing.T) {\n\tcfg := testConfig()\n\n\tname, ok := resolveAgent(cfg, forgejo.Event{Kind: config.EventIssueNew})\n\tif !ok || name != \"leon\" {\n\t\tt.Fatalf(\"expected leon, got %q, %v\", name, ok)\n\t}\n}\n\nfunc TestResolveAgentPRReviewStatic(t *testing.T) {\n\tcfg := testConfig()\n\n\tname, ok := resolveAgent(cfg, forgejo.Event{Kind: config.EventPRReview, ReviewID: 42})\n\tif !ok || name != \"greg\" {\n\t\tt.Fatalf(\"expected greg, got %q, %v\", name, ok)\n\t}\n}"}
1 occurrences replaced
Thinking
Next, I'll add a test to confirm that pr:review events created by the resolved agent (greg) are discarded. I'll add it after `TestSchedulerDropsEventAuthoredByTargetAgent`.
Tool 🔧 edit_file {"path": "internal/scheduler/scheduler_test.go", "find": "func TestSchedulerDispatchesCommentFromSomeoneElse(t *testing.T) {", "replace": "// A review the pr:review agent submits on the same PR must not trigger\n// itself: the event's author (the reviewer) is the resolved agent.\nfunc TestSchedulerDropsPRReviewAuthoredByTargetAgent(t *testing.T) {\n\tcfg := testConfig()\n\tst := newStore(t)\n\tfg := &fakeForgejo{}\n\trunner := &fakeRunner{}\n\tlogger := slog.New(slog.DiscardHandler)\n\n\tsched := New(cfg, st, fg, runner, livelog.NewHub(), logger)\n\n\tevents := make(chan forgejo.Event, 1)\n\tevents <- forgejo.Event{Kind: config.EventPRReview, Author: \"greg\", Owner: \"acme\", Repo: \"widgets\", Index: 9, ReviewID: 42}\n\tclose(events)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\n\tsched.Run(ctx, events)\n\tsched.Shutdown(ctx)\n\n\tjobs, err := st.ListJobs(ctx, 10)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(jobs) != 0 {\n\t\tt.Fatalf(\"expected greg's own review not to trigger greg, got %+v\", jobs)\n\t}\n}\n\nfunc TestSchedulerDispatchesPRReviewFromSomeoneElse(t *testing.T) {\n\tcfg := testConfig()\n\tst := newStore(t)\n\tfg := &fakeForgejo{}\n\trunner := &fakeRunner{}\n\tlogger := slog.New(slog.DiscardHandler)\n\n\tsched := New(cfg, st, fg, runner, livelog.NewHub(), logger)\n\n\tevents := make(chan forgejo.Event, 1)\n\tevents <- forgejo.Event{Kind: config.EventPRReview, Author: \"leon\", Owner: \"acme\", Repo: \"widgets\", Index: 9, ReviewID: 42}\n\tclose(events)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\n\tsched.Run(ctx, events)\n\tsched.Shutdown(ctx)\n\n\tjobs, err := st.ListJobs(ctx, 10)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(jobs) != 1 || jobs[0].Agent != \"greg\" {\n\t\tt.Fatalf(\"expected a review from a different author to dispatch to greg, got %+v\", jobs)\n\t}\n}\n\nfunc TestSchedulerDispatchesCommentFromSomeoneElse(t *testing.T) {"}
{"path": "internal/scheduler/scheduler_test.go", "find": "func TestSchedulerDispatchesCommentFromSomeoneElse(t *testing.T) {", "replace": "// A review the pr:review agent submits on the same PR must not trigger\n// itself: the event's author (the reviewer) is the resolved agent.\nfunc TestSchedulerDropsPRReviewAuthoredByTargetAgent(t *testing.T) {\n\tcfg := testConfig()\n\tst := newStore(t)\n\tfg := &fakeForgejo{}\n\trunner := &fakeRunner{}\n\tlogger := slog.New(slog.DiscardHandler)\n\n\tsched := New(cfg, st, fg, runner, livelog.NewHub(), logger)\n\n\tevents := make(chan forgejo.Event, 1)\n\tevents <- forgejo.Event{Kind: config.EventPRReview, Author: \"greg\", Owner: \"acme\", Repo: \"widgets\", Index: 9, ReviewID: 42}\n\tclose(events)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\n\tsched.Run(ctx, events)\n\tsched.Shutdown(ctx)\n\n\tjobs, err := st.ListJobs(ctx, 10)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(jobs) != 0 {\n\t\tt.Fatalf(\"expected greg's own review not to trigger greg, got %+v\", jobs)\n\t}\n}\n\nfunc TestSchedulerDispatchesPRReviewFromSomeoneElse(t *testing.T) {\n\tcfg := testConfig()\n\tst := newStore(t)\n\tfg := &fakeForgejo{}\n\trunner := &fakeRunner{}\n\tlogger := slog.New(slog.DiscardHandler)\n\n\tsched := New(cfg, st, fg, runner, livelog.NewHub(), logger)\n\n\tevents := make(chan forgejo.Event, 1)\n\tevents <- forgejo.Event{Kind: config.EventPRReview, Author: \"leon\", Owner: \"acme\", Repo: \"widgets\", Index: 9, ReviewID: 42}\n\tclose(events)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\n\tsched.Run(ctx, events)\n\tsched.Shutdown(ctx)\n\n\tjobs, err := st.ListJobs(ctx, 10)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(jobs) != 1 || jobs[0].Agent != \"greg\" {\n\t\tt.Fatalf(\"expected a review from a different author to dispatch to greg, got %+v\", jobs)\n\t}\n}\n\nfunc TestSchedulerDispatchesCommentFromSomeoneElse(t *testing.T) {"}
1 occurrences replaced
Thinking
Next, I'll add tool tests for the two new tools. Let's add them to `tools_test.go`. To follow the conventions, I'll check the existing tool tests (TestCommentToolCallsForgejo, TestAssignIssueToolCallsForgejo).
Now let me look at the existing tool tests to follow conventions, then add tests for the new tools:
Tool 🔧 read_file {"path": "internal/tools/tools_test.go", "start_line": 111, "end_line": 160}
{"path": "internal/tools/tools_test.go", "start_line": 111, "end_line": 160}
{"path":"internal/tools/tools_test.go","content":"\t}\r\n\r\n\tif fc.lastGitSubcommand != \"commit\" {\r\n\t\tt.Fatalf(\"expected subcommand %q, got %q\", \"commit\", fc.lastGitSubcommand)\r\n\t}\r\n\tif len(fc.lastGitArgs) != 2 || fc.lastGitArgs[1] != \"fix: handle it's edge case\" {\r\n\t\tt.Fatalf(\"expected args passed through unescaped, got %v\", fc.lastGitArgs)\r\n\t}\r\n}\r\n\r\nfunc TestCommentToolCallsForgejo(t *testing.T) {\r\n\tfg := \u0026fakeForgejoActions{}\r\n\tfc := \u0026fakeContext{fg: fg}\r\n\r\n\tif _, err := comment(fc, commentParams{Body: \"hello\"}); err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\tif len(fg.comments) != 1 || fg.comments[0] != \"hello\" {\r\n\t\tt.Fatalf(\"unexpected comments: %v\", fg.comments)\r\n\t}\r\n}\r\n\r\nfunc TestAssignIssueToolCallsForgejo(t *testing.T) {\r\n\tfg := \u0026fakeForgejoActions{}\r\n\tfc := \u0026fakeContext{fg: fg}\r\n\r\n\tout, err := assignIssue(fc, assignIssueParams{Agent: \"greg\"})\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif out != \"Assigned to greg\" {\r\n\t\tt.Fatalf(\"unexpected result: %q\", out)\r\n\t}\r\n\tif len(fg.assigned) != 1 || fg.assigned[0] != \"greg\" {\r\n\t\tt.Fatalf(\"unexpected assignments: %v\", fg.assigned)\r\n\t}\r\n}\r\n\r\nfunc TestGrepSearchUsesExtendedRegex(t *testing.T) {\r\n\tfc := \u0026fakeContext{output: \"/project/foo.go:1:foo|bar\\n\"}\r\n\r\n\tout, err := grepSearch(fc, grepSearchParams{Pattern: `foo|bar`})\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif !strings.Contains(fc.lastCmd, \"grep -Rn -I -E\") {\r\n\t\tt.Fatalf(\"expected grep to be invoked with -E (extended regex), got %q\", fc.lastCmd)\r\n\t}\r\n\tif !strings.Contains(out, \"foo|bar\") {\r","start_line":111,"end_line":160,"total_lines":191,"truncated":true}
Thinking
I'll add tool tests after `TestAssignIssueToolCallsForgejo`.
Tool 🔧 edit_file {"path": "internal/tools/tools_test.go", "find": "func TestAssignIssueToolCallsForgejo(t *testing.T) {\n\tfg := &fakeForgejoActions{}\n\tfc := &fakeContext{fg: fg}\n\n\tout, err := assignIssue(fc, assignIssueParams{Agent: \"greg\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif out != \"Assigned to greg\" {\n\t\tt.Fatalf(\"unexpected result: %q\", out)\n\t}\n\tif len(fg.assigned) != 1 || fg.assigned[0] != \"greg\" {\n\t\tt.Fatalf(\"unexpected assignments: %v\", fg.assigned)\n\t}\n}", "replace": "func TestAssignIssueToolCallsForgejo(t *testing.T) {\n\tfg := &fakeForgejoActions{}\n\tfc := &fakeContext{fg: fg}\n\n\tout, err := assignIssue(fc, assignIssueParams{Agent: \"greg\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif out != \"Assigned to greg\" {\n\t\tt.Fatalf(\"unexpected result: %q\", out)\n\t}\n\tif len(fg.assigned) != 1 || fg.assigned[0] != \"greg\" {\n\t\tt.Fatalf(\"unexpected assignments: %v\", fg.assigned)\n\t}\n}\n\nfunc TestSubmitReviewToolCallsForgejo(t *testing.T) {\n\tfg := &fakeForgejoActions{}\n\tfc := &fakeContext{fg: fg}\n\n\tout, err := submitReview(fc, submitReviewParams{State: \"changes_requested\", Body: \"please fix\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif out != \"Review submitted\" {\n\t\tt.Fatalf(\"unexpected result: %q\", out)\n\t}\n\tif len(fg.reviews) != 1 || fg.reviews[0] != \"changes_requested\" {\n\t\tt.Fatalf(\"unexpected reviews: %v\", fg.reviews)\n\t}\n}\n\n// An approval needs no body, but any other verdict does.\nfunc TestSubmitReviewRequiresBodyUnlessApproved(t *testing.T) {\n\tfg := &fakeForgejoActions{}\n\tfc := &fakeContext{fg: fg}\n\n\tif _, err := submitReview(fc, submitReviewParams{State: \"approved\", Body: \"\"}); err != nil {\n\t\tt.Fatalf(\"approval with empty body should be allowed, got %v\", err)\n\t}\n\n\tif _, err := submitReview(fc, submitReviewParams{State: \"changes_requested\", Body: \" \"}); err == nil {\n\t\tt.Fatal(\"expected an error for a changes_requested review with an empty body\")\n\t}\n}\n\nfunc TestResolveReviewCommentToolCallsForgejo(t *testing.T) {\n\tfg := &fakeForgejoActions{}\n\tfc := &fakeContext{fg: fg}\n\n\tout, err := resolveReviewComment(fc, resolveReviewCommentParams{CommentID: 7})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif out != \"Review comment resolved\" {\n\t\tt.Fatalf(\"unexpected result: %q\", out)\n\t}\n\tif len(fg.resolvedComments) != 1 || fg.resolvedComments[0] != 7 {\n\t\tt.Fatalf(\"unexpected resolved comments: %v\", fg.resolvedComments)\n\t}\n}"}
{"path": "internal/tools/tools_test.go", "find": "func TestAssignIssueToolCallsForgejo(t *testing.T) {\n\tfg := &fakeForgejoActions{}\n\tfc := &fakeContext{fg: fg}\n\n\tout, err := assignIssue(fc, assignIssueParams{Agent: \"greg\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif out != \"Assigned to greg\" {\n\t\tt.Fatalf(\"unexpected result: %q\", out)\n\t}\n\tif len(fg.assigned) != 1 || fg.assigned[0] != \"greg\" {\n\t\tt.Fatalf(\"unexpected assignments: %v\", fg.assigned)\n\t}\n}", "replace": "func TestAssignIssueToolCallsForgejo(t *testing.T) {\n\tfg := &fakeForgejoActions{}\n\tfc := &fakeContext{fg: fg}\n\n\tout, err := assignIssue(fc, assignIssueParams{Agent: \"greg\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif out != \"Assigned to greg\" {\n\t\tt.Fatalf(\"unexpected result: %q\", out)\n\t}\n\tif len(fg.assigned) != 1 || fg.assigned[0] != \"greg\" {\n\t\tt.Fatalf(\"unexpected assignments: %v\", fg.assigned)\n\t}\n}\n\nfunc TestSubmitReviewToolCallsForgejo(t *testing.T) {\n\tfg := &fakeForgejoActions{}\n\tfc := &fakeContext{fg: fg}\n\n\tout, err := submitReview(fc, submitReviewParams{State: \"changes_requested\", Body: \"please fix\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif out != \"Review submitted\" {\n\t\tt.Fatalf(\"unexpected result: %q\", out)\n\t}\n\tif len(fg.reviews) != 1 || fg.reviews[0] != \"changes_requested\" {\n\t\tt.Fatalf(\"unexpected reviews: %v\", fg.reviews)\n\t}\n}\n\n// An approval needs no body, but any other verdict does.\nfunc TestSubmitReviewRequiresBodyUnlessApproved(t *testing.T) {\n\tfg := &fakeForgejoActions{}\n\tfc := &fakeContext{fg: fg}\n\n\tif _, err := submitReview(fc, submitReviewParams{State: \"approved\", Body: \"\"}); err != nil {\n\t\tt.Fatalf(\"approval with empty body should be allowed, got %v\", err)\n\t}\n\n\tif _, err := submitReview(fc, submitReviewParams{State: \"changes_requested\", Body: \"   \"}); err == nil {\n\t\tt.Fatal(\"expected an error for a changes_requested review with an empty body\")\n\t}\n}\n\nfunc TestResolveReviewCommentToolCallsForgejo(t *testing.T) {\n\tfg := &fakeForgejoActions{}\n\tfc := &fakeContext{fg: fg}\n\n\tout, err := resolveReviewComment(fc, resolveReviewCommentParams{CommentID: 7})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif out != \"Review comment resolved\" {\n\t\tt.Fatalf(\"unexpected result: %q\", out)\n\t}\n\tif len(fg.resolvedComments) != 1 || fg.resolvedComments[0] != 7 {\n\t\tt.Fatalf(\"unexpected resolved comments: %v\", fg.resolvedComments)\n\t}\n}"}
1 occurrences replaced
Thinking
Alright, let's run all the tests.
Now let me run the full test suite:
Tool 🔧 bash {"command": "cd /project && go test ./... 2>&1 | tail -25", "timeout": 300}
{"command": "cd /project && go test ./... 2>&1 | tail -25", "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	0.013s
ok  	github.com/abrander/zoo/internal/forgejo	0.692s
ok  	github.com/abrander/zoo/internal/livelog	(cached)
ok  	github.com/abrander/zoo/internal/llm	(cached)
ok  	github.com/abrander/zoo/internal/scheduler	1.901s
ok  	github.com/abrander/zoo/internal/store	(cached)
ok  	github.com/abrander/zoo/internal/tools	0.003s
ok  	github.com/abrander/zoo/internal/web	(cached)
Thinking
All tests pass. Next, let's run `go vet` to detect issues, and also check `gofmt`.
All tests pass. Let me run `go vet` and `gofmt` to be thorough:
Tool 🔧 bash {"command": "cd /project && go vet ./... 2>&1 | head -20; echo \"--- gofmt ---\"; gofmt -l . 2>&1 | head", "timeout": 300}
{"command": "cd /project && go vet ./... 2>&1 | head -20; echo \"--- gofmt ---\"; gofmt -l . 2>&1 | head", "timeout": 300}
--- gofmt ---
internal/forgejo/client.go
internal/forgejo/poll_test.go
internal/scheduler/scheduler_test.go
internal/tools/tools_test.go
Thinking
`go vet` passed (no output). However, `gofmt` is reporting 4 files as unformatted. Let's run `gofmt -w` on them.
`go vet` is clean. Let me fix formatting:
Tool 🔧 bash {"command": "cd /project && gofmt -w internal/forgejo/client.go internal/forgejo/poll_test.go internal/scheduler/scheduler_test.go internal/tools/tools_test.go && gofmt -l . && echo \"gofmt clean\" && go build ./... && go test ./... 2>&1 | tail -12", "timeout": 300}
{"command": "cd /project && gofmt -w internal/forgejo/client.go internal/forgejo/poll_test.go internal/scheduler/scheduler_test.go internal/tools/tools_test.go && gofmt -l . && echo \"gofmt clean\" && go build ./... && go test ./... 2>&1 | tail -12", "timeout": 300}
gofmt clean
?   	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)
Thinking
Everything's formatted, builds, and tests pass. Let me check what gofmt changed in client.go (it was probably the alignment of the `detail := &ReviewDetail{...}` block). Now, let me step back and review the whole implementation for correctness and edge cases: 1. **Webhook path**: `pull_request_review` with action `submitted` → `pr:review` event with ReviewID, ReviewState, ReviewBody, HeadRef, Author=reviewer. ✓ 2. **Polling path**: `pollPulls` → for each updated PR, `pollReviews` lists reviews, filters by `Submitted.After(since)` and non-zero Submitted, dispatches `pr:review` event (HeadRef is empty). ✓ 3. **Deduplication**: Both paths use `prReviewID(reviewID)` = `pr-review-{id}`. Webhook and polling converge to the same ID. ✓ 4. **Scheduler**: `pr:review` is a static event kind → resolved via `cfg.EventAgent`. Self-filter drops events where author == resolved agent. ✓ 5. **Run**: For `pr:review`: - headRef = ev.HeadRef or fetched via PullRequestInfo. - clonePRHead checks out the head branch. - Fetches ReviewDetail (verdict, body, inline comments). - seedMessage includes the review section and the PR branch wording. ✓ 6. **Tools**: `submit_review`, `resolve_review_comment`. ✓ Now, let me think about potential issues: **Issue 1: `clonePRHead` and the `refs/pull/N/head` ref.** The `refs/pull/N/head` ref is a Gitea/Forgejo convention. Let me double-check that Forgejo exposes it. Gitea exposes `refs/pull/{number}/head` and `refs/pull/{number}/merge`. Forgejo is a fork of Gitea, so it should be the same. This should work. But wait — there's a subtle issue. `git fetch origin refs/pull/N/head` — for this to work, the remote needs to allow fetching that ref. Gitea/Forgejo allows fetching `refs/pull/N/head` via the git protocol. Yes, this is a standard way to fetch PR heads in Gitea. Good. **Issue 2: `clonePRHead` clones the default branch first, then fetches the PR head.** This is 2 network operations. It's fine. But there's a subtle issue: `git clone --branch <default> --single-branch` only clones the default branch. Then `git fetch origin refs/pull/N/head` fetches the PR head into FETCH_HEAD. Then `git checkout -B headRef FETCH_HEAD` creates a local branch `headRef` at FETCH_HEAD. Good. But what if the PR head branch name (headRef) collides with the default branch name? E.g., if the PR is from `main` to `main`? That doesn't make sense. Or if headRef == defaultBranch. In that case, `checkout -B headRef FETCH_HEAD` would reset the default branch to the PR head. But that's a local branch, so it's fine. Actually, if headRef == defaultBranch, the clone already checked out defaultBranch, and `checkout -B defaultBranch FETCH_HEAD` would just reset it. That's an edge case that's unlikely to be a problem. **Issue 3: The agent pushes to the head branch.** The agent's `git push` uses `runGitAuthed` with the shared zoo token. It pushes to `origin headRef`. For this to work, the zoo token needs push access to that branch. In the agent workflow, the PR is opened by an agent (via `open_pull_request`), and the head branch is `issue-{index}-{author}` in the same repo. The zoo token has push access. Good. But wait — the agent's working branch is `headRef` (e.g., `issue-9-greg`). When the agent runs `git push`, what does it push? The `git` tool runs `git push` with the given args. If the agent runs `git push origin headRef` or just `git push`, git will push the current branch. Let me check the git tool to see how push works. Actually, the system prompt says "git add/git commit/git push your branch." The agent knows its working branch (from the seed message). So it would run `git push origin <headRef>`. The git tool passes that through to `runGitAuthed`. Good. But actually, there's a subtle issue: `git push` without args pushes the current branch to its upstream. After `checkout -B headRef FETCH_HEAD`, is there an upstream configured? `checkout -B headRef FETCH_HEAD` creates the branch but doesn't set upstream. So `git push` without args might fail with "no upstream branch." The agent would need to run `git push origin headRef`. The seed message tells the agent the working branch name, so it can push explicitly. But to be safe, maybe I should set the upstream. Hmm, let me reconsider. In the existing `cloneAndBranch`, after `checkout -b branch`, there's no upstream either. So the agent already has to push explicitly (`git push origin branch`). So this is consistent with existing behavior. The agent knows the branch name from the seed message. Good. I won't add upstream setup (to stay consistent with existing behavior). Actually, wait, let me double-check: does the existing flow set upstream? `cloneAndBranch` does `git clone --branch default --single-branch` (which sets origin/default as the current branch's upstream), then `git checkout -b branch` (new branch, no upstream). So the new branch has no upstream. The agent pushes explicitly. So my `clonePRHead` is consistent. Good. **Issue 4: `ReviewDetail` fetch in `Run` uses `r.forgejo` (the shared client), not the per-agent client.** Let me check: in `Run`, `r.forgejo` is the shared client. `ReviewDetail` is called on `r.forgejo`. That's fine — it's a read operation. The per-agent client (`r.forgejoAs`) is used for actions (comments, reviews, etc.) so they're attributed to the agent. Reading review details as the shared identity is fine. Good. Actually, wait, let me double-check. In `Run`: ```go review, err = r.forgejo.ReviewDetail(ev.Owner, ev.Repo, ev.Index, ev.ReviewID) ``` Yes, `r.forgejo` is the shared client. Good. **Issue 5: `PullRequestInfo` fetch in `Run` uses `r.forgejo`.** ```go prInfo, err := r.forgejo.PullRequestInfo(ev.Owner, ev.Repo, ev.Index) ``` Yes, shared client. Good. **Issue 6: The `review` variable is declared before the if/else, and used in seedMessage.** Let me double-check that the code compiles and the variable is in scope. Yes, I declared `var review *forgejo.ReviewDetail` before the if/else, and it's used in the seedMessage call later. Good. And the build passed. **Issue 7: What if `ev.Kind == pr:review` but `ev.ReviewID == 0`?** That shouldn't happen (both webhook and polling set ReviewID). But if it did, `ReviewDetail` would fetch review 0 and fail, degrading to nil. That's acceptable. **Issue 8: The `HeadRef` field on the event for `pr:new`.** I decided not to set HeadRef for `pr:new` (only for `pr:review`). Let me double-check that I didn't accidentally set it. In `pullRequestEvent` (pr:new), I didn't add HeadRef. Good. So `pr:new` runs still branch from default. Good. Wait, but should I set HeadRef for pr:new too? The issue is specifically about pr:review. Let me keep pr:new unchanged to be surgical. Good. **Issue 9: The `Event.HeadRef` field — is it used anywhere else?** Only in `Run` for pr:review. Good. **Issue 10: The seed message for pr:review — the `defaultBranch` parameter.** In the pr:review case, the branchLine doesn't use defaultBranch. That's fine, it's still passed but unused in that branch. Good. Now, let me reconsider the **workflow documentation**. The issue says "define the workflow." I should document the workflow clearly. The best places are: 1. A comment on the issue (explaining the workflow + what I implemented). 2. The PR description. 3. Possibly a section in the README or a doc file. Let me check if there's a README. Let me also reconsider: is there a README in the repo? The file listing didn't show a README. Let me check. Actually, the file listing at the top didn't show a README.md. Let me verify. Let me also reconsider the scope of "define the workflow." I think documenting it in the PR description + issue comment is sufficient. I could also add a short doc comment, but the code comments already explain the design. Let me write a clear workflow description in the issue comment and PR. Now, let me reconsider one more potential issue: **the `submit_review` tool and the self-trigger loop.** Scenario: greg is the PR author. leon is configured for pr:review. leon submits a review (via submit_review, or as a human). That triggers pr:review → greg reacts. greg fixes, pushes, resolves comments, comments. Now, greg's actions: - greg's `comment` on the PR → `issue_comment` webhook, but the PR comment is ignored (pr:comment is out of scope). Actually wait, does commenting on a PR fire `issue_comment`? Yes, but the webhook decoder ignores PR comments (`if p.Issue.PullRequest != nil { return Event{}, false, nil }`). And the polling path doesn't dispatch PR comments. So no trigger. Good. - greg's push to the PR head → `pull_request` `synchronize` action. The webhook decoder only handles `opened`. So no trigger. Good. - greg's `resolve_review_comment` → does that fire a webhook? Resolving a review comment might fire `pull_request_review_comment` with action... hmm. Actually, I don't think resolving fires a webhook in Gitea/Forgejo. And even if it did, I'm not handling `pull_request_review_comment`. So no trigger. Good. - greg's `submit_review` (if greg submits a review on its own PR) → `pull_request_review` `submitted` → pr:review event with author=greg. The resolved agent for pr:review is... in my example config, greg. So author (greg) == resolved (greg) → dropped. Good, no self-loop. But what if the pr:review agent is leon, and greg (the author) submits a review on its own PR? Then author=greg, resolved=leon → not dropped → leon runs and reacts to greg's review on greg's own PR. That's a bit odd but not a loop (leon would react, and leon's reaction might submit a review → author=leon, resolved=leon → dropped). So it terminates. Good. So there's no infinite loop. The self-filter + only handling `submitted` + not handling PR comments/syncs all combine to prevent loops. Now, let me reconsider: **does submitting a review update the PR's `Updated` timestamp?** Yes. So the polling path would see the PR as updated and check its reviews. But the review is already deduped by ID (if the webhook already dispatched it). If only polling is active, the review is dispatched once (deduped). Good. Now, let me also think about **the ordering of the review section in the seed message**. Let me re-examine the format string: ```go return fmt.Sprintf( "You were triggered by a %q event on %s/%s.\n\n"+ "%s%s"+ "%sTitle: %s\n\nBody:\n%s\n\n%sFull event payload:\n```json\n%s\n```", ev.Kind, ev.Owner, ev.Repo, instructionsSection, branchLine, reviewSection, ev.Title, ev.Body, commentsSection, raw) ``` So the order is: 1. "You were triggered by..." 2. instructionsSection (ends with "\n\n") 3. branchLine (ends with "\n\n") 4. reviewSection (ends with "\n" from renderReviewSection, which returns b.String() + "\n") 5. "Title: ..." Wait, reviewSection ends with "\n" (single newline), and then "Title:" follows immediately. So it would be: ``` Inline comments (2): 1. [src/foo.go:42] leon (comment id 7): This is wrong. Title: Fix the thing ``` That's not ideal — there's no blank line between the review section and "Title:". Let me fix renderReviewSection to end with "\n\n" (blank line). Actually, it returns `b.String() + "\n"`. b.String() already ends with "\n" (the last Fprintf). So b.String() + "\n" = ends with "\n\n". Wait, let me check. The last thing written to b in renderReviewSection is either the review body lines or the inline comment lines, each ending with "\n". So b.String() ends with "\n". Then `+ "\n"` makes it end with "\n\n". So reviewSection ends with a blank line. Then "Title:" starts on a new line after the blank line. Wait, let me re-trace. If there are inline comments, the last Fprintf is: ```go for _, line := range strings.Split(c.Body, "\n") { fmt.Fprintf(&b, " %s\n", line) } ``` So the last line is " And this.\n". So b.String() ends with "And this.\n". Then + "\n" = "And this.\n\n". So reviewSection = "...And this.\n\n". Then the format string appends "Title: ...". So: ``` And this. Title: Fix the thing ``` Good, there's a blank line. If there are no inline comments but there is a review body, the last Fprintf is the review body line " <line>\n". So b.String() ends with "\n", + "\n" = "\n\n". Good. If there's no review body and no inline comments (just the verdict), the last Fprintf is " Verdict: ...\n". So b.String() ends with "\n", + "\n" = "\n\n". Good. So reviewSection always ends with "\n\n". Now, the format string: `"%s%s" + "%sTitle: ..."`. So instructionsSection + branchLine + reviewSection + "Title:". Since reviewSection ends with "\n\n", "Title:" starts on a fresh line after a blank line. Good. But wait, what if reviewSection is empty (review is nil)? Then it's instructionsSection + branchLine + "" + "Title:". branchLine ends with "\n\n". So "Title:" starts after a blank line. Good. Let me verify with the test. The test `TestSeedMessageIncludesReview` checks that the review section is before "Full event payload:". And it checks for specific strings. Let me make sure the test passes (it did). Good. Now, let me reconsider: **the branchLine for pr:review** says "the pull request's head branch — commits you push here update the pull request directly." Good. Now, let me also double-check: **does the agent know how to push to the PR head branch?** The seed message says the working branch is the head branch. The system prompt says "git add/git commit/git push your branch." So the agent would `git push origin <headRef>`. Good. Now, let me reconsider the **`resolve_review_comment` tool description**. It says "The comment's id is in the review briefing (and the raw event payload)." Good, the briefing includes "comment id N". Good. Now, let me reconsider one more thing: **the `submit_review` tool's enum property.** I used `AddEnumProperty("state", ..., []string{"approved", "changes_requested", "comment"}, true)`. And in `submitReview`, I pass `params.State` directly to `ctx.Forgejo().SubmitReview(params.State, params.Body)`. And the client maps "approved"→APPROVED, "changes_requested"→REQUEST_CHANGES, "comment"→COMMENT. Good. And the tool validates that body is required unless approved. Good. But wait, there's a subtle issue: the enum values are "approved", "changes_requested", "comment". The client's `SubmitReview` switch handles exactly these. Good. And `ReviewStateApproved = "approved"`, etc. Good. Now, let me reconsider: **should the `submit_review` tool be available for all event kinds, or only PR events?** It's registered globally (in init()). So it's available for all runs. But it only makes sense for PR events (the triggering issue/PR must be a PR). If an agent calls `submit_review` on an issue (not a PR), the API call would fail (you can't review an issue). That's acceptable — the tool would return an error. The agent shouldn't call it on issues. The tool description says "the pull request that triggered this run," implying it's for PRs. Good enough. Similarly, `resolve_review_comment` only makes sense for PRs. Good. Now, let me reconsider whether I've broken the **`pr:new` flow**. For `pr:new`, `ev.Kind != pr:review`, so it goes to the else branch (cloneAndBranch from default). review is nil. seedMessage has no review section. branchLine is the default wording. Good, unchanged. Now, let me reconsider the **web dashboard**. The `Job` struct has `EventKind string`. For pr:review, EventKind = "pr:review". The dashboard displays it. No changes needed. Good. Now, let me reconsider the **`/event` file**. For pr:review, `ev.Raw` is the webhook payload (webhook path) or the marshaled review (polling path). Both are written to /event. The agent can read it. Good. Now, let me think about whether I should handle the **`pull_request_review_comment`** webhook event (standalone review comments). I decided not to, to avoid double-triggering with `pull_request_review`. But let me reconsider: in Gitea/Forgejo, when a review with inline comments is submitted, does it fire both `pull_request_review` (submitted) and `pull_request_review_comment` (created, per comment)? If it does, and I only handle `pull_request_review`, I'm fine (one trigger per review). If I also handled `pull_request_review_comment`, I'd get multiple triggers per review. So not handling it is correct. But what about standalone review comments (created via `POST /pulls/{index}/comments` without a review)? Those fire `pull_request_review_comment` (created) but not `pull_request_review`. So they'd be missed. That's a v1 limitation. I'll document it. Actually, let me reconsider. Is it common for someone to leave a standalone review comment (not as part of a review)? In Gitea, you can comment on a PR diff without submitting a review (it becomes a pending review). When you then submit the review, it fires `pull_request_review`. So the standalone comment is part of a (pending) review, and the submission triggers the event. So in practice, most review comments come with a review submission. The only case missed is a comment that's never submitted (stays pending). That's rare. I'll document it as a limitation. OK, I think the implementation is solid. Let me now also add a **README or doc** about the workflow. Actually, let me check if there's a README first. Let me also reconsider: the issue says "define the workflow and implement the Zoo side." I've implemented the Zoo side. For "define the workflow," I'll write it up clearly in the issue comment and PR description. I could also add a short section to a doc file. Let me check if there's a README or docs directory. Let me list the root again and check for README/docs.
Everything is green and formatted. Let me check for a README or docs to see where workflow documentation should live:
Tool 🔧 bash {"command": "cd /project && ls -la && echo \"--- any md files ---\" && find . -name \"*.md\" -not -path \"./.git/*\" 2>/dev/null"}
{"command": "cd /project && ls -la && echo \"--- any md files ---\" && find . -name \"*.md\" -not -path \"./.git/*\" 2>/dev/null"}
total 52
drwxrwxr-x  5 1000 1000  4096 Aug 23 22:31 .
drwxr-xr-x  1 root root  4096 Aug 23 22:31 ..
drwxrwxr-x  8 1000 1000  4096 Aug 23 23:10 .git
-rw-rw-r--  1 1000 1000    22 Aug 23 22:31 .gitignore
drwxrwxr-x  3 1000 1000  4096 Aug 23 22:31 cmd
-rw-rw-r--  1 1000 1000  2628 Aug 23 22:31 go.mod
-rw-rw-r--  1 1000 1000 16578 Aug 23 22:31 go.sum
drwxrwxr-x 11 1000 1000  4096 Aug 23 22:31 internal
-rw-rw-r--  1 1000 1000  2117 Aug 23 22:52 zoo.hcl.example
--- any md files ---
./internal/agentrun/system.md
Thinking
There's no README. The only Markdown file is `system.md` (the agent's system prompt). Let me reconsider the system prompt (`system.md`). It says the following: - "Your working tree is at `/project`, checked out on a new branch from the repository's default branch." But in the case of `pr:review`, the working tree is checked out on the PR's head branch, not on a new branch from the default branch. The seed message overrides this with a specific branch line, but the system prompt's general statement could be misleading. Let me update the system prompt to be accurate for both cases. Actually, the seed message (user message) has the specific branch line, and that's what the agent sees for this run. The system prompt is generic. But to avoid contradiction, let me soften the system prompt's wording so it doesn't assert "a new branch from the default branch" as an absolute. Let me update the system prompt's bullet points: - "Your working tree is at `/project`, checked out on a new branch from the repository's default branch." → "Your working tree is at `/project`. Which branch it's on depends on the event that triggered you (the briefing below says) — usually a new branch from the default branch, but for PR review events it's the PR's own head branch." Hmm, that's a bit verbose. Let me make it concise and accurate: - "Your working tree is at `/project`, checked out on the branch described in the briefing below (usually a new branch from the default branch; for PR review events, the PR's own head branch)." Actually, let me reconsider. The system prompt is a static default. The seed message (user) provides the specific branch info. The two shouldn't contradict. Let me make the system prompt's wording generic enough to cover both. Let me update it like this: ``` - Your working tree is at `/project`, checked out on the working branch named in your briefing below. For most events that's a fresh branch off the repository's default branch; for a PR review event it's the pull request's own head branch, so pushes update the PR directly. ``` That's accurate. Let me make this edit. Also, the "Tools" section of the system prompt lists the Forgejo tools: ``` - `comment`, `open_pull_request`, `request_review`, `add_label`, `remove_label`, `close_issue`, `reopen_issue`, `assign_issue` act directly on the Forgejo issue/PR that triggered this run — use `comment` to report back to the person who filed it, and `open_pull_request` once you've pushed a branch with your changes. ``` I should add the new tools `submit_review` and `resolve_review_comment` to this list. Let me update it. Let me update system.md.
No README exists. The `system.md` (agent system prompt) makes two claims that are now inaccurate for `pr:review` runs — the working-branch description and the tool list. Let me update it to stay consistent:
Tool 🔧 edit_file {"path": "internal/agentrun/system.md", "find": "- Your working tree is at `/project`, checked out on a fresh branch off\n the repository's default branch. Only `/project` persists; nothing\n outside it survives between tool calls.", "replace": "- Your working tree is at `/project`, checked out on the working branch\n named in your briefing below. For most events that's a fresh branch\n off the repository's default branch; for a PR review event it's the\n pull request's own head branch, so commits you push update the PR\n directly. Only `/project` persists; nothing outside it survives\n between tool calls."}
{"path": "internal/agentrun/system.md", "find": "- Your working tree is at `/project`, checked out on a fresh branch off\n  the repository's default branch. Only `/project` persists; nothing\n  outside it survives between tool calls.", "replace": "- Your working tree is at `/project`, checked out on the working branch\n  named in your briefing below. For most events that's a fresh branch\n  off the repository's default branch; for a PR review event it's the\n  pull request's own head branch, so commits you push update the PR\n  directly. Only `/project` persists; nothing outside it survives\n  between tool calls."}
1 occurrences replaced
Thinking
Next, I'll update the tool list in system.md to include the two new tools.
Tool 🔧 edit_file {"path": "internal/agentrun/system.md", "find": "- `comment`, `open_pull_request`, `request_review`, `add_label`,\n `remove_label`, `close_issue`, `reopen_issue`, `assign_issue` act\n directly on the Forgejo issue/PR that triggered this run — use\n `comment` to report back to the person who filed it, and\n `open_pull_request` once you've pushed a branch with your changes.", "replace": "- `comment`, `open_pull_request`, `request_review`, `submit_review`,\n `resolve_review_comment`, `add_label`, `remove_label`, `close_issue`,\n `reopen_issue`, `assign_issue` act directly on the Forgejo issue/PR\n that triggered this run — use `comment` to report back to the person\n who filed it, `open_pull_request` once you've pushed a branch with\n your changes, `submit_review` to leave a review verdict, and\n `resolve_review_comment` to mark addressed inline review comments as\n resolved."}
{"path": "internal/agentrun/system.md", "find": "- `comment`, `open_pull_request`, `request_review`, `add_label`,\n  `remove_label`, `close_issue`, `reopen_issue`, `assign_issue` act\n  directly on the Forgejo issue/PR that triggered this run — use\n  `comment` to report back to the person who filed it, and\n  `open_pull_request` once you've pushed a branch with your changes.", "replace": "- `comment`, `open_pull_request`, `request_review`, `submit_review`,\n  `resolve_review_comment`, `add_label`, `remove_label`, `close_issue`,\n  `reopen_issue`, `assign_issue` act directly on the Forgejo issue/PR\n  that triggered this run — use `comment` to report back to the person\n  who filed it, `open_pull_request` once you've pushed a branch with\n  your changes, `submit_review` to leave a review verdict, and\n  `resolve_review_comment` to mark addressed inline review comments as\n  resolved."}
1 occurrences replaced
Thinking
Alright, let's rebuild and rerun the tests to make sure everything still passes. Also, let me reconsider the docker integration tests — `docker_integration_test.go`. Let me check whether it references `cloneAndBranch` or the seed message in a way that would be affected.
Let me check the docker integration test isn't affected, then rebuild and re-test:
Tool 🔧 bash {"command": "cd /project && grep -n \"cloneAndBranch\\|seedMessage\\|clonePRHead\\|HeadRef\\|ReviewDetail\\|EventPRReview\" internal/agentrun/docker_integration_test.go internal/agentrun/docker.go 2>/dev/null; echo \"=== build+test ===\"; go build ./... && go test ./... 2>&1 | tail -12", "timeout": 300}
{"command": "cd /project && grep -n \"cloneAndBranch\\|seedMessage\\|clonePRHead\\|HeadRef\\|ReviewDetail\\|EventPRReview\" internal/agentrun/docker_integration_test.go internal/agentrun/docker.go 2>/dev/null; echo \"=== build+test ===\"; go build ./... && go test ./... 2>&1 | tail -12", "timeout": 300}
=== build+test ===
?   	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)
Thinking
Everything's passing. Next, to verify the changes are correct, let me review the entire diff. I'll take a look at the git diff. Actually, let me reconsider one more potential issue regarding `clonePRHead`. When cloning the default branch with `--single-branch` and then running `git fetch origin refs/pull/N/head`, the fetch needs authentication. I'm using `gitAuthArgs(token)` for the fetch. Good. But there's a subtle point: `git fetch origin refs/pull/N/head` — the `refs/pull/N/head` ref. Let me confirm that this works with the `--single-branch` clone. A `--single-branch` clone sets `remote.origin.fetch` to only fetch the default branch. But an explicit `git fetch origin refs/pull/N/head` overrides that and fetches that specific ref. So it should work. Good. Next, let me verify the entire diff to make sure everything is consistent. I'll run git diff.
All green. Let me review the complete diff to verify coherence:
Tool 🔧 bash {"command": "cd /project && git add -A && git status --short && echo \"=== DIFF STAT ===\" && git diff --cached --stat"}
{"command": "cd /project && git add -A && git status --short && echo \"=== DIFF STAT ===\" && git diff --cached --stat"}
M  internal/agentrun/clone.go
M  internal/agentrun/forgejo.go
M  internal/agentrun/run.go
M  internal/agentrun/seed_test.go
M  internal/agentrun/system.md
M  internal/config/config.go
M  internal/config/config_test.go
M  internal/forgejo/client.go
M  internal/forgejo/client_test.go
M  internal/forgejo/event.go
M  internal/forgejo/poll.go
A  internal/forgejo/poll_test.go
M  internal/forgejo/webhook.go
M  internal/forgejo/webhook_test.go
M  internal/scheduler/scheduler_test.go
M  internal/tools/context.go
M  internal/tools/forgejo.go
M  internal/tools/tools_test.go
M  zoo.hcl.example
=== DIFF STAT ===
 internal/agentrun/clone.go           |  23 +++++
 internal/agentrun/forgejo.go         |  14 +++
 internal/agentrun/run.go             | 116 +++++++++++++++++++++--
 internal/agentrun/seed_test.go       |  69 +++++++++++++-
 internal/agentrun/system.md          |  22 +++--
 internal/config/config.go            |   2 +
 internal/config/config_test.go       |  25 +++++
 internal/forgejo/client.go           | 159 +++++++++++++++++++++++++++++++
 internal/forgejo/client_test.go      | 178 +++++++++++++++++++++++++++++++++++
 internal/forgejo/event.go            |  15 +++
 internal/forgejo/poll.go             |  52 +++++++++-
 internal/forgejo/poll_test.go        | 156 ++++++++++++++++++++++++++++++
 internal/forgejo/webhook.go          |  55 +++++++++++
 internal/forgejo/webhook_test.go     |  80 ++++++++++++++++
 internal/scheduler/scheduler_test.go |  70 +++++++++++++-
 internal/tools/context.go            |   9 ++
 internal/tools/forgejo.go            |  51 ++++++++++
 internal/tools/tools_test.go         |  60 +++++++++++-
 zoo.hcl.example                      |  10 ++
 19 files changed, 1142 insertions(+), 24 deletions(-)
Thinking
Let's check the diff of the core run.go to make sure the logic is correct.
Tool 🔧 bash {"command": "cd /project && git diff --cached internal/agentrun/run.go"}
{"command": "cd /project && git diff --cached internal/agentrun/run.go"}
diff --git a/internal/agentrun/run.go b/internal/agentrun/run.go
index 9555f56..f9f8178 100644
--- a/internal/agentrun/run.go
+++ b/internal/agentrun/run.go
@@ -124,11 +124,49 @@ func (r *Runner) Run(ctx context.Context, jobID string, agent config.AgentConfig
 		}
 	}()
 
-	branch := fmt.Sprintf("issue-%d-%s", ev.Index, agent.Name)
 	projectDir := filepath.Join(workDir, "project")
 
-	if err := cloneAndBranch(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, branch, projectDir); err != nil {
-		return fmt.Errorf("prepare git working tree: %w", err)
+	// A pr:review run works on the PR's own head branch, so the agent's
+	// commits push straight to the PR. Every other event kind branches
+	// off the default branch as usual.
+	var review *forgejo.ReviewDetail
+	branch := fmt.Sprintf("issue-%d-%s", ev.Index, agent.Name)
+
+	if ev.Kind == forgejo.EventPRReview {
+		headRef := ev.HeadRef
+		if headRef == "" {
+			// The polling path doesn't carry the head ref; fetch it.
+			prInfo, err := r.forgejo.PullRequestInfo(ev.Owner, ev.Repo, ev.Index)
+			if err != nil {
+				return fmt.Errorf("look up pull request head: %w", err)
+			}
+			headRef = prInfo.HeadRef
+		}
+
+		if headRef == "" {
+			return fmt.Errorf("pr:review event has no pull request head branch to check out")
+		}
+
+		if err := clonePRHead(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, headRef, ev.Index, projectDir); err != nil {
+			return fmt.Errorf("prepare git working tree: %w", err)
+		}
+
+		branch = headRef
+
+		// Fetch the full review (verdict, body, inline comments) so the
+		// agent sees all the feedback, not just the triggering event. A
+		// failure degrades to no review detail rather than failing the
+		// run: the agent can still do its job, just without the inline
+		// comments.
+		review, err = r.forgejo.ReviewDetail(ev.Owner, ev.Repo, ev.Index, ev.ReviewID)
+		if err != nil {
+			logger.Warn("fetch review detail failed; agent will not see inline review comments", "error", err)
+			review = nil
+		}
+	} else {
+		if err := cloneAndBranch(ctx, repoInfo.CloneURL, r.forgejo.Token(), repoInfo.DefaultBranch, branch, projectDir); err != nil {
+			return fmt.Errorf("prepare git working tree: %w", err)
+		}
 	}
 
 	roster := buildRoster(r.forgejo, r.cfg.Agents, logger)
@@ -221,7 +259,7 @@ func (r *Runner) Run(ctx context.Context, jobID string, agent config.AgentConfig
 
 	messages := []llm.Message{
 		{Role: "system", Content: systemPrompt},
-		{Role: "user", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions, comments)},
+		{Role: "user", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions, comments, review)},
 	}
 
 	hooks := r.streamHooks(jobID, logAppend)
@@ -305,7 +343,7 @@ func (r *Runner) streamHooks(jobID string, logAppend func(stream, line string))
 	}
 }
 
-func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, comments []forgejo.IssueComment) string {
+func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, comments []forgejo.IssueComment, review *forgejo.ReviewDetail) string {
 	raw, _ := json.MarshalIndent(json.RawMessage(ev.Raw), "", "  ")
 
 	var instructionsSection string
@@ -313,6 +351,18 @@ func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, c
 		instructionsSection = fmt.Sprintf("Instructions for this event, from zoo.hcl:\n%s\n\n", instructions)
 	}
 
+	// A pr:review run works on the PR's own head branch, not a fresh
+	// branch off the default branch.
+	branchLine := fmt.Sprintf("Your working branch is %q, checked out from the default branch %q.\n\n", branch, defaultBranch)
+	if ev.Kind == forgejo.EventPRReview {
+		branchLine = fmt.Sprintf("Your working branch is %q, the pull request's head branch — commits you push here update the pull request directly.\n\n", branch)
+	}
+
+	var reviewSection string
+	if review != nil {
+		reviewSection = renderReviewSection(review)
+	}
+
 	var commentsSection string
 	if len(comments) > 0 {
 		var b strings.Builder
@@ -327,7 +377,57 @@ func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, c
 
 	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\n%sFull event payload:\n```json\n%s\n```",
-		ev.Kind, ev.Owner, ev.Repo, instructionsSection, branch, defaultBranch, ev.Title, ev.Body, commentsSection, raw)
+			"%s%s"+
+			"%sTitle: %s\n\nBody:\n%s\n\n%sFull event payload:\n```json\n%s\n```",
+		ev.Kind, ev.Owner, ev.Repo, instructionsSection, branchLine, reviewSection, ev.Title, ev.Body, commentsSection, raw)
+}
+
+// renderReviewSection renders the submitted review as a briefing
+// section: the verdict, the review body, and each inline comment with
+// its location and id (the id is what the agent passes to
+// resolve_review_comment once it has addressed the feedback).
+func renderReviewSection(review *forgejo.ReviewDetail) string {
+	var b strings.Builder
+
+	fmt.Fprintf(&b, "Review submitted by %s:\n", review.Reviewer)
+	fmt.Fprintf(&b, "  Verdict: %s\n", humanReviewState(review.State))
+
+	if review.Body != "" {
+		b.WriteString("  Review body:\n")
+		for _, line := range strings.Split(review.Body, "\n") {
+			fmt.Fprintf(&b, "  %s\n", line)
+		}
+	}
+
+	if len(review.Comments) > 0 {
+		fmt.Fprintf(&b, "  Inline comments (%d):\n", len(review.Comments))
+
+		for i, c := range review.Comments {
+			where := c.Path
+			if c.Line > 0 {
+				where = fmt.Sprintf("%s:%d", c.Path, c.Line)
+			}
+
+			fmt.Fprintf(&b, "  %d. [%s] %s (comment id %d):\n", i+1, where, c.Author, c.ID)
+			for _, line := range strings.Split(c.Body, "\n") {
+				fmt.Fprintf(&b, "     %s\n", line)
+			}
+		}
+	}
+
+	return b.String() + "\n"
+}
+
+// humanReviewState maps a Forgejo review state to a readable verdict.
+func humanReviewState(state string) string {
+	switch state {
+	case "APPROVED":
+		return "approved"
+	case "REQUEST_CHANGES":
+		return "changes requested"
+	case "COMMENT":
+		return "comment"
+	default:
+		return state
+	}
 }
Thinking
The diff for run.go looks good. Next, let me re-verify the diff for client.go and confirm that the gofmt changes are fine. Actually, there's one thing I want to reconsider. In `Run`, when `ev.Kind == forgejo.EventPRReview`, it's fetching `ReviewDetail` via `r.forgejo` (the shared client). But `r.forgejo` is `*forgejo.Client`. Let me verify that `ReviewDetail` and `PullRequestInfo` are methods on `*forgejo.Client`. Yes, they are. Good. Now, let me reconsider one more edge case: what happens if a PR is merged or closed and then a review is submitted? In that case, `clonePRHead` would still work (the PR head ref still exists). The agent would check out the head branch. But pushing to a merged PR's head branch is pointless. That's an edge case, and the agent can handle it (it'll see that the PR is merged/closed from the briefing/raw payload). Not a concern for v1. Next, let me reconsider the **`ReviewDetail` fetch when `ev.ReviewID` is 0**. If for some reason ReviewID is 0 (shouldn't happen), `GetPullReview(owner, repo, index, 0)` would fail (review 0 doesn't exist), and it would degrade to nil. No problem. Now, let me reconsider whether we should also set `HeadRef` in `reviewToEvent` (the polling path). Currently, it's not set (the issue list doesn't have the head ref). So on the polling path, `Run` fetches it via `PullRequestInfo`. That's one extra API call, but only for pr:review runs triggered via polling. Acceptable. Actually, wait — could we avoid the extra call by fetching the PR info in `pollReviews` and setting HeadRef? That would be an extra API call per updated PR (not just per new review). Since we're already calling `ListPullReviews` per updated PR, adding `GetPullRequest` per updated PR would double the API calls for all PRs, not just those with new reviews. So it's better to fetch only in `Run` when needed (only for pr:review runs). My current approach is optimal. Good. Now, let me reconsider the **test for the polling path's HeadRef**. My poll test doesn't set HeadRef (it's empty). And `Run` fetches it. But I'm not testing `Run` itself (it requires Docker). So the HeadRef-fetching path in `Run` isn't unit-tested. That's fine — it's a simple API call, and `PullRequestInfo` is tested separately. Next, let me verify that the whole thing compiles and the tests pass, then commit. Actually, let me reconsider one more thing: **the `Event.HeadRef` field and its use in the webhook path**. In `pullRequestReviewEvent`, I set `HeadRef` from `p.PullRequest.Head.Ref`. Good. And in `Run`, if `ev.HeadRef != ""`, I use it directly (no API call). Good. But wait — there's a subtle issue. What if the webhook payload's `HeadRef` is stale (the PR's head branch was renamed after the webhook was sent)? In that case, `clonePRHead` with the stale headRef would... let me think. `clonePRHead` clones the default branch, then fetches `refs/pull/N/head` (the current PR head, not the stale branch name), then runs `checkout -B <staleHeadRef> FETCH_HEAD`. So even if headRef is stale, the fetch gets the current PR head, and the local branch is named with the (stale) headRef. Then when the agent pushes to `<staleHeadRef>`, it would push to a branch with the stale name, not the actual PR head branch! That would create a new branch, not update the PR. Hmm, that's a problem. But how likely is it that the head branch is renamed between the webhook and the run? Very rare. And if it happens, the agent would push to the wrong branch. To be safe, maybe I should always fetch the current head ref in `Run` (ignoring `ev.HeadRef`). That way, even if the webhook's HeadRef is stale, we get the current one. The cost is one extra API call per pr:review run (even on the webhook path). Given that reliability matters more than one API call, let me always fetch. Wait, but actually, let me reconsider. `clonePRHead` fetches `refs/pull/N/head` regardless of headRef. So the commit being checked out is always the current PR head. The only thing headRef affects is the name of the local branch (and therefore the push target). If headRef is stale, the push goes to the wrong branch name. So the fix is: always fetch the current head ref in `Run` for pr:review. Let me change it to always call `PullRequestInfo` (not just when `ev.HeadRef == ""`). That guarantees the push target is the current PR head branch. Actually, let me reconsider. Is it worth it? The webhook payload's head ref is the PR head at the time of the review. If the author renames the branch after that, the PR head changes. But the review is on the old commit. Hmm. This is getting into the weeds. Let me take the robust approach: always fetch the current head ref in `Run` for pr:review. That's the most correct behavior (push to the PR's current head branch). The `ev.HeadRef` from the webhook is then just informational (and used as a fallback if the fetch fails? no, if the fetch fails, the run fails). Wait, but if I always fetch, then `ev.HeadRef` isn't used at all in `Run`. Should I remove it from the event? No — it's still useful as data (and the test asserts it). But for the working tree, I'll use the fetched one. Hmm, let me reconsider. Actually, there's a simpler and more robust approach: in `Run`, for pr:review, always fetch `PullRequestInfo` to get the current head ref, and use that. Let me change the code: ```go if ev.Kind == forgejo.EventPRReview { // Always fetch the current head ref: the webhook's copy could be // stale if the PR's head branch was renamed since the review. prInfo, err := r.forgejo.PullRequestInfo(ev.Owner, ev.Repo, ev.Index) if err != nil { return fmt.Errorf("look up pull request head: %w", err) } headRef := prInfo.HeadRef if headRef == "" { // Fall back to the event's head ref (webhook path) if the API // didn't return one. headRef = ev.HeadRef } if headRef == "" { return fmt.Errorf("pr:review event has no pull request head branch to check out") } ... } ``` This is more robust. Let me make this change. Actually, wait. Let me reconsider whether this extra API call is acceptable. Every pr:review run makes one extra `GetPullRequest` call. That's fine — we're already making several API calls per run. And correctness (pushing to the right branch) is more important. Let me make this change. Let me update the code in run.go.
The run.go diff looks good. One robustness improvement: the webhook's `HeadRef` could be stale if the PR's head branch was renamed after the review was submitted — and the push target depends on it. Let me always fetch the current head ref, using the event's as a fallback:
Tool 🔧 edit_file {"path": "internal/agentrun/run.go", "find": "\tif ev.Kind == forgejo.EventPRReview {\n\t\theadRef := ev.HeadRef\n\t\tif headRef == \"\" {\n\t\t\t// The polling path doesn't carry the head ref; fetch it.\n\t\t\tprInfo, err := r.forgejo.PullRequestInfo(ev.Owner, ev.Repo, ev.Index)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"look up pull request head: %w\", err)\n\t\t\t}\n\t\t\theadRef = prInfo.HeadRef\n\t\t}\n\n\t\tif headRef == \"\" {\n\t\t\treturn fmt.Errorf(\"pr:review event has no pull request head branch to check out\")\n\t\t}", "replace": "\tif ev.Kind == forgejo.EventPRReview {\n\t\t// Always fetch the current head ref, not just when the event\n\t\t// lacks one (the polling path doesn't carry it): the webhook's\n\t\t// copy could be stale if the PR's head branch was renamed since\n\t\t// the review, and the push target depends on it.\n\t\theadRef := ev.HeadRef\n\n\t\tif prInfo, err := r.forgejo.PullRequestInfo(ev.Owner, ev.Repo, ev.Index); err != nil {\n\t\t\tlogger.Warn(\"fetch pull request head failed; falling back to the event's head ref\", \"error\", err)\n\t\t} else if prInfo.HeadRef != \"\" {\n\t\t\theadRef = prInfo.HeadRef\n\t\t}\n\n\t\tif headRef == \"\" {\n\t\t\treturn fmt.Errorf(\"pr:review event has no pull request head branch to check out\")\n\t\t}"}
{"path": "internal/agentrun/run.go", "find": "\tif ev.Kind == forgejo.EventPRReview {\n\t\theadRef := ev.HeadRef\n\t\tif headRef == \"\" {\n\t\t\t// The polling path doesn't carry the head ref; fetch it.\n\t\t\tprInfo, err := r.forgejo.PullRequestInfo(ev.Owner, ev.Repo, ev.Index)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"look up pull request head: %w\", err)\n\t\t\t}\n\t\t\theadRef = prInfo.HeadRef\n\t\t}\n\n\t\tif headRef == \"\" {\n\t\t\treturn fmt.Errorf(\"pr:review event has no pull request head branch to check out\")\n\t\t}", "replace": "\tif ev.Kind == forgejo.EventPRReview {\n\t\t// Always fetch the current head ref, not just when the event\n\t\t// lacks one (the polling path doesn't carry it): the webhook's\n\t\t// copy could be stale if the PR's head branch was renamed since\n\t\t// the review, and the push target depends on it.\n\t\theadRef := ev.HeadRef\n\n\t\tif prInfo, err := r.forgejo.PullRequestInfo(ev.Owner, ev.Repo, ev.Index); err != nil {\n\t\t\tlogger.Warn(\"fetch pull request head failed; falling back to the event's head ref\", \"error\", err)\n\t\t} else if prInfo.HeadRef != \"\" {\n\t\t\theadRef = prInfo.HeadRef\n\t\t}\n\n\t\tif headRef == \"\" {\n\t\t\treturn fmt.Errorf(\"pr:review event has no pull request head branch to check out\")\n\t\t}"}
1 occurrences replaced
Thinking
Alright, let's rebuild and rerun the tests.
Tool 🔧 bash {"command": "cd /project && go build ./... && go test ./... 2>&1 | tail -12", "timeout": 300}
{"command": "cd /project && go build ./... && go test ./... 2>&1 | tail -12", "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	(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)
Thinking
Everything's passing. Next, to make sure the code is clean, let's do a final review of the complete diff. Let me check the diffs for client.go and the other files. Let me check the diff for client.go.
Let me review the client.go diff and the remaining core diffs:
Tool 🔧 bash {"command": "cd /project && git diff --cached internal/forgejo/client.go internal/forgejo/event.go internal/forgejo/webhook.go internal/forgejo/poll.go"}
{"command": "cd /project && git diff --cached internal/forgejo/client.go internal/forgejo/event.go internal/forgejo/webhook.go internal/forgejo/poll.go"}
diff --git a/internal/forgejo/client.go b/internal/forgejo/client.go
index cc97bfe..6fb5366 100644
--- a/internal/forgejo/client.go
+++ b/internal/forgejo/client.go
@@ -4,6 +4,9 @@ package forgejo
 
 import (
 	"fmt"
+	"io"
+	"net/http"
+	"strings"
 	"time"
 
 	sdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
@@ -208,6 +211,162 @@ func (c *Client) RequestReview(owner, repo string, index int64, reviewers []stri
 	return nil
 }
 
+// Review states an agent can submit, in the friendly names the tools
+// expose. SubmitReview maps them onto the SDK's ReviewStateType.
+const (
+	ReviewStateApproved       = "approved"
+	ReviewStateChangesRequest = "changes_requested"
+	ReviewStateComment        = "comment"
+)
+
+// SubmitReview submits a review on the pull request with the given
+// verdict and body. state is one of ReviewStateApproved,
+// ReviewStateChangesRequest, or ReviewStateComment. A body is required
+// for anything other than an approval (Forgejo enforces this too).
+func (c *Client) SubmitReview(owner, repo string, index int64, state, body string) error {
+	var sdkState sdk.ReviewStateType
+
+	switch state {
+	case ReviewStateApproved:
+		sdkState = sdk.ReviewStateApproved
+	case ReviewStateChangesRequest:
+		sdkState = sdk.ReviewStateRequestChanges
+	case ReviewStateComment:
+		sdkState = sdk.ReviewStateComment
+	default:
+		return fmt.Errorf("submit review on %s/%s#%d: unknown review state %q", owner, repo, index, state)
+	}
+
+	if _, _, err := c.sdk.CreatePullReview(owner, repo, index, sdk.CreatePullReviewOptions{State: sdkState, Body: body}); err != nil {
+		return fmt.Errorf("submit review on %s/%s#%d: %w", owner, repo, index, err)
+	}
+
+	return nil
+}
+
+// ReviewCommentDetail is one inline comment on a pull request review,
+// in the shape zoo needs when briefing an agent: where it points, what
+// it says, and its id (so the agent can resolve it once addressed).
+type ReviewCommentDetail struct {
+	ID     int64
+	Path   string
+	Line   int
+	Body   string
+	Author string
+}
+
+// ReviewDetail is the review context zoo briefs an agent with when a
+// pr:review event fires: the review's verdict and body, plus its inline
+// comments.
+type ReviewDetail struct {
+	ID       int64
+	State    string
+	Body     string
+	Reviewer string
+	Comments []ReviewCommentDetail
+}
+
+// ReviewDetail fetches a pull request review and its inline comments.
+// The webhook payload carries the review but not its inline comments,
+// so this is how a reacting agent gets the full feedback.
+func (c *Client) ReviewDetail(owner, repo string, index, reviewID int64) (*ReviewDetail, error) {
+	review, _, err := c.sdk.GetPullReview(owner, repo, index, reviewID)
+	if err != nil {
+		return nil, fmt.Errorf("get review %d on %s/%s#%d: %w", reviewID, owner, repo, index, err)
+	}
+
+	comments, _, err := c.sdk.ListPullReviewComments(owner, repo, index, reviewID)
+	if err != nil {
+		return nil, fmt.Errorf("list comments on review %d of %s/%s#%d: %w", reviewID, owner, repo, index, err)
+	}
+
+	detail := &ReviewDetail{
+		ID:    review.ID,
+		State: string(review.State),
+		Body:  review.Body,
+	}
+
+	if review.Reviewer != nil {
+		detail.Reviewer = review.Reviewer.UserName
+	}
+
+	for _, cm := range comments {
+		author := ""
+		if cm.Reviewer != nil {
+			author = cm.Reviewer.UserName
+		}
+
+		line := int(cm.LineNum)
+		if cm.OldLineNum != 0 && cm.LineNum == 0 {
+			line = int(cm.OldLineNum)
+		}
+
+		detail.Comments = append(detail.Comments, ReviewCommentDetail{
+			ID:     cm.ID,
+			Path:   cm.Path,
+			Line:   line,
+			Body:   cm.Body,
+			Author: author,
+		})
+	}
+
+	return detail, nil
+}
+
+// ResolveReviewComment marks the inline review comment with the given id
+// as resolved, i.e. the feedback it contains has been addressed. The SDK
+// doesn't expose this endpoint, so it's a direct API call.
+func (c *Client) ResolveReviewComment(owner, repo string, index, commentID int64) error {
+	path := fmt.Sprintf("/repos/%s/%s/pulls/%d/review_comments/%d/resolve", owner, repo, index, commentID)
+
+	req, err := http.NewRequest(http.MethodPost, c.baseURL+"/api/v1"+path, nil)
+	if err != nil {
+		return fmt.Errorf("resolve review comment %d: %w", commentID, err)
+	}
+
+	req.Header.Set("Authorization", "token "+c.token)
+	req.Header.Set("Content-Type", "application/json")
+
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return fmt.Errorf("resolve review comment %d: %w", commentID, err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode/100 != 2 {
+		body, _ := io.ReadAll(resp.Body)
+
+		return fmt.Errorf("resolve review comment %d: %s: %s", commentID, resp.Status, strings.TrimSpace(string(body)))
+	}
+
+	return nil
+}
+
+// PullRequestInfo is the branch metadata agentrun needs to check out a
+// pull request's head.
+type PullRequestInfo struct {
+	HeadRef string
+	BaseRef string
+}
+
+// PullRequestInfo returns the pull request's head and base branch refs.
+func (c *Client) PullRequestInfo(owner, repo string, index int64) (PullRequestInfo, error) {
+	pr, _, err := c.sdk.GetPullRequest(owner, repo, index)
+	if err != nil {
+		return PullRequestInfo{}, fmt.Errorf("get pull request %s/%s#%d: %w", owner, repo, index, err)
+	}
+
+	info := PullRequestInfo{}
+	if pr.Head != nil {
+		info.HeadRef = pr.Head.Ref
+	}
+	if pr.Base != nil {
+		info.BaseRef = pr.Base.Ref
+	}
+
+	return info, nil
+}
+
 // CloseIssue closes the given issue or pull request.
 func (c *Client) CloseIssue(owner, repo string, index int64) error {
 	return c.setIssueState(owner, repo, index, sdk.StateClosed)
diff --git a/internal/forgejo/event.go b/internal/forgejo/event.go
index d456c7b..bf8696f 100644
--- a/internal/forgejo/event.go
+++ b/internal/forgejo/event.go
@@ -10,6 +10,7 @@ const (
 	EventIssueComment  = "issue:comment"
 	EventIssueAssigned = "issue:assigned"
 	EventPRNew         = "pr:new"
+	EventPRReview      = "pr:review"
 )
 
 // Event is the normalized shape produced by both the webhook receiver and
@@ -34,6 +35,20 @@ type Event struct {
 	// Comment is set for EventIssueComment.
 	Comment string
 
+	// Review fields, set for EventPRReview: the submitted review's id,
+	// verdict, and body. Inline review comments are not part of the
+	// webhook payload, so they're fetched separately when briefing the
+	// agent (see Client.ReviewDetail).
+	ReviewID    int64
+	ReviewState string
+	ReviewBody  string
+
+	// HeadRef is set for PR events when the source payload carries the
+	// PR's head branch: the branch the PR's changes live on. A run
+	// reacting to the PR checks it out so its pushes update the PR
+	// directly.
+	HeadRef string
+
 	// Raw is the full source payload (webhook body, or a synthesized
 	// equivalent when polling), written to /event in the agent container.
 	Raw json.RawMessage
diff --git a/internal/forgejo/poll.go b/internal/forgejo/poll.go
index 3bf05fc..b231a63 100644
--- a/internal/forgejo/poll.go
+++ b/internal/forgejo/poll.go
@@ -145,14 +145,45 @@ func (w *Watcher) pollPulls(ctx context.Context) error {
 			next = issue.Updated
 		}
 
+		owner, repo := issue.Repository.Owner, issue.Repository.Name
+
 		if issue.Created.After(since) {
-			w.dispatch(issueToPRNewEvent(issue, issue.Repository.Owner, issue.Repository.Name))
+			w.dispatch(issueToPRNewEvent(issue, owner, repo))
 		}
+
+		w.pollReviews(ctx, owner, repo, issue, since)
 	}
 
 	return w.store.SetWatermark(ctx, watermarkPulls, next.Format(time.RFC3339))
 }
 
+// pollReviews dispatches a pr:review event for each review submitted on
+// the PR since the last poll. The reviews listing has no server-side
+// "since" filter, so every review on an updated PR is fetched and
+// filtered client-side by submitted time. Submitting a review bumps the
+// PR's updated timestamp, so the PR is listed in the same poll that sees
+// the review and the watermark can't run past it; dedup by review id
+// keeps a review from being dispatched twice.
+func (w *Watcher) pollReviews(ctx context.Context, owner, repo string, pr *sdk.Issue, since time.Time) {
+	reviews, _, err := w.client.sdk.ListPullReviews(owner, repo, pr.Index, sdk.ListPullReviewsOptions{})
+	if err != nil {
+		w.logger.Warn("poll reviews failed", "owner", owner, "repo", repo, "pr", pr.Index, "error", err)
+		return
+	}
+
+	for _, review := range reviews {
+		if review.Reviewer == nil || review.Submitted.IsZero() {
+			continue
+		}
+
+		if !review.Submitted.After(since) {
+			continue
+		}
+
+		w.dispatch(reviewToEvent(pr, review, owner, repo))
+	}
+}
+
 func (w *Watcher) watermark(ctx context.Context, key string) (time.Time, error) {
 	v, err := w.store.GetWatermark(ctx, key)
 	if err != nil {
@@ -236,3 +267,22 @@ func issueToPRNewEvent(issue *sdk.Issue, owner, repo string) Event {
 		Raw:    raw,
 	}
 }
+
+func reviewToEvent(pr *sdk.Issue, review *sdk.PullReview, owner, repo string) Event {
+	raw, _ := json.Marshal(review)
+
+	return Event{
+		Kind:        EventPRReview,
+		ID:          prReviewID(review.ID),
+		Owner:       owner,
+		Repo:        repo,
+		Index:       pr.Index,
+		Title:       pr.Title,
+		Body:        pr.Body,
+		Author:      posterName(review.Reviewer),
+		ReviewID:    review.ID,
+		ReviewState: string(review.State),
+		ReviewBody:  review.Body,
+		Raw:         raw,
+	}
+}
diff --git a/internal/forgejo/webhook.go b/internal/forgejo/webhook.go
index 72a617e..647c219 100644
--- a/internal/forgejo/webhook.go
+++ b/internal/forgejo/webhook.go
@@ -30,6 +30,13 @@ type pullRequestPayload struct {
 	Repository  *sdk.Repository  `json:"repository"`
 }
 
+type pullRequestReviewPayload struct {
+	Action      string           `json:"action"`
+	Review      *sdk.PullReview  `json:"review"`
+	PullRequest *sdk.PullRequest `json:"pull_request"`
+	Repository  *sdk.Repository  `json:"repository"`
+}
+
 // WebhookHandler returns the http.Handler to mount at (e.g.)
 // /webhooks/forgejo. If secret is non-empty, deliveries are verified via
 // the SDK's X-Forgejo-Signature middleware; callers should always set a
@@ -99,6 +106,15 @@ func decodeWebhookEvent(kind string, body []byte) (Event, bool, error) {
 
 		return pullRequestEvent(p, body)
 
+	case "pull_request_review":
+		var p pullRequestReviewPayload
+
+		if err := json.Unmarshal(body, &p); err != nil {
+			return Event{}, false, err
+		}
+
+		return pullRequestReviewEvent(p, body)
+
 	default:
 		return Event{}, false, nil
 	}
@@ -198,6 +214,38 @@ func pullRequestEvent(p pullRequestPayload, raw []byte) (Event, bool, error) {
 	}, true, nil
 }
 
+// pullRequestReviewEvent turns a pull_request_review delivery into a
+// pr:review event. Only "submitted" is interesting: it's the moment a
+// review lands with a verdict. "edited" and "dismissed" are follow-ups
+// to a review that already triggered a run, and "pending" reviews have
+// no verdict to react to yet.
+func pullRequestReviewEvent(p pullRequestReviewPayload, raw []byte) (Event, bool, error) {
+	if p.Action != "submitted" || p.Review == nil || p.PullRequest == nil || p.Repository == nil {
+		return Event{}, false, nil
+	}
+
+	headRef := ""
+	if p.PullRequest.Head != nil {
+		headRef = p.PullRequest.Head.Ref
+	}
+
+	return Event{
+		Kind:        EventPRReview,
+		ID:          prReviewID(p.Review.ID),
+		Owner:       repoOwner(p.Repository),
+		Repo:        p.Repository.Name,
+		Index:       p.PullRequest.Index,
+		Title:       p.PullRequest.Title,
+		Body:        p.PullRequest.Body,
+		Author:      posterName(p.Review.Reviewer),
+		ReviewID:    p.Review.ID,
+		ReviewState: string(p.Review.State),
+		ReviewBody:  p.Review.Body,
+		HeadRef:     headRef,
+		Raw:         raw,
+	}, true, nil
+}
+
 func repoOwner(r *sdk.Repository) string {
 	if r.Owner == nil {
 		return ""
@@ -235,3 +283,10 @@ func issueCommentID(commentID int64) string {
 func prNewID(prID int64) string {
 	return fmt.Sprintf("pr-new-%d", prID)
 }
+
+// prReviewID keys the review itself: a review is submitted once, so its
+// id is a stable dedup key across the webhook delivery and the poller's
+// later sighting of the same review.
+func prReviewID(reviewID int64) string {
+	return fmt.Sprintf("pr-review-%d", reviewID)
+}
Thinking
Everything looks consistent. Next, let me check the remaining diffs (tools, agentrun/forgejo.go, clone.go, system.md, zoo.hcl.example) and re-verify them.
Tool 🔧 bash {"command": "cd /project && git diff --cached internal/tools/ internal/agentrun/forgejo.go internal/agentrun/clone.go internal/agentrun/system.md zoo.hcl.example internal/config/config.go"}
{"command": "cd /project && git diff --cached internal/tools/ internal/agentrun/forgejo.go internal/agentrun/clone.go internal/agentrun/system.md zoo.hcl.example internal/config/config.go"}
diff --git a/internal/agentrun/clone.go b/internal/agentrun/clone.go
index 86feb7c..2ae1adf 100644
--- a/internal/agentrun/clone.go
+++ b/internal/agentrun/clone.go
@@ -38,6 +38,29 @@ func cloneAndBranch(ctx context.Context, cloneURL, token, defaultBranch, branch,
 	return nil
 }
 
+// clonePRHead clones cloneURL into dir and checks out the pull request's
+// head as a local branch named headRef, so commits the agent makes push
+// straight to the PR. The head is fetched via the PR's refs/pull/<n>/head
+// ref rather than by branch name, which also works for PRs opened from a
+// fork, where the head branch doesn't exist in the base repo.
+func clonePRHead(ctx context.Context, cloneURL, token, defaultBranch, headRef string, index int64, dir string) error {
+	args := append(gitAuthArgs(token), "clone", "--branch", defaultBranch, "--single-branch", cloneURL, dir)
+	if out, err := runGit(ctx, "", args...); err != nil {
+		return fmt.Errorf("clone: %w: %s", err, out)
+	}
+
+	fetchArgs := append(gitAuthArgs(token), "fetch", "--no-tags", "origin", fmt.Sprintf("refs/pull/%d/head", index))
+	if out, err := runGit(ctx, dir, fetchArgs...); err != nil {
+		return fmt.Errorf("fetch PR %d head: %w: %s", index, err, out)
+	}
+
+	if out, err := runGit(ctx, dir, "checkout", "-B", headRef, "FETCH_HEAD"); err != nil {
+		return fmt.Errorf("checkout PR head %q: %w: %s", headRef, err, out)
+	}
+
+	return nil
+}
+
 // runGit runs a git subcommand on the host (not inside any container)
 // with no Forgejo credential attached.
 func runGit(ctx context.Context, dir string, args ...string) (string, error) {
diff --git a/internal/agentrun/forgejo.go b/internal/agentrun/forgejo.go
index 795055f..cbb47e8 100644
--- a/internal/agentrun/forgejo.go
+++ b/internal/agentrun/forgejo.go
@@ -85,3 +85,17 @@ func (a *runForgejoActions) AssignIssue(agentName string) error {
 
 	return err
 }
+
+func (a *runForgejoActions) SubmitReview(state, body string) error {
+	err := a.client.SubmitReview(a.owner, a.repo, a.index, state, body)
+	a.logResult("submit_review", err, "state", state)
+
+	return err
+}
+
+func (a *runForgejoActions) ResolveReviewComment(commentID int64) error {
+	err := a.client.ResolveReviewComment(a.owner, a.repo, a.index, commentID)
+	a.logResult("resolve_review_comment", err, "comment_id", commentID)
+
+	return err
+}
diff --git a/internal/agentrun/system.md b/internal/agentrun/system.md
index 1c8d42f..302637f 100644
--- a/internal/agentrun/system.md
+++ b/internal/agentrun/system.md
@@ -3,9 +3,12 @@ triggers you from Forgejo (a Gitea-family forge) issue/PR events.
 
 **Your environment**
 
-- Your working tree is at `/project`, checked out on a fresh branch off
-  the repository's default branch. Only `/project` persists; nothing
-  outside it survives between tool calls.
+- Your working tree is at `/project`, checked out on the working branch
+  named in your briefing below. For most events that's a fresh branch
+  off the repository's default branch; for a PR review event it's the
+  pull request's own head branch, so commits you push update the PR
+  directly. Only `/project` persists; nothing outside it survives
+  between tool calls.
 - The event that triggered you (issue or pull request JSON) is available
   at `/event` inside the container, and is also included below.
 - You have a real git remote configured with push access. When you're
@@ -16,11 +19,14 @@ triggers you from Forgejo (a Gitea-family forge) issue/PR events.
 
 - `bash`, `git`, `read_file`, `write_file`, `list_files`, `grep_search`,
   `move_file`, `remove_file` operate on the project container.
-- `comment`, `open_pull_request`, `request_review`, `add_label`,
-  `remove_label`, `close_issue`, `reopen_issue`, `assign_issue` act
-  directly on the Forgejo issue/PR that triggered this run — use
-  `comment` to report back to the person who filed it, and
-  `open_pull_request` once you've pushed a branch with your changes.
+- `comment`, `open_pull_request`, `request_review`, `submit_review`,
+  `resolve_review_comment`, `add_label`, `remove_label`, `close_issue`,
+  `reopen_issue`, `assign_issue` act directly on the Forgejo issue/PR
+  that triggered this run — use `comment` to report back to the person
+  who filed it, `open_pull_request` once you've pushed a branch with
+  your changes, `submit_review` to leave a review verdict, and
+  `resolve_review_comment` to mark addressed inline review comments as
+  resolved.
 
 Below, in "Your identity" and "Other agents", you'll find your own role
 (from your Forgejo profile) and a roster of the other agents zoo runs,
diff --git a/internal/config/config.go b/internal/config/config.go
index 7966de7..136b698 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -15,12 +15,14 @@ const (
 	EventIssueComment  = "issue:comment"
 	EventIssueAssigned = "issue:assigned"
 	EventPRNew         = "pr:new"
+	EventPRReview      = "pr:review"
 )
 
 var staticEventKinds = map[string]bool{
 	EventIssueNew:     true,
 	EventIssueComment: true,
 	EventPRNew:        true,
+	EventPRReview:     true,
 }
 
 type Config struct {
diff --git a/internal/tools/context.go b/internal/tools/context.go
index e49eba7..fa0a32d 100644
--- a/internal/tools/context.go
+++ b/internal/tools/context.go
@@ -44,4 +44,13 @@ type ForgejoActions interface {
 	// their name (which doubles as their Forgejo username); the
 	// resulting issue:assigned event triggers that agent's run.
 	AssignIssue(agentName string) error
+
+	// SubmitReview submits a review on the triggering pull request with
+	// the given verdict (forgejo.ReviewStateApproved,
+	// ReviewStateChangesRequest, or ReviewStateComment) and body.
+	SubmitReview(state, body string) error
+
+	// ResolveReviewComment marks the inline review comment with the
+	// given id as resolved, once its feedback has been addressed.
+	ResolveReviewComment(commentID int64) error
 }
diff --git a/internal/tools/forgejo.go b/internal/tools/forgejo.go
index 330d231..82819d0 100644
--- a/internal/tools/forgejo.go
+++ b/internal/tools/forgejo.go
@@ -2,6 +2,7 @@ package tools
 
 import (
 	"fmt"
+	"strings"
 
 	"github.com/abrander/zoo/internal/llm"
 )
@@ -78,6 +79,56 @@ func requestReview(ctx Context, params requestReviewParams) (string, error) {
 	return "Review requested", nil
 }
 
+type submitReviewParams struct {
+	State string `json:"state"`
+	Body  string `json:"body"`
+}
+
+func init() {
+	tool := llm.NewTool(
+		"submit_review",
+		"Submit a review on the pull request that triggered this run: approve it, request changes, or leave review feedback. A submitted review triggers the PR author's agent, so use it to react to a PR (e.g. after addressing earlier feedback) or to review someone else's PR.")
+
+	tool.AddEnumProperty("state", "The review verdict", []string{"approved", "changes_requested", "comment"}, true)
+	tool.AddStringProperty("body", "The review text, in Markdown. Required unless state is 'approved'.", true)
+
+	Register(tool, submitReview)
+}
+
+func submitReview(ctx Context, params submitReviewParams) (string, error) {
+	if params.State != "approved" && strings.TrimSpace(params.Body) == "" {
+		return "", fmt.Errorf("body is required unless state is 'approved'")
+	}
+
+	if err := ctx.Forgejo().SubmitReview(params.State, params.Body); err != nil {
+		return "", err
+	}
+
+	return "Review submitted", nil
+}
+
+type resolveReviewCommentParams struct {
+	CommentID int64 `json:"comment_id"`
+}
+
+func init() {
+	tool := llm.NewTool(
+		"resolve_review_comment",
+		"Mark an inline review comment on the pull request as resolved, once you have addressed the feedback it contains. The comment's id is in the review briefing (and the raw event payload).")
+
+	tool.AddIntegerProperty("comment_id", "The id of the inline review comment to resolve", true)
+
+	Register(tool, resolveReviewComment)
+}
+
+func resolveReviewComment(ctx Context, params resolveReviewCommentParams) (string, error) {
+	if err := ctx.Forgejo().ResolveReviewComment(params.CommentID); err != nil {
+		return "", err
+	}
+
+	return "Review comment resolved", nil
+}
+
 type labelParams struct {
 	Name string `json:"name"`
 }
diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go
index eee2203..2109061 100644
--- a/internal/tools/tools_test.go
+++ b/internal/tools/tools_test.go
@@ -8,8 +8,10 @@ import (
 )
 
 type fakeForgejoActions struct {
-	comments []string
-	assigned []string
+	comments         []string
+	assigned         []string
+	reviews          []string
+	resolvedComments []int64
 }
 
 func (f *fakeForgejoActions) Comment(body string) error {
@@ -26,6 +28,14 @@ func (f *fakeForgejoActions) AssignIssue(agentName string) error {
 	f.assigned = append(f.assigned, agentName)
 	return nil
 }
+func (f *fakeForgejoActions) SubmitReview(state, body string) error {
+	f.reviews = append(f.reviews, state)
+	return nil
+}
+func (f *fakeForgejoActions) ResolveReviewComment(commentID int64) error {
+	f.resolvedComments = append(f.resolvedComments, commentID)
+	return nil
+}
 
 type fakeContext struct {
 	lastCmd string
@@ -137,6 +147,52 @@ func TestAssignIssueToolCallsForgejo(t *testing.T) {
 	}
 }
 
+func TestSubmitReviewToolCallsForgejo(t *testing.T) {
+	fg := &fakeForgejoActions{}
+	fc := &fakeContext{fg: fg}
+
+	out, err := submitReview(fc, submitReviewParams{State: "changes_requested", Body: "please fix"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if out != "Review submitted" {
+		t.Fatalf("unexpected result: %q", out)
+	}
+	if len(fg.reviews) != 1 || fg.reviews[0] != "changes_requested" {
+		t.Fatalf("unexpected reviews: %v", fg.reviews)
+	}
+}
+
+// An approval needs no body, but any other verdict does.
+func TestSubmitReviewRequiresBodyUnlessApproved(t *testing.T) {
+	fg := &fakeForgejoActions{}
+	fc := &fakeContext{fg: fg}
+
+	if _, err := submitReview(fc, submitReviewParams{State: "approved", Body: ""}); err != nil {
+		t.Fatalf("approval with empty body should be allowed, got %v", err)
+	}
+
+	if _, err := submitReview(fc, submitReviewParams{State: "changes_requested", Body: "   "}); err == nil {
+		t.Fatal("expected an error for a changes_requested review with an empty body")
+	}
+}
+
+func TestResolveReviewCommentToolCallsForgejo(t *testing.T) {
+	fg := &fakeForgejoActions{}
+	fc := &fakeContext{fg: fg}
+
+	out, err := resolveReviewComment(fc, resolveReviewCommentParams{CommentID: 7})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if out != "Review comment resolved" {
+		t.Fatalf("unexpected result: %q", out)
+	}
+	if len(fg.resolvedComments) != 1 || fg.resolvedComments[0] != 7 {
+		t.Fatalf("unexpected resolved comments: %v", fg.resolvedComments)
+	}
+}
+
 func TestGrepSearchUsesExtendedRegex(t *testing.T) {
 	fc := &fakeContext{output: "/project/foo.go:1:foo|bar\n"}
 
diff --git a/zoo.hcl.example b/zoo.hcl.example
index 51a0f01..6727650 100644
--- a/zoo.hcl.example
+++ b/zoo.hcl.example
@@ -68,3 +68,13 @@ event "pr:new" {
     agent        = "greg"
     instructions = "Please review this pull request and respond appropriately."
 }
+
+# A review was submitted on a pull request (approved, changes requested,
+# or comment). The agent's working tree is checked out on the PR's head
+# branch, so changes it pushes update the PR directly. It should address
+# the feedback, reply to the review, and resolve the inline comments it
+# has addressed.
+event "pr:review" {
+    agent        = "greg"
+    instructions = "A review was submitted on this pull request. Address the feedback, push your changes to the PR branch, reply to the review, and resolve the inline comments you have addressed."
+}
Thinking
Everything looks consistent and clean. Next, let me reconsider one more point about the `submit_review` tool description: "The submitted review triggers the PR author's agent." Actually, this isn't exactly accurate — the submitted review triggers the agent configured for `pr:review` (static), not necessarily "the PR author's agent." Let me fix this wording to be accurate. Let me update the tool description: "The submitted review triggers a `pr:review` run for the configured agent." Let me make it accurate. Actually, the description says "The submitted review triggers the PR author's agent, so use it to react to the PR (e.g., after addressing previous feedback) or to review someone else's PR." The phrase "triggers the PR author's agent" is inaccurate. Let me change it to "triggers a pr:review run." Let me fix it.
One wording fix — the `submit_review` description says it triggers "the PR author's agent," but resolution is the statically configured `pr:review` agent. Let me make it accurate:
Tool 🔧 edit_file {"path": "internal/tools/forgejo.go", "find": "\ttool := llm.NewTool(\n\t\t\"submit_review\",\n\t\t\"Submit a review on the pull request that triggered this run: approve it, request changes, or leave review feedback. A submitted review triggers the PR author's agent, so use it to react to a PR (e.g. after addressing earlier feedback) or to review someone else's PR.\")", "replace": "\ttool := llm.NewTool(\n\t\t\"submit_review\",\n\t\t\"Submit a review on the pull request that triggered this run: approve it, request changes, or leave review feedback. A submitted review fires a pr:review event that triggers a run, so use it to react to a PR (e.g. after addressing earlier feedback) or to review someone else's PR.\")"}
{"path": "internal/tools/forgejo.go", "find": "\ttool := llm.NewTool(\n\t\t\"submit_review\",\n\t\t\"Submit a review on the pull request that triggered this run: approve it, request changes, or leave review feedback. A submitted review triggers the PR author's agent, so use it to react to a PR (e.g. after addressing earlier feedback) or to review someone else's PR.\")", "replace": "\ttool := llm.NewTool(\n\t\t\"submit_review\",\n\t\t\"Submit a review on the pull request that triggered this run: approve it, request changes, or leave review feedback. A submitted review fires a pr:review event that triggers a run, so use it to react to a PR (e.g. after addressing earlier feedback) or to review someone else's PR.\")"}
1 occurrences replaced
Thinking
Alright, let's run the full test suite one more time, and also run go vet and gofmt.
Tool 🔧 bash {"command": "cd /project && go build ./... && go vet ./... && gofmt -l . && echo \"=== all clean ===\" && go test ./... 2>&1 | tail -12", "timeout": 300}
{"command": "cd /project && go build ./... && go vet ./... && gofmt -l . && echo \"=== all clean ===\" && go test ./... 2>&1 | tail -12", "timeout": 300}
=== all clean ===
?   	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	(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	0.003s
ok  	github.com/abrander/zoo/internal/web	(cached)
Thinking
Everything's clean and passing. Next, let me check the git tool to make sure the agent can push to the PR head branch. Let me look at the git tool and check which subcommands are allowed.
All clean. Let me verify the `git` tool allows the push the agent will need (to confirm the "react" path works end-to-end):
Tool 🔧 read_file {"path": "internal/tools/git.go", "start_line": 1, "end_line": 80}
{"path": "internal/tools/git.go", "start_line": 1, "end_line": 80}
{"path":"internal/tools/git.go","content":"package tools\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"fmt\"\r\n\t\"slices\"\r\n\t\"strings\"\r\n\r\n\t\"github.com/abrander/zoo/internal/llm\"\r\n)\r\n\r\n// gitAllowed intentionally excludes remote-mutating subcommands other\r\n// than push: agents are expected to commit and push their own branch,\r\n// not touch remotes/config/etc.\r\nvar gitAllowed = []string{\r\n\t\"add\",\r\n\t\"branch\",\r\n\t\"checkout\",\r\n\t\"commit\",\r\n\t\"diff\",\r\n\t\"fetch\",\r\n\t\"log\",\r\n\t\"ls-remote\",\r\n\t\"pull\",\r\n\t\"push\",\r\n\t\"show\",\r\n\t\"status\",\r\n\t\"rm\",\r\n}\r\n\r\ntype gitParams struct {\r\n\tSubcommand string   `json:\"subcommand\"`\r\n\tArgs       []string `json:\"args\"`\r\n}\r\n\r\nfunc init() {\r\n\ttool := llm.NewTool(\r\n\t\t\"git\",\r\n\t\t\"Run a git subcommand with optional arguments against the project's working tree\")\r\n\r\n\ttool.AddEnumProperty(\"subcommand\", \"The git subcommand to run\", gitAllowed, true)\r\n\ttool.AddStringArrayProperty(\"args\", \"Additional arguments to pass to the git subcommand, e.g. ['HEAD~1', 'HEAD']\", false)\r\n\r\n\tRegister(tool, git)\r\n}\r\n\r\n// git runs subcommand via Context.Git, which executes it outside the\r\n// container (see that method's doc comment for why): the container\r\n// itself is never given a credential capable of authenticating against\r\n// Forgejo, so pushing has to happen on the host side instead.\r\nfunc git(ctx Context, params gitParams) (string, error) {\r\n\tif !slices.Contains(gitAllowed, params.Subcommand) {\r\n\t\treturn \"\", fmt.Errorf(\"git subcommand '%s' is not allowed. Only %s are permitted\", params.Subcommand, strings.Join(gitAllowed, \", \"))\r\n\t}\r\n\r\n\toutput, err := ctx.Git(context.Background(), params.Subcommand, params.Args...)\r\n\tif err != nil {\r\n\t\treturn \"\", err\r\n\t}\r\n\r\n\tif output == \"\" {\r\n\t\tswitch params.Subcommand {\r\n\t\tcase \"diff\":\r\n\t\t\treturn \"No changes detected.\", nil\r\n\r\n\t\tcase \"status\":\r\n\t\t\treturn \"No status output.\", nil\r\n\t\t}\r\n\t}\r\n\r\n\treturn strings.TrimSpace(output), nil\r\n}\r","start_line":1,"end_line":72,"total_lines":72,"truncated":false}
Thinking
The `git` tool allows `push`. And `runContext.Git` handles `push` via `runGitAuthed` (with the token). So the agent can push to the PR head branch. Now I'm confident the implementation is complete and correct. Let me commit the changes and push the branch, then open a PR and comment on the issue. Let me write a clear commit message. Actually, before committing, let me reconsider whether I should run the docker integration tests. They're probably gated behind a build tag or require Docker. Let me check.
The `git` tool allows `push`, and `runContext.Git` authenticates it host-side — so the agent can push fixes to the PR head branch. Let me check whether the docker integration test is gated (so I know if it runs in CI):
Tool 🔧 bash {"command": "cd /project && head -20 internal/agentrun/docker_integration_test.go"}
{"command": "cd /project && head -20 internal/agentrun/docker_integration_test.go"}
//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)
	}

Thinking
The Docker integration tests are gated behind the `docker` build tag, so they won't run in the normal `go test ./...`. That's fine. Alright, let's commit and push. I'll write a good commit message. Let me stage everything and commit.
The docker integration test is build-tag-gated, so it's not part of the normal suite. My changes are complete. Let me commit and push:
Tool 🔧 bash {"command": "cd /project && git add -A && git -c user.name=\"Greg Coolio\" -c user.email=\"greg@noreply.localhost\" commit -q -m \"Add pr:review event so agents react to PR reviews\n\nDefine and implement the Zoo side of the PR review workflow:\n\n- New pr:review event kind, fired when a review is submitted on a PR.\n Detected via the pull_request_review webhook (action=submitted) and,\n as a fallback, by polling reviews on updated PRs. Both paths dedup on\n the review id, so a review runs once regardless of source.\n- A pr:review run checks out the PR's own head branch (via\n refs/pull/<n>/head, which also works for fork PRs) so the agent's\n pushes update the PR directly, and briefs it with the review verdict,\n body, and inline comments (fetched, since the webhook payload omits\n them).\n- New tools: submit_review (approve / request changes / comment, which\n fires pr:review and closes the loop) and resolve_review_comment (mark\n addressed inline feedback as resolved).\n- Config: pr:review is a static event kind (zoo.hcl) like pr:new.\n- Loop safety: the scheduler's existing self-trigger guard drops a\n review authored by the agent it would trigger, and only 'submitted'\n (not edited/dismissed/pending) fires, so reactions don't re-trigger.\n\nCloses #36\" && git log --oneline -1"}
{"command": "cd /project && git add -A && git -c user.name=\"Greg Coolio\" -c user.email=\"greg@noreply.localhost\" commit -q -m \"Add pr:review event so agents react to PR reviews\n\nDefine and implement the Zoo side of the PR review workflow:\n\n- New pr:review event kind, fired when a review is submitted on a PR.\n  Detected via the pull_request_review webhook (action=submitted) and,\n  as a fallback, by polling reviews on updated PRs. Both paths dedup on\n  the review id, so a review runs once regardless of source.\n- A pr:review run checks out the PR's own head branch (via\n  refs/pull/<n>/head, which also works for fork PRs) so the agent's\n  pushes update the PR directly, and briefs it with the review verdict,\n  body, and inline comments (fetched, since the webhook payload omits\n  them).\n- New tools: submit_review (approve / request changes / comment, which\n  fires pr:review and closes the loop) and resolve_review_comment (mark\n  addressed inline feedback as resolved).\n- Config: pr:review is a static event kind (zoo.hcl) like pr:new.\n- Loop safety: the scheduler's existing self-trigger guard drops a\n  review authored by the agent it would trigger, and only 'submitted'\n  (not edited/dismissed/pending) fires, so reactions don't re-trigger.\n\nCloses #36\" && git log --oneline -1"}
eb16dd3 (HEAD -> issue-36-greg) Add pr:review event so agents react to PR reviews
Thinking
Alright, let's push the branch.
Tool 🔧 git {"subcommand": "push", "args": ["origin", "issue-36-greg"]}
{"subcommand": "push", "args": ["origin", "issue-36-greg"]}
To https://code.stdio.dk/abrander/zoo.git
 * [new branch]      issue-36-greg -> issue-36-greg
Thinking
The branch has been pushed. Next, I'll open a PR from `issue-36-greg` to `main`, and then comment on the issue. Let me write a good PR description that includes the workflow definition.
Branch pushed. Now let me open the pull request with the workflow definition:
Tool 🔧 open_pull_request {"head": "issue-36-greg", "base": "main", "title": "Add pr:review event so agents react to PR reviews", "body": "## What this does\n\nDefines the PR review workflow and implements the Zoo side of it (closes #36).\n\n### The workflow\n\n1. **A review lands.** A human or an agent submits a review on a PR — approve, request changes, or comment, with optional inline comments.\n2. **Zoo detects it.** Via the `pull_request_review` webhook (`action: submitted`), or — when webhooks aren't reachable — by polling reviews on PRs updated since the last watermark. Both paths produce the same normalized `pr:review` event and dedup on the review id, so a review always runs exactly once regardless of source.\n3. **Zoo dispatches it.** `pr:review` is a static event kind (like `pr:new`): the agent named in the `event \"pr:review\"` block in `zoo.hcl` handles it.\n4. **The agent is set up to react.**\n - Its working tree is checked out on the **PR's own head branch** (fetched via `refs/pull/<n>/head`, which also works for PRs opened from a fork), so commits it pushes update the PR directly.\n - Its briefing includes the review **verdict**, **body**, and every **inline comment** (file:line, text, and the comment id) — fetched from the API, since the webhook payload omits inline comments — plus the full PR comment thread and raw payload.\n5. **The agent reacts.** It addresses the feedback and pushes to the PR branch, replies to the review with `comment`, marks addressed inline comments resolved with the new `resolve_review_comment` tool, and can submit its own review with the new `submit_review` tool (approve / request changes / comment).\n\n### Loop safety\n\n- Only `submitted` reviews fire (not `edited`/`dismissed`/`pending`), so follow-ups to an already-handled review don't re-trigger.\n- The scheduler's existing self-trigger guard drops an event authored by the agent it would resolve to — so a review the `pr:review` agent submits itself never triggers itself.\n- An agent's reactions (PR comments, pushes, resolving comments) don't map to any handled event, so they don't re-trigger.\n\n### Changes\n\n- `internal/forgejo`: `EventPRReview` kind; `pull_request_review` webhook decode; `pollReviews` fallback; `ReviewDetail`/`PullRequestInfo`/`SubmitReview`/`ResolveReviewComment` client methods (the last is a direct API call — the SDK doesn't expose the resolve endpoint).\n- `internal/config`: `pr:review` as a static event kind; `zoo.hcl.example` gains an `event \"pr:review\"` block.\n- `internal/agentrun`: `clonePRHead` (check out the PR head); `Run` wires the head branch + review detail for `pr:review`; `seedMessage` renders the review section and the PR-branch wording; system prompt updated.\n- `internal/tools`: `submit_review` and `resolve_review_comment` tools + `ForgejoActions` interface.\n- Tests: webhook decode (incl. non-submitted ignored + webhook/poll id parity), poll dispatch (new vs. old/pending), client methods against an httptest Forgejo, config validation, scheduler resolution + self-drop, tool wiring, seed-message rendering.\n\n### Known limitations (v1)\n\n- A review's inline comments are captured at submission; a *standalone* review comment that's never part of a submitted review doesn't fire `pr:review` (the `pull_request_review_comment` event is intentionally not handled to avoid double-triggering a review that already fired).\n- Re-reviewing after the agent's fix is still manual (or via a future `pr:sync` event) — pushing to the PR doesn't currently re-trigger the reviewer.\n- The reacting agent must have push access to the PR head branch (true for same-repo agent PRs; a fork-owned head branch the zoo token can't push to will fail the run with a clear error).\n\nAll existing and new tests pass (`go test ./...`), `go vet` and `gofmt` clean."}
{"head": "issue-36-greg", "base": "main", "title": "Add pr:review event so agents react to PR reviews", "body": "## What this does\n\nDefines the PR review workflow and implements the Zoo side of it (closes #36).\n\n### The workflow\n\n1. **A review lands.** A human or an agent submits a review on a PR — approve, request changes, or comment, with optional inline comments.\n2. **Zoo detects it.** Via the `pull_request_review` webhook (`action: submitted`), or — when webhooks aren't reachable — by polling reviews on PRs updated since the last watermark. Both paths produce the same normalized `pr:review` event and dedup on the review id, so a review always runs exactly once regardless of source.\n3. **Zoo dispatches it.** `pr:review` is a static event kind (like `pr:new`): the agent named in the `event \"pr:review\"` block in `zoo.hcl` handles it.\n4. **The agent is set up to react.**\n   - Its working tree is checked out on the **PR's own head branch** (fetched via `refs/pull/<n>/head`, which also works for PRs opened from a fork), so commits it pushes update the PR directly.\n   - Its briefing includes the review **verdict**, **body**, and every **inline comment** (file:line, text, and the comment id) — fetched from the API, since the webhook payload omits inline comments — plus the full PR comment thread and raw payload.\n5. **The agent reacts.** It addresses the feedback and pushes to the PR branch, replies to the review with `comment`, marks addressed inline comments resolved with the new `resolve_review_comment` tool, and can submit its own review with the new `submit_review` tool (approve / request changes / comment).\n\n### Loop safety\n\n- Only `submitted` reviews fire (not `edited`/`dismissed`/`pending`), so follow-ups to an already-handled review don't re-trigger.\n- The scheduler's existing self-trigger guard drops an event authored by the agent it would resolve to — so a review the `pr:review` agent submits itself never triggers itself.\n- An agent's reactions (PR comments, pushes, resolving comments) don't map to any handled event, so they don't re-trigger.\n\n### Changes\n\n- `internal/forgejo`: `EventPRReview` kind; `pull_request_review` webhook decode; `pollReviews` fallback; `ReviewDetail`/`PullRequestInfo`/`SubmitReview`/`ResolveReviewComment` client methods (the last is a direct API call — the SDK doesn't expose the resolve endpoint).\n- `internal/config`: `pr:review` as a static event kind; `zoo.hcl.example` gains an `event \"pr:review\"` block.\n- `internal/agentrun`: `clonePRHead` (check out the PR head); `Run` wires the head branch + review detail for `pr:review`; `seedMessage` renders the review section and the PR-branch wording; system prompt updated.\n- `internal/tools`: `submit_review` and `resolve_review_comment` tools + `ForgejoActions` interface.\n- Tests: webhook decode (incl. non-submitted ignored + webhook/poll id parity), poll dispatch (new vs. old/pending), client methods against an httptest Forgejo, config validation, scheduler resolution + self-drop, tool wiring, seed-message rendering.\n\n### Known limitations (v1)\n\n- A review's inline comments are captured at submission; a *standalone* review comment that's never part of a submitted review doesn't fire `pr:review` (the `pull_request_review_comment` event is intentionally not handled to avoid double-triggering a review that already fired).\n- Re-reviewing after the agent's fix is still manual (or via a future `pr:sync` event) — pushing to the PR doesn't currently re-trigger the reviewer.\n- The reacting agent must have push access to the PR head branch (true for same-repo agent PRs; a fork-owned head branch the zoo token can't push to will fail the run with a clear error).\n\nAll existing and new tests pass (`go test ./...`), `go vet` and `gofmt` clean."}
Pull request opened
Thinking
The PR has been opened. Next, I'll add a comment on the issue summarizing what was done (the workflow definition + implementation), and reference the PR.
Now let me comment on the issue to report back:
Tool 🔧 comment {"body": "@leon handled this — PR is up for review. Here's the workflow I defined and the Zoo side I implemented.\n\n## The PR review workflow\n\n1. **A review lands** on a PR (approve / request changes / comment, with optional inline comments) — from a human or another agent.\n2. **Zoo detects it** as a new `pr:review` event, via the `pull_request_review` webhook (`action: submitted`) or the polling fallback (reviews on PRs updated since the last watermark). Both paths dedup on the review id, so a review runs exactly once regardless of source.\n3. **Zoo dispatches** to the agent named in the `event \"pr:review\"` block in `zoo.hcl` (a static event kind, like `pr:new`).\n4. **The agent is set up to react**: its working tree is checked out on the **PR's own head branch** (via `refs/pull/<n>/head`, so fork PRs work too) and its briefing carries the review verdict, body, and every inline comment (file:line + text + comment id).\n5. **The agent reacts**: fixes the code and pushes to the PR branch, replies with `comment`, resolves addressed inline comments with the new `resolve_review_comment` tool, and can submit its own review with the new `submit_review` tool — which fires `pr:review` again, closing the loop.\n\n**Loop safety:** only `submitted` reviews fire (not edited/dismissed/pending); the scheduler's existing self-trigger guard drops a review authored by the agent it would trigger; and an agent's reactions (PR comments, pushes, resolving comments) map to no handled event.\n\n## What changed (Zoo side)\n\n- `internal/forgejo` — `pr:review` event kind, webhook decode, poll fallback, and client methods `ReviewDetail`, `PullRequestInfo`, `SubmitReview`, `ResolveReviewComment` (the last is a direct API call; the SDK doesn't expose the resolve endpoint).\n- `internal/agentrun` — `clonePRHead` checks out the PR head; the run briefs the agent with the full review; system prompt updated.\n- `internal/tools` — `submit_review` and `resolve_review_comment`.\n- `internal/config` + `zoo.hcl.example` — `pr:review` as a configurable static event.\n- Tests across all of the above; `go test ./...`, `go vet`, `gofmt` all clean.\n\n**Known v1 limitations:** a standalone review comment never attached to a submitted review doesn't fire (avoids double-triggering); re-review after the fix is still manual (a future `pr:sync` event would automate it); the reacting agent needs push access to the PR head branch.\n\nFull details in the PR description."}
{"body": "@leon handled this — PR is up for review. Here's the workflow I defined and the Zoo side I implemented.\n\n## The PR review workflow\n\n1. **A review lands** on a PR (approve / request changes / comment, with optional inline comments) — from a human or another agent.\n2. **Zoo detects it** as a new `pr:review` event, via the `pull_request_review` webhook (`action: submitted`) or the polling fallback (reviews on PRs updated since the last watermark). Both paths dedup on the review id, so a review runs exactly once regardless of source.\n3. **Zoo dispatches** to the agent named in the `event \"pr:review\"` block in `zoo.hcl` (a static event kind, like `pr:new`).\n4. **The agent is set up to react**: its working tree is checked out on the **PR's own head branch** (via `refs/pull/<n>/head`, so fork PRs work too) and its briefing carries the review verdict, body, and every inline comment (file:line + text + comment id).\n5. **The agent reacts**: fixes the code and pushes to the PR branch, replies with `comment`, resolves addressed inline comments with the new `resolve_review_comment` tool, and can submit its own review with the new `submit_review` tool — which fires `pr:review` again, closing the loop.\n\n**Loop safety:** only `submitted` reviews fire (not edited/dismissed/pending); the scheduler's existing self-trigger guard drops a review authored by the agent it would trigger; and an agent's reactions (PR comments, pushes, resolving comments) map to no handled event.\n\n## What changed (Zoo side)\n\n- `internal/forgejo` — `pr:review` event kind, webhook decode, poll fallback, and client methods `ReviewDetail`, `PullRequestInfo`, `SubmitReview`, `ResolveReviewComment` (the last is a direct API call; the SDK doesn't expose the resolve endpoint).\n- `internal/agentrun` — `clonePRHead` checks out the PR head; the run briefs the agent with the full review; system prompt updated.\n- `internal/tools` — `submit_review` and `resolve_review_comment`.\n- `internal/config` + `zoo.hcl.example` — `pr:review` as a configurable static event.\n- Tests across all of the above; `go test ./...`, `go vet`, `gofmt` all clean.\n\n**Known v1 limitations:** a standalone review comment never attached to a submitted review doesn't fire (avoids double-triggering); re-review after the fix is still manual (a future `pr:sync` event would automate it); the reacting agent needs push access to the PR head branch.\n\nFull details in the PR description."}
Comment posted
Thinking
I've completed everything: 1. Defined the workflow. 2. Implemented the Zoo side. 3. Added tests, all passing. 4. Committed and pushed the branch. 5. Opened a PR with a detailed description. 6. Commented on the issue. Let me do a final check to make sure everything is in place. I'll verify that the PR was opened correctly and that the branch was pushed. Actually, I've already confirmed that the push succeeded and the PR was opened. Let me do one more final check that the working tree is clean and everything is committed.
Let me do a final verification that everything is committed and the tree is clean:
Tool 🔧 bash {"command": "cd /project && git status --short && echo \"=== clean tree ===\" && git log --oneline -2 && echo \"=== final test run ===\" && go test ./... 2>&1 | grep -v \"no test files\"", "timeout": 300}
{"command": "cd /project && git status --short && echo \"=== clean tree ===\" && git log --oneline -2 && echo \"=== final test run ===\" && go test ./... 2>&1 | grep -v \"no test files\"", "timeout": 300}
=== clean tree ===
eb16dd3 (HEAD -> issue-36-greg) Add pr:review event so agents react to PR reviews
42fe9d9 (origin/main, origin/HEAD, main) Merge pull request 'Include full issue comment thread in the agent's seed message' (#34) from issue-33-greg into main
=== final test run ===
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)
Thinking
Everything is complete: - Working tree is clean. - Commit has been pushed to `issue-36-greg`. - All tests pass. - PR has been opened. - Issue has been commented on. Task complete. Let me summarize what I did.
Done. Here's a summary of what I delivered for issue #36 ("PR reviews"): ## Workflow defined 1. **A review lands** on a PR (approve / request changes / comment, with optional inline comments) — from a human or another agent. 2. **Zoo detects it** as a new `pr:review` event — via the `pull_request_review` webhook (`action: submitted`) or the polling fallback (reviews on PRs updated since the last watermark). Both paths dedup on the review id, so a review runs exactly once regardless of source. 3. **Zoo dispatches** to the agent named in the `event "pr:review"` block in `zoo.hcl` (a static event kind, consistent with `pr:new`). 4. **The agent is set up to react**: working tree checked out on the **PR's own head branch** (via `refs/pull/<n>/head`, so fork PRs work) and briefed with the review verdict, body, and every inline comment (file:line + text + comment id). 5. **The agent reacts**: fixes code and pushes to the PR branch, replies with `comment`, resolves addressed inline comments, and can submit its own review — which fires `pr:review` again, closing the loop. Loop safety comes from: only `submitted` firing (not edited/dismissed/pending), the existing self-trigger guard, and reactions mapping to no handled event. ## Zoo side implemented - **`internal/forgejo`** — `pr:review` event kind; `pull_request_review` webhook decode; `pollReviews` fallback; client methods `ReviewDetail`, `PullRequestInfo`, `SubmitReview`, and `ResolveReviewComment` (a direct API call, since the SDK lacks the resolve endpoint). - **`internal/agentrun`** — `clonePRHead` (checks out the PR head so pushes update the PR); `Run` wires the head branch + full review detail for `pr:review`; `seedMessage` renders the review section; system prompt updated. - **`internal/tools`** — new `submit_review` and `resolve_review_comment` tools. - **`internal/config` + `zoo.hcl.example`** — `pr:review` as a configurable static event. - **Tests** across webhook decode, poll dispatch, client methods, config, scheduler resolution/self-drop, tool wiring, and seed-message rendering. `go test ./...`, `go vet`, and `gofmt` are all clean. The branch `issue-36-greg` is pushed, the PR is open for review, and I've left a comment on the issue with the full workflow and known v1 limitations.