Details and log output for this agent run.
{"path":".","recursive":false}
drwxr-xr-x 6 user user 4096 Aug 26 23:49 . drwxr-xr-x 1 root root 4096 Aug 26 23:49 .. drwxr-xr-x 7 user user 4096 Aug 26 23:49 .git -rw-r--r-- 1 user user 22 Aug 26 23:49 .gitignore -rw-r--r-- 1 user user 6387 Aug 26 23:49 README.md drwxr-xr-x 3 user user 4096 Aug 26 23:49 cmd drwxr-xr-x 2 user user 4096 Aug 26 23:49 docker -rw-r--r-- 1 user user 2628 Aug 26 23:49 go.mod -rw-r--r-- 1 user user 16578 Aug 26 23:49 go.sum drwxr-xr-x 11 user user 4096 Aug 26 23:49 internal -rw-r--r-- 1 user user 3092 Aug 26 23:49 zoo.hcl.example
{"command":"cd /project && git log --oneline -5 && git status"}
49416c8 Merge pull request 'Live dashboard at /: jobs-changed SSE stream' (#67) from issue-64-greg into main bf26c51 web: make the dashboard at / live via a jobs-changed SSE stream f443489 Merge pull request 'grep_search: treat a wildcard-free glob as a literal file path' (#66) from issue-65-greg into main 474ef42 grep_search: treat a wildcard-free glob as a literal file path 081d4bd Merge pull request 'livelog: open reasoning/content blocks lazily, on first real text' (#63) from issue-62-greg into main On branch issue-72-greg nothing to commit, working tree clean
{"command":"cd /project && find . -type f -name \"*.go\" | head -50 && echo \"---\" && cat README.md"}
./cmd/zoo/main.go
./internal/config/config.go
./internal/config/config_test.go
./internal/web/templates.go
./internal/web/web_test.go
./internal/web/web.go
./internal/livelog/hub.go
./internal/livelog/hub_test.go
./internal/tools/forgejo.go
./internal/tools/grep_search.go
./internal/tools/context.go
./internal/tools/remove_file.go
./internal/tools/registry.go
./internal/tools/write_file.go
./internal/tools/move_file.go
./internal/tools/read_file.go
./internal/tools/edit-file.go
./internal/tools/list_files.go
./internal/tools/bash.go
./internal/tools/shell.go
./internal/tools/tools_test.go
./internal/llm/error.go
./internal/llm/arguments_test.go
./internal/llm/streamer_test.go
./internal/llm/client.go
./internal/llm/streamer.go
./internal/llm/arguments.go
./internal/llm/error_test.go
./internal/llm/types.go
./internal/store/store.go
./internal/store/store_test.go
./internal/forgejo/watcher_test.go
./internal/forgejo/poll.go
./internal/forgejo/watcher.go
./internal/forgejo/repo_filter.go
./internal/forgejo/webhook_test.go
./internal/forgejo/webhook.go
./internal/forgejo/repo_filter_test.go
./internal/forgejo/client.go
./internal/forgejo/poll_test.go
./internal/forgejo/client_test.go
./internal/forgejo/event.go
./internal/agentrun/forgejo.go
./internal/agentrun/run_test.go
./internal/agentrun/roster_test.go
./internal/agentrun/context.go
./internal/agentrun/loop.go
./internal/agentrun/sandboxgit.go
./internal/agentrun/loop_test.go
./internal/agentrun/docker.go
---
# πΎ Zoo
**Autonomous AI agents that work your Forgejo issues & pull requests.**
Zoo watches a [Forgejo](https://codeberg.org/forgejo/forgejo) instance for issue and pull request events, then dispatches them to configured AI agents running in isolated Docker containers. Each agent has its own role, LLM model, and personality β like a menagerie working together to keep your projects moving.
---
## β¨ Features
- **Multi-agent orchestration** β Assign different tasks to specialized agents (reviewers, managers, developers).
- **Event-driven routing** β Configure which agent handles `issue:new`, `pr:new`, `issue:comment`, `issue:assigned`, and more.
- **LLM flexibility** β Plug in any OpenAI-compatible API; each agent gets its own model choice.
- **Isolated execution** β Agents run in Docker containers with full filesystem access but no persistence between runs.
- **Live dashboard** β Real-time web UI showing active agents, logs, and job history.
- **Webhook & polling support** β React to events instantly via webhooks, or fall back to polling.
---
## π Quick Start
### Prerequisites
| Requirement | Version |
|-------------|---------|
| Go | 1.26+ |
| Docker | Latest |
| Forgejo | Any (self-hosted or codeberg.dk) |
| LLM endpoint | OpenAI-compatible API |
### Configuration
Copy the example config and customize it:
```bash
cp zoo.hcl.example zoo.hcl
```
Edit `zoo.hcl` with your Forgejo credentials, LLM tokens, and agent definitions. See the [configuration reference](#-configuration-reference) below.
### Running
```bash
go build -o zoo ./cmd/zoo
./zoo
```
The daemon starts on port `:8080` by default. Open your browser to see the dashboard.
---
## π₯ Meet the Agents
The example configuration includes four agents, each with a distinct role:
| Agent | Role | Suggested LLM | Handles |
|----------|-----------------------|---------------------|----------------------------------|
| **leon** | Engineering Manager | Qwen 3.8 | New issues, comments |
| **greg** | Senior Developer | Qwen 3.8 | Pull request reviews |
| **anna** | UI/UX Designer | Qwen 3.6 | Design-related issues & PRs |
| **mika** | Junior Developer | Qwen 3.6 | Assigned issues |
You can add, remove, or reassign agents freely in your `zoo.hcl`.
---
## βοΈ Configuration Reference
All settings live in a single HCL file (`zoo.hcl`). Here's what each section controls:
### LLM Definitions
Define one or more LLM endpoints. Agents reference these by name.
```hcl
llm "Qwen 3.6" {
openai = "https://your-llm-endpoint"
token = "YOUR_API_TOKEN"
model = "model-name"
}
```
### Forgejo Connection
```hcl
forgejo {
url = "https://code.stdio.dk"
token = "ZOO_SERVICE_TOKEN"
webhook_secret = "SHARED_SECRET" # optional if using polling
}
```
### Environment
```hcl
environment {
docker_image = "golang:latest" # base image for agent containers
max_live_agents = 5 # concurrent agent limit
}
```
### Agent Definition
```hcl
agent "anna" {
llm = "Qwen 3.6"
token = "ANNA_FORGEJO_TOKEN"
}
```
The optional `token` is the agent's own Forgejo token. When set, the
agent acts as itself on Forgejo (comments, PRs, ...) and its sandbox's
git authenticates with it too β the initial clone and all remote git
operations (pull, push, ...) run inside the container with that
credential. Without it, the shared `forgejo.token` is used.
### Event Routing
Map event types to agents with optional custom instructions:
```hcl
event "issue:new" {
agent = "leon"
instructions = "Triage this issue."
}
event "issue:assigned" {
// No `agent` β dynamically matches the assignee's username
instructions = "Please handle this issue."
}
event "pr:new" {
agent = "greg"
instructions = "Review this pull request."
}
```
---
## π§ Command-Line Flags
| Flag | Default | Description |
|-------------------|-----------------|----------------------------------------------|
| `-config` | `zoo.hcl` | Path to the configuration file |
| `-db` | `zoo.db` | SQLite database path |
| `-listen` | `:8080` | HTTP listen address |
| `-run-timeout` | (see source) | Max wall-clock time per agent run |
| `-keep-on-failure`| `false` | Preserve container after failure for debugging |
---
## π Project Structure
```
βββ cmd/zoo/main.go # Entry point
βββ internal/
β βββ agentrun/ # Docker-based agent execution
β βββ config/ # HCL config loader & validator
β βββ forgejo/ # Webhook receiver & event poller
β βββ livelog/ # Real-time log streaming hub
β βββ llm/ # LLM client & streaming
β βββ scheduler/ # Event-to-agent dispatcher
β βββ store/ # SQLite state management
β βββ tools/ # Agent tool registry
β βββ web/ # Dashboard HTTP server
βββ zoo.hcl.example # Configuration template
```
---
## π Development
### Running Tests
```bash
go test ./...
```
### Adding a New Agent
1. Define the agent in `zoo.hcl`:
```hcl
agent "newagent" {
llm = "Qwen 3.6"
token = "NEW_AGENT_TOKEN"
}
```
2. Add an event route:
```hcl
event "issue:new" {
agent = "newagent"
}
```
3. Update your Forgejo profile with the agent's role description.
### Adding a New Tool
Tools are registered in `internal/tools/registry.go`. Each tool is a Go function that operates on the agent's `/project` directory. See existing tools for patterns.
---
## π€ Contributing
Contributions are welcome! Please:
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Open a pull request
---
## π License
This project is available as open source under the terms of the [MIT License](LICENSE).
---
*Made with β€οΈ by the [zoo](https://code.stdio.dk/abrander/zoo) team.*
{"end_line":200,"path":"internal/forgejo/event.go","start_line":1}
{"path":"internal/forgejo/event.go","content":"package forgejo\n\nimport \"encoding/json\"\n\n// Event kinds, matching the values used as event block labels in\n// config.Config (config avoids importing this package, so the kind\n// strings are duplicated by value, not by identity).\nconst (\n\tEventIssueNew = \"issue:new\"\n\tEventIssueComment = \"issue:comment\"\n\tEventIssueAssigned = \"issue:assigned\"\n\tEventPRNew = \"pr:new\"\n\tEventPRReview = \"pr:review\"\n)\n\n// Event is the normalized shape produced by both the webhook receiver and\n// the polling fallback, and is what the scheduler dispatches on.\ntype Event struct {\n\tKind string\n\n\t// ID uniquely identifies this event for dedup, regardless of whether\n\t// it arrived via webhook or polling.\n\tID string\n\n\tOwner, Repo string\n\tIndex int64 // issue or PR number\n\n\tTitle, Body string\n\tAuthor string\n\n\t// Assignee is set for EventIssueAssigned: the Forgejo username the\n\t// issue was assigned to.\n\tAssignee string\n\n\t// PRAuthor is set for PR events: the Forgejo username of the user\n\t// who opened the pull request. For EventPRReview the scheduler\n\t// resolves the event to this user's agent, so the agent that opened\n\t// the PR reacts to the review of it.\n\tPRAuthor string\n\n\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}","start_line":1,"end_line":61,"total_lines":61,"truncated":false}
{"end_line":200,"path":"internal/forgejo/webhook.go","start_line":1}
{"path":"internal/forgejo/webhook.go","content":"package forgejo\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"time\"\n\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\n)\n\ntype issuePayload struct {\n\tAction string `json:\"action\"`\n\tIssue *sdk.Issue `json:\"issue\"`\n\tRepository *sdk.Repository `json:\"repository\"`\n}\n\ntype issueCommentPayload struct {\n\tAction string `json:\"action\"`\n\tIssue *sdk.Issue `json:\"issue\"`\n\tComment *sdk.Comment `json:\"comment\"`\n\tRepository *sdk.Repository `json:\"repository\"`\n}\n\ntype 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}\n\n// WebhookHandler returns the http.Handler to mount at (e.g.)\n// /webhooks/forgejo. If secret is non-empty, deliveries are verified via\n// the SDK's X-Forgejo-Signature middleware; callers should always set a\n// secret for anything reachable off localhost.\nfunc WebhookHandler(secret string, logger *slog.Logger, emit func(Event)) http.Handler {\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tbody, err := io.ReadAll(r.Body)\n\t\tif err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tkind := r.Header.Get(\"X-Forgejo-Event\")\n\t\tif kind == \"\" {\n\t\t\tkind = r.Header.Get(\"X-Gitea-Event\")\n\t\t}\n\n\t\tev, ok, err := decodeWebhookEvent(kind, body)\n\t\tif err != nil {\n\t\t\tlogger.Warn(\"failed to decode webhook payload\", \"event\", kind, \"error\", err)\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\n\t\tif ok {\n\t\t\temit(ev)\n\t\t}\n\n\t\tw.WriteHeader(http.StatusOK)\n\t})\n\n\tif secret == \"\" {\n\t\tlogger.Warn(\"forgejo webhook_secret is not set; incoming webhook deliveries are not authenticated\")\n\n\t\treturn handler\n\t}\n\n\treturn sdk.VerifyWebhookSignatureMiddleware(secret)(handler)\n}\n\nfunc decodeWebhookEvent(kind string, body []byte) (Event, bool, error) {\n\tswitch kind {\n\tcase \"issues\":\n\t\tvar p issuePayload\n\n\t\tif err := json.Unmarshal(body, \u0026p); err != nil {\n\t\t\treturn Event{}, false, err\n\t\t}\n\n\t\treturn issueEvent(p, body)\n\n\tcase \"issue_comment\":\n\t\tvar p issueCommentPayload\n\n\t\tif err := json.Unmarshal(body, \u0026p); err != nil {\n\t\t\treturn Event{}, false, err\n\t\t}\n\n\t\treturn issueCommentEvent(p, body)\n\n\tcase \"pull_request\":\n\t\tvar p pullRequestPayload\n\n\t\tif err := json.Unmarshal(body, \u0026p); 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, \u0026p); 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}\n\nfunc issueEvent(p issuePayload, raw []byte) (Event, bool, error) {\n\tif p.Issue == nil || p.Repository == nil {\n\t\treturn Event{}, false, nil\n\t}\n\n\towner := repoOwner(p.Repository)\n\n\tswitch p.Action {\n\tcase \"opened\":\n\t\treturn Event{\n\t\t\tKind: EventIssueNew,\n\t\t\tID: issueNewID(p.Issue.ID),\n\t\t\tOwner: owner,\n\t\t\tRepo: p.Repository.Name,\n\t\t\tIndex: p.Issue.Index,\n\t\t\tTitle: p.Issue.Title,\n\t\t\tBody: p.Issue.Body,\n\t\t\tAuthor: posterName(p.Issue.Poster),\n\t\t\tRaw: raw,\n\t\t}, true, nil\n\n\tcase \"assigned\":\n\t\tif len(p.Issue.Assignees) == 0 {\n\t\t\treturn Event{}, false, nil\n\t\t}\n\n\t\t// Webhook payloads only carry the single latest assignment as a\n\t\t// distinct field on some Gitea/Forgejo versions; using the last\n\t\t// entry in the current assignee list is the closest stable\n\t\t// approximation available from the Issue object alone.\n\t\tassignee := p.Issue.Assignees[len(p.Issue.Assignees)-1]\n\n\t\treturn Event{\n\t\t\tKind: EventIssueAssigned,\n\t\t\tID: issueAssignedID(p.Issue.ID, assignee.UserName, p.Issue.Updated),\n\t\t\tOwner: owner,\n\t\t\tRepo: p.Repository.Name,\n\t\t\tIndex: p.Issue.Index,\n\t\t\tTitle: p.Issue.Title,\n\t\t\tBody: p.Issue.Body,\n\t\t\tAuthor: posterName(p.Issue.Poster),\n\t\t\tAssignee: assignee.UserName,\n\t\t\tRaw: raw,\n\t\t}, true, nil\n\n\tdefault:\n\t\treturn Event{}, false, nil\n\t}\n}\n\nfunc issueCommentEvent(p issueCommentPayload, raw []byte) (Event, bool, error) {\n\tif p.Action != \"created\" || p.Issue == nil || p.Comment == nil || p.Repository == nil {\n\t\treturn Event{}, false, nil\n\t}\n\n\t// Comments on pull requests arrive on this same event in\n\t// Gitea/Forgejo (PRs are issues under the hood); pr:comment is out\n\t// of scope for v1.\n\tif p.Issue.PullRequest != nil {\n\t\treturn Event{}, false, nil\n\t}\n\n\treturn Event{\n\t\tKind: EventIssueComment,\n\t\tID: issueCommentID(p.Comment.ID),\n\t\tOwner: repoOwner(p.Repository),\n\t\tRepo: p.Repository.Name,\n\t\tIndex: p.Issue.Index,\n\t\tTitle: p.Issue.Title,\n\t\tBody: p.Issue.Body,\n\t\tAuthor: posterName(p.Comment.Poster),\n\t\tComment: p.Comment.Body,\n\t\tRaw: raw,\n\t}, true, nil\n}\n\nfunc pullRequestEvent(p pullRequestPayload, raw []byte) (Event, bool, error) {\n\tif p.Action != \"opened\" || p.PullRequest == nil || p.Repository == nil {","start_line":1,"end_line":200,"total_lines":294,"truncated":true}
{"end_line":294,"path":"internal/forgejo/webhook.go","start_line":200}
{"path":"internal/forgejo/webhook.go","content":"\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\tPRAuthor: 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\tPRAuthor: posterName(p.PullRequest.Poster),\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}\n\nfunc repoOwner(r *sdk.Repository) string {\n\tif r.Owner == nil {\n\t\treturn \"\"\n\t}\n\n\treturn r.Owner.UserName\n}\n\nfunc posterName(u *sdk.User) string {\n\tif u == nil {\n\t\treturn \"\"\n\t}\n\n\treturn u.UserName\n}\n\nfunc issueNewID(issueID int64) string {\n\treturn fmt.Sprintf(\"issue-new-%d\", issueID)\n}\n\n// issueAssignedID keys one *occurrence* of an assignment, not the\n// (issue, assignee) pair: Forgejo bumps the issue's updated timestamp on\n// every assignment, so assigning the same issue to the same agent again\n// yields a new id and triggers a new run, while the webhook delivery and\n// the poller's later sighting of that same assignment still collapse\n// onto one id and run once.\nfunc issueAssignedID(issueID int64, assignee string, assignedAt time.Time) string {\n\treturn fmt.Sprintf(\"issue-assigned-%d-%s-%d\", issueID, assignee, assignedAt.Unix())\n}\n\nfunc issueCommentID(commentID int64) string {\n\treturn fmt.Sprintf(\"issue-comment-%d\", commentID)\n}\n\nfunc 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}","start_line":200,"end_line":294,"total_lines":294,"truncated":false}
{"end_line":200,"path":"internal/forgejo/poll.go","start_line":1}
{"path":"internal/forgejo/poll.go","content":"package forgejo\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"time\"\n\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\n)\n\nconst (\n\twatermarkIssues = \"poll:issues\"\n\twatermarkPulls = \"poll:pulls\"\n)\n\n// pollOnce lists issues and pull requests updated since the stored\n// watermark, across every repo the configured token can see, and\n// dispatches synthesized Events for anything new. It's the fallback path\n// for when Forgejo webhooks aren't set up or reachable.\nfunc (w *Watcher) pollOnce(ctx context.Context) {\n\tif err := w.pollIssues(ctx); err != nil {\n\t\tw.logger.Warn(\"poll issues failed\", \"error\", err)\n\t}\n\n\tif err := w.pollPulls(ctx); err != nil {\n\t\tw.logger.Warn(\"poll pull requests failed\", \"error\", err)\n\t}\n}\n\nfunc (w *Watcher) pollIssues(ctx context.Context) error {\n\tsince, err := w.watermark(ctx, watermarkIssues)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tissues, _, err := w.client.sdk.ListIssues(sdk.ListIssueOption{\n\t\tType: sdk.IssueTypeIssue,\n\t\tState: sdk.StateAll,\n\t\tSince: since,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"list issues: %w\", err)\n\t}\n\n\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.Comments == 0 \u0026\u0026 issue.Created.After(since) {\n\t\t\tw.dispatch(issueToNewEvent(issue, owner, repo))\n\t\t} else if issue.Updated.After(since) {\n\t\t\tif err := w.pollNewComments(ctx, owner, repo, issue, since); err != nil {\n\t\t\t\tw.logger.Warn(\"poll issue comments failed\", \"owner\", owner, \"repo\", repo, \"issue\", issue.Index, \"error\", err)\n\t\t\t}\n\t\t}\n\n\t\tw.pollAssignments(ctx, owner, repo, issue)\n\t}\n\n\treturn w.store.SetWatermark(ctx, watermarkIssues, next.Format(time.RFC3339))\n}\n\n// pollAssignments dispatches an assigned event for each assignee that\n// wasn't on the issue the last time we looked. Listing only ever shows\n// current state, so without that comparison every unrelated update to an\n// assigned issue (a comment, an edit) would look like a fresh\n// assignment; and because the event id now varies per assignment\n// occurrence, dedup no longer masks that.\n//\n// The tradeoff is that an unassign and a re-assign to the same user\n// landing inside a single poll interval look like no change at all, and\n// only the webhook path catches them.\nfunc (w *Watcher) pollAssignments(ctx context.Context, owner, repo string, issue *sdk.Issue) {\n\tcurrent := make([]string, 0, len(issue.Assignees))\n\n\tfor _, assignee := range issue.Assignees {\n\t\tif assignee == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurrent = append(current, assignee.UserName)\n\t}\n\n\tadded, err := w.store.SyncAssignees(ctx, issue.ID, current)\n\tif err != nil {\n\t\tw.logger.Warn(\"sync assignees failed\", \"owner\", owner, \"repo\", repo, \"issue\", issue.Index, \"error\", err)\n\t\treturn\n\t}\n\n\tfor _, assignee := range added {\n\t\tw.dispatch(issueToAssignedEvent(issue, owner, repo, assignee))\n\t}\n}\n\nfunc (w *Watcher) pollNewComments(ctx context.Context, owner, repo string, issue *sdk.Issue, since time.Time) error {\n\tcomments, _, err := w.client.sdk.ListIssueComments(owner, repo, issue.Index, sdk.ListIssueCommentOptions{Since: since})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, comment := range comments {\n\t\tif !comment.Created.After(since) {\n\t\t\tcontinue\n\t\t}\n\n\t\tw.dispatch(issueToCommentEvent(issue, owner, repo, comment))\n\t}\n\n\treturn nil\n}\n\nfunc (w *Watcher) pollPulls(ctx context.Context) error {\n\tsince, err := w.watermark(ctx, watermarkPulls)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tissues, _, err := w.client.sdk.ListIssues(sdk.ListIssueOption{\n\t\tType: sdk.IssueTypePull,\n\t\tState: sdk.StateAll,\n\t\tSince: since,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"list pull requests: %w\", err)\n\t}\n\n\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}\n\nfunc (w *Watcher) watermark(ctx context.Context, key string) (time.Time, error) {\n\tv, err := w.store.GetWatermark(ctx, key)\n\tif err != nil {\n\t\treturn time.Time{}, err\n\t}\n\n\tif v == \"\" {\n\t\treturn time.Time{}, nil\n\t}\n\n\tt, err := time.Parse(time.RFC3339, v)\n\tif err != nil {\n\t\treturn time.Time{}, fmt.Errorf(\"parse watermark %q: %w\", key, err)\n\t}","start_line":1,"end_line":200,"total_lines":290,"truncated":true}
{"end_line":290,"path":"internal/forgejo/poll.go","start_line":200}
{"path":"internal/forgejo/poll.go","content":"\t}\n\n\treturn t, nil\n}\n\nfunc issueToNewEvent(issue *sdk.Issue, owner, repo string) Event {\n\traw, _ := json.Marshal(issue)\n\n\treturn Event{\n\t\tKind: EventIssueNew,\n\t\tID: issueNewID(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 issueToAssignedEvent(issue *sdk.Issue, owner, repo, assignee string) Event {\n\traw, _ := json.Marshal(issue)\n\n\treturn Event{\n\t\tKind: EventIssueAssigned,\n\t\tID: issueAssignedID(issue.ID, assignee, issue.Updated),\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\tAssignee: assignee,\n\t\tRaw: raw,\n\t}\n}\n\nfunc issueToCommentEvent(issue *sdk.Issue, owner, repo string, comment *sdk.Comment) Event {\n\traw, _ := json.Marshal(comment)\n\n\treturn Event{\n\t\tKind: EventIssueComment,\n\t\tID: issueCommentID(comment.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(comment.Poster),\n\t\tComment: comment.Body,\n\t\tRaw: raw,\n\t}\n}\n\nfunc 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\tPRAuthor: 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\tPRAuthor: posterName(pr.Poster),\n\t\tReviewID: review.ID,\n\t\tReviewState: string(review.State),\n\t\tReviewBody: review.Body,\n\t\tRaw: raw,\n\t}\n}","start_line":200,"end_line":290,"total_lines":290,"truncated":false}
{"end_line":200,"path":"internal/forgejo/watcher.go","start_line":1}
{"path":"internal/forgejo/watcher.go","content":"package forgejo\n\nimport (\n\t\"context\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\nconst PollInterval = 10 * time.Second\n\n// Watcher merges the webhook receiver and the polling fallback into a\n// single deduplicated Event stream.\ntype Watcher struct {\n\tclient *Client\n\tstore *store.Store\n\tsecret string\n\trepos *RepoFilter\n\tlogger *slog.Logger\n\n\tevents chan Event\n}\n\nfunc NewWatcher(client *Client, st *store.Store, webhookSecret string, repos *RepoFilter, logger *slog.Logger) *Watcher {\n\treturn \u0026Watcher{\n\t\tclient: client,\n\t\tstore: st,\n\t\tsecret: webhookSecret,\n\t\trepos: repos,\n\t\tlogger: logger,\n\t\tevents: make(chan Event, 64),\n\t}\n}\n\n// Handler returns the http.Handler to mount for incoming webhook\n// deliveries.\nfunc (w *Watcher) Handler() http.Handler {\n\treturn WebhookHandler(w.secret, w.logger, w.dispatch)\n}\n\n// Events returns the deduplicated stream consumed by the scheduler.\nfunc (w *Watcher) Events() \u003c-chan Event {\n\treturn w.events\n}\n\n// Run drives the polling fallback until ctx is canceled. The webhook\n// handler runs independently as part of the daemon's HTTP server.\nfunc (w *Watcher) Run(ctx context.Context) {\n\tticker := time.NewTicker(PollInterval)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase \u003c-ctx.Done():\n\t\t\treturn\n\n\t\tcase \u003c-ticker.C:\n\t\t\tw.pollOnce(ctx)\n\t\t}\n\t}\n}\n\n// dispatch drops ev if its repository isn't watched, dedupes it against\n// the store (regardless of whether it came from the webhook or the\n// poller) and, if new, forwards it to Events(). The repo filter runs\n// before MarkSeen on purpose: events from unwatched repositories must\n// not occupy dedup ids, so that a repository added to the config later\n// still has its new events processed.\nfunc (w *Watcher) dispatch(ev Event) {\n\tif !w.repos.Matches(ev.Owner, ev.Repo) {\n\t\tw.logger.Debug(\"ignoring event from unwatched repository\", \"owner\", ev.Owner, \"repo\", ev.Repo, \"kind\", ev.Kind, \"id\", ev.ID)\n\t\treturn\n\t}\n\n\tisNew, err := w.store.MarkSeen(context.Background(), ev.ID)\n\tif err != nil {\n\t\tw.logger.Error(\"dedup check failed, dropping event\", \"id\", ev.ID, \"error\", err)\n\t\treturn\n\t}\n\n\tif !isNew {\n\t\treturn\n\t}\n\n\tselect {\n\tcase w.events \u003c- ev:\n\n\tdefault:\n\t\tw.logger.Warn(\"event channel full, dropping event\", \"id\", ev.ID, \"kind\", ev.Kind)\n\t}\n}","start_line":1,"end_line":93,"total_lines":93,"truncated":false}
{"end_line":200,"path":"internal/forgejo/client.go","start_line":1}
{"path":"internal/forgejo/client.go","content":"// Package forgejo wraps the Forgejo SDK and turns webhook deliveries and\n// polling results into a common Event stream for the scheduler.\npackage forgejo\n\nimport (\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)\n\n// Client is zoo's single shared Forgejo identity, used both for the\n// event sources (webhook/poll) and for actions agents/scheduler take\n// (comments, labels, PRs).\ntype Client struct {\n\tsdk *sdk.Client\n\n\tbaseURL string\n\ttoken string\n}\n\nfunc NewClient(cfg config.Forgejo) (*Client, error) {\n\tc, err := sdk.NewClient(cfg.URL, sdk.SetToken(cfg.Token))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"forgejo client: %w\", err)\n\t}\n\n\treturn \u0026Client{sdk: c, baseURL: cfg.URL, token: cfg.Token}, nil\n}\n\n// Token returns the shared zoo Forgejo identity's token, e.g. for\n// authenticating a host-side git clone/push against Forgejo (see\n// internal/agentrun) without ever writing the credential into a working\n// tree an agent's container can read.\nfunc (c *Client) Token() string {\n\treturn c.token\n}\n\n// As returns a new Client that authenticates as the given token.\n// This is used to create per-agent clients so each agent acts as\n// themselves on Forgejo, without needing a global token with sudo\n// privileges.\nfunc (c *Client) As(token string) *Client {\n\tclient, _ := sdk.NewClient(c.baseURL, sdk.SetToken(token))\n\treturn \u0026Client{sdk: client, baseURL: c.baseURL, token: token}\n}\n\n// Sudo returns a new Client that impersonates username (via Forgejo's\n// \"Sudo:\" header) on every API call it makes, using the same underlying\n// token. Actions an agent takes through it β comments, labels, PRs,\n// assignment β are attributed to that agent's own Forgejo account\n// instead of the shared zoo identity. The token must belong to a user\n// with sudo scope/admin rights for this to work; Forgejo rejects the\n// header otherwise.\n//\n// Deprecated: use As(token) with a per-agent token instead. Kept for\n// backward compatibility during migration.\nfunc (c *Client) Sudo(username string) (*Client, error) {\n\tsudoClient, err := sdk.NewClient(c.baseURL, sdk.SetToken(c.token), sdk.SetSudo(username))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"forgejo client sudo %q: %w\", username, err)\n\t}\n\n\treturn \u0026Client{sdk: sudoClient, baseURL: c.baseURL, token: c.token}, nil\n}\n\n// CreateIssueComment posts a comment on the given issue or pull request\n// (Forgejo/Gitea treat PRs as issues for commenting purposes).\nfunc (c *Client) CreateIssueComment(owner, repo string, index int64, body string) error {\n\t_, _, err := c.sdk.CreateIssueComment(owner, repo, index, sdk.CreateIssueCommentOption{Body: body})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"comment on %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\treturn nil\n}\n\n// IssueComment is one comment on an issue or pull request, in the\n// shape zoo needs when briefing an agent: who said what, and when.\ntype IssueComment struct {\n\tAuthor string\n\tBody string\n\tCreated time.Time\n}\n\n// ListIssueComments fetches every comment on the given issue or pull\n// request, oldest first. PRs are issues under the hood in Forgejo, so\n// the same endpoint serves both. Pages are walked until exhausted so\n// the result isn't capped by the server's default page size.\nfunc (c *Client) ListIssueComments(owner, repo string, index int64) ([]IssueComment, error) {\n\tconst pageSize = 50\n\n\tvar all []*sdk.Comment\n\n\tfor page := 1; ; page++ {\n\t\tbatch, _, err := c.sdk.ListIssueComments(owner, repo, index, sdk.ListIssueCommentOptions{\n\t\t\tListOptions: sdk.ListOptions{Page: page, PageSize: pageSize},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"list comments on %s/%s#%d (page %d): %w\", owner, repo, index, page, err)\n\t\t}\n\n\t\tall = append(all, batch...)\n\n\t\tif len(batch) \u003c pageSize {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tout := make([]IssueComment, 0, len(all))\n\tfor _, cm := range all {\n\t\tauthor := \"\"\n\t\tif cm.Poster != nil {\n\t\t\tauthor = cm.Poster.UserName\n\t\t}\n\n\t\tout = append(out, IssueComment{Author: author, Body: cm.Body, Created: cm.Created})\n\t}\n\n\treturn out, nil\n}\n\n// AddLabel attaches the label with the given name to an issue/PR,\n// creating the label (with a default color) on the repo first if it\n// doesn't already exist.\nfunc (c *Client) AddLabel(owner, repo string, index int64, name string) error {\n\tid, err := c.labelID(owner, repo, name)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, _, err = c.sdk.AddIssueLabels(owner, repo, index, sdk.IssueLabelsOption{Labels: []int64{id}})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"add label %q to %s/%s#%d: %w\", name, owner, repo, index, err)\n\t}\n\n\treturn nil\n}\n\n// RemoveLabel detaches the label with the given name from an issue/PR, if\n// both the label and the attachment exist.\nfunc (c *Client) RemoveLabel(owner, repo string, index int64, name string) error {\n\tlabels, _, err := c.sdk.GetIssueLabels(owner, repo, index, sdk.ListLabelsOptions{})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"list labels on %s/%s#%d: %w\", owner, repo, index, err)\n\t}\n\n\tfor _, l := range labels {\n\t\tif l.Name == name {\n\t\t\t_, err := c.sdk.DeleteIssueLabel(owner, repo, index, l.ID)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"remove label %q from %s/%s#%d: %w\", name, owner, repo, index, err)\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (c *Client) labelID(owner, repo, name string) (int64, error) {\n\tlabels, _, err := c.sdk.ListRepoLabels(owner, repo, sdk.ListLabelsOptions{})\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"list labels on %s/%s: %w\", owner, repo, err)\n\t}\n\n\tfor _, l := range labels {\n\t\tif l.Name == name {\n\t\t\treturn l.ID, nil\n\t\t}\n\t}\n\n\tcreated, _, err := c.sdk.CreateLabel(owner, repo, sdk.CreateLabelOption{\n\t\tName: name,\n\t\tColor: \"#ee0000\",\n\t})\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"create label %q on %s/%s: %w\", name, owner, repo, err)\n\t}\n\n\treturn created.ID, nil\n}\n\n// CreatePullRequest opens a PR from head into base.\nfunc (c *Client) CreatePullRequest(owner, repo, head, base, title, body string) error {\n\t_, _, err := c.sdk.CreatePullRequest(owner, repo, sdk.CreatePullRequestOption{\n\t\tHead: head,\n\t\tBase: base,\n\t\tTitle: title,\n\t\tBody: body,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"create pull request %s/%s %s-\u003e%s: %w\", owner, repo, head, base, err)\n\t}\n\n\treturn nil\n}\n","start_line":1,"end_line":200,"total_lines":432,"truncated":true}
{"end_line":200,"path":"internal/config/config.go","start_line":1}
{"path":"internal/config/config.go","content":"// Package config loads and validates zoo's HCL configuration file.\npackage config\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/hashicorp/hcl/v2/hclsimple\"\n)\n\n// Known event kinds. issue:assigned and pr:review are resolved\n// dynamically (agent name must match the Forgejo assignee's username,\n// or the pull request author's username, respectively) so they never\n// carry an `agent` attribute; the rest map statically to one\n// configured agent.\nconst (\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}\n\ntype Config struct {\n\tLLMs []LLM `hcl:\"llm,block\"`\n\tForgejo Forgejo `hcl:\"forgejo,block\"`\n\tEnvironment Environment `hcl:\"environment,block\"`\n\tAgents []Agent `hcl:\"agent,block\"`\n\tEvents []Event `hcl:\"event,block\"`\n\tWeb *Web `hcl:\"web,block\"`\n}\n\n// Web configures the dashboard's optional bearer-token gate. Leave the\n// block out of zoo.hcl entirely to run without one (fine on localhost;\n// put a real gate or a proxy in front for anything else).\ntype Web struct {\n\tToken string `hcl:\"token,optional\"`\n}\n\ntype LLM struct {\n\tName string `hcl:\"name,label\"`\n\tOpenAI string `hcl:\"openai\"`\n\tToken string `hcl:\"token\"`\n\tModel string `hcl:\"model\"`\n}\n\ntype Forgejo struct {\n\tURL string `hcl:\"url\"`\n\tToken string `hcl:\"token\"`\n\tWebhookSecret string `hcl:\"webhook_secret,optional\"`\n\n\t// Repos is the allowlist of repository patterns to watch, e.g.\n\t// [\"acme/*\", \"acme/widgets\"]. Patterns are \"owner/repo\" pairs with\n\t// glob wildcards; \"*\" watches everything on the instance. An empty\n\t// list keeps the historical behavior of watching every repository\n\t// the token can see.\n\tRepos []string `hcl:\"repos,optional\"`\n}\n\ntype Environment struct {\n\tDockerImage string `hcl:\"docker_image\"`\n\tMaxLive int `hcl:\"max_live_agents\"`\n}\n\ntype Agent struct {\n\tName string `hcl:\"name,label\"`\n\tLLM string `hcl:\"llm\"`\n\tToken string `hcl:\"token,optional\"`\n}\n\ntype Event struct {\n\tKind string `hcl:\"name,label\"`\n\tAgent string `hcl:\"agent,optional\"`\n\tInstructions string `hcl:\"instructions,optional\"`\n}\n\n// Load reads and validates the config file at path.\nfunc Load(path string) (*Config, error) {\n\tvar cfg Config\n\n\tif err := hclsimple.DecodeFile(path, nil, \u0026cfg); err != nil {\n\t\treturn nil, fmt.Errorf(\"parse config: %w\", err)\n\t}\n\n\tif err := cfg.Validate(); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid config: %w\", err)\n\t}\n\n\treturn \u0026cfg, nil\n}\n\n// Validate checks that the config is internally consistent: every\n// reference between blocks resolves, and required values are set.\nfunc (c *Config) Validate() error {\n\tllmNames := make(map[string]bool, len(c.LLMs))\n\tfor _, l := range c.LLMs {\n\t\tif l.OpenAI == \"\" || l.Token == \"\" || l.Model == \"\" {\n\t\t\treturn fmt.Errorf(\"llm %q: openai, token, and model are required\", l.Name)\n\t\t}\n\t\tllmNames[l.Name] = true\n\t}\n\n\tif c.Forgejo.URL == \"\" || c.Forgejo.Token == \"\" {\n\t\treturn fmt.Errorf(\"forgejo: url and token are required\")\n\t}\n\n\tfor _, p := range c.Forgejo.Repos {\n\t\tif err := validRepoPattern(p); err != nil {\n\t\t\treturn fmt.Errorf(\"forgejo: %w\", err)\n\t\t}\n\t}\n\n\tif c.Environment.MaxLive \u003c 1 {\n\t\treturn fmt.Errorf(\"environment: max_live_agents must be \u003e= 1, got %d\", c.Environment.MaxLive)\n\t}\n\n\tif c.Environment.DockerImage == \"\" {\n\t\treturn fmt.Errorf(\"environment: docker_image is required\")\n\t}\n\n\tagentNames := make(map[string]bool, len(c.Agents))\n\tfor _, a := range c.Agents {\n\t\tif !llmNames[a.LLM] {\n\t\t\treturn fmt.Errorf(\"agent %q: references undeclared llm %q\", a.Name, a.LLM)\n\t\t}\n\t\tagentNames[a.Name] = true\n\t}\n\n\tseenEventKinds := make(map[string]bool, len(c.Events))\n\tfor _, e := range c.Events {\n\t\tif seenEventKinds[e.Kind] {\n\t\t\treturn fmt.Errorf(\"event %q: declared more than once\", e.Kind)\n\t\t}\n\t\tseenEventKinds[e.Kind] = true\n\n\t\tswitch {\n\t\tcase e.Kind == EventIssueAssigned:\n\t\t\tif e.Agent != \"\" {\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)\n\t\t\t}\n\n\t\tcase e.Kind == EventPRReview:\n\t\t\tif e.Agent != \"\" {\n\t\t\t\treturn fmt.Errorf(\"event %q: must not set agent; the agent whose name matches the pull request author's username is triggered dynamically\", e.Kind)\n\t\t\t}\n\n\t\tcase staticEventKinds[e.Kind]:\n\t\t\tif e.Agent == \"\" {\n\t\t\t\treturn fmt.Errorf(\"event %q: agent is required\", e.Kind)\n\t\t\t}\n\t\t\tif !agentNames[e.Agent] {\n\t\t\t\treturn fmt.Errorf(\"event %q: references undeclared agent %q\", e.Kind, e.Agent)\n\t\t\t}\n\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"event %q: unknown event kind\", e.Kind)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// validRepoPattern checks the shape of a forgejo `repos` entry. It must\n// be a non-empty \"owner/repo\" pair (or a bare owner name, or \"*\") β at\n// most one slash, none at either end. Glob syntax within the segments\n// is validated later by forgejo.NewRepoFilter.\nfunc validRepoPattern(p string) error {\n\tif p == \"\" {\n\t\treturn fmt.Errorf(\"repos: pattern must not be empty\")\n\t}\n\n\tif strings.Count(p, \"/\") \u003e 1 {\n\t\treturn fmt.Errorf(\"repos: pattern %q must be \\\"owner/repo\\\" (at most one \\\"/\\\")\", p)\n\t}\n\n\tif strings.HasPrefix(p, \"/\") || strings.HasSuffix(p, \"/\") {\n\t\treturn fmt.Errorf(\"repos: pattern %q must not start or end with \\\"/\\\"\", p)\n\t}\n\n\treturn nil\n}\n\n// AgentConfig holds an agent's full configuration including its\n// Forgejo token (if configured).\ntype AgentConfig struct {\n\tName string\n\tLLM string\n\tToken string\n}\n\n// AgentByName returns the agent config with the given name, if declared.\nfunc (c *Config) AgentByName(name string) (AgentConfig, bool) {\n\tfor _, a := range c.Agents {\n\t\tif a.Name == name {","start_line":1,"end_line":200,"total_lines":246,"truncated":true}
{"end_line":250,"path":"internal/agentrun/forgejo.go","start_line":1}
{"path":"internal/agentrun/forgejo.go","content":"package agentrun\n\nimport (\n\t\"log/slog\"\n\n\t\"github.com/abrander/zoo/internal/forgejo\"\n)\n\n// runForgejoActions implements tools.ForgejoActions, binding the\n// generic forgejo.Client to the single issue/PR that triggered this run\n// so agent tool calls don't need to specify owner/repo/index themselves.\ntype runForgejoActions struct {\n\tclient *forgejo.Client\n\towner, repo string\n\tindex int64\n\tlogger *slog.Logger\n}\n\n// logResult logs a forge action once it's known to have succeeded or\n// failed, so every comment, PR, label, or issue-state change an agent\n// makes is traceable in zoo's own logs, not just in Forgejo's history.\nfunc (a *runForgejoActions) logResult(action string, err error, args ...any) {\n\tfields := append([]any{\"owner\", a.owner, \"repo\", a.repo, \"index\", a.index}, args...)\n\n\tif err != nil {\n\t\ta.logger.Warn(\"forge action failed\", append(append([]any{\"action\", action}, fields...), \"error\", err)...)\n\t\treturn\n\t}\n\n\ta.logger.Info(\"forge action\", append([]any{\"action\", action}, fields...)...)\n}\n\nfunc (a *runForgejoActions) Comment(body string) error {\n\terr := a.client.CreateIssueComment(a.owner, a.repo, a.index, body)\n\ta.logResult(\"comment\", err)\n\n\treturn err\n}\n\nfunc (a *runForgejoActions) OpenPullRequest(head, base, title, body string) error {\n\terr := a.client.CreatePullRequest(a.owner, a.repo, head, base, title, body)\n\ta.logResult(\"open_pull_request\", err, \"head\", head, \"base\", base, \"title\", title)\n\n\treturn err\n}\n\nfunc (a *runForgejoActions) RequestReview(reviewers []string) error {\n\terr := a.client.RequestReview(a.owner, a.repo, a.index, reviewers)\n\ta.logResult(\"request_review\", err, \"reviewers\", reviewers)\n\n\treturn err\n}\n\nfunc (a *runForgejoActions) AddLabel(name string) error {\n\terr := a.client.AddLabel(a.owner, a.repo, a.index, name)\n\ta.logResult(\"add_label\", err, \"label\", name)\n\n\treturn err\n}\n\nfunc (a *runForgejoActions) RemoveLabel(name string) error {\n\terr := a.client.RemoveLabel(a.owner, a.repo, a.index, name)\n\ta.logResult(\"remove_label\", err, \"label\", name)\n\n\treturn err\n}\n\nfunc (a *runForgejoActions) CloseIssue() error {\n\terr := a.client.CloseIssue(a.owner, a.repo, a.index)\n\ta.logResult(\"close_issue\", err)\n\n\treturn err\n}\n\nfunc (a *runForgejoActions) ReopenIssue() error {\n\terr := a.client.ReopenIssue(a.owner, a.repo, a.index)\n\ta.logResult(\"reopen_issue\", err)\n\n\treturn err\n}\n\nfunc (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}","start_line":1,"end_line":94,"total_lines":94,"truncated":false}
{"query":"Comments"}
/project/README.md:58:| **leon** | Engineering Manager | Qwen 3.8 | New issues, comments |
/project/README.md:112:agent acts as itself on Forgejo (comments, PRs, ...) and its sandbox's
/project/.git/info/exclude:2:# Lines that start with '#' are comments.
/project/.git/hooks/commit-msg.sample:47: # Skip comments and blank lines at the start of the file.
/project/internal/tools/tools_test.go:12: comments []string
/project/internal/tools/tools_test.go:18: f.comments = append(f.comments, body)
/project/internal/tools/tools_test.go:92: if len(fg.comments) != 1 || fg.comments[0] != "hello" {
/project/internal/tools/tools_test.go:93: t.Fatalf("unexpected comments: %v", fg.comments)
/project/internal/forgejo/poll.go:59: if issue.Comments == 0 && issue.Created.After(since) {
/project/internal/forgejo/poll.go:62: if err := w.pollNewComments(ctx, owner, repo, issue, since); err != nil {
/project/internal/forgejo/poll.go:63: w.logger.Warn("poll issue comments failed", "owner", owner, "repo", repo, "issue", issue.Index, "error", err)
/project/internal/forgejo/poll.go:105:func (w *Watcher) pollNewComments(ctx context.Context, owner, repo string, issue *sdk.Issue, since time.Time) error {
/project/internal/forgejo/poll.go:106: comments, _, err := w.client.sdk.ListIssueComments(owner, repo, issue.Index, sdk.ListIssueCommentOptions{Since: since})
/project/internal/forgejo/poll.go:111: for _, comment := range comments {
/project/internal/forgejo/webhook_test.go:98: "body": "Please address the inline comments.",
/project/internal/forgejo/webhook_test.go:235: if ev.ReviewID != 42 || ev.ReviewState != "REQUEST_CHANGES" || ev.ReviewBody != "Please address the inline comments." {
/project/internal/forgejo/webhook.go:178: // Comments on pull requests arrive on this same event in
/project/internal/forgejo/client.go:16:// (comments, labels, PRs).
/project/internal/forgejo/client.go:52:// token. Actions an agent takes through it β comments, labels, PRs,
/project/internal/forgejo/client.go:88:// ListIssueComments fetches every comment on the given issue or pull
/project/internal/forgejo/client.go:92:func (c *Client) ListIssueComments(owner, repo string, index int64) ([]IssueComment, error) {
/project/internal/forgejo/client.go:98: batch, _, err := c.sdk.ListIssueComments(owner, repo, index, sdk.ListIssueCommentOptions{
/project/internal/forgejo/client.go:102: return nil, fmt.Errorf("list comments on %s/%s#%d (page %d): %w", owner, repo, index, page, err)
/project/internal/forgejo/client.go:257:// comments.
/project/internal/forgejo/client.go:263: Comments []ReviewCommentDetail
/project/internal/forgejo/client.go:266:// ReviewDetail fetches a pull request review and its inline comments.
/project/internal/forgejo/client.go:267:// The webhook payload carries the review but not its inline comments,
/project/internal/forgejo/client.go:275: comments, _, err := c.sdk.ListPullReviewComments(owner, repo, index, reviewID)
/project/internal/forgejo/client.go:277: return nil, fmt.Errorf("list comments on review %d of %s/%s#%d: %w", reviewID, owner, repo, index, err)
/project/internal/forgejo/client.go:290: for _, cm := range comments {
/project/internal/forgejo/client.go:301: detail.Comments = append(detail.Comments, ReviewCommentDetail{
/project/internal/forgejo/poll_test.go:56: "body": "Please address the inline comments.",
/project/internal/forgejo/poll_test.go:82: if ev.ReviewID != 42 || ev.ReviewState != "REQUEST_CHANGES" || ev.ReviewBody != "Please address the inline comments." {
/project/internal/forgejo/client_test.go:16:// /repos/{owner}/{repo}/issues/{index}/comments endpoint.
/project/internal/forgejo/client_test.go:47:func TestListIssueCommentsFetchesAllPages(t *testing.T) {
/project/internal/forgejo/client_test.go:52: mux.HandleFunc("/api/v1/repos/acme/widgets/issues/5/comments", func(w http.ResponseWriter, r *http.Request) {
/project/internal/forgejo/client_test.go:81: comments, err := client.ListIssueComments("acme", "widgets", 5)
/project/internal/forgejo/client_test.go:83: t.Fatalf("list comments: %v", err)
/project/internal/forgejo/client_test.go:86: if len(comments) != total {
/project/internal/forgejo/client_test.go:87: t.Fatalf("expected %d comments, got %d", total, len(comments))
/project/internal/forgejo/client_test.go:90: for i, c := range comments {
/project/internal/forgejo/client_test.go:102:func TestListIssueCommentsEmpty(t *testing.T) {
/project/internal/forgejo/client_test.go:105: mux.HandleFunc("/api/v1/repos/acme/widgets/issues/7/comments", func(w http.ResponseWriter, r *http.Request) {
/project/internal/forgejo/client_test.go:115: comments, err := client.ListIssueComments("acme", "widgets", 7)
/project/internal/forgejo/client_test.go:117: t.Fatalf("list comments: %v", err)
/project/internal/forgejo/client_test.go:120: if len(comments) != 0 {
/project/internal/forgejo/client_test.go:121: t.Fatalf("expected no comments, got %d", len(comments))
/project/internal/forgejo/client_test.go:134: "body": "Please address the inline comments.",
/project/internal/forgejo/client_test.go:139: mux.HandleFunc("/api/v1/repos/acme/widgets/pulls/9/reviews/42/comments", func(w http.ResponseWriter, r *http.Request) {
/project/internal/forgejo/client_test.go:160: if detail.Body != "Please address the inline comments." {
/project/internal/forgejo/client_test.go:163: if len(detail.Comments) != 2 {
/project/internal/forgejo/client_test.go:164: t.Fatalf("expected 2 comments, got %d", len(detail.Comments))
/project/internal/forgejo/client_test.go:166: if detail.Comments[0].ID != 7 || detail.Comments[0].Path != "src/foo.go" || detail.Comments[0].Line != 42 || detail.Comments[0].Author != "leon" {
/project/internal/forgejo/client_test.go:167: t.Fatalf("unexpected comment 0: %+v", detail.Comments[0])
/project/internal/forgejo/client_test.go:170: if detail.Comments[1].ID != 8 || detail.Comments[1].Line != 10 {
/project/internal/forgejo/client_test.go:171: t.Fatalf("unexpected comment 1: %+v", detail.Comments[1])
/project/internal/forgejo/event.go:45: // verdict, and body. Inline review comments are not part of the
/project/internal/agentrun/seed_test.go:31:func TestSeedMessageIncludesAllComments(t *testing.T) {
/project/internal/agentrun/seed_test.go:34: comments := []forgejo.IssueComment{
/project/internal/agentrun/seed_test.go:39: msg := seedMessage(ev, "issue-5-greg", "main", "", comments, nil)
/project/internal/agentrun/seed_test.go:42: "Comments (2):",
/project/internal/agentrun/seed_test.go:51: // Comments come before the raw payload, so the agent reads them
/project/internal/agentrun/seed_test.go:53: if !strings.Contains(msg, "Comments (2):") ||
/project/internal/agentrun/seed_test.go:54: strings.Index(msg, "Comments (2):") > strings.Index(msg, "Full event payload:") {
/project/internal/agentrun/seed_test.go:55: t.Fatalf("expected comments section before the raw payload, got:\n%s", msg)
/project/internal/agentrun/seed_test.go:59:func TestSeedMessageOmitsEmptyComments(t *testing.T) {
/project/internal/agentrun/seed_test.go:64: if strings.Contains(msg, "Comments") {
/project/internal/agentrun/seed_test.go:65: t.Fatalf("expected no comments section, got: %s", msg)
/project/internal/agentrun/seed_test.go:79: ReviewBody: "Please address the inline comments.",
/project/internal/agentrun/seed_test.go:86: Body: "Please address the inline comments.",
/project/internal/agentrun/seed_test.go:88: Comments: []forgejo.ReviewCommentDetail{
/project/internal/agentrun/seed_test.go:101: "Please address the inline comments.",
/project/internal/agentrun/seed_test.go:102: "Inline comments (2):",
/project/internal/agentrun/run.go:163: // Fetch the full review (verdict, body, inline comments) so the
/project/internal/agentrun/run.go:167: // comments.
/project/internal/agentrun/run.go:170: logger.Warn("fetch review detail failed; agent will not see inline review comments", "error", err)
/project/internal/agentrun/run.go:256: // no comments rather than failing the run: the agent can still do
/project/internal/agentrun/run.go:258: comments, err := r.forgejo.ListIssueComments(ev.Owner, ev.Repo, ev.Index)
/project/internal/agentrun/run.go:260: logger.Warn("fetch issue comments failed; agent will not see prior comments", "error", err)
/project/internal/agentrun/run.go:261: comments = nil
/project/internal/agentrun/run.go:266: {Role: "user", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions, comments, review)},
/project/internal/agentrun/run.go:384:func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, comments []forgejo.IssueComment, review *forgejo.ReviewDetail) string {
/project/internal/agentrun/run.go:404: var commentsSection string
/project/internal/agentrun/run.go:405: if len(comments) > 0 {
/project/internal/agentrun/run.go:407: fmt.Fprintf(&b, "Comments (%d):\n\n", len(comments))
/project/internal/agentrun/run.go:409: for i, c := range comments {
/project/internal/agentrun/run.go:413: commentsSection = b.String()
/project/internal/agentrun/run.go:420: ev.Kind, ev.Owner, ev.Repo, instructionsSection, branchLine, reviewSection, ev.Title, ev.Body, commentsSection, raw)
/project/internal/agentrun/run.go:440: if len(review.Comments) > 0 {
/project/internal/agentrun/run.go:441: fmt.Fprintf(&b, " Inline comments (%d):\n", len(review.Comments))
/project/internal/agentrun/run.go:443: for i, c := range review.Comments {
/project/internal/scheduler/scheduler_test.go:84: comments []string
/project/internal/scheduler/scheduler_test.go:91: f.comments = append(f.comments, body)
/project/internal/scheduler/scheduler_test.go:172: if len(fg.comments) != 0 || len(fg.labels) != 0 {
/project/internal/scheduler/scheduler_test.go:173: t.Fatalf("expected no failure reporting on success, got comments=%v labels=%v", fg.comments, fg.labels)
/project/internal/scheduler/scheduler_test.go:251: if len(fg.comments) != 1 || len(fg.labels) != 1 || fg.labels[0] != FailureLabel {
/project/internal/scheduler/scheduler_test.go:252: t.Fatalf("expected failure comment+label, got comments=%v labels=%v", fg.comments, fg.labels)
/project/zoo.hcl.example:92:# addressed. (Forgejo has no API to mark review comments resolved, so
{"end_line":120,"path":"internal/agentrun/run.go","start_line":1}
{"path":"internal/agentrun/run.go","content":"// Package agentrun creates the throwaway per-job environment an agent\n// runs in: a Docker container with the agent's git working tree cloned\n// inside it (authenticated with the agent's own Forgejo token), the\n// triggering event written to /event, and the tool-calling loop\n// (internal/llm + internal/tools) driven against it.\npackage agentrun\n\nimport (\n\t\"context\"\n\t_ \"embed\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/forgejo\"\n\t\"github.com/abrander/zoo/internal/livelog\"\n\t\"github.com/abrander/zoo/internal/llm\"\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\n//go:embed system.md\nvar defaultSystemPrompt string\n\n// DefaultTimeout bounds a single agent run's wall-clock time if the\n// caller doesn't override it.\nconst DefaultTimeout = 120 * time.Minute\n\ntype Runner struct {\n\tdocker *dockerRuntime\n\tforgejo *forgejo.Client\n\tstore *store.Store\n\thub *livelog.Hub\n\tcfg *config.Config\n\tlogger *slog.Logger\n\ttimeout time.Duration\n\tkeepOnFailure bool\n\n\tagentClientsMu sync.Mutex\n\tagentClients map[string]*forgejo.Client\n}\n\nfunc NewRunner(cfg *config.Config, fg *forgejo.Client, st *store.Store, hub *livelog.Hub, logger *slog.Logger, timeout time.Duration, keepOnFailure bool) (*Runner, error) {\n\tdocker, err := newDockerRuntime()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif timeout \u003c= 0 {\n\t\ttimeout = DefaultTimeout\n\t}\n\n\treturn \u0026Runner{\n\t\tdocker: docker,\n\t\tforgejo: fg,\n\t\tstore: st,\n\t\thub: hub,\n\t\tcfg: cfg,\n\t\tlogger: logger,\n\t\ttimeout: timeout,\n\t\tkeepOnFailure: keepOnFailure,\n\t\tagentClients: make(map[string]*forgejo.Client),\n\t}, nil\n}\n\n// forgejoAs returns a Forgejo client that authenticates as the given\n// agent (using the agent's own token from config). This lets each agent\n// act as themselves on Forgejo without needing a global token with sudo\n// privileges. Clients are built once per agent and cached, since\n// constructing one costs an extra API round trip.\n//\n// If the agent has no token configured, falls back to the shared zoo\n// identity so existing deployments without per-agent tokens still work.\nfunc (r *Runner) forgejoAs(agentName, token string) *forgejo.Client {\n\tr.agentClientsMu.Lock()\n\tdefer r.agentClientsMu.Unlock()\n\n\tif c, ok := r.agentClients[agentName]; ok {\n\t\treturn c\n\t}\n\n\tvar c *forgejo.Client\n\tif token != \"\" {\n\t\tc = r.forgejo.As(token)\n\t} else {\n\t\t// Fallback: use shared identity. Optionally log a warning\n\t\t// if we ever want to enforce per-agent tokens.\n\t\tc = r.forgejo\n\t}\n\n\tr.agentClients[agentName] = c\n\n\treturn c\n}\n\n// Run implements scheduler.Runner.\nfunc (r *Runner) Run(ctx context.Context, jobID string, agent config.AgentConfig, llmCfg config.LLM, dockerImage string, ev forgejo.Event) error {\n\tctx, cancel := context.WithTimeout(ctx, r.timeout)\n\tdefer cancel()\n\n\tlogger := r.logger.With(\"job\", jobID, \"agent\", agent.Name)\n\n\trepoInfo, err := r.forgejo.RepositoryInfo(ev.Owner, ev.Repo)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"look up repository: %w\", err)\n\t}\n\n\tworkDir, err := os.MkdirTemp(\"\", \"zoo-run-*\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"create work dir: %w\", err)\n\t}\n\n\tsucceeded := false\n\n\tdefer func() {","start_line":1,"end_line":120,"total_lines":471,"truncated":true}
{"end_line":120,"path":"cmd/zoo/main.go","start_line":1}
{"path":"cmd/zoo/main.go","content":"// Command zoo runs the daemon: it watches a Forgejo instance for\n// issue/PR events, dispatches them to configured AI agents running in\n// Docker containers, and serves a small dashboard over the result.\npackage main\n\nimport (\n\t\"context\"\n\t\"flag\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/abrander/zoo/internal/agentrun\"\n\t\"github.com/abrander/zoo/internal/config\"\n\t\"github.com/abrander/zoo/internal/forgejo\"\n\t\"github.com/abrander/zoo/internal/livelog\"\n\t\"github.com/abrander/zoo/internal/scheduler\"\n\t\"github.com/abrander/zoo/internal/store\"\n\t\"github.com/abrander/zoo/internal/web\"\n)\n\nfunc main() {\n\tif err := run(); err != nil {\n\t\tfmt.Fprintln(os.Stderr, \"zoo:\", err)\n\n\t\tos.Exit(1)\n\t}\n}\n\nfunc run() error {\n\tvar (\n\t\tconfigPath = flag.String(\"config\", \"zoo.hcl\", \"path to the zoo.hcl config file\")\n\t\tdbPath = flag.String(\"db\", \"zoo.db\", \"path to the sqlite state database\")\n\t\tlisten = flag.String(\"listen\", \":8080\", \"address to serve webhooks and the dashboard on\")\n\t\trunTimeout = flag.Duration(\"run-timeout\", agentrun.DefaultTimeout, \"wall-clock timeout for a single agent run\")\n\t\tkeepOnFailure = flag.Bool(\"keep-on-failure\", false, \"keep the container and clone around after a failed run, for debugging\")\n\t)\n\n\tflag.Parse()\n\n\tlogger := slog.New(slog.NewTextHandler(os.Stderr, nil))\n\n\tcfg, err := config.Load(*configPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"load config: %w\", err)\n\t}\n\n\tst, err := store.Open(*dbPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"open store: %w\", err)\n\t}\n\tdefer st.Close()\n\n\tif n, err := st.ReapOrphanedJobs(context.Background()); err != nil {\n\t\tlogger.Warn(\"failed to reap orphaned jobs\", \"error\", err)\n\t} else if n \u003e 0 {\n\t\tlogger.Warn(\"reaped jobs left running by a previous process instance\", \"count\", n)\n\t}\n\n\tfgClient, err := forgejo.NewClient(cfg.Forgejo)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"forgejo client: %w\", err)\n\t}\n\n\trepoFilter, err := forgejo.NewRepoFilter(cfg.Forgejo.Repos)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"forgejo repos: %w\", err)\n\t}\n\n\twatcher := forgejo.NewWatcher(fgClient, st, cfg.Forgejo.WebhookSecret, repoFilter, logger.With(\"component\", \"forgejo\"))\n\n\thub := livelog.NewHub()\n\n\trunner, err := agentrun.NewRunner(cfg, fgClient, st, hub, logger.With(\"component\", \"agentrun\"), *runTimeout, *keepOnFailure)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"agent runner: %w\", err)\n\t}\n\n\tsched := scheduler.New(cfg, st, fgClient, runner, hub, logger.With(\"component\", \"scheduler\"))\n\tdashboard := web.New(cfg, st, hub, fgClient)\n\n\tmux := http.NewServeMux()\n\n\tmux.Handle(\"/webhooks/forgejo\", watcher.Handler())\n\n\tmux.HandleFunc(\"/healthz\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(\"ok\"))\n\t})\n\n\tmux.Handle(\"/\", dashboard.Handler())\n\n\tserver := \u0026http.Server{Addr: *listen, Handler: mux}\n\n\tctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)\n\tdefer stop()\n\n\tgo watcher.Run(ctx)\n\tgo sched.Run(ctx, watcher.Events())\n\n\tserveErr := make(chan error, 1)\n\n\tgo func() {\n\t\tlogger.Info(\"listening\", \"addr\", *listen)\n\t\tif err := server.ListenAndServe(); err != nil \u0026\u0026 err != http.ErrServerClosed {\n\t\t\tserveErr \u003c- err\n\t\t}\n\t}()\n\n\tselect {\n\tcase \u003c-ctx.Done():\n\t\tlogger.Info(\"shutting down\")\n\n\tcase err := \u003c-serveErr:\n\t\tstop()\n","start_line":1,"end_line":120,"total_lines":136,"truncated":true}
{"end_line":280,"path":"internal/agentrun/run.go","start_line":120}
{"path":"internal/agentrun/run.go","content":"\tdefer func() {\n\t\tif succeeded || !r.keepOnFailure {\n\t\t\tos.RemoveAll(workDir)\n\t\t} else {\n\t\t\tlogger.Warn(\"keeping work dir after failure\", \"dir\", workDir)\n\t\t}\n\t}()\n\n\t// The container bind-mounts projectDir as /project and does the\n\t// initial clone into it, so the (empty) directory must exist on the\n\t// host before the container is created β otherwise Docker would\n\t// create it itself, root-owned.\n\tprojectDir := filepath.Join(workDir, \"project\")\n\n\tif err := os.MkdirAll(projectDir, 0o755); err != nil {\n\t\treturn fmt.Errorf(\"create project dir: %w\", err)\n\t}\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\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}\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}\n\n\troster := buildRoster(r.forgejo, r.cfg.Agents, logger)\n\tgitName, gitEmail := gitIdentity(agent.Name, roster)\n\n\t// The credential the sandbox's git uses for remote operations: the\n\t// agent's own Forgejo token when configured, so its git activity is\n\t// attributed to its own account, falling back to the shared zoo\n\t// identity for deployments without per-agent tokens (mirroring\n\t// forgejoAs).\n\tgitUser, gitToken := \"zoo\", r.forgejo.Token()\n\n\tif agent.Token != \"\" {\n\t\tgitUser, gitToken = agent.Name, agent.Token\n\t}\n\n\teventPath := filepath.Join(workDir, \"event.json\")\n\tif err := os.WriteFile(eventPath, ev.Raw, 0o644); err != nil {\n\t\treturn fmt.Errorf(\"write event file: %w\", err)\n\t}\n\n\tcontainerID, err := r.docker.createContainer(ctx, dockerImage, []string{\n\t\tprojectDir + \":/project\",\n\t\teventPath + \":/event:ro\",\n\t}, fmt.Sprintf(\"zoo-issue-%d-%s\", ev.Index, agent.Name))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"start container: %w\", err)\n\t}\n\n\tdefer func() {\n\t\tcleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)\n\t\tdefer cleanupCancel()\n\t\tif err := r.docker.remove(cleanupCtx, containerID); err != nil {\n\t\t\tlogger.Warn(\"failed to remove container\", \"container\", containerID, \"error\", err)\n\t\t}\n\t}()\n\n\t// Git must simply work inside the sandbox: safe.directory, commit\n\t// identity, and the remote credential all go into the container's\n\t// system gitconfig (see configureSandboxGit).\n\tif err := configureSandboxGit(ctx, r.docker, containerID, repoInfo.CloneURL, gitUser, gitToken, gitName, gitEmail); err != nil {\n\t\treturn fmt.Errorf(\"configure git in container: %w\", err)\n\t}\n\n\t// The initial clone happens inside the sandbox, so the working tree\n\t// is owned by the container's user and git never runs on the host.\n\tif ev.Kind == forgejo.EventPRReview {\n\t\tif err := clonePRHead(ctx, r.docker, containerID, repoInfo.CloneURL, repoInfo.DefaultBranch, branch, ev.Index); err != nil {\n\t\t\treturn fmt.Errorf(\"prepare git working tree: %w\", err)\n\t\t}\n\t} else {\n\t\tif err := cloneAndBranch(ctx, r.docker, containerID, repoInfo.CloneURL, repoInfo.DefaultBranch, branch); err != nil {\n\t\t\treturn fmt.Errorf(\"prepare git working tree: %w\", err)\n\t\t}\n\t}\n\n\tlogAppend := func(stream, line string) {\n\t\tif err := r.store.AppendLog(context.Background(), jobID, stream, line); err != nil {\n\t\t\tlogger.Warn(\"failed to append log\", \"error\", err)\n\t\t}\n\t}\n\n\trunCtx := \u0026runContext{\n\t\tdocker: r.docker,\n\t\tcontainerID: containerID,\n\t\tforgejo: \u0026runForgejoActions{\n\t\t\tclient: r.forgejoAs(agent.Name, agent.Token),\n\t\t\towner: ev.Owner,\n\t\t\trepo: ev.Repo,\n\t\t\tindex: ev.Index,\n\t\t\tlogger: logger,\n\t\t},\n\t}\n\n\tllmClient := llm.NewClient(llmCfg)\n\n\tsystemPrompt := defaultSystemPrompt + identitySection(agent.Name, roster)\n\n\tinstructions := r.cfg.EventInstructions(ev.Kind)\n\n\t// Fetch the full comment thread so the agent sees everything that's\n\t// been said on the issue/PR, not just the triggering event (which\n\t// only carries the latest comment, if any). A failure degrades to\n\t// no comments rather than failing the run: the agent can still do\n\t// its job, just without prior context.\n\tcomments, err := r.forgejo.ListIssueComments(ev.Owner, ev.Repo, ev.Index)\n\tif err != nil {\n\t\tlogger.Warn(\"fetch issue comments failed; agent will not see prior comments\", \"error\", err)\n\t\tcomments = nil\n\t}\n\n\tmessages := []llm.Message{\n\t\t{Role: \"system\", Content: systemPrompt},\n\t\t{Role: \"user\", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions, comments, review)},\n\t}\n\n\thooks := r.streamHooks(jobID, logAppend)\n\n\t_, err = runLoop(ctx, llmClient, runCtx, messages, hooks)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"agent loop: %w\", err)\n\t}\n\n\tsucceeded = true\n\n\treturn nil\n}\n","start_line":120,"end_line":280,"total_lines":471,"truncated":true}
{"command":"cd /project 2>/dev/null; cd /project && grep -rn \"GetUser\\|/user\" $(go env GOMODCACHE)/codeberg.org/mvdkleijn/forgejo-sdk*/forgejo/v2/*.go 2>/dev/null | grep -i \"func\\|path\" | head -30"}
{"command":"cd /project && go env GOMODCACHE && ls $(go env GOMODCACHE)/codeberg.org/mvdkleijn/ 2>/dev/null"}
ERROR CALLING TOOL: command exited with status 2: /home/user/go/pkg/mod
{"command":"cd /project && cat /project/go.mod && cd /project && go build ./... 2>&1 | head -20"}
module github.com/abrander/zoo go 1.26.4 require ( codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 github.com/docker/docker v28.5.2+incompatible github.com/google/uuid v1.6.0 github.com/hashicorp/hcl/v2 v2.24.0 modernc.org/sqlite v1.56.0 ) require ( github.com/42wim/httpsig v1.2.3 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/agext/levenshtein v1.2.1 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.8.1 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.1.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/zclconf/go-cty v1.16.3 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect go.opentelemetry.io/otel v1.45.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 // indirect go.opentelemetry.io/otel/metric v1.45.0 // indirect go.opentelemetry.io/otel/trace v1.45.0 // indirect golang.org/x/crypto v0.39.0 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.47.0 // indirect gotest.tools/v3 v3.5.2 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) go: downloading github.com/docker/docker v28.5.2+incompatible go: downloading github.com/hashicorp/hcl/v2 v2.24.0 go: downloading codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 go: downloading modernc.org/sqlite v1.56.0 go: downloading github.com/google/uuid v1.6.0 go: downloading github.com/docker/go-connections v0.8.1 go: downloading github.com/docker/go-units v0.5.0 go: downloading github.com/moby/docker-image-spec v1.3.1 go: downloading github.com/opencontainers/image-spec v1.1.1 go: downloading github.com/containerd/errdefs v1.0.0 go: downloading github.com/containerd/errdefs/pkg v0.3.0 go: downloading github.com/distribution/reference v0.6.0 go: downloading github.com/opencontainers/go-digest v1.0.0 go: downloading github.com/pkg/errors v0.9.1 go: downloading go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 go: downloading go.opentelemetry.io/otel/trace v1.45.0 go: downloading go.opentelemetry.io/otel v1.45.0 go: downloading github.com/42wim/httpsig v1.2.3 go: downloading github.com/go-fed/httpsig v1.1.0 go: downloading github.com/hashicorp/go-version v1.7.0
{"command":"cd /project && go build ./... 2>&1 | tail -5; echo \"BUILD EXIT: $?\"","timeout":300}
go: downloading github.com/dustin/go-humanize v1.0.1 go: downloading modernc.org/mathutil v1.7.1 go: downloading modernc.org/memory v1.11.0 go: downloading golang.org/x/text v0.40.0 go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec BUILD EXIT: 0
{"command":"SDK=$(ls -d /home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0) && grep -n \"func (c \\*Client) GetUser\\|func (c \\*Client) GetUserInfo\\|\\\"/user\\\"\" $SDK/*.go | head -20"}
/home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/user.go:65:func (c *Client) GetUserInfo(user string) (*User, *Response, error) {
/home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/user.go:77: resp, err := c.getParsedResponse("GET", "/user", nil, nil, u)
/home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/user.go:82:func (c *Client) GetUserByID(id int64) (*User, *Response, error) {
/home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0/user_settings.go:45:func (c *Client) GetUserSettings() (*UserSettings, *Response, error) {
{"command":"SDK=/home/user/go/pkg/mod/codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2@v2.2.0 && sed -n '55,95p' $SDK/user.go"}
// User visibility level option
Visibility VisibleType `json:"visibility"`
// user counts
FollowerCount int `json:"followers_count"`
FollowingCount int `json:"following_count"`
StarredRepoCount int `json:"starred_repos_count"`
}
// GetUserInfo get user info by user's name
func (c *Client) GetUserInfo(user string) (*User, *Response, error) {
if err := escapeValidatePathSegments(&user); err != nil {
return nil, nil, err
}
u := new(User)
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/users/%s", user), nil, nil, u)
return u, resp, err
}
// GetMyUserInfo get user info of current user
func (c *Client) GetMyUserInfo() (*User, *Response, error) {
u := new(User)
resp, err := c.getParsedResponse("GET", "/user", nil, nil, u)
return u, resp, err
}
// GetUserByID returns user by a given user ID
func (c *Client) GetUserByID(id int64) (*User, *Response, error) {
if id < 0 {
return nil, nil, fmt.Errorf("invalid user id %d", id)
}
query := make(url.Values)
query.Add("uid", strconv.FormatInt(id, 10))
users, resp, err := c.searchUsers(query.Encode())
if err != nil {
return nil, resp, err
}
if len(users) == 1 {
return users[0], resp, err
{"end_line":120,"path":"internal/forgejo/watcher_test.go","start_line":1}
{"path":"internal/forgejo/watcher_test.go","content":"package forgejo\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"log/slog\"\n\t\"testing\"\n\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\n// newTestWatcher builds a Watcher backed by a real (temp) store. The\n// client is nil because dispatch never touches it.\nfunc newTestWatcher(t *testing.T, patterns ...string) *Watcher {\n\tt.Helper()\n\n\tst, err := store.Open(t.TempDir() + \"/zoo.db\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { st.Close() })\n\n\trepos, err := NewRepoFilter(patterns)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tlogger := slog.New(slog.NewTextHandler(io.Discard, nil))\n\n\treturn NewWatcher(nil, st, \"\", repos, logger)\n}\n\nfunc testEvent(owner, repo string) Event {\n\treturn Event{\n\t\tKind: EventIssueNew,\n\t\tID: \"issue-new-1\",\n\t\tOwner: owner,\n\t\tRepo: repo,\n\t}\n}\n\n// TestWatcherDispatchForwardsWatchedRepo: an event from a matching\n// repository is deduped and forwarded to the event stream.\nfunc TestWatcherDispatchForwardsWatchedRepo(t *testing.T) {\n\tw := newTestWatcher(t, \"acme/*\")\n\n\tw.dispatch(testEvent(\"acme\", \"widgets\"))\n\n\tev, ok := \u003c-w.Events()\n\tif !ok {\n\t\tt.Fatal(\"expected an event on the stream\")\n\t}\n\tif ev.Owner != \"acme\" || ev.Repo != \"widgets\" {\n\t\tt.Fatalf(\"unexpected event: %+v\", ev)\n\t}\n}\n\n// TestWatcherDispatchDropsUnwatchedRepo: an event from a non-matching\n// repository is dropped before dedup, so it neither reaches the stream\n// nor occupies a dedup id.\nfunc TestWatcherDispatchDropsUnwatchedRepo(t *testing.T) {\n\tw := newTestWatcher(t, \"acme/*\")\n\n\tw.dispatch(testEvent(\"bob\", \"widgets\"))\n\n\tselect {\n\tcase ev := \u003c-w.Events():\n\t\tt.Fatalf(\"unwatched repo event leaked onto the stream: %+v\", ev)\n\tdefault:\n\t}\n\n\t// The dedup id must be free: if the user adds bob/* to the config\n\t// later, new events for bob/widgets must still be processed.\n\tif isNew, err := w.store.MarkSeen(context.Background(), testEvent(\"bob\", \"widgets\").ID); err != nil {\n\t\tt.Fatal(err)\n\t} else if !isNew {\n\t\tt.Fatal(\"unwatched repo event was marked seen; it would be deduped away if the repo were watched later\")\n\t}\n}\n\n// TestWatcherDispatchEmptyFilterWatchesEverything: with no patterns\n// configured, every repository passes, preserving the historical\n// behavior.\nfunc TestWatcherDispatchEmptyFilterWatchesEverything(t *testing.T) {\n\tw := newTestWatcher(t)\n\n\tw.dispatch(testEvent(\"anyone\", \"anything\"))\n\n\tif _, ok := \u003c-w.Events(); !ok {\n\t\tt.Fatal(\"expected an event on the stream\")\n\t}\n}","start_line":1,"end_line":92,"total_lines":92,"truncated":false}
{"end_line":120,"path":"internal/forgejo/poll_test.go","start_line":1}
{"path":"internal/forgejo/poll_test.go","content":"package forgejo\n\nimport (\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)\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// newTestPollWatcher returns a Watcher whose client talks to the test\n// server and whose events can be read from Events(). The repo filter is\n// nil (watch everything), since the poll tests are about the polling\n// path, not the filter.\nfunc newTestPollWatcher(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), \"\", nil, 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 := newTestPollWatcher(t, server.URL)\n\tsince := time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC)\n\n\tw.pollReviews(context.Background(), \"acme\", \"widgets\", \u0026sdk.Issue{Index: 9, Title: \"Fix the thing\", Body: \"This fixes it\", Poster: \u0026sdk.User{UserName: \"greg\"}}, since)\n\n\tselect {\n\tcase ev := \u003c-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\t// PRAuthor is the user who opened the PR: the agent it resolves to.\n\t\tif ev.PRAuthor != \"greg\" {\n\t\t\tt.Fatalf(\"expected PR author greg, got %q\", ev.PRAuthor)\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 := newTestPollWatcher(t, server.URL)\n\tsince := time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC)","start_line":1,"end_line":120,"total_lines":162,"truncated":true}
{"end_line":162,"path":"internal/forgejo/poll_test.go","start_line":120}
{"path":"internal/forgejo/poll_test.go","content":"\tsince := time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC)\n\n\tw.pollReviews(context.Background(), \"acme\", \"widgets\", \u0026sdk.Issue{Index: 9}, since)\n\n\tselect {\n\tcase ev := \u003c-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 := newTestPollWatcher(t, server.URL)\n\tsince := time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC)\n\n\tw.pollReviews(context.Background(), \"acme\", \"widgets\", \u0026sdk.Issue{Index: 9}, since)\n\n\tselect {\n\tcase ev := \u003c-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}","start_line":120,"end_line":162,"total_lines":162,"truncated":false}
{"command":"cd /project 2>/dev/null; cd /project && ls internal/scheduler/ && grep -n \"ListIssueComments\\|Comment\" internal/scheduler/*.go | grep -v _test | head -20"}
scheduler.go
scheduler_test.go
internal/scheduler/scheduler.go:24: CreateIssueComment(owner, repo string, index int64, body string) error
internal/scheduler/scheduler.go:239: if err := s.forgejo.CreateIssueComment(ev.Owner, ev.Repo, ev.Index, body); err != nil {
{"end_line":471,"path":"internal/agentrun/run.go","start_line":380}
{"path":"internal/agentrun/run.go","content":"\t\t},\n\t}\n}\n\nfunc 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) \u003e 0 {\n\t\tvar b strings.Builder\n\t\tfmt.Fprintf(\u0026b, \"Comments (%d):\\n\\n\", len(comments))\n\n\t\tfor i, c := range comments {\n\t\t\tfmt.Fprintf(\u0026b, \"%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 lets the agent refer to a specific\n// comment in its reply).\nfunc renderReviewSection(review *forgejo.ReviewDetail) string {\n\tvar b strings.Builder\n\n\tfmt.Fprintf(\u0026b, \"Review submitted by %s:\\n\", review.Reviewer)\n\tfmt.Fprintf(\u0026b, \" 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(\u0026b, \" %s\\n\", line)\n\t\t}\n\t}\n\n\tif len(review.Comments) \u003e 0 {\n\t\tfmt.Fprintf(\u0026b, \" 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 \u003e 0 {\n\t\t\t\twhere = fmt.Sprintf(\"%s:%d\", c.Path, c.Line)\n\t\t\t}\n\n\t\t\tfmt.Fprintf(\u0026b, \" %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(\u0026b, \" %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}","start_line":380,"end_line":471,"total_lines":471,"truncated":false}
{"end_line":432,"path":"internal/forgejo/client.go","start_line":200}
{"path":"internal/forgejo/client.go","content":"\n// 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, what\n// it says, and its id (so the agent can refer to it in its reply).\ntype ReviewCommentDetail struct {\n\tID int64\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 := \u0026ReviewDetail{\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 \u0026\u0026 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\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})\n\t}\n\n\treturn detail, 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}\n\n// CloseIssue closes the given issue or pull request.\nfunc (c *Client) CloseIssue(owner, repo string, index int64) error {\n\treturn c.setIssueState(owner, repo, index, sdk.StateClosed)\n}\n\n// ReopenIssue reopens the given issue or pull request.\nfunc (c *Client) ReopenIssue(owner, repo string, index int64) error {\n\treturn c.setIssueState(owner, repo, index, sdk.StateOpen)\n}\n\n// RepositoryInfo returns the pieces of repo metadata agentrun needs to\n// clone and branch off of the right place.\ntype RepositoryInfo struct {\n\tDefaultBranch string\n\tCloneURL string\n}\n\nfunc (c *Client) RepositoryInfo(owner, repo string) (RepositoryInfo, error) {\n\tr, _, err := c.sdk.GetRepo(owner, repo)\n\tif err != nil {\n\t\treturn RepositoryInfo{}, fmt.Errorf(\"get repo %s/%s: %w\", owner, repo, err)\n\t}\n\n\treturn RepositoryInfo{DefaultBranch: r.DefaultBranch, CloneURL: r.CloneURL}, nil\n}\n\nfunc (c *Client) setIssueState(owner, repo string, index int64, state sdk.StateType) error {\n\t_, _, err := c.sdk.EditIssue(owner, repo, index, sdk.EditIssueOption{State: \u0026state})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"set state %q on %s/%s#%d: %w\", state, owner, repo, index, err)\n\t}\n\n\treturn nil\n}\n\n// AgentProfile is what zoo reads off an agent's Forgejo account: its bio\n// (surfaced in the system prompt), the name/email used to set git commit\n// authorship inside that agent's container, and its avatar URL (surfaced\n// on the dashboard).\ntype AgentProfile struct {\n\tDescription string\n\tFullName string\n\tEmail string\n\tAvatarURL string\n}\n\n// AgentProfile fetches the Forgejo profile of the user account named\n// username. Agent config labels double as their Forgejo usernames (see\n// issue:assigned resolution), so this is how an agent's \"what it does\"\n// text, and its commit identity, are authored: by editing that account's\n// profile on Forgejo, not in zoo.hcl.\nfunc (c *Client) AgentProfile(username string) (AgentProfile, error) {\n\tu, _, err := c.sdk.GetUserInfo(username)\n\tif err != nil {\n\t\treturn AgentProfile{}, fmt.Errorf(\"get user %q: %w\", username, err)\n\t}\n\n\tp := AgentProfile{\n\t\tDescription: u.Description,\n\t\tFullName: u.FullName,\n\t\tEmail: u.Email,\n\t\tAvatarURL: u.AvatarURL,\n\t}\n\n\treturn p, nil\n}\n\n// AssignIssue sets the sole assignee of an issue/PR to username. Used by\n// the assign_issue tool so agents can hand work off to each other: the\n// resulting issue:assigned event (webhook or poll) triggers whichever\n// agent's config label matches username.\nfunc (c *Client) AssignIssue(owner, repo string, index int64, username string) error {\n\t_, _, err := c.sdk.EditIssue(owner, repo, index, sdk.EditIssueOption{Assignees: []string{username}})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"assign %s/%s#%d to %q: %w\", owner, repo, index, username, err)\n\t}\n\n\treturn nil\n}\n\n// IssueTitle fetches the title of an issue or pull request from Forgejo.\n// Returns an empty string (not an error) if the client is nil or the\n// issue cannot be fetched β the caller should degrade gracefully.\nfunc (c *Client) IssueTitle(owner, repo string, index int64) string {\n\tif c == nil || c.sdk == nil {\n\t\treturn \"\"\n\t}\n\n\tissue, _, err := c.sdk.GetIssue(owner, repo, index)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\treturn issue.Title\n}","start_line":200,"end_line":432,"total_lines":432,"truncated":false}
{"command":"cd /project && ls docker/ && cat docker/* 2>/dev/null | head -60"}
Dockerfile
group
passwd
shadow
uid1000-sudo
FROM debian:unstable
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
clang \
cpp \
curl \
forgejo-cli \
g++ \
gcc \
git \
gnupg \
golang-go \
libc6-dev \
make \
mercurial \
netbase \
openssh-client \
pkg-config \
procps \
sq \
subversion \
sudo \
wget \
&& \
rm -rf /var/lib/apt/lists/*
ADD --chmod=0644 passwd /etc/passwd
ADD --chmod=0644 group /etc/group
ADD --chmod=0640 shadow /etc/shadow
ADD --chmod=0644 uid1000-sudo /etc/sudoers.d/uid1000
RUN mkdir -p /home/user && \
chown 1000:1000 /home/user
USER 1000:1000
root:x:0:
user:x:1000:
nogroup:x:65534:
root:x:0:0:root:/root:/bin/bash
user:x:1000:1000:user,,,:/home/user:/bin/bash
nobody:x:65534:65534::
root:$y$j9T$ElEG3FgmW16OtVys3v4FL0$tunDvgySurBjIXi5UzXdVToBSZ1VE2zm/PG/oc2yeE/:19136:0:99999:7:::
user:$y$j9T$ElEG3FgmW16OtVys3v4FL0$tunDvgySurBjIXi5UzXdVToBSZ1VE2zm/PG/oc2yeE/:19136:0:99999:7:::
user ALL=(ALL) NOPASSWD: ALL
{"end_line":80,"path":"internal/agentrun/seed_test.go","start_line":1}
{"path":"internal/agentrun/seed_test.go","content":"package agentrun\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/abrander/zoo/internal/forgejo\"\n)\n\nfunc TestSeedMessageIncludesInstructions(t *testing.T) {\n\tev := forgejo.Event{Kind: \"issue:comment\", Owner: \"acme\", Repo: \"widgets\", Index: 5, Title: \"t\", Body: \"b\", Raw: []byte(`{}`)}\n\n\tmsg := seedMessage(ev, \"issue-5-leon\", \"main\", \"Please review the comment and respond appropriately.\", nil, 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}\n\nfunc TestSeedMessageIncludesAllComments(t *testing.T) {\n\tev := forgejo.Event{Kind: \"issue:assigned\", Owner: \"acme\", Repo: \"widgets\", Index: 5, Title: \"t\", Body: \"b\", Raw: []byte(`{}`)}\n\n\tcomments := []forgejo.IssueComment{\n\t\t{Author: \"alice\", Body: \"Please also handle the edge case.\", Created: time.Date(2026, 8, 24, 9, 0, 0, 0, time.UTC)},\n\t\t{Author: \"bob\", Body: \"And add a test for it.\", Created: time.Date(2026, 8, 24, 9, 5, 0, 0, time.UTC)},\n\t}\n\n\tmsg := seedMessage(ev, \"issue-5-greg\", \"main\", \"\", comments, 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} {\n\t\tif !strings.Contains(msg, want) {\n\t\t\tt.Fatalf(\"expected %q in seed message, got:\\n%s\", want, msg)\n\t\t}\n\t}\n\n\t// Comments come before the raw payload, so the agent reads them\n\t// as part of the briefing, not buried in JSON.\n\tif !strings.Contains(msg, \"Comments (2):\") ||\n\t\tstrings.Index(msg, \"Comments (2):\") \u003e strings.Index(msg, \"Full event payload:\") {\n\t\tt.Fatalf(\"expected comments section before the raw payload, got:\\n%s\", msg)\n\t}\n}\n\nfunc TestSeedMessageOmitsEmptyComments(t *testing.T) {\n\tev := forgejo.Event{Kind: \"issue:new\", Owner: \"acme\", Repo: \"widgets\", Index: 5, Title: \"t\", Body: \"b\", Raw: []byte(`{}`)}\n\n\tmsg := seedMessage(ev, \"issue-5-greg\", \"main\", \"\", nil, 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(`{}`),","start_line":1,"end_line":80,"total_lines":128,"truncated":true}
{"query":"NewRunner|NewWatcher"}
/project/cmd/zoo/main.go:74: watcher := forgejo.NewWatcher(fgClient, st, cfg.Forgejo.WebhookSecret, repoFilter, logger.With("component", "forgejo"))
/project/cmd/zoo/main.go:78: runner, err := agentrun.NewRunner(cfg, fgClient, st, hub, logger.With("component", "agentrun"), *runTimeout, *keepOnFailure)
/project/internal/forgejo/watcher_test.go:30: return NewWatcher(nil, st, "", repos, logger)
/project/internal/forgejo/watcher.go:26:func NewWatcher(client *Client, st *store.Store, webhookSecret string, repos *RepoFilter, logger *slog.Logger) *Watcher {
/project/internal/forgejo/poll_test.go:43: return NewWatcher(client, newTestStore(t), "", nil, slog.New(slog.DiscardHandler))
/project/internal/agentrun/run.go:48:func NewRunner(cfg *config.Config, fg *forgejo.Client, st *store.Store, hub *livelog.Hub, logger *slog.Logger, timeout time.Duration, keepOnFailure bool) (*Runner, error) {
{"end_line":60,"path":"internal/forgejo/client_test.go","start_line":1}
{"path":"internal/forgejo/client_test.go","content":"package forgejo\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strconv\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/abrander/zoo/internal/config\"\n)\n\n// Wire shapes matching the SDK's Comment type, as Forgejo serves the\n// /repos/{owner}/{repo}/issues/{index}/comments endpoint.\ntype wireUser struct {\n\tLogin string `json:\"login\"`\n}\n\ntype wireComment struct {\n\tID int64 `json:\"id\"`\n\tPoster wireUser `json:\"user\"`\n\tBody string `json:\"body\"`\n\tCreated time.Time `json:\"created_at\"`\n}\n\n// newTestServer returns an httptest server that answers the SDK's\n// /api/v1/version probe (made by NewClient) plus the routes registered\n// on the returned mux.\nfunc newTestServer(t *testing.T) (*httptest.Server, *http.ServeMux) {\n\tt.Helper()\n\n\tmux := http.NewServeMux()\n\n\tmux.HandleFunc(\"/api/v1/version\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_, _ = w.Write([]byte(`{\"version\":\"1.23.0\"}`))\n\t})\n\n\tserver := httptest.NewServer(mux)\n\tt.Cleanup(server.Close)\n\n\treturn server, mux\n}\n\nfunc TestListIssueCommentsFetchesAllPages(t *testing.T) {\n\tconst total = 120 // 3 pages at the client's page size of 50\n\n\tserver, mux := newTestServer(t)\n\n\tmux.HandleFunc(\"/api/v1/repos/acme/widgets/issues/5/comments\", func(w http.ResponseWriter, r *http.Request) {\n\t\tpage, _ := strconv.ParseInt(r.URL.Query().Get(\"page\"), 10, 64)\n\t\tlimit, _ := strconv.ParseInt(r.URL.Query().Get(\"limit\"), 10, 64)\n\n\t\tstart := (page - 1) * limit\n\t\tend := start + limit\n\t\tif end \u003e total {\n\t\t\tend = total\n\t\t}","start_line":1,"end_line":60,"total_lines":254,"truncated":true}
{"command":"cd /project 2>/dev/null; cd /project && grep -n \"newTestServer\" internal/forgejo/*_test.go | head -5 && grep -n \"func newTestServer\" -A 15 internal/forgejo/*.go"}
internal/forgejo/client_test.go:28:// newTestServer returns an httptest server that answers the SDK's
internal/forgejo/client_test.go:31:func newTestServer(t *testing.T) (*httptest.Server, *http.ServeMux) {
internal/forgejo/client_test.go:50: server, mux := newTestServer(t)
internal/forgejo/client_test.go:103: server, mux := newTestServer(t)
internal/forgejo/client_test.go:126: server, mux := newTestServer(t)
internal/forgejo/client_test.go:31:func newTestServer(t *testing.T) (*httptest.Server, *http.ServeMux) {
internal/forgejo/client_test.go-32- t.Helper()
internal/forgejo/client_test.go-33-
internal/forgejo/client_test.go-34- mux := http.NewServeMux()
internal/forgejo/client_test.go-35-
internal/forgejo/client_test.go-36- mux.HandleFunc("/api/v1/version", func(w http.ResponseWriter, r *http.Request) {
internal/forgejo/client_test.go-37- w.Header().Set("Content-Type", "application/json")
internal/forgejo/client_test.go-38- _, _ = w.Write([]byte(`{"version":"1.23.0"}`))
internal/forgejo/client_test.go-39- })
internal/forgejo/client_test.go-40-
internal/forgejo/client_test.go-41- server := httptest.NewServer(mux)
internal/forgejo/client_test.go-42- t.Cleanup(server.Close)
internal/forgejo/client_test.go-43-
internal/forgejo/client_test.go-44- return server, mux
internal/forgejo/client_test.go-45-}
internal/forgejo/client_test.go-46-
{"end_line":50,"path":"internal/agentrun/run_test.go","start_line":1}
{"path":"internal/agentrun/run_test.go","content":"package agentrun\n\nimport (\n\t\"io\"\n\t\"log/slog\"\n\t\"testing\"\n\n\t\"github.com/abrander/zoo/internal/livelog\"\n)\n\n// streamHooksTestRunner builds a Runner with just the fields streamHooks\n// touches (hub + logger), so the test doesn't have to stand up Docker.\nfunc streamHooksTestRunner(t *testing.T) (*Runner, *livelog.Hub) {\n\tt.Helper()\n\n\thub := livelog.NewHub()\n\n\treturn \u0026Runner{hub: hub, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}, hub\n}\n\n// collectEvents subscribes to jobID, runs fn (which publishes events\n// synchronously), and returns everything published, in order.\nfunc collectEvents(t *testing.T, hub *livelog.Hub, jobID string, fn func()) []livelog.Event {\n\tt.Helper()\n\n\tbuffered, ch, cancel := hub.Subscribe(jobID)\n\tdefer cancel()\n\n\tfn()\n\n\tevents := append([]livelog.Event(nil), buffered...)\n\ndrain:\n\tfor {\n\t\tselect {\n\t\tcase ev, ok := \u003c-ch:\n\t\t\tif !ok {\n\t\t\t\tbreak drain\n\t\t\t}\n\t\t\tevents = append(events, ev)\n\t\tdefault:\n\t\t\tbreak drain\n\t\t}\n\t}\n\n\treturn events\n}\n\nfunc hasType(events []livelog.Event, want livelog.Type) bool {\n\tfor _, ev := range events {","start_line":1,"end_line":50,"total_lines":159,"truncated":true}
{"find":"// Token returns the shared zoo Forgejo identity's token, e.g. for\n// authenticating a host-side git clone/push against Forgejo (see\n// internal/agentrun) without ever writing the credential into a working\n// tree an agent's container can read.\nfunc (c *Client) Token() string {\n\treturn c.token\n}","path":"internal/forgejo/client.go","replace":"// Token returns the shared zoo Forgejo identity's token, e.g. for\n// authenticating a host-side git clone/push against Forgejo (see\n// internal/agentrun) without ever writing the credential into a working\n// tree an agent's container can read.\nfunc (c *Client) Token() string {\n\treturn c.token\n}\n\n// Owner returns the Forgejo username that owns this client's token. For\n// the shared master client that's the human operator whose comments are\n// always directed at humans, never at agents, and which zoo therefore\n// ignores everywhere (see Watcher.dispatch and agentrun's briefing).\nfunc (c *Client) Owner() (string, error) {\n\tu, _, err := c.sdk.GetMyUserInfo()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"get current user: %w\", err)\n\t}\n\n\treturn u.UserName, nil\n}"}
1 occurrences replaced
{"find":"// Watcher merges the webhook receiver and the polling fallback into a\n// single deduplicated Event stream.\ntype Watcher struct {\n\tclient *Client\n\tstore *store.Store\n\tsecret string\n\trepos *RepoFilter\n\tlogger *slog.Logger\n\n\tevents chan Event\n}\n\nfunc NewWatcher(client *Client, st *store.Store, webhookSecret string, repos *RepoFilter, logger *slog.Logger) *Watcher {\n\treturn &Watcher{\n\t\tclient: client,\n\t\tstore: st,\n\t\tsecret: webhookSecret,\n\t\trepos: repos,\n\t\tlogger: logger,\n\t\tevents: make(chan Event, 64),\n\t}\n}","path":"internal/forgejo/watcher.go","replace":"// Watcher merges the webhook receiver and the polling fallback into a\n// single deduplicated Event stream.\ntype Watcher struct {\n\tclient *Client\n\tstore *store.Store\n\tsecret string\n\trepos *RepoFilter\n\tlogger *slog.Logger\n\n\t// masterUser is the Forgejo username that owns the shared master\n\t// token. Comments by that user are always directed at human\n\t// operators (log messages, debug notes), never at agents, so they\n\t// are dropped in dispatch. An empty value disables the filter.\n\tmasterUser string\n\n\tevents chan Event\n}\n\nfunc NewWatcher(client *Client, st *store.Store, webhookSecret string, repos *RepoFilter, masterUser string, logger *slog.Logger) *Watcher {\n\treturn &Watcher{\n\t\tclient: client,\n\t\tstore: st,\n\t\tsecret: webhookSecret,\n\t\trepos: repos,\n\t\tlogger: logger,\n\t\tmasterUser: masterUser,\n\t\tevents: make(chan Event, 64),\n\t}\n}"}
1 occurrences replaced
{"end_line":55,"path":"internal/forgejo/watcher.go","start_line":14}
{"path":"internal/forgejo/watcher.go","content":"// Watcher merges the webhook receiver and the polling fallback into a\n// single deduplicated Event stream.\ntype Watcher struct {\n\tclient *Client\n\tstore *store.Store\n\tsecret string\n\trepos *RepoFilter\n\tlogger *slog.Logger\n\n\t// masterUser is the Forgejo username that owns the shared master\n\t// token. Comments by that user are always directed at human\n\t// operators (log messages, debug notes), never at agents, so they\n\t// are dropped in dispatch. An empty value disables the filter.\n\tmasterUser string\n\n\tevents chan Event\n}\n\nfunc NewWatcher(client *Client, st *store.Store, webhookSecret string, repos *RepoFilter, masterUser string, logger *slog.Logger) *Watcher {\n\treturn \u0026Watcher{\n\t\tclient: client,\n\t\tstore: st,\n\t\tsecret: webhookSecret,\n\t\trepos: repos,\n\t\tlogger: logger,\n\t\tmasterUser: masterUser,\n\t\tevents: make(chan Event, 64),\n\t}\n}\n\n// Handler returns the http.Handler to mount for incoming webhook\n// deliveries.\nfunc (w *Watcher) Handler() http.Handler {\n\treturn WebhookHandler(w.secret, w.logger, w.dispatch)\n}\n\n// Events returns the deduplicated stream consumed by the scheduler.\nfunc (w *Watcher) Events() \u003c-chan Event {\n\treturn w.events\n}\n\n// Run drives the polling fallback until ctx is canceled. The webhook","start_line":14,"end_line":55,"total_lines":100,"truncated":true}
{"find":"// dispatch drops ev if its repository isn't watched, dedupes it against\n// the store (regardless of whether it came from the webhook or the\n// poller) and, if new, forwards it to Events(). The repo filter runs\n// before MarkSeen on purpose: events from unwatched repositories must\n// not occupy dedup ids, so that a repository added to the config later\n// still has its new events processed.\nfunc (w *Watcher) dispatch(ev Event) {\n\tif !w.repos.Matches(ev.Owner, ev.Repo) {","path":"internal/forgejo/watcher.go","replace":""}
1 occurrences replaced
{"end_line":100,"path":"internal/forgejo/watcher.go","start_line":55}
{"path":"internal/forgejo/watcher.go","content":"// Run drives the polling fallback until ctx is canceled. The webhook\n// handler runs independently as part of the daemon's HTTP server.\nfunc (w *Watcher) Run(ctx context.Context) {\n\tticker := time.NewTicker(PollInterval)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase \u003c-ctx.Done():\n\t\t\treturn\n\n\t\tcase \u003c-ticker.C:\n\t\t\tw.pollOnce(ctx)\n\t\t}\n\t}\n}\n\n\n\t\tw.logger.Debug(\"ignoring event from unwatched repository\", \"owner\", ev.Owner, \"repo\", ev.Repo, \"kind\", ev.Kind, \"id\", ev.ID)\n\t\treturn\n\t}\n\n\tisNew, err := w.store.MarkSeen(context.Background(), ev.ID)\n\tif err != nil {\n\t\tw.logger.Error(\"dedup check failed, dropping event\", \"id\", ev.ID, \"error\", err)\n\t\treturn\n\t}\n\n\tif !isNew {\n\t\treturn\n\t}\n\n\tselect {\n\tcase w.events \u003c- ev:\n\n\tdefault:\n\t\tw.logger.Warn(\"event channel full, dropping event\", \"id\", ev.ID, \"kind\", ev.Kind)\n\t}\n}","start_line":55,"end_line":93,"total_lines":93,"truncated":false}
{"find":"}\n\n\n\t\tw.logger.Debug(\"ignoring event from unwatched repository\", \"owner\", ev.Owner, \"repo\", ev.Repo, \"kind\", ev.Kind, \"id\", ev.ID)\n\t\treturn\n\t}","path":"internal/forgejo/watcher.go","replace":"}\n\n// dispatch drops ev if its repository isn't watched, dedupes it against\n// the store (regardless of whether it came from the webhook or the\n// poller) and, if new, forwards it to Events(). The repo filter runs\n// before MarkSeen on purpose: events from unwatched repositories must\n// not occupy dedup ids, so that a repository added to the config later\n// still has its new events processed.\nfunc (w *Watcher) dispatch(ev Event) {\n\t// Comments by the master token's owner are always directed at human\n\t// operators (log messages, debug notes), never at agents. Drop them\n\t// before dedup, like unwatched-repo events, so they neither trigger\n\t// a run nor occupy a dedup id. Non-comment events by that user (new\n\t// issues, assignments, ...) are still work for agents and pass.\n\tif w.masterUser != \"\" && ev.Kind == EventIssueComment && ev.Author == w.masterUser {\n\t\tw.logger.Debug(\"ignoring comment by master token owner\", \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index, \"id\", ev.ID)\n\t\treturn\n\t}\n\n\tif !w.repos.Matches(ev.Owner, ev.Repo) {\n\t\tw.logger.Debug(\"ignoring event from unwatched repository\", \"owner\", ev.Owner, \"repo\", ev.Repo, \"kind\", ev.Kind, \"id\", ev.ID)\n\t\treturn\n\t}"}
1 occurrences replaced
{"find":"\tfgClient, err := forgejo.NewClient(cfg.Forgejo)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"forgejo client: %w\", err)\n\t}\n\n\trepoFilter, err := forgejo.NewRepoFilter(cfg.Forgejo.Repos)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"forgejo repos: %w\", err)\n\t}\n\n\twatcher := forgejo.NewWatcher(fgClient, st, cfg.Forgejo.WebhookSecret, repoFilter, logger.With(\"component\", \"forgejo\"))\n\n\thub := livelog.NewHub()\n\n\trunner, err := agentrun.NewRunner(cfg, fgClient, st, hub, logger.With(\"component\", \"agentrun\"), *runTimeout, *keepOnFailure)","path":"cmd/zoo/main.go","replace":""}
1 occurrences replaced
{"end_line":90,"path":"cmd/zoo/main.go","start_line":55}
{"path":"cmd/zoo/main.go","content":"\t}\n\tdefer st.Close()\n\n\tif n, err := st.ReapOrphanedJobs(context.Background()); err != nil {\n\t\tlogger.Warn(\"failed to reap orphaned jobs\", \"error\", err)\n\t} else if n \u003e 0 {\n\t\tlogger.Warn(\"reaped jobs left running by a previous process instance\", \"count\", n)\n\t}\n\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"agent runner: %w\", err)\n\t}\n\n\tsched := scheduler.New(cfg, st, fgClient, runner, hub, logger.With(\"component\", \"scheduler\"))\n\tdashboard := web.New(cfg, st, hub, fgClient)\n\n\tmux := http.NewServeMux()\n\n\tmux.Handle(\"/webhooks/forgejo\", watcher.Handler())\n\n\tmux.HandleFunc(\"/healthz\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(\"ok\"))\n\t})\n\n\tmux.Handle(\"/\", dashboard.Handler())\n\n\tserver := \u0026http.Server{Addr: *listen, Handler: mux}\n\n\tctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)\n\tdefer stop()\n\n\tgo watcher.Run(ctx)\n\tgo sched.Run(ctx, watcher.Events())\n","start_line":55,"end_line":90,"total_lines":122,"truncated":true}
{"find":"\t}\n\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"agent runner: %w\", err)\n\t}","path":"cmd/zoo/main.go","replace":"\t}\n\n\tfgClient, err := forgejo.NewClient(cfg.Forgejo)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"forgejo client: %w\", err)\n\t}\n\n\t// Resolve who owns the master token: their comments are always\n\t// directed at human operators, never at agents, and zoo must ignore\n\t// them everywhere. A short retry absorbs Forgejo coming up slightly\n\t// after zoo; if it still fails, the token is unusable and nothing\n\t// else in the daemon would work either, so fail fast.\n\tmasterUser, err := resolveMasterUser(fgClient, logger)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlogger.Info(\"master token owner\", \"user\", masterUser)\n\n\trepoFilter, err := forgejo.NewRepoFilter(cfg.Forgejo.Repos)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"forgejo repos: %w\", err)\n\t}\n\n\twatcher := forgejo.NewWatcher(fgClient, st, cfg.Forgejo.WebhookSecret, repoFilter, masterUser, logger.With(\"component\", \"forgejo\"))\n\n\thub := livelog.NewHub()\n\n\trunner, err := agentrun.NewRunner(cfg, fgClient, st, hub, masterUser, logger.With(\"component\", \"agentrun\"), *runTimeout, *keepOnFailure)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"agent runner: %w\", err)\n\t}"}
1 occurrences replaced
{"end_line":150,"path":"cmd/zoo/main.go","start_line":100}
{"path":"cmd/zoo/main.go","content":"\tmux.Handle(\"/webhooks/forgejo\", watcher.Handler())\n\n\tmux.HandleFunc(\"/healthz\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(\"ok\"))\n\t})\n\n\tmux.Handle(\"/\", dashboard.Handler())\n\n\tserver := \u0026http.Server{Addr: *listen, Handler: mux}\n\n\tctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)\n\tdefer stop()\n\n\tgo watcher.Run(ctx)\n\tgo sched.Run(ctx, watcher.Events())\n\n\tserveErr := make(chan error, 1)\n\n\tgo func() {\n\t\tlogger.Info(\"listening\", \"addr\", *listen)\n\t\tif err := server.ListenAndServe(); err != nil \u0026\u0026 err != http.ErrServerClosed {\n\t\t\tserveErr \u003c- err\n\t\t}\n\t}()\n\n\tselect {\n\tcase \u003c-ctx.Done():\n\t\tlogger.Info(\"shutting down\")\n\n\tcase err := \u003c-serveErr:\n\t\tstop()\n\n\t\tlogger.Error(\"http server failed\", \"error\", err)\n\t}\n\n\tshutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\n\tdefer cancel()\n\n\tif err := server.Shutdown(shutdownCtx); err != nil {\n\t\tlogger.Warn(\"http server shutdown\", \"error\", err)\n\t}\n\n\tif err := sched.Shutdown(shutdownCtx); err != nil {\n\t\tlogger.Warn(\"scheduler did not drain in time\", \"error\", err)\n\t}\n\n\treturn nil\n}","start_line":100,"end_line":148,"total_lines":148,"truncated":false}
{"find":"\tif err := sched.Shutdown(shutdownCtx); err != nil {\n\t\tlogger.Warn(\"scheduler did not drain in time\", \"error\", err)\n\t}\n\n\treturn nil\n}","path":"cmd/zoo/main.go","replace":"\tif err := sched.Shutdown(shutdownCtx); err != nil {\n\t\tlogger.Warn(\"scheduler did not drain in time\", \"error\", err)\n\t}\n\n\treturn nil\n}\n\n// resolveMasterUser asks Forgejo who owns the master token, retrying a\n// few times to ride out Forgejo coming up slightly after zoo.\nfunc resolveMasterUser(client *forgejo.Client, logger *slog.Logger) (string, error) {\n\tconst attempts = 5\n\n\tvar lastErr error\n\n\tfor attempt := 1; attempt <= attempts; attempt++ {\n\t\towner, err := client.Owner()\n\t\tif err == nil {\n\t\t\treturn owner, nil\n\t\t}\n\n\t\tlastErr = err\n\n\t\tif attempt < attempts {\n\t\t\tlogger.Warn(\"resolving master token owner\", \"attempt\", attempt, \"error\", err)\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t}\n\t}\n\n\treturn \"\", fmt.Errorf(\"resolve master token owner: %w\", lastErr)\n}"}
1 occurrences replaced
{"find":"type Runner struct {\n\tdocker *dockerRuntime\n\tforgejo *forgejo.Client\n\tstore *store.Store\n\thub *livelog.Hub\n\tcfg *config.Config\n\tlogger *slog.Logger\n\ttimeout time.Duration\n\tkeepOnFailure bool\n\n\tagentClientsMu sync.Mutex\n\tagentClients map[string]*forgejo.Client\n}\n\nfunc NewRunner(cfg *config.Config, fg *forgejo.Client, st *store.Store, hub *livelog.Hub, logger *slog.Logger, timeout time.Duration, keepOnFailure bool) (*Runner, error) {\n\tdocker, err := newDockerRuntime()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif timeout <= 0 {\n\t\ttimeout = DefaultTimeout\n\t}\n\n\treturn &Runner{\n\t\tdocker: docker,\n\t\tforgejo: fg,\n\t\tstore: st,\n\t\thub: hub,\n\t\tcfg: cfg,\n\t\tlogger: logger,\n\t\ttimeout: timeout,\n\t\tkeepOnFailure: keepOnFailure,\n\t\tagentClients: make(map[string]*forgejo.Client),\n\t}, nil\n}","path":"internal/agentrun/run.go","replace":"type Runner struct {\n\tdocker *dockerRuntime\n\tforgejo *forgejo.Client\n\tstore *store.Store\n\thub *livelog.Hub\n\tcfg *config.Config\n\tlogger *slog.Logger\n\ttimeout time.Duration\n\tkeepOnFailure bool\n\n\t// masterUser is the Forgejo username that owns the shared master\n\t// token. Its comments are always directed at human operators, never\n\t// at agents, so they're kept out of the agent's briefing. An empty\n\t// value disables the filter.\n\tmasterUser string\n\n\tagentClientsMu sync.Mutex\n\tagentClients map[string]*forgejo.Client\n}\n\nfunc NewRunner(cfg *config.Config, fg *forgejo.Client, st *store.Store, hub *livelog.Hub, masterUser string, logger *slog.Logger, timeout time.Duration, keepOnFailure bool) (*Runner, error) {\n\tdocker, err := newDockerRuntime()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif timeout <= 0 {\n\t\ttimeout = DefaultTimeout\n\t}\n\n\treturn &Runner{\n\t\tdocker: docker,\n\t\tforgejo: fg,\n\t\tstore: st,\n\t\thub: hub,\n\t\tcfg: cfg,\n\t\tlogger: logger,\n\t\ttimeout: timeout,\n\t\tkeepOnFailure: keepOnFailure,\n\t\tmasterUser: masterUser,\n\t\tagentClients: make(map[string]*forgejo.Client),\n\t}, nil\n}"}
1 occurrences replaced
{"find":"\tcomments, err := r.forgejo.ListIssueComments(ev.Owner, ev.Repo, ev.Index)\n\tif err != nil {\n\t\tlogger.Warn(\"fetch issue comments failed; agent will not see prior comments\", \"error\", err)\n\t\tcomments = nil\n\t}","path":"internal/agentrun/run.go","replace":"\tcomments, err := r.forgejo.ListIssueComments(ev.Owner, ev.Repo, ev.Index)\n\tif err != nil {\n\t\tlogger.Warn(\"fetch issue comments failed; agent will not see prior comments\", \"error\", err)\n\t\tcomments = nil\n\t}\n\n\t// The master token owner's comments are for human operators, not\n\t// agents: keep them out of the briefing too.\n\tcomments = filterMasterComments(comments, r.masterUser)"}
1 occurrences replaced
{"find":"func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, comments []forgejo.IssueComment, review *forgejo.ReviewDetail) string {","path":"internal/agentrun/run.go","replace":"// filterMasterComments drops comments by the master token's owner: they\n// are always directed at human operators (log messages, debug notes),\n// never at agents, so an agent reading them could only be confused by\n// them. An empty masterUser is a no-op.\nfunc filterMasterComments(comments []forgejo.IssueComment, masterUser string) []forgejo.IssueComment {\n\tif masterUser == \"\" {\n\t\treturn comments\n\t}\n\n\tout := make([]forgejo.IssueComment, 0, len(comments))\n\tfor _, c := range comments {\n\t\tif c.Author == masterUser {\n\t\t\tcontinue\n\t\t}\n\n\t\tout = append(out, c)\n\t}\n\n\treturn out\n}\n\nfunc seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, comments []forgejo.IssueComment, review *forgejo.ReviewDetail) string {"}
1 occurrences replaced
{"command":"cd /project 2>/dev/null; cd /project && go build ./... 2>&1 | head -20; echo \"EXIT: $?\""}
EXIT: 0
{"find":"// newTestWatcher builds a Watcher backed by a real (temp) store. The\n// client is nil because dispatch never touches it.\nfunc newTestWatcher(t *testing.T, patterns ...string) *Watcher {\n\tt.Helper()\n\n\tst, err := store.Open(t.TempDir() + \"/zoo.db\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { st.Close() })\n\n\trepos, err := NewRepoFilter(patterns)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tlogger := slog.New(slog.NewTextHandler(io.Discard, nil))\n\n\treturn NewWatcher(nil, st, \"\", repos, logger)\n}","path":"internal/forgejo/watcher_test.go","replace":""}
1 occurrences replaced
{"end_line":40,"path":"internal/forgejo/watcher_test_test.go","start_line":1}
ERROR CALLING TOOL: read "internal/forgejo/watcher_test_test.go": command exited with status 1: read_file: no such file: /project/internal/forgejo/watcher_test_test.go
{"end_line":40,"path":"internal/forgejo/watcher_test.go","start_line":1}
{"path":"internal/forgejo/watcher_test.go","content":"package forgejo\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"log/slog\"\n\t\"testing\"\n\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\n\n\nfunc testEvent(owner, repo string) Event {\n\treturn Event{\n\t\tKind: EventIssueNew,\n\t\tID: \"issue-new-1\",\n\t\tOwner: owner,\n\t\tRepo: repo,\n\t}\n}\n\n// TestWatcherDispatchForwardsWatchedRepo: an event from a matching\n// repository is deduped and forwarded to the event stream.\nfunc TestWatcherDispatchForwardsWatchedRepo(t *testing.T) {\n\tw := newTestWatcher(t, \"acme/*\")\n\n\tw.dispatch(testEvent(\"acme\", \"widgets\"))\n\n\tev, ok := \u003c-w.Events()\n\tif !ok {\n\t\tt.Fatal(\"expected an event on the stream\")\n\t}\n\tif ev.Owner != \"acme\" || ev.Repo != \"widgets\" {\n\t\tt.Fatalf(\"unexpected event: %+v\", ev)\n\t}\n}\n\n// TestWatcherDispatchDropsUnwatchedRepo: an event from a non-matching\n// repository is dropped before dedup, so it neither reaches the stream","start_line":1,"end_line":40,"total_lines":73,"truncated":true}
{"find":"\t\"","path":"internal/forgejo/watcher_test.go","replace":")\n\n// newTestWatcher builds a Watcher backed by a real (temp) store. The\n// client is nil because dispatch never touches it.\nfunc newTestWatcher(t *testing.T, masterUser string, patterns ...string) *Watcher {\n\tt.Helper()\n\n\tst, err := store.Open(t.TempDir() + \"/zoo.db\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { st.Close() })\n\n\trepos, err := NewRepoFilter(patterns)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tlogger := slog.New(slog.NewTextHandler(io.Discard, nil))\n\n\treturn NewWatcher(nil, st, \"\", repos, masterUser, logger)\n}"}
ERROR CALLING TOOL: 5 occurrences of "\t\"" found in "internal/forgejo/watcher_test.go", but replace_all is false
{"find":"\t\"github.com/abrander/zoo/internal/store\"\n)\n\n\n\nfunc testEvent(owner, repo string) Event {","path":"internal/forgejo/watcher_test.go","replace":"\t\"github.com/abrander/zoo/internal/store\"\n)\n\n// newTestWatcher builds a Watcher backed by a real (temp) store. The\n// client is nil because dispatch never touches it.\nfunc newTestWatcher(t *testing.T, masterUser string, patterns ...string) *Watcher {\n\tt.Helper()\n\n\tst, err := store.Open(t.TempDir() + \"/zoo.db\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { st.Close() })\n\n\trepos, err := NewRepoFilter(patterns)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n"}
1 occurrences replaced
{"end_line":1,"path":"internal/forgejo/watcher_test.go","start_line":1}
{"path":"internal/forgejo/watcher_test.go","content":"package forgejo","start_line":1,"end_line":1,"total_lines":86,"truncated":true}
{"command":"cd /project 2>/dev/null; cd /project && cat -A internal/forgejo/watcher_test.go | head -50"}
package forgejo$
$
import ($
^I"context"$
^I"io"$
^I"log/slog"$
^I"testing"$
$
^I"github.com/abrander/zoo/internal/store"$
)$
$
// newTestWatcher builds a Watcher backed by a real (temp) store. The$
// client is nil because dispatch never touches it.$
func newTestWatcher(t *testing.T, masterUser string, patterns ...string) *Watcher {$
^It.Helper()$
$
^Ist, err := store.Open(t.TempDir() + "/zoo.db")$
^Iif err != nil {$
^I^It.Fatal(err)$
^I}$
^It.Cleanup(func() { st.Close() })$
$
^Irepos, err := NewRepoFilter(patterns)$
^Iif err != nil {$
^I^It.Fatal(err)$
^I}$
$
^Ireturn Event{$
^I^IKind: EventIssueNew,$
^I^IID: "issue-new-1",$
^I^IOwner: owner,$
^I^IRepo: repo,$
^I}$
}$
$
// TestWatcherDispatchForwardsWatchedRepo: an event from a matching$
// repository is deduped and forwarded to the event stream.$
func TestWatcherDispatchForwardsWatchedRepo(t *testing.T) {$
^Iw := newTestWatcher(t, "acme/*")$
$
^Iw.dispatch(testEvent("acme", "widgets"))$
$
^Iev, ok := <-w.Events()$
^Iif !ok {$
^I^It.Fatal("expected an event on the stream")$
^I}$
^Iif ev.Owner != "acme" || ev.Repo != "widgets" {$
^I^It.Fatalf("unexpected event: %+v", ev)$
^I}$
}$
{"content":"package forgejo\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"log/slog\"\n\t\"testing\"\n\n\t\"github.com/abrander/zoo/internal/store\"\n)\n\n// newTestWatcher builds a Watcher backed by a real (temp) store. The\n// client is nil because dispatch never touches it.\nfunc newTestWatcher(t *testing.T, masterUser string, patterns ...string) *Watcher {\n\tt.Helper()\n\n\tst, err := store.Open(t.TempDir() + \"/zoo.db\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tt.Cleanup(func() { st.Close() })\n\n\trepos, err := NewRepoFilter(patterns)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tlogger := slog.New(slog.NewTextHandler(io.Discard, nil))\n\n\treturn NewWatcher(nil, st, \"\", repos, masterUser, logger)\n}\n\nfunc testEvent(owner, repo string) Event {\n\treturn Event{\n\t\tKind: EventIssueNew,\n\t\tID: \"issue-new-1\",\n\t\tOwner: owner,\n\t\tRepo: repo,\n\t}\n}\n\nfunc testCommentEvent(author string) Event {\n\treturn Event{\n\t\tKind: EventIssueComment,\n\t\tID: \"issue-comment-1\",\n\t\tOwner: \"acme\",\n\t\tRepo: \"widgets\",\n\t\tIndex: 5,\n\t\tAuthor: author,\n\t\tComment: \"a comment\",\n\t}\n}\n\n// TestWatcherDispatchForwardsWatchedRepo: an event from a matching\n// repository is deduped and forwarded to the event stream.\nfunc TestWatcherDispatchForwardsWatchedRepo(t *testing.T) {\n\tw := newTestWatcher(t, \"acme/*\")\n\n\tw.dispatch(testEvent(\"acme\", \"widgets\"))\n\n\tev, ok := <-w.Events()\n\tif !ok {\n\t\tt.Fatal(\"expected an event on the stream\")\n\t}\n\tif ev.Owner != \"acme\" || ev.Repo != \"widgets\" {\n\t\tt.Fatalf(\"unexpected event: %+v\", ev)\n\t}\n}\n\n// TestWatcherDispatchDropsUnwatchedRepo: an event from a non-matching\n// repository is dropped before dedup, so it neither reaches the stream\n// nor occupies a dedup id.\nfunc TestWatcherDispatchDropsUnwatchedRepo(t *testing.T) {\n\tw := newTestWatcher(t, \"\", \"acme/*\")\n\n\tw.dispatch(testEvent(\"bob\", \"widgets\"))\n\n\tselect {\n\tcase ev := <-w.Events():\n\t\tt.Fatalf(\"unwatched repo event leaked onto the stream: %+v\", ev)\n\tdefault:\n\t}\n\n\t// The dedup id must be free: if the user adds bob/* to the config\n\t// later, new events for bob/widgets must still be processed.\n\tif isNew, err := w.store.MarkSeen(context.Background(), testEvent(\"bob\", \"widgets\").ID); err != nil {\n\t\tt.Fatal(err)\n\t} else if !isNew {\n\t\tt.Fatal(\"unwatched repo event was marked seen; it would be deduped away if the repo were watched later\")\n\t}\n}\n\n// TestWatcherDispatchEmptyFilterWatchesEverything: with no patterns\n// configured, every repository passes, preserving the historical\n// behavior.\nfunc TestWatcherDispatchEmptyFilterWatchesEverything(t *testing.T) {\n\tw := newTestWatcher(t, \"\")\n\n\tw.dispatch(testEvent(\"anyone\", \"anything\"))\n\n\tif _, ok := <-w.Events(); !ok {\n\t\tt.Fatal(\"expected an event on the stream\")\n\t}\n}\n\n// TestWatcherDispatchDropsMasterUserComments: a comment by the master\n// token's owner is always directed at human operators, never at agents,\n// so it is dropped before dedup β it neither triggers a run nor\n// occupies a dedup id.\nfunc TestWatcherDispatchDropsMasterUserComments(t *testing.T) {\n\tw := newTestWatcher(t, \"abrander\", \"acme/*\")\n\n\tw.dispatch(testCommentEvent(\"abrander\"))\n\n\tselect {\n\tcase ev := <-w.Events():\n\t\tt.Fatalf(\"master user comment leaked onto the stream: %+v\", ev)\n\tdefault:\n\t}\n\n\t// The dedup id must stay free, mirroring the unwatched-repo\n\t// behavior: nothing about this comment may be remembered.\n\tif isNew, err := w.store.MarkSeen(context.Background(), testCommentEvent(\"abrander\").ID); err != nil {\n\t\tt.Fatal(err)\n\t} else if !isNew {\n\t\tt.Fatal(\"master user comment was marked seen; it should have been dropped before dedup\")\n\t}\n}\n\n// TestWatcherDispatchForwardsOtherUsersComments: the filter only drops\n// comments by the master user; everyone else's comments still trigger\n// runs.\nfunc TestWatcherDispatchForwardsOtherUsersComments(t *testing.T) {\n\tw := newTestWatcher(t, \"abrander\", \"acme/*\")\n\n\tw.dispatch(testCommentEvent(\"leon\"))\n\n\tev, ok := <-w.Events()\n\tif !ok {\n\t\tt.Fatal(\"expected an event on the stream\")\n\t}\n\tif ev.Kind != EventIssueComment || ev.Author != \"leon\" {\n\t\tt.Fatalf(\"unexpected event: %+v\", ev)\n\t}\n}\n\n// TestWatcherDispatchMasterUserNonCommentEventsPass: only comments by\n// the master user are ignored. New issues, assignments, and PRs opened\n// by that user are still work for agents and must be forwarded.\nfunc TestWatcherDispatchMasterUserNonCommentEventsPass(t *testing.T) {\n\tw := newTestWatcher(t, \"abrander\", \"acme/*\")\n\n\tev := testEvent(\"acme\", \"widgets\")\n\tev.Author = \"abrander\"\n\n\tw.dispatch(ev)\n\n\tgot, ok := <-w.Events()\n\tif !ok {\n\t\tt.Fatal(\"expected an event on the stream\")\n\t}\n\tif got.Kind != EventIssueNew || got.Author != \"abrander\" {\n\t\tt.Fatalf(\"unexpected event: %+v\", got)\n\t}\n}\n","path":"internal/forgejo/watcher_test.go"}
OK
{"find":"\treturn NewWatcher(client, newTestStore(t), \"\", nil, slog.New(slog.DiscardHandler))","path":"internal/forgejo/poll_test.go","replace":"","replace_all":false}
1 occurrences replaced
{"command":"cd /project 2>/dev/null; cd /project && sed -n '30,50p' internal/forgejo/poll_test.go"}
// newTestPollWatcher returns a Watcher whose client talks to the test
// server and whose events can be read from Events(). The repo filter is
// nil (watch everything), since the poll tests are about the polling
// path, not the filter.
func newTestPollWatcher(t *testing.T, serverURL string) *Watcher {
t.Helper()
client, err := NewClient(config.Forgejo{URL: serverURL, Token: "test"})
if err != nil {
t.Fatalf("new client: %v", err)
}
}
func TestPollReviewsDispatchesNewReview(t *testing.T) {
server, mux := newTestServer(t)
mux.HandleFunc("/api/v1/repos/acme/widgets/pulls/9/reviews", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
{"find":"\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\n}","path":"internal/forgejo/poll_test.go","replace":"\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\t// masterUser is empty: the poll tests are about the polling path,\n\t// not the master-user comment filter.\n\treturn NewWatcher(client, newTestStore(t), \"\", nil, \"\", slog.New(slog.DiscardHandler))\n}"}
1 occurrences replaced
{"end_line":254,"path":"internal/forgejo/client_test.go","start_line":230}
{"path":"internal/forgejo/client_test.go","content":"\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}","start_line":230,"end_line":254,"total_lines":254,"truncated":false}
{"find":"\tif err := client.SubmitReview(\"acme\", \"widgets\", 9, \"bogus\", \"body\"); err == nil {\n\t\tt.Fatal(\"expected an error for an unknown review state\")\n\t}\n}","path":"internal/forgejo/client_test.go","replace":"\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}\n\n// TestOwner: the token owner is resolved via GET /user, which is how\n// zoo learns whose comments to ignore everywhere.\nfunc TestOwner(t *testing.T) {\n\tserver, mux := newTestServer(t)\n\n\tmux.HandleFunc(\"/api/v1/user\", func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodGet {\n\t\t\tt.Errorf(\"expected GET /user, got %s\", r.Method)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t_ = json.NewEncoder(w).Encode(map[string]string{\"login\": \"abrander\"})\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\towner, err := client.Owner()\n\tif err != nil {\n\t\tt.Fatalf(\"owner: %v\", err)\n\t}\n\tif owner != \"abrander\" {\n\t\tt.Fatalf(\"expected owner %q, got %q\", \"abrander\", owner)\n\t}\n}"}
1 occurrences replaced
{"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, nil)\n\n\tif strings.Contains(msg, \"Comments\") {\n\t\tt.Fatalf(\"expected no comments section, got: %s\", msg)\n\t}\n}","path":"internal/agentrun/seed_test.go","replace":""}
1 occurrences replaced
{"command":"cd /project 2>/dev/null; cd /project && sed -n '55,75p' internal/agentrun/seed_test.go"}
t.Fatalf("expected comments section before the raw payload, got:\n%s", msg)
}
}
func TestSeedMessageIncludesReview(t *testing.T) {
ev := forgejo.Event{
Kind: forgejo.EventPRReview,
Owner: "acme",
Repo: "widgets",
Index: 9,
Title: "Fix the thing",
Body: "This fixes it",
ReviewID: 42,
ReviewState: "REQUEST_CHANGES",
ReviewBody: "Please address the inline comments.",
Raw: []byte(`{}`),
}
review := &forgejo.ReviewDetail{
{"find":"\t\tt.Fatalf(\"expected comments section before the raw payload, got:\\n%s\", msg)\n\t}\n}\n\n\n\nfunc TestSeedMessageIncludesReview(t *testing.T) {","path":"internal/agentrun/seed_test.go","replace":"\t\tt.Fatalf(\"expected comments section before the raw payload, got:\\n%s\", msg)\n\t}\n}\n\nfunc TestSeedMessageOmitsEmptyComments(t *testing.T) {\n\tev := forgejo.Event{Kind: \"issue:new\", Owner: \"acme\", Repo: \"widgets\", Index: 5, Title: \"t\", Body: \"b\", Raw: []byte(`{}`)}\n\n\tmsg := seedMessage(ev, \"issue-5-greg\", \"main\", \"\", nil, nil)\n\n\tif strings.Contains(msg, \"Comments\") {\n\t\tt.Fatalf(\"expected no comments section, got: %s\", msg)\n\t}\n}\n\n// TestFilterMasterComments: comments by the master token's owner are\n// always directed at human operators, never at agents, so they must not\n// reach the agent's briefing.\nfunc TestFilterMasterComments(t *testing.T) {\n\tcomments := []forgejo.IssueComment{\n\t\t{Author: \"abrander\", Body: \"debug: restarted the worker\"},\n\t\t{Author: \"leon\", Body: \"Please also handle the edge case.\"},\n\t\t{Author: \"abrander\", Body: \"log: deploy finished\"},\n\t\t{Author: \"mika\", Body: \"And add a test for it.\"},\n\t}\n\n\tgot := filterMasterComments(comments, \"abrander\")\n\n\tif len(got) != 2 {\n\t\tt.Fatalf(\"expected 2 comments, got %d: %+v\", len(got), got)\n\t}\n\tif got[0].Author != \"leon\" || got[1].Author != \"mika\" {\n\t\tt.Fatalf(\"unexpected survivors: %+v\", got)\n\t}\n\n\t// An empty masterUser (filter disabled) keeps everything.\n\tif got := filterMasterComments(comments, \"\"); len(got) != len(comments) {\n\t\tt.Fatalf(\"empty masterUser must be a no-op, got %d comments\", len(got))\n\t}\n}\n\nfunc TestSeedMessageIncludesReview(t *testing.T) {"}
1 occurrences replaced
{"command":"cd /project 2>/dev/null; cd /project && go build ./... && go vet ./... 2>&1 | head -20; echo \"VET EXIT: $?\"","timeout":300}
VET EXIT: 0
{"command":"cd /project 2>/dev/null; cd /project && go test ./... 2>&1 | tail -30","timeout":30}
? github.com/abrander/zoo/cmd/zoo [no test files] ok github.com/abrander/zoo/internal/agentrun 0.025s ok github.com/abrander/zoo/internal/config 0.006s ok github.com/abrander/zoo/internal/forgejo 1.805s ok github.com/abrander/zoo/internal/livelog 0.052s ok github.com/abrander/zoo/internal/llm 0.003s ok github.com/abrander/zoo/internal/scheduler 3.187s ok github.com/abrander/zoo/internal/store 1.663s ok github.com/abrander/zoo/internal/tools 0.003s ok github.com/abrander/zoo/internal/web 1.611s
{"command":"cd /project 2>/dev/null; cd /project && go test ./internal/forgejo/ -run 'TestWatcherDispatch|TestOwner' -v 2>&1 | grep -E \"^(=== RUN|--- (PASS|FAIL)|PASS|FAIL|ok)\" && go test ./internal/agentrun/ -run 'TestFilterMasterComments' -v 2>&1 | grep -E \"^(=== RUN|--- (PASS|FAIL)|PASS|FAIL|ok)\"","timeout":10}
=== RUN TestOwner --- PASS: TestOwner (0.00s) === RUN TestWatcherDispatchForwardsWatchedRepo --- PASS: TestWatcherDispatchForwardsWatchedRepo (0.14s) === RUN TestWatcherDispatchDropsUnwatchedRepo --- PASS: TestWatcherDispatchDropsUnwatchedRepo (0.13s) === RUN TestWatcherDispatchEmptyFilterWatchesEverything --- PASS: TestWatcherDispatchEmptyFilterWatchesEverything (0.13s) === RUN TestWatcherDispatchDropsMasterUserComments --- PASS: TestWatcherDispatchDropsMasterUserComments (0.14s) === RUN TestWatcherDispatchForwardsOtherUsersComments --- PASS: TestWatcherDispatchForwardsOtherUsersComments (0.14s) === RUN TestWatcherDispatchMasterUserNonCommentEventsPass --- PASS: TestWatcherDispatchMasterUserNonCommentEventsPass (0.13s) PASS ok github.com/abrander/zoo/internal/forgejo 0.825s === RUN TestFilterMasterComments --- PASS: TestFilterMasterComments (0.00s) PASS ok github.com/abrander/zoo/internal/agentrun 0.004s
{"find":"- **Multi-agent orchestration** β Assign different tasks to specialized agents (reviewers, managers, developers).\n- **Event-driven routing** β Configure which agent handles `issue:new`, `pr:new`, `issue:comment`, `issue:assigned`, and more.","path":"README.md","replace":"- **Multi-agent orchestration** β Assign different tasks to specialized agents (reviewers, managers, developers).\n- **Event-driven routing** β Configure which agent handles `issue:new`, `pr:new`, `issue:comment`, `issue:assigned`, and more.\n- **Human-only comments ignored** β Comments by the owner of the master Forgejo token are always treated as messages to human operators: they never trigger an agent run and never appear in an agent's briefing."}
1 occurrences replaced
{"command":"cd /project 2>/dev/null; cd /project && git diff"}
diff --git a/README.md b/README.md
index fad2ef1..373710d 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@ Zoo watches a [Forgejo](https://codeberg.org/forgejo/forgejo) instance for issue
- **Multi-agent orchestration** β Assign different tasks to specialized agents (reviewers, managers, developers).
- **Event-driven routing** β Configure which agent handles `issue:new`, `pr:new`, `issue:comment`, `issue:assigned`, and more.
+- **Human-only comments ignored** β Comments by the owner of the master Forgejo token are always treated as messages to human operators: they never trigger an agent run and never appear in an agent's briefing.
- **LLM flexibility** β Plug in any OpenAI-compatible API; each agent gets its own model choice.
- **Isolated execution** β Agents run in Docker containers with full filesystem access but no persistence between runs.
- **Live dashboard** β Real-time web UI showing active agents, logs, and job history.
diff --git a/cmd/zoo/main.go b/cmd/zoo/main.go
index 06a533b..9d302ad 100644
--- a/cmd/zoo/main.go
+++ b/cmd/zoo/main.go
@@ -66,16 +66,28 @@ func run() error {
return fmt.Errorf("forgejo client: %w", err)
}
+ // Resolve who owns the master token: their comments are always
+ // directed at human operators, never at agents, and zoo must ignore
+ // them everywhere. A short retry absorbs Forgejo coming up slightly
+ // after zoo; if it still fails, the token is unusable and nothing
+ // else in the daemon would work either, so fail fast.
+ masterUser, err := resolveMasterUser(fgClient, logger)
+ if err != nil {
+ return err
+ }
+
+ logger.Info("master token owner", "user", masterUser)
+
repoFilter, err := forgejo.NewRepoFilter(cfg.Forgejo.Repos)
if err != nil {
return fmt.Errorf("forgejo repos: %w", err)
}
- watcher := forgejo.NewWatcher(fgClient, st, cfg.Forgejo.WebhookSecret, repoFilter, logger.With("component", "forgejo"))
+ watcher := forgejo.NewWatcher(fgClient, st, cfg.Forgejo.WebhookSecret, repoFilter, masterUser, logger.With("component", "forgejo"))
hub := livelog.NewHub()
- runner, err := agentrun.NewRunner(cfg, fgClient, st, hub, logger.With("component", "agentrun"), *runTimeout, *keepOnFailure)
+ runner, err := agentrun.NewRunner(cfg, fgClient, st, hub, masterUser, logger.With("component", "agentrun"), *runTimeout, *keepOnFailure)
if err != nil {
return fmt.Errorf("agent runner: %w", err)
}
@@ -134,3 +146,27 @@ func run() error {
return nil
}
+
+// resolveMasterUser asks Forgejo who owns the master token, retrying a
+// few times to ride out Forgejo coming up slightly after zoo.
+func resolveMasterUser(client *forgejo.Client, logger *slog.Logger) (string, error) {
+ const attempts = 5
+
+ var lastErr error
+
+ for attempt := 1; attempt <= attempts; attempt++ {
+ owner, err := client.Owner()
+ if err == nil {
+ return owner, nil
+ }
+
+ lastErr = err
+
+ if attempt < attempts {
+ logger.Warn("resolving master token owner", "attempt", attempt, "error", err)
+ time.Sleep(2 * time.Second)
+ }
+ }
+
+ return "", fmt.Errorf("resolve master token owner: %w", lastErr)
+}
diff --git a/internal/agentrun/run.go b/internal/agentrun/run.go
index 7a84f20..beec210 100644
--- a/internal/agentrun/run.go
+++ b/internal/agentrun/run.go
@@ -41,11 +41,17 @@ type Runner struct {
timeout time.Duration
keepOnFailure bool
+ // masterUser is the Forgejo username that owns the shared master
+ // token. Its comments are always directed at human operators, never
+ // at agents, so they're kept out of the agent's briefing. An empty
+ // value disables the filter.
+ masterUser string
+
agentClientsMu sync.Mutex
agentClients map[string]*forgejo.Client
}
-func NewRunner(cfg *config.Config, fg *forgejo.Client, st *store.Store, hub *livelog.Hub, logger *slog.Logger, timeout time.Duration, keepOnFailure bool) (*Runner, error) {
+func NewRunner(cfg *config.Config, fg *forgejo.Client, st *store.Store, hub *livelog.Hub, masterUser string, logger *slog.Logger, timeout time.Duration, keepOnFailure bool) (*Runner, error) {
docker, err := newDockerRuntime()
if err != nil {
return nil, err
@@ -64,6 +70,7 @@ func NewRunner(cfg *config.Config, fg *forgejo.Client, st *store.Store, hub *liv
logger: logger,
timeout: timeout,
keepOnFailure: keepOnFailure,
+ masterUser: masterUser,
agentClients: make(map[string]*forgejo.Client),
}, nil
}
@@ -261,6 +268,10 @@ func (r *Runner) Run(ctx context.Context, jobID string, agent config.AgentConfig
comments = nil
}
+ // The master token owner's comments are for human operators, not
+ // agents: keep them out of the briefing too.
+ comments = filterMasterComments(comments, r.masterUser)
+
messages := []llm.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: seedMessage(ev, branch, repoInfo.DefaultBranch, instructions, comments, review)},
@@ -381,6 +392,27 @@ func (r *Runner) streamHooks(jobID string, logAppend func(stream, line string))
}
}
+// filterMasterComments drops comments by the master token's owner: they
+// are always directed at human operators (log messages, debug notes),
+// never at agents, so an agent reading them could only be confused by
+// them. An empty masterUser is a no-op.
+func filterMasterComments(comments []forgejo.IssueComment, masterUser string) []forgejo.IssueComment {
+ if masterUser == "" {
+ return comments
+ }
+
+ out := make([]forgejo.IssueComment, 0, len(comments))
+ for _, c := range comments {
+ if c.Author == masterUser {
+ continue
+ }
+
+ out = append(out, c)
+ }
+
+ return out
+}
+
func seedMessage(ev forgejo.Event, branch, defaultBranch, instructions string, comments []forgejo.IssueComment, review *forgejo.ReviewDetail) string {
raw, _ := json.MarshalIndent(json.RawMessage(ev.Raw), "", " ")
diff --git a/internal/agentrun/seed_test.go b/internal/agentrun/seed_test.go
index a44ed77..3d161c7 100644
--- a/internal/agentrun/seed_test.go
+++ b/internal/agentrun/seed_test.go
@@ -66,6 +66,32 @@ func TestSeedMessageOmitsEmptyComments(t *testing.T) {
}
}
+// TestFilterMasterComments: comments by the master token's owner are
+// always directed at human operators, never at agents, so they must not
+// reach the agent's briefing.
+func TestFilterMasterComments(t *testing.T) {
+ comments := []forgejo.IssueComment{
+ {Author: "abrander", Body: "debug: restarted the worker"},
+ {Author: "leon", Body: "Please also handle the edge case."},
+ {Author: "abrander", Body: "log: deploy finished"},
+ {Author: "mika", Body: "And add a test for it."},
+ }
+
+ got := filterMasterComments(comments, "abrander")
+
+ if len(got) != 2 {
+ t.Fatalf("expected 2 comments, got %d: %+v", len(got), got)
+ }
+ if got[0].Author != "leon" || got[1].Author != "mika" {
+ t.Fatalf("unexpected survivors: %+v", got)
+ }
+
+ // An empty masterUser (filter disabled) keeps everything.
+ if got := filterMasterComments(comments, ""); len(got) != len(comments) {
+ t.Fatalf("empty masterUser must be a no-op, got %d comments", len(got))
+ }
+}
+
func TestSeedMessageIncludesReview(t *testing.T) {
ev := forgejo.Event{
Kind: forgejo.EventPRReview,
diff --git a/internal/forgejo/client.go b/internal/forgejo/client.go
index 61377f2..9420f22 100644
--- a/internal/forgejo/client.go
+++ b/internal/forgejo/client.go
@@ -38,6 +38,19 @@ func (c *Client) Token() string {
return c.token
}
+// Owner returns the Forgejo username that owns this client's token. For
+// the shared master client that's the human operator whose comments are
+// always directed at humans, never at agents, and which zoo therefore
+// ignores everywhere (see Watcher.dispatch and agentrun's briefing).
+func (c *Client) Owner() (string, error) {
+ u, _, err := c.sdk.GetMyUserInfo()
+ if err != nil {
+ return "", fmt.Errorf("get current user: %w", err)
+ }
+
+ return u.UserName, nil
+}
+
// As returns a new Client that authenticates as the given token.
// This is used to create per-agent clients so each agent acts as
// themselves on Forgejo, without needing a global token with sudo
diff --git a/internal/forgejo/client_test.go b/internal/forgejo/client_test.go
index 7b27125..c1d03fe 100644
--- a/internal/forgejo/client_test.go
+++ b/internal/forgejo/client_test.go
@@ -252,3 +252,30 @@ func TestSubmitReviewUnknownState(t *testing.T) {
t.Fatal("expected an error for an unknown review state")
}
}
+
+// TestOwner: the token owner is resolved via GET /user, which is how
+// zoo learns whose comments to ignore everywhere.
+func TestOwner(t *testing.T) {
+ server, mux := newTestServer(t)
+
+ mux.HandleFunc("/api/v1/user", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ t.Errorf("expected GET /user, got %s", r.Method)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]string{"login": "abrander"})
+ })
+
+ client, err := NewClient(config.Forgejo{URL: server.URL, Token: "test"})
+ if err != nil {
+ t.Fatalf("new client: %v", err)
+ }
+
+ owner, err := client.Owner()
+ if err != nil {
+ t.Fatalf("owner: %v", err)
+ }
+ if owner != "abrander" {
+ t.Fatalf("expected owner %q, got %q", "abrander", owner)
+ }
+}
diff --git a/internal/forgejo/poll_test.go b/internal/forgejo/poll_test.go
index 501cee6..e9cf62a 100644
--- a/internal/forgejo/poll_test.go
+++ b/internal/forgejo/poll_test.go
@@ -40,7 +40,9 @@ func newTestPollWatcher(t *testing.T, serverURL string) *Watcher {
t.Fatalf("new client: %v", err)
}
- return NewWatcher(client, newTestStore(t), "", nil, slog.New(slog.DiscardHandler))
+ // masterUser is empty: the poll tests are about the polling path,
+ // not the master-user comment filter.
+ return NewWatcher(client, newTestStore(t), "", nil, "", slog.New(slog.DiscardHandler))
}
func TestPollReviewsDispatchesNewReview(t *testing.T) {
diff --git a/internal/forgejo/watcher.go b/internal/forgejo/watcher.go
index f220f3c..af38ab3 100644
--- a/internal/forgejo/watcher.go
+++ b/internal/forgejo/watcher.go
@@ -20,17 +20,24 @@ type Watcher struct {
repos *RepoFilter
logger *slog.Logger
+ // masterUser is the Forgejo username that owns the shared master
+ // token. Comments by that user are always directed at human
+ // operators (log messages, debug notes), never at agents, so they
+ // are dropped in dispatch. An empty value disables the filter.
+ masterUser string
+
events chan Event
}
-func NewWatcher(client *Client, st *store.Store, webhookSecret string, repos *RepoFilter, logger *slog.Logger) *Watcher {
+func NewWatcher(client *Client, st *store.Store, webhookSecret string, repos *RepoFilter, masterUser string, logger *slog.Logger) *Watcher {
return &Watcher{
- client: client,
- store: st,
- secret: webhookSecret,
- repos: repos,
- logger: logger,
- events: make(chan Event, 64),
+ client: client,
+ store: st,
+ secret: webhookSecret,
+ repos: repos,
+ logger: logger,
+ masterUser: masterUser,
+ events: make(chan Event, 64),
}
}
@@ -69,6 +76,16 @@ func (w *Watcher) Run(ctx context.Context) {
// not occupy dedup ids, so that a repository added to the config later
// still has its new events processed.
func (w *Watcher) dispatch(ev Event) {
+ // Comments by the master token's owner are always directed at human
+ // operators (log messages, debug notes), never at agents. Drop them
+ // before dedup, like unwatched-repo events, so they neither trigger
+ // a run nor occupy a dedup id. Non-comment events by that user (new
+ // issues, assignments, ...) are still work for agents and pass.
+ if w.masterUser != "" && ev.Kind == EventIssueComment && ev.Author == w.masterUser {
+ w.logger.Debug("ignoring comment by master token owner", "owner", ev.Owner, "repo", ev.Repo, "index", ev.Index, "id", ev.ID)
+ return
+ }
+
if !w.repos.Matches(ev.Owner, ev.Repo) {
w.logger.Debug("ignoring event from unwatched repository", "owner", ev.Owner, "repo", ev.Repo, "kind", ev.Kind, "id", ev.ID)
return
diff --git a/internal/forgejo/watcher_test.go b/internal/forgejo/watcher_test.go
index 2219647..70b6037 100644
--- a/internal/forgejo/watcher_test.go
+++ b/internal/forgejo/watcher_test.go
@@ -11,7 +11,7 @@ import (
// newTestWatcher builds a Watcher backed by a real (temp) store. The
// client is nil because dispatch never touches it.
-func newTestWatcher(t *testing.T, patterns ...string) *Watcher {
+func newTestWatcher(t *testing.T, masterUser string, patterns ...string) *Watcher {
t.Helper()
st, err := store.Open(t.TempDir() + "/zoo.db")
@@ -27,7 +27,7 @@ func newTestWatcher(t *testing.T, patterns ...string) *Watcher {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- return NewWatcher(nil, st, "", repos, logger)
+ return NewWatcher(nil, st, "", repos, masterUser, logger)
}
func testEvent(owner, repo string) Event {
@@ -39,6 +39,18 @@ func testEvent(owner, repo string) Event {
}
}
+func testCommentEvent(author string) Event {
+ return Event{
+ Kind: EventIssueComment,
+ ID: "issue-comment-1",
+ Owner: "acme",
+ Repo: "widgets",
+ Index: 5,
+ Author: author,
+ Comment: "a comment",
+ }
+}
+
// TestWatcherDispatchForwardsWatchedRepo: an event from a matching
// repository is deduped and forwarded to the event stream.
func TestWatcherDispatchForwardsWatchedRepo(t *testing.T) {
@@ -59,7 +71,7 @@ func TestWatcherDispatchForwardsWatchedRepo(t *testing.T) {
// repository is dropped before dedup, so it neither reaches the stream
// nor occupies a dedup id.
func TestWatcherDispatchDropsUnwatchedRepo(t *testing.T) {
- w := newTestWatcher(t, "acme/*")
+ w := newTestWatcher(t, "", "acme/*")
w.dispatch(testEvent("bob", "widgets"))
@@ -82,7 +94,7 @@ func TestWatcherDispatchDropsUnwatchedRepo(t *testing.T) {
// configured, every repository passes, preserving the historical
// behavior.
func TestWatcherDispatchEmptyFilterWatchesEverything(t *testing.T) {
- w := newTestWatcher(t)
+ w := newTestWatcher(t, "")
w.dispatch(testEvent("anyone", "anything"))
@@ -90,3 +102,64 @@ func TestWatcherDispatchEmptyFilterWatchesEverything(t *testing.T) {
t.Fatal("expected an event on the stream")
}
}
+
+// TestWatcherDispatchDropsMasterUserComments: a comment by the master
+// token's owner is always directed at human operators, never at agents,
+// so it is dropped before dedup β it neither triggers a run nor
+// occupies a dedup id.
+func TestWatcherDispatchDropsMasterUserComments(t *testing.T) {
+ w := newTestWatcher(t, "abrander", "acme/*")
+
+ w.dispatch(testCommentEvent("abrander"))
+
+ select {
+ case ev := <-w.Events():
+ t.Fatalf("master user comment leaked onto the stream: %+v", ev)
+ default:
+ }
+
+ // The dedup id must stay free, mirroring the unwatched-repo
+ // behavior: nothing about this comment may be remembered.
+ if isNew, err := w.store.MarkSeen(context.Background(), testCommentEvent("abrander").ID); err != nil {
+ t.Fatal(err)
+ } else if !isNew {
+ t.Fatal("master user comment was marked seen; it should have been dropped before dedup")
+ }
+}
+
+// TestWatcherDispatchForwardsOtherUsersComments: the filter only drops
+// comments by the master user; everyone else's comments still trigger
+// runs.
+func TestWatcherDispatchForwardsOtherUsersComments(t *testing.T) {
+ w := newTestWatcher(t, "abrander", "acme/*")
+
+ w.dispatch(testCommentEvent("leon"))
+
+ ev, ok := <-w.Events()
+ if !ok {
+ t.Fatal("expected an event on the stream")
+ }
+ if ev.Kind != EventIssueComment || ev.Author != "leon" {
+ t.Fatalf("unexpected event: %+v", ev)
+ }
+}
+
+// TestWatcherDispatchMasterUserNonCommentEventsPass: only comments by
+// the master user are ignored. New issues, assignments, and PRs opened
+// by that user are still work for agents and must be forwarded.
+func TestWatcherDispatchMasterUserNonCommentEventsPass(t *testing.T) {
+ w := newTestWatcher(t, "abrander", "acme/*")
+
+ ev := testEvent("acme", "widgets")
+ ev.Author = "abrander"
+
+ w.dispatch(ev)
+
+ got, ok := <-w.Events()
+ if !ok {
+ t.Fatal("expected an event on the stream")
+ }
+ if got.Kind != EventIssueNew || got.Author != "abrander" {
+ t.Fatalf("unexpected event: %+v", got)
+ }
+}
{"command":"cd /project 2>/dev/null; cd /project && grep -rn \"Comment\" internal/web/ internal/scheduler/scheduler.go 2>/dev/null | grep -v \"_test\" | head -10; echo \"---\"; gofmt -l . | grep -v \"^$\"; echo \"FMT EXIT: $?\""}
internal/scheduler/scheduler.go:24: CreateIssueComment(owner, repo string, index int64, body string) error
internal/scheduler/scheduler.go:239: if err := s.forgejo.CreateIssueComment(ev.Owner, ev.Repo, ev.Index, body); err != nil {
---
internal/tools/tools_test.go
FMT EXIT: 0
{"command":"cd /project 2>/dev/null; cd /project && git stash && gofmt -l . ; echo \"---\"; git stash pop && git status --short"}
Saved working directory and index state WIP on issue-72-greg: 49416c8 Merge pull request 'Live dashboard at /: jobs-changed SSE stream' (#67) from issue-64-greg into main
internal/tools/tools_test.go
---
On branch issue-72-greg
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: README.md
modified: cmd/zoo/main.go
modified: internal/agentrun/run.go
modified: internal/agentrun/seed_test.go
modified: internal/forgejo/client.go
modified: internal/forgejo/client_test.go
modified: internal/forgejo/poll_test.go
modified: internal/forgejo/watcher.go
modified: internal/forgejo/watcher_test.go
no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (abbe4e0d259b022d0e7418f556a5b4c3e896a189)
M README.md
M cmd/zoo/main.go
M internal/agentrun/run.go
M internal/agentrun/seed_test.go
M internal/forgejo/client.go
M internal/forgejo/client_test.go
M internal/forgejo/poll_test.go
M internal/forgejo/watcher.go
M internal/forgejo/watcher_test.go
{"command":"cd /project 2>/dev/null; cd /project && go build ./... && go test ./... 2>&1 | tail -12","timeout":30}
? 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 2>/dev/null; cd /project && git add -A && git commit -m \"Ignore comments by the master token owner everywhere\n\nComments written by the owner of the master Forgejo token are always\ndirected at human operators (log messages, debug notes), never at\nagents. Resolve the token's owner via GET /user at startup and filter\nits comments in the two places comments reach agents:\n\n- Watcher.dispatch: issue:comment events by the master user are\n dropped before dedup (webhook and poll paths alike), so they neither\n trigger a run nor occupy a dedup id. Non-comment events by that user\n (new issues, assignments, PRs) still pass β that's how work gets\n assigned to agents.\n- agentrun briefing: the master user's comments are removed from the\n comment thread an agent is seeded with.\n\nStartup fails fast (after a short retry) if the owner cannot be\nresolved: an unusable master token breaks everything else in the\ndaemon anyway.\" && git push origin issue-72-greg 2>&1 | tail -3"}
[issue-72-greg 81330d0] Ignore comments by the master token owner everywhere 9 files changed, 242 insertions(+), 15 deletions(-) To https://code.stdio.dk/abrander/zoo.git * [new branch] issue-72-greg -> issue-72-greg
{"base":"main","body":"## Summary\n\nImplements #72: comments written by the owner of the master Forgejo token are ignored everywhere. They are always directed at human operators (log messages, debug notes), never at agents.\n\n## How it works\n\n**Resolving the owner** β `forgejo.Client.Owner()` calls `GET /user` with the master token to get its owner's username. `main` resolves this at startup (5 attempts, 2s apart, to ride out Forgejo starting slightly after zoo) and fails fast if it can't: an unusable master token breaks polling and every agent run anyway, so a clear startup error beats a silently-disabled filter. The owner is logged at startup.\n\n**Filtering, at the two places comments reach agents:**\n\n1. **Event dispatch** (`Watcher.dispatch`) β the single choke point both the webhook and poll paths flow through. An `issue:comment` event whose author is the master user is dropped *before* dedup, mirroring the existing unwatched-repo filter: it neither triggers a run nor occupies a dedup id.\n2. **Agent briefing** (`agentrun.Run`) β the master user's comments are removed from the comment thread the agent is seeded with, so a debug comment on an issue can't confuse an agent triggered by a different event on that same issue.\n\n**Deliberately not filtered:** non-comment events by the master user (`issue:new`, `issue:assigned`, `pr:new`, `pr:review`). Those are how work gets assigned to agents β filtering them would break the system (this very issue was opened and assigned by the master user). PR reviews are also left alone: they're deliberate verdicts, not casual comments.\n\n## Changes\n\n- `internal/forgejo/client.go` β new `Client.Owner()`\n- `internal/forgejo/watcher.go` β `masterUser` on `Watcher`, filter in `dispatch`\n- `cmd/zoo/main.go` β startup resolution with retry, passed to watcher and runner\n- `internal/agentrun/run.go` β `masterUser` on `Runner`, `filterMasterComments` applied to the briefing\n- `README.md` β feature note\n- Tests: `TestOwner`, `TestWatcherDispatchDropsMasterUserComments` (incl. dedup-id stays free), `TestWatcherDispatchForwardsOtherUsersComments`, `TestWatcherDispatchMasterUserNonCommentEventsPass`, `TestFilterMasterComments`\n\n## Verification\n\n`go build ./...`, `go vet ./...`, `go test ./...` β all pass.","head":"issue-72-greg","title":"Ignore comments by the master token owner everywhere"}
Pull request opened
{"body":"Done β PR opened from `issue-72-greg`.\n\n**What I implemented:** comments by the owner of the master Forgejo token are now ignored everywhere, in both of the places comments can reach agents:\n\n1. **Event dispatch** β the token's owner is resolved once at startup via `GET /user` (with a short retry; startup fails fast if it can't be resolved, since an unusable master token breaks the daemon anyway). `Watcher.dispatch` β the single choke point shared by the webhook and poll paths β then drops any `issue:comment` event authored by that user *before* dedup, so such comments neither trigger a run nor occupy a dedup id.\n2. **Agent briefings** β the master user's comments are filtered out of the comment thread an agent is seeded with, so a debug/log comment on an issue can't confuse an agent triggered by a different event on that same issue.\n\n**Deliberately out of scope:** non-comment events by the master user (`issue:new`, `issue:assigned`, `pr:new`, `pr:review`) still work β those are how work gets assigned to agents (this issue itself was opened and assigned by you). PR reviews are likewise left alone, as they're deliberate verdicts rather than casual comments.\n\n**Tests:** new coverage for owner resolution, the dispatch filter (including that the dedup id stays free and that other users' comments still pass), and the briefing filter. `go build`, `go vet`, and the full test suite all pass."}
Comment posted