Details and log output for this agent run.
{"path": ".", "recursive": true}
/project/cmd /project/cmd/zoo /project/cmd/zoo/main.go /project/go.mod /project/go.sum /project/internal /project/internal/agentrun /project/internal/agentrun/clone.go /project/internal/agentrun/context.go /project/internal/agentrun/docker.go /project/internal/agentrun/docker_integration_test.go /project/internal/agentrun/forgejo.go /project/internal/agentrun/loop.go /project/internal/agentrun/roster.go /project/internal/agentrun/roster_test.go /project/internal/agentrun/run.go /project/internal/agentrun/seed_test.go /project/internal/agentrun/system.md /project/internal/config /project/internal/config/config.go /project/internal/config/config_test.go /project/internal/forgejo /project/internal/forgejo/client.go /project/internal/forgejo/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
{"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}
{"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}
{"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}
{"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}
{"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}
{"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}
{"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}
{"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}
{"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}
{"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}
{"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}
{"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}
{"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}
{"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}
{"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}
{"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}
{"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}
{"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}
{"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}
{"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}
{"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 )
{"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
{"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
{"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
{"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
}
{"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"` ---
{"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] {
{"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) {
{"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}
{"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}
{"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}
{"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}
{"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}
{"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}
{"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) {
{"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}
{"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 {
{"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"`
}
{"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"`
}
{"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.
{"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
{"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)
{"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 {
{"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}
{"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}
{"query": "EventKind|event_kind|Kind", "glob": "internal/web/*.go", "context_after": 1}
No matches found.
{"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}
{"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}
{"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),
{"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
}
{"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
}
{"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}
{"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}
{"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
{"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
{"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
{"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
{"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
{"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
{"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
{"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
{"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}
{"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
{"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
{"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
{"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
{"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
{"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
{"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}
{"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
{"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
{"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
{"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}
{"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
{"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
{"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
{"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
{"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
{"command": "cd /project && go build ./... 2>&1 | head -40", "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
{"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
{"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
{"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
{"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
{"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)
{"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
{"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
{"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}
{"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
{"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
{"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
{"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}
{"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
{"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
{"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
{"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
{"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
{"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
{"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}
{"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
{"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)
{"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
{"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)
{"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
{"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
{"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
{"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)
{"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(-)
{"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
+ }
}
{"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
{"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)
{"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)
+}
{"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."
+}
{"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
{"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)
{"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}
{"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)
}
{"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
{"subcommand": "push", "args": ["origin", "issue-36-greg"]}
To https://code.stdio.dk/abrander/zoo.git * [new branch] issue-36-greg -> issue-36-greg
{"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
{"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
{"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)