Details and log output for this agent run.
{"path": ".", "recursive": true}
/project/cmd /project/cmd/zoo /project/cmd/zoo/main.go /project/go.mod /project/go.sum /project/internal /project/internal/agentrun /project/internal/agentrun/clone.go /project/internal/agentrun/context.go /project/internal/agentrun/docker.go /project/internal/agentrun/docker_integration_test.go /project/internal/agentrun/forgejo.go /project/internal/agentrun/loop.go /project/internal/agentrun/roster.go /project/internal/agentrun/roster_test.go /project/internal/agentrun/run.go /project/internal/agentrun/seed_test.go /project/internal/agentrun/system.md /project/internal/config /project/internal/config/config.go /project/internal/config/config_test.go /project/internal/forgejo /project/internal/forgejo/client.go /project/internal/forgejo/client_test.go /project/internal/forgejo/event.go /project/internal/forgejo/poll.go /project/internal/forgejo/watcher.go /project/internal/forgejo/webhook.go /project/internal/forgejo/webhook_test.go /project/internal/livelog /project/internal/livelog/hub.go /project/internal/livelog/hub_test.go /project/internal/llm /project/internal/llm/client.go /project/internal/llm/streamer.go /project/internal/llm/streamer_test.go /project/internal/llm/types.go /project/internal/scheduler /project/internal/scheduler/scheduler.go /project/internal/scheduler/scheduler_test.go /project/internal/store /project/internal/store/schema.sql /project/internal/store/store.go /project/internal/store/store_test.go /project/internal/tools /project/internal/tools/bash.go /project/internal/tools/context.go /project/internal/tools/edit-file.go /project/internal/tools/forgejo.go /project/internal/tools/git.go /project/internal/tools/grep_search.go /project/internal/tools/list_files.go /project/internal/tools/move_file.go /project/internal/tools/read_file.go /project/internal/tools/registry.go /project/internal/tools/remove_file.go /project/internal/tools/shell.go /project/internal/tools/tools_test.go /project/internal/tools/write_file.go /project/internal/web /project/internal/web/templates.go /project/internal/web/web.go /project/internal/web/web_test.go /project/zoo.hcl.example
{"path": "/event", "start_line": 1, "end_line": 50}
{"path":"/event","content":"{\"id\":70,\"url\":\"https://code.stdio.dk/api/v1/repos/abrander/zoo/issues/39\",\"html_url\":\"https://code.stdio.dk/abrander/zoo/issues/39\",\"number\":39,\"user\":{\"id\":1,\"login\":\"abrander\",\"login_name\":\"\",\"source_id\":0,\"full_name\":\"Anders Brander\",\"email\":\"anders@brander.dk\",\"html_url\":\"https://code.stdio.dk/abrander\",\"avatar_url\":\"https://code.stdio.dk/avatar/d87ec4c86bb6c9d188d5e32e2424583d\",\"language\":\"en-US\",\"is_admin\":true,\"last_login\":\"2026-08-24T10:22:37+02:00\",\"created\":\"2025-03-27T21:24:00+01:00\",\"restricted\":false,\"active\":true,\"prohibit_login\":false,\"location\":\"\",\"website\":\"\",\"description\":\"Real human :-)\\r\\n\\r\\nI manage all this. Talk to me if you get stuck.\",\"visibility\":\"limited\",\"followers_count\":0,\"following_count\":0,\"starred_repos_count\":0},\"original_author\":\"\",\"original_author_id\":0,\"title\":\"List of repositories\",\"body\":\"In the configuration, we should have a list of repositories that we're interested in. And then we should ignore all other. Support wildcard. Ie \\\"organisation/*\\\" or even just \\\"*\\\" if the user want to watch everything on the Forgejo instance.\",\"ref\":\"\",\"labels\":[],\"milestone\":null,\"assignees\":[{\"id\":2,\"login\":\"greg\",\"login_name\":\"\",\"source_id\":0,\"full_name\":\"Greg Coolio\",\"email\":\"greg@noreply.localhost\",\"html_url\":\"https://code.stdio.dk/greg\",\"avatar_url\":\"https://code.stdio.dk/avatars/893e287248431844784e5109d4b0b7eaa5fe7ea602efdec156f7b57c7034aa57\",\"language\":\"\",\"is_admin\":false,\"last_login\":\"0001-01-01T00:00:00Z\",\"created\":\"2026-08-18T20:13:06+02:00\",\"restricted\":false,\"active\":false,\"prohibit_login\":false,\"location\":\"\",\"website\":\"\",\"description\":\"\\n### Senior Developer \\u0026 Architect\\n\\n- 15+ years building scalable systems and leading teams.\\n- Expert in architecture, clean code and problem solving.\\n- Turns complex problems into elegant solutions.\\n\\t\\t\",\"visibility\":\"limited\",\"followers_count\":0,\"following_count\":0,\"starred_repos_count\":0}],\"state\":\"open\",\"is_locked\":false,\"comments\":0,\"created_at\":\"2026-08-24T10:41:36+02:00\",\"updated_at\":\"2026-08-24T10:42:50+02:00\",\"closed_at\":null,\"due_date\":null,\"pull_request\":null,\"repository\":{\"id\":6,\"name\":\"zoo\",\"owner\":\"abrander\",\"full_name\":\"abrander/zoo\"}}","start_line":1,"end_line":0,"total_lines":0,"truncated":false}
{"path": "internal/config/config.go", "start_line": 1, "end_line": 400}
{"path":"internal/config/config.go","content":"// Package config loads and validates zoo's HCL configuration file.\r\npackage config\r\n\r\nimport (\r\n\t\"fmt\"\r\n\r\n\t\"github.com/hashicorp/hcl/v2/hclsimple\"\r\n)\r\n\r\n// Known event kinds. issue:assigned is resolved dynamically (agent name\r\n// must match the Forgejo assignee's username) so it never carries an\r\n// `agent` attribute; the rest map statically to one configured agent.\r\nconst (\r\n\tEventIssueNew = \"issue:new\"\r\n\tEventIssueComment = \"issue:comment\"\r\n\tEventIssueAssigned = \"issue:assigned\"\r\n\tEventPRNew = \"pr:new\"\r\n)\r\n\r\nvar staticEventKinds = map[string]bool{\r\n\tEventIssueNew: true,\r\n\tEventIssueComment: true,\r\n\tEventPRNew: true,\r\n}\r\n\r\ntype Config struct {\r\n\tLLMs []LLM `hcl:\"llm,block\"`\r\n\tForgejo Forgejo `hcl:\"forgejo,block\"`\r\n\tEnvironment Environment `hcl:\"environment,block\"`\r\n\tAgents []Agent `hcl:\"agent,block\"`\r\n\tEvents []Event `hcl:\"event,block\"`\r\n\tWeb *Web `hcl:\"web,block\"`\r\n}\r\n\r\n// Web configures the dashboard's optional bearer-token gate. Leave the\r\n// block out of zoo.hcl entirely to run without one (fine on localhost;\r\n// put a real gate or a proxy in front for anything else).\r\ntype Web struct {\r\n\tToken string `hcl:\"token,optional\"`\r\n}\r\n\r\ntype LLM struct {\r\n\tName string `hcl:\"name,label\"`\r\n\tOpenAI string `hcl:\"openai\"`\r\n\tToken string `hcl:\"token\"`\r\n\tModel string `hcl:\"model\"`\r\n}\r\n\r\ntype Forgejo struct {\r\n\tURL string `hcl:\"url\"`\r\n\tToken string `hcl:\"token\"`\r\n\tWebhookSecret string `hcl:\"webhook_secret,optional\"`\r\n}\r\n\r\ntype Environment struct {\r\n\tDockerImage string `hcl:\"docker_image\"`\r\n\tMaxLive int `hcl:\"max_live_agents\"`\r\n}\r\n\r\ntype Agent struct {\r\n\tName string `hcl:\"name,label\"`\r\n\tLLM string `hcl:\"llm\"`\r\n\tToken string `hcl:\"token,optional\"`\r\n}\r\n\r\ntype Event struct {\r\n\tKind string `hcl:\"name,label\"`\r\n\tAgent string `hcl:\"agent,optional\"`\r\n\tInstructions string `hcl:\"instructions,optional\"`\r\n}\r\n\r\n// Load reads and validates the config file at path.\r\nfunc Load(path string) (*Config, error) {\r\n\tvar cfg Config\r\n\r\n\tif err := hclsimple.DecodeFile(path, nil, \u0026cfg); err != nil {\r\n\t\treturn nil, fmt.Errorf(\"parse config: %w\", err)\r\n\t}\r\n\r\n\tif err := cfg.Validate(); err != nil {\r\n\t\treturn nil, fmt.Errorf(\"invalid config: %w\", err)\r\n\t}\r\n\r\n\treturn \u0026cfg, nil\r\n}\r\n\r\n// Validate checks that the config is internally consistent: every\r\n// reference between blocks resolves, and required values are set.\r\nfunc (c *Config) Validate() error {\r\n\tllmNames := make(map[string]bool, len(c.LLMs))\r\n\tfor _, l := range c.LLMs {\r\n\t\tif l.OpenAI == \"\" || l.Token == \"\" || l.Model == \"\" {\r\n\t\t\treturn fmt.Errorf(\"llm %q: openai, token, and model are required\", l.Name)\r\n\t\t}\r\n\t\tllmNames[l.Name] = true\r\n\t}\r\n\r\n\tif c.Forgejo.URL == \"\" || c.Forgejo.Token == \"\" {\r\n\t\treturn fmt.Errorf(\"forgejo: url and token are required\")\r\n\t}\r\n\r\n\tif c.Environment.MaxLive \u003c 1 {\r\n\t\treturn fmt.Errorf(\"environment: max_live_agents must be \u003e= 1, got %d\", c.Environment.MaxLive)\r\n\t}\r\n\r\n\tif c.Environment.DockerImage == \"\" {\r\n\t\treturn fmt.Errorf(\"environment: docker_image is required\")\r\n\t}\r\n\r\n\tagentNames := make(map[string]bool, len(c.Agents))\r\n\tfor _, a := range c.Agents {\r\n\t\tif !llmNames[a.LLM] {\r\n\t\t\treturn fmt.Errorf(\"agent %q: references undeclared llm %q\", a.Name, a.LLM)\r\n\t\t}\r\n\t\tagentNames[a.Name] = true\r\n\t}\r\n\r\n\tseenEventKinds := make(map[string]bool, len(c.Events))\r\n\tfor _, e := range c.Events {\r\n\t\tif seenEventKinds[e.Kind] {\r\n\t\t\treturn fmt.Errorf(\"event %q: declared more than once\", e.Kind)\r\n\t\t}\r\n\t\tseenEventKinds[e.Kind] = true\r\n\r\n\t\tswitch {\r\n\t\tcase e.Kind == EventIssueAssigned:\r\n\t\t\tif e.Agent != \"\" {\r\n\t\t\t\treturn fmt.Errorf(\"event %q: must not set agent; the agent whose name matches the Forgejo assignee's username is triggered dynamically\", e.Kind)\r\n\t\t\t}\r\n\r\n\t\tcase staticEventKinds[e.Kind]:\r\n\t\t\tif e.Agent == \"\" {\r\n\t\t\t\treturn fmt.Errorf(\"event %q: agent is required\", e.Kind)\r\n\t\t\t}\r\n\t\t\tif !agentNames[e.Agent] {\r\n\t\t\t\treturn fmt.Errorf(\"event %q: references undeclared agent %q\", e.Kind, e.Agent)\r\n\t\t\t}\r\n\r\n\t\tdefault:\r\n\t\t\treturn fmt.Errorf(\"event %q: unknown event kind\", e.Kind)\r\n\t\t}\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\n// AgentConfig holds an agent's full configuration including its\r\n// Forgejo token (if configured).\r\ntype AgentConfig struct {\r\n\tName string\r\n\tLLM string\r\n\tToken string\r\n}\r\n\r\n// AgentByName returns the agent config with the given name, if declared.\r\nfunc (c *Config) AgentByName(name string) (AgentConfig, bool) {\r\n\tfor _, a := range c.Agents {\r\n\t\tif a.Name == name {\r\n\t\t\treturn AgentConfig{\r\n\t\t\t\tName: a.Name,\r\n\t\t\t\tLLM: a.LLM,\r\n\t\t\t\tToken: a.Token,\r\n\t\t\t}, true\r\n\t\t}\r\n\t}\r\n\r\n\treturn AgentConfig{}, false\r\n}\r\n\r\n// LLMByName returns the llm config with the given name, if declared.\r\nfunc (c *Config) LLMByName(name string) (LLM, bool) {\r\n\tfor _, l := range c.LLMs {\r\n\t\tif l.Name == name {\r\n\t\t\treturn l, true\r\n\t\t}\r\n\t}\r\n\r\n\treturn LLM{}, false\r\n}\r\n\r\n// EventAgent returns the statically configured agent name for the given\r\n// event kind, if one is declared and set.\r\nfunc (c *Config) EventAgent(kind string) (string, bool) {\r\n\tfor _, e := range c.Events {\r\n\t\tif e.Kind == kind {\r\n\t\t\treturn e.Agent, e.Agent != \"\"\r\n\t\t}\r\n\t}\r\n\r\n\treturn \"\", false\r\n}\r\n\r\n// EventInstructions returns the configured freeform instructions for the\r\n// given event kind, e.g. \"Please review this pull request and respond\r\n// appropriately.\" Returns \"\" if the event isn't declared or has none.\r\nfunc (c *Config) EventInstructions(kind string) string {\r\n\tfor _, e := range c.Events {\r\n\t\tif e.Kind == kind {\r\n\t\t\treturn e.Instructions\r\n\t\t}\r\n\t}\r\n\r\n\treturn \"\"\r\n}\r","start_line":1,"end_line":204,"total_lines":204,"truncated":false}
{"path": "zoo.hcl.example", "start_line": 1, "end_line": 200}
{"path":"zoo.hcl.example","content":"# zoo.hcl - Configuration for the zoo daemon.\r\n#\r\n# Copy this file to zoo.hcl (gitignored) and fill in real values.\r\n\r\nllm \"Qwen 3.6\" {\r\n openai = \"https://code.stdio.dk\"\r\n token = \"TOKEN\"\r\n model = \"Qwen3.6\"\r\n}\r\n\r\nllm \"Qwen 3.8\" {\r\n openai = \"https://code.stdio.dk\"\r\n token = \"TOKEN\"\r\n model = \"Qwen3.8\"\r\n}\r\n\r\nforgejo {\r\n url = \"https://code.stdio.dk\"\r\n token = \"ZOO_SERVICE_TOKEN\"\r\n # Shared secret configured on the Forgejo webhook. Required to accept\r\n # webhook deliveries; polling works without it.\r\n webhook_secret = \"EXAMPLE_WEBHOOK_SECRET\"\r\n}\r\n\r\nenvironment {\r\n docker_image = \"golang:latest\"\r\n max_live_agents = 5\r\n}\r\n\r\nagent \"anna\" {\r\n llm = \"Qwen 3.6\"\r\n token = \"ANNA_FORGEJO_TOKEN\"\r\n}\r\n\r\nagent \"greg\" {\r\n llm = \"Qwen 3.8\"\r\n token = \"GREG_FORGEJO_TOKEN\"\r\n}\r\n\r\n# \"leon\" doubles as the expected Forgejo assignee username for\r\n# issue:assigned events - see the event block below.\r\nagent \"leon\" {\r\n llm = \"Qwen 3.8\"\r\n token = \"LEON_FORGEJO_TOKEN\"\r\n}\r\n\r\nagent \"mika\" {\r\n llm = \"Qwen 3.6\"\r\n token = \"MIKA_FORGEJO_TOKEN\"\r\n}\r\n\r\nevent \"issue:new\" {\r\n agent = \"leon\"\r\n}\r\n\r\nevent \"issue:assigned\" {\r\n // No `agent` here: the agent whose name matches the Forgejo\r\n // assignee's username is triggered.\r\n instructions = \"Please handle this issue.\"\r\n}\r\n\r\nevent \"issue:comment\" {\r\n agent = \"leon\"\r\n instructions = \"Please review what this comment says and respond appropriately.\"\r\n}\r\n\r\nevent \"pr:new\" {\r\n agent = \"greg\"\r\n instructions = \"Please review this pull request and respond appropriately.\"\r\n}\r","start_line":1,"end_line":70,"total_lines":70,"truncated":false}
{"path": "internal/forgejo/watcher.go", "start_line": 1, "end_line": 300}
{"path":"internal/forgejo/watcher.go","content":"package forgejo\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"log/slog\"\r\n\t\"net/http\"\r\n\t\"time\"\r\n\r\n\t\"github.com/abrander/zoo/internal/store\"\r\n)\r\n\r\nconst PollInterval = 10 * time.Second\r\n\r\n// Watcher merges the webhook receiver and the polling fallback into a\r\n// single deduplicated Event stream.\r\ntype Watcher struct {\r\n\tclient *Client\r\n\tstore *store.Store\r\n\tsecret string\r\n\tlogger *slog.Logger\r\n\r\n\tevents chan Event\r\n}\r\n\r\nfunc NewWatcher(client *Client, st *store.Store, webhookSecret string, logger *slog.Logger) *Watcher {\r\n\treturn \u0026Watcher{\r\n\t\tclient: client,\r\n\t\tstore: st,\r\n\t\tsecret: webhookSecret,\r\n\t\tlogger: logger,\r\n\t\tevents: make(chan Event, 64),\r\n\t}\r\n}\r\n\r\n// Handler returns the http.Handler to mount for incoming webhook\r\n// deliveries.\r\nfunc (w *Watcher) Handler() http.Handler {\r\n\treturn WebhookHandler(w.secret, w.logger, w.dispatch)\r\n}\r\n\r\n// Events returns the deduplicated stream consumed by the scheduler.\r\nfunc (w *Watcher) Events() \u003c-chan Event {\r\n\treturn w.events\r\n}\r\n\r\n// Run drives the polling fallback until ctx is canceled. The webhook\r\n// handler runs independently as part of the daemon's HTTP server.\r\nfunc (w *Watcher) Run(ctx context.Context) {\r\n\tticker := time.NewTicker(PollInterval)\r\n\tdefer ticker.Stop()\r\n\r\n\tfor {\r\n\t\tselect {\r\n\t\tcase \u003c-ctx.Done():\r\n\t\t\treturn\r\n\r\n\t\tcase \u003c-ticker.C:\r\n\t\t\tw.pollOnce(ctx)\r\n\t\t}\r\n\t}\r\n}\r\n\r\n// dispatch dedupes ev against the store (regardless of whether it came\r\n// from the webhook or the poller) and, if new, forwards it to Events().\r\nfunc (w *Watcher) dispatch(ev Event) {\r\n\tisNew, err := w.store.MarkSeen(context.Background(), ev.ID)\r\n\tif err != nil {\r\n\t\tw.logger.Error(\"dedup check failed, dropping event\", \"id\", ev.ID, \"error\", err)\r\n\t\treturn\r\n\t}\r\n\r\n\tif !isNew {\r\n\t\treturn\r\n\t}\r\n\r\n\tselect {\r\n\tcase w.events \u003c- ev:\r\n\r\n\tdefault:\r\n\t\tw.logger.Warn(\"event channel full, dropping event\", \"id\", ev.ID, \"kind\", ev.Kind)\r\n\t}\r\n}\r","start_line":1,"end_line":82,"total_lines":82,"truncated":false}
{"path": "internal/forgejo/webhook.go", "start_line": 1, "end_line": 300}
{"path":"internal/forgejo/webhook.go","content":"package forgejo\r\n\r\nimport (\r\n\t\"encoding/json\"\r\n\t\"fmt\"\r\n\t\"io\"\r\n\t\"log/slog\"\r\n\t\"net/http\"\r\n\t\"time\"\r\n\r\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\r\n)\r\n\r\ntype issuePayload struct {\r\n\tAction string `json:\"action\"`\r\n\tIssue *sdk.Issue `json:\"issue\"`\r\n\tRepository *sdk.Repository `json:\"repository\"`\r\n}\r\n\r\ntype issueCommentPayload struct {\r\n\tAction string `json:\"action\"`\r\n\tIssue *sdk.Issue `json:\"issue\"`\r\n\tComment *sdk.Comment `json:\"comment\"`\r\n\tRepository *sdk.Repository `json:\"repository\"`\r\n}\r\n\r\ntype pullRequestPayload struct {\r\n\tAction string `json:\"action\"`\r\n\tPullRequest *sdk.PullRequest `json:\"pull_request\"`\r\n\tRepository *sdk.Repository `json:\"repository\"`\r\n}\r\n\r\n// WebhookHandler returns the http.Handler to mount at (e.g.)\r\n// /webhooks/forgejo. If secret is non-empty, deliveries are verified via\r\n// the SDK's X-Forgejo-Signature middleware; callers should always set a\r\n// secret for anything reachable off localhost.\r\nfunc WebhookHandler(secret string, logger *slog.Logger, emit func(Event)) http.Handler {\r\n\thandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\r\n\t\tbody, err := io.ReadAll(r.Body)\r\n\t\tif err != nil {\r\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\r\n\t\t\treturn\r\n\t\t}\r\n\r\n\t\tkind := r.Header.Get(\"X-Forgejo-Event\")\r\n\t\tif kind == \"\" {\r\n\t\t\tkind = r.Header.Get(\"X-Gitea-Event\")\r\n\t\t}\r\n\r\n\t\tev, ok, err := decodeWebhookEvent(kind, body)\r\n\t\tif err != nil {\r\n\t\t\tlogger.Warn(\"failed to decode webhook payload\", \"event\", kind, \"error\", err)\r\n\t\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\r\n\t\t\treturn\r\n\t\t}\r\n\r\n\t\tif ok {\r\n\t\t\temit(ev)\r\n\t\t}\r\n\r\n\t\tw.WriteHeader(http.StatusOK)\r\n\t})\r\n\r\n\tif secret == \"\" {\r\n\t\tlogger.Warn(\"forgejo webhook_secret is not set; incoming webhook deliveries are not authenticated\")\r\n\r\n\t\treturn handler\r\n\t}\r\n\r\n\treturn sdk.VerifyWebhookSignatureMiddleware(secret)(handler)\r\n}\r\n\r\nfunc decodeWebhookEvent(kind string, body []byte) (Event, bool, error) {\r\n\tswitch kind {\r\n\tcase \"issues\":\r\n\t\tvar p issuePayload\r\n\r\n\t\tif err := json.Unmarshal(body, \u0026p); err != nil {\r\n\t\t\treturn Event{}, false, err\r\n\t\t}\r\n\r\n\t\treturn issueEvent(p, body)\r\n\r\n\tcase \"issue_comment\":\r\n\t\tvar p issueCommentPayload\r\n\r\n\t\tif err := json.Unmarshal(body, \u0026p); err != nil {\r\n\t\t\treturn Event{}, false, err\r\n\t\t}\r\n\r\n\t\treturn issueCommentEvent(p, body)\r\n\r\n\tcase \"pull_request\":\r\n\t\tvar p pullRequestPayload\r\n\r\n\t\tif err := json.Unmarshal(body, \u0026p); err != nil {\r\n\t\t\treturn Event{}, false, err\r\n\t\t}\r\n\r\n\t\treturn pullRequestEvent(p, body)\r\n\r\n\tdefault:\r\n\t\treturn Event{}, false, nil\r\n\t}\r\n}\r\n\r\nfunc issueEvent(p issuePayload, raw []byte) (Event, bool, error) {\r\n\tif p.Issue == nil || p.Repository == nil {\r\n\t\treturn Event{}, false, nil\r\n\t}\r\n\r\n\towner := repoOwner(p.Repository)\r\n\r\n\tswitch p.Action {\r\n\tcase \"opened\":\r\n\t\treturn Event{\r\n\t\t\tKind: EventIssueNew,\r\n\t\t\tID: issueNewID(p.Issue.ID),\r\n\t\t\tOwner: owner,\r\n\t\t\tRepo: p.Repository.Name,\r\n\t\t\tIndex: p.Issue.Index,\r\n\t\t\tTitle: p.Issue.Title,\r\n\t\t\tBody: p.Issue.Body,\r\n\t\t\tAuthor: posterName(p.Issue.Poster),\r\n\t\t\tRaw: raw,\r\n\t\t}, true, nil\r\n\r\n\tcase \"assigned\":\r\n\t\tif len(p.Issue.Assignees) == 0 {\r\n\t\t\treturn Event{}, false, nil\r\n\t\t}\r\n\r\n\t\t// Webhook payloads only carry the single latest assignment as a\r\n\t\t// distinct field on some Gitea/Forgejo versions; using the last\r\n\t\t// entry in the current assignee list is the closest stable\r\n\t\t// approximation available from the Issue object alone.\r\n\t\tassignee := p.Issue.Assignees[len(p.Issue.Assignees)-1]\r\n\r\n\t\treturn Event{\r\n\t\t\tKind: EventIssueAssigned,\r\n\t\t\tID: issueAssignedID(p.Issue.ID, assignee.UserName, p.Issue.Updated),\r\n\t\t\tOwner: owner,\r\n\t\t\tRepo: p.Repository.Name,\r\n\t\t\tIndex: p.Issue.Index,\r\n\t\t\tTitle: p.Issue.Title,\r\n\t\t\tBody: p.Issue.Body,\r\n\t\t\tAuthor: posterName(p.Issue.Poster),\r\n\t\t\tAssignee: assignee.UserName,\r\n\t\t\tRaw: raw,\r\n\t\t}, true, nil\r\n\r\n\tdefault:\r\n\t\treturn Event{}, false, nil\r\n\t}\r\n}\r\n\r\nfunc issueCommentEvent(p issueCommentPayload, raw []byte) (Event, bool, error) {\r\n\tif p.Action != \"created\" || p.Issue == nil || p.Comment == nil || p.Repository == nil {\r\n\t\treturn Event{}, false, nil\r\n\t}\r\n\r\n\t// Comments on pull requests arrive on this same event in\r\n\t// Gitea/Forgejo (PRs are issues under the hood); pr:comment is out\r\n\t// of scope for v1.\r\n\tif p.Issue.PullRequest != nil {\r\n\t\treturn Event{}, false, nil\r\n\t}\r\n\r\n\treturn Event{\r\n\t\tKind: EventIssueComment,\r\n\t\tID: issueCommentID(p.Comment.ID),\r\n\t\tOwner: repoOwner(p.Repository),\r\n\t\tRepo: p.Repository.Name,\r\n\t\tIndex: p.Issue.Index,\r\n\t\tTitle: p.Issue.Title,\r\n\t\tBody: p.Issue.Body,\r\n\t\tAuthor: posterName(p.Comment.Poster),\r\n\t\tComment: p.Comment.Body,\r\n\t\tRaw: raw,\r\n\t}, true, nil\r\n}\r\n\r\nfunc pullRequestEvent(p pullRequestPayload, raw []byte) (Event, bool, error) {\r\n\tif p.Action != \"opened\" || p.PullRequest == nil || p.Repository == nil {\r\n\t\treturn Event{}, false, nil\r\n\t}\r\n\r\n\treturn Event{\r\n\t\tKind: EventPRNew,\r\n\t\tID: prNewID(p.PullRequest.ID),\r\n\t\tOwner: repoOwner(p.Repository),\r\n\t\tRepo: p.Repository.Name,\r\n\t\tIndex: p.PullRequest.Index,\r\n\t\tTitle: p.PullRequest.Title,\r\n\t\tBody: p.PullRequest.Body,\r\n\t\tAuthor: posterName(p.PullRequest.Poster),\r\n\t\tRaw: raw,\r\n\t}, true, nil\r\n}\r\n\r\nfunc repoOwner(r *sdk.Repository) string {\r\n\tif r.Owner == nil {\r\n\t\treturn \"\"\r\n\t}\r\n\r\n\treturn r.Owner.UserName\r\n}\r\n\r\nfunc posterName(u *sdk.User) string {\r\n\tif u == nil {\r\n\t\treturn \"\"\r\n\t}\r\n\r\n\treturn u.UserName\r\n}\r\n\r\nfunc issueNewID(issueID int64) string {\r\n\treturn fmt.Sprintf(\"issue-new-%d\", issueID)\r\n}\r\n\r\n// issueAssignedID keys one *occurrence* of an assignment, not the\r\n// (issue, assignee) pair: Forgejo bumps the issue's updated timestamp on\r\n// every assignment, so assigning the same issue to the same agent again\r\n// yields a new id and triggers a new run, while the webhook delivery and\r\n// the poller's later sighting of that same assignment still collapse\r\n// onto one id and run once.\r\nfunc issueAssignedID(issueID int64, assignee string, assignedAt time.Time) string {\r\n\treturn fmt.Sprintf(\"issue-assigned-%d-%s-%d\", issueID, assignee, assignedAt.Unix())\r\n}\r\n\r\nfunc issueCommentID(commentID int64) string {\r\n\treturn fmt.Sprintf(\"issue-comment-%d\", commentID)\r\n}\r\n\r\nfunc prNewID(prID int64) string {\r\n\treturn fmt.Sprintf(\"pr-new-%d\", prID)\r\n}\r","start_line":1,"end_line":237,"total_lines":237,"truncated":false}
{"path": "internal/forgejo/poll.go", "start_line": 1, "end_line": 250}
{"path":"internal/forgejo/poll.go","content":"package forgejo\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"encoding/json\"\r\n\t\"fmt\"\r\n\t\"time\"\r\n\r\n\tsdk \"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2\"\r\n)\r\n\r\nconst (\r\n\twatermarkIssues = \"poll:issues\"\r\n\twatermarkPulls = \"poll:pulls\"\r\n)\r\n\r\n// pollOnce lists issues and pull requests updated since the stored\r\n// watermark, across every repo the configured token can see, and\r\n// dispatches synthesized Events for anything new. It's the fallback path\r\n// for when Forgejo webhooks aren't set up or reachable.\r\nfunc (w *Watcher) pollOnce(ctx context.Context) {\r\n\tif err := w.pollIssues(ctx); err != nil {\r\n\t\tw.logger.Warn(\"poll issues failed\", \"error\", err)\r\n\t}\r\n\r\n\tif err := w.pollPulls(ctx); err != nil {\r\n\t\tw.logger.Warn(\"poll pull requests failed\", \"error\", err)\r\n\t}\r\n}\r\n\r\nfunc (w *Watcher) pollIssues(ctx context.Context) error {\r\n\tsince, err := w.watermark(ctx, watermarkIssues)\r\n\tif err != nil {\r\n\t\treturn err\r\n\t}\r\n\r\n\tissues, _, err := w.client.sdk.ListIssues(sdk.ListIssueOption{\r\n\t\tType: sdk.IssueTypeIssue,\r\n\t\tState: sdk.StateAll,\r\n\t\tSince: since,\r\n\t})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"list issues: %w\", err)\r\n\t}\r\n\r\n\tnext := since\r\n\r\n\tfor _, issue := range issues {\r\n\t\tif issue.Repository == nil {\r\n\t\t\tcontinue\r\n\t\t}\r\n\r\n\t\tif issue.Updated.After(next) {\r\n\t\t\tnext = issue.Updated\r\n\t\t}\r\n\r\n\t\towner, repo := issue.Repository.Owner, issue.Repository.Name\r\n\r\n\t\tif issue.Comments == 0 \u0026\u0026 issue.Created.After(since) {\r\n\t\t\tw.dispatch(issueToNewEvent(issue, owner, repo))\r\n\t\t} else if issue.Updated.After(since) {\r\n\t\t\tif err := w.pollNewComments(ctx, owner, repo, issue, since); err != nil {\r\n\t\t\t\tw.logger.Warn(\"poll issue comments failed\", \"owner\", owner, \"repo\", repo, \"issue\", issue.Index, \"error\", err)\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tw.pollAssignments(ctx, owner, repo, issue)\r\n\t}\r\n\r\n\treturn w.store.SetWatermark(ctx, watermarkIssues, next.Format(time.RFC3339))\r\n}\r\n\r\n// pollAssignments dispatches an assigned event for each assignee that\r\n// wasn't on the issue the last time we looked. Listing only ever shows\r\n// current state, so without that comparison every unrelated update to an\r\n// assigned issue (a comment, an edit) would look like a fresh\r\n// assignment; and because the event id now varies per assignment\r\n// occurrence, dedup no longer masks that.\r\n//\r\n// The tradeoff is that an unassign and a re-assign to the same user\r\n// landing inside a single poll interval look like no change at all, and\r\n// only the webhook path catches them.\r\nfunc (w *Watcher) pollAssignments(ctx context.Context, owner, repo string, issue *sdk.Issue) {\r\n\tcurrent := make([]string, 0, len(issue.Assignees))\r\n\r\n\tfor _, assignee := range issue.Assignees {\r\n\t\tif assignee == nil {\r\n\t\t\tcontinue\r\n\t\t}\r\n\r\n\t\tcurrent = append(current, assignee.UserName)\r\n\t}\r\n\r\n\tadded, err := w.store.SyncAssignees(ctx, issue.ID, current)\r\n\tif err != nil {\r\n\t\tw.logger.Warn(\"sync assignees failed\", \"owner\", owner, \"repo\", repo, \"issue\", issue.Index, \"error\", err)\r\n\t\treturn\r\n\t}\r\n\r\n\tfor _, assignee := range added {\r\n\t\tw.dispatch(issueToAssignedEvent(issue, owner, repo, assignee))\r\n\t}\r\n}\r\n\r\nfunc (w *Watcher) pollNewComments(ctx context.Context, owner, repo string, issue *sdk.Issue, since time.Time) error {\r\n\tcomments, _, err := w.client.sdk.ListIssueComments(owner, repo, issue.Index, sdk.ListIssueCommentOptions{Since: since})\r\n\tif err != nil {\r\n\t\treturn err\r\n\t}\r\n\r\n\tfor _, comment := range comments {\r\n\t\tif !comment.Created.After(since) {\r\n\t\t\tcontinue\r\n\t\t}\r\n\r\n\t\tw.dispatch(issueToCommentEvent(issue, owner, repo, comment))\r\n\t}\r\n\r\n\treturn nil\r\n}\r\n\r\nfunc (w *Watcher) pollPulls(ctx context.Context) error {\r\n\tsince, err := w.watermark(ctx, watermarkPulls)\r\n\tif err != nil {\r\n\t\treturn err\r\n\t}\r\n\r\n\tissues, _, err := w.client.sdk.ListIssues(sdk.ListIssueOption{\r\n\t\tType: sdk.IssueTypePull,\r\n\t\tState: sdk.StateAll,\r\n\t\tSince: since,\r\n\t})\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"list pull requests: %w\", err)\r\n\t}\r\n\r\n\tnext := since\r\n\r\n\tfor _, issue := range issues {\r\n\t\tif issue.Repository == nil {\r\n\t\t\tcontinue\r\n\t\t}\r\n\r\n\t\tif issue.Updated.After(next) {\r\n\t\t\tnext = issue.Updated\r\n\t\t}\r\n\r\n\t\tif issue.Created.After(since) {\r\n\t\t\tw.dispatch(issueToPRNewEvent(issue, issue.Repository.Owner, issue.Repository.Name))\r\n\t\t}\r\n\t}\r\n\r\n\treturn w.store.SetWatermark(ctx, watermarkPulls, next.Format(time.RFC3339))\r\n}\r\n\r\nfunc (w *Watcher) watermark(ctx context.Context, key string) (time.Time, error) {\r\n\tv, err := w.store.GetWatermark(ctx, key)\r\n\tif err != nil {\r\n\t\treturn time.Time{}, err\r\n\t}\r\n\r\n\tif v == \"\" {\r\n\t\treturn time.Time{}, nil\r\n\t}\r\n\r\n\tt, err := time.Parse(time.RFC3339, v)\r\n\tif err != nil {\r\n\t\treturn time.Time{}, fmt.Errorf(\"parse watermark %q: %w\", key, err)\r\n\t}\r\n\r\n\treturn t, nil\r\n}\r\n\r\nfunc issueToNewEvent(issue *sdk.Issue, owner, repo string) Event {\r\n\traw, _ := json.Marshal(issue)\r\n\r\n\treturn Event{\r\n\t\tKind: EventIssueNew,\r\n\t\tID: issueNewID(issue.ID),\r\n\t\tOwner: owner,\r\n\t\tRepo: repo,\r\n\t\tIndex: issue.Index,\r\n\t\tTitle: issue.Title,\r\n\t\tBody: issue.Body,\r\n\t\tAuthor: posterName(issue.Poster),\r\n\t\tRaw: raw,\r\n\t}\r\n}\r\n\r\nfunc issueToAssignedEvent(issue *sdk.Issue, owner, repo, assignee string) Event {\r\n\traw, _ := json.Marshal(issue)\r\n\r\n\treturn Event{\r\n\t\tKind: EventIssueAssigned,\r\n\t\tID: issueAssignedID(issue.ID, assignee, issue.Updated),\r\n\t\tOwner: owner,\r\n\t\tRepo: repo,\r\n\t\tIndex: issue.Index,\r\n\t\tTitle: issue.Title,\r\n\t\tBody: issue.Body,\r\n\t\tAuthor: posterName(issue.Poster),\r\n\t\tAssignee: assignee,\r\n\t\tRaw: raw,\r\n\t}\r\n}\r\n\r\nfunc issueToCommentEvent(issue *sdk.Issue, owner, repo string, comment *sdk.Comment) Event {\r\n\traw, _ := json.Marshal(comment)\r\n\r\n\treturn Event{\r\n\t\tKind: EventIssueComment,\r\n\t\tID: issueCommentID(comment.ID),\r\n\t\tOwner: owner,\r\n\t\tRepo: repo,\r\n\t\tIndex: issue.Index,\r\n\t\tTitle: issue.Title,\r\n\t\tBody: issue.Body,\r\n\t\tAuthor: posterName(comment.Poster),\r\n\t\tComment: comment.Body,\r\n\t\tRaw: raw,\r\n\t}\r\n}\r\n\r\nfunc issueToPRNewEvent(issue *sdk.Issue, owner, repo string) Event {\r\n\traw, _ := json.Marshal(issue)\r\n\r\n\treturn Event{\r\n\t\tKind: EventPRNew,\r\n\t\tID: prNewID(issue.ID),\r\n\t\tOwner: owner,\r\n\t\tRepo: repo,\r\n\t\tIndex: issue.Index,\r\n\t\tTitle: issue.Title,\r\n\t\tBody: issue.Body,\r\n\t\tAuthor: posterName(issue.Poster),\r\n\t\tRaw: raw,\r\n\t}\r\n}\r","start_line":1,"end_line":238,"total_lines":238,"truncated":false}
{"path": "internal/forgejo/event.go", "start_line": 1, "end_line": 120}
{"path":"internal/forgejo/event.go","content":"package forgejo\r\n\r\nimport \"encoding/json\"\r\n\r\n// Event kinds, matching the values used as event block labels in\r\n// config.Config (config avoids importing this package, so the kind\r\n// strings are duplicated by value, not by identity).\r\nconst (\r\n\tEventIssueNew = \"issue:new\"\r\n\tEventIssueComment = \"issue:comment\"\r\n\tEventIssueAssigned = \"issue:assigned\"\r\n\tEventPRNew = \"pr:new\"\r\n)\r\n\r\n// Event is the normalized shape produced by both the webhook receiver and\r\n// the polling fallback, and is what the scheduler dispatches on.\r\ntype Event struct {\r\n\tKind string\r\n\r\n\t// ID uniquely identifies this event for dedup, regardless of whether\r\n\t// it arrived via webhook or polling.\r\n\tID string\r\n\r\n\tOwner, Repo string\r\n\tIndex int64 // issue or PR number\r\n\r\n\tTitle, Body string\r\n\tAuthor string\r\n\r\n\t// Assignee is set for EventIssueAssigned: the Forgejo username the\r\n\t// issue was assigned to.\r\n\tAssignee string\r\n\r\n\t// Comment is set for EventIssueComment.\r\n\tComment string\r\n\r\n\t// Raw is the full source payload (webhook body, or a synthesized\r\n\t// equivalent when polling), written to /event in the agent container.\r\n\tRaw json.RawMessage\r\n}\r","start_line":1,"end_line":40,"total_lines":40,"truncated":false}
{"path": "cmd/zoo/main.go", "start_line": 1, "end_line": 250}
{"path":"cmd/zoo/main.go","content":"// Command zoo runs the daemon: it watches a Forgejo instance for\r\n// issue/PR events, dispatches them to configured AI agents running in\r\n// Docker containers, and serves a small dashboard over the result.\r\npackage main\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"flag\"\r\n\t\"fmt\"\r\n\t\"log/slog\"\r\n\t\"net/http\"\r\n\t\"os\"\r\n\t\"os/signal\"\r\n\t\"syscall\"\r\n\t\"time\"\r\n\r\n\t\"github.com/abrander/zoo/internal/agentrun\"\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n\t\"github.com/abrander/zoo/internal/forgejo\"\r\n\t\"github.com/abrander/zoo/internal/livelog\"\r\n\t\"github.com/abrander/zoo/internal/scheduler\"\r\n\t\"github.com/abrander/zoo/internal/store\"\r\n\t\"github.com/abrander/zoo/internal/web\"\r\n)\r\n\r\nfunc main() {\r\n\tif err := run(); err != nil {\r\n\t\tfmt.Fprintln(os.Stderr, \"zoo:\", err)\r\n\r\n\t\tos.Exit(1)\r\n\t}\r\n}\r\n\r\nfunc run() error {\r\n\tvar (\r\n\t\tconfigPath = flag.String(\"config\", \"zoo.hcl\", \"path to the zoo.hcl config file\")\r\n\t\tdbPath = flag.String(\"db\", \"zoo.db\", \"path to the sqlite state database\")\r\n\t\tlisten = flag.String(\"listen\", \":8080\", \"address to serve webhooks and the dashboard on\")\r\n\t\trunTimeout = flag.Duration(\"run-timeout\", agentrun.DefaultTimeout, \"wall-clock timeout for a single agent run\")\r\n\t\tkeepOnFailure = flag.Bool(\"keep-on-failure\", false, \"keep the container and clone around after a failed run, for debugging\")\r\n\t)\r\n\r\n\tflag.Parse()\r\n\r\n\tlogger := slog.New(slog.NewTextHandler(os.Stderr, nil))\r\n\r\n\tcfg, err := config.Load(*configPath)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"load config: %w\", err)\r\n\t}\r\n\r\n\tst, err := store.Open(*dbPath)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"open store: %w\", err)\r\n\t}\r\n\tdefer st.Close()\r\n\r\n\tif n, err := st.ReapOrphanedJobs(context.Background()); err != nil {\r\n\t\tlogger.Warn(\"failed to reap orphaned jobs\", \"error\", err)\r\n\t} else if n \u003e 0 {\r\n\t\tlogger.Warn(\"reaped jobs left running by a previous process instance\", \"count\", n)\r\n\t}\r\n\r\n\tfgClient, err := forgejo.NewClient(cfg.Forgejo)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"forgejo client: %w\", err)\r\n\t}\r\n\r\n\twatcher := forgejo.NewWatcher(fgClient, st, cfg.Forgejo.WebhookSecret, logger.With(\"component\", \"forgejo\"))\r\n\r\n\thub := livelog.NewHub()\r\n\r\n\trunner, err := agentrun.NewRunner(cfg, fgClient, st, hub, logger.With(\"component\", \"agentrun\"), *runTimeout, *keepOnFailure)\r\n\tif err != nil {\r\n\t\treturn fmt.Errorf(\"agent runner: %w\", err)\r\n\t}\r\n\r\n\tsched := scheduler.New(cfg, st, fgClient, runner, hub, logger.With(\"component\", \"scheduler\"))\r\n\tdashboard := web.New(cfg, st, hub, fgClient)\r\n\r\n\tmux := http.NewServeMux()\r\n\r\n\tmux.Handle(\"/webhooks/forgejo\", watcher.Handler())\r\n\r\n\tmux.HandleFunc(\"/healthz\", func(w http.ResponseWriter, r *http.Request) {\r\n\t\tw.WriteHeader(http.StatusOK)\r\n\t\tw.Write([]byte(\"ok\"))\r\n\t})\r\n\r\n\tmux.Handle(\"/\", dashboard.Handler())\r\n\r\n\tserver := \u0026http.Server{Addr: *listen, Handler: mux}\r\n\r\n\tctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)\r\n\tdefer stop()\r\n\r\n\tgo watcher.Run(ctx)\r\n\tgo sched.Run(ctx, watcher.Events())\r\n\r\n\tserveErr := make(chan error, 1)\r\n\r\n\tgo func() {\r\n\t\tlogger.Info(\"listening\", \"addr\", *listen)\r\n\t\tif err := server.ListenAndServe(); err != nil \u0026\u0026 err != http.ErrServerClosed {\r\n\t\t\tserveErr \u003c- err\r\n\t\t}\r\n\t}()\r\n\r\n\tselect {\r\n\tcase \u003c-ctx.Done():\r\n\t\tlogger.Info(\"shutting down\")\r\n\r\n\tcase err := \u003c-serveErr:\r\n\t\tstop()\r\n\r\n\t\tlogger.Error(\"http server failed\", \"error\", err)\r\n\t}\r\n\r\n\tshutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)\r\n\tdefer cancel()\r\n\r\n\tif err := server.Shutdown(shutdownCtx); err != nil {\r\n\t\tlogger.Warn(\"http server shutdown\", \"error\", err)\r\n\t}\r\n\r\n\tif err := sched.Shutdown(shutdownCtx); err != nil {\r\n\t\tlogger.Warn(\"scheduler did not drain in time\", \"error\", err)\r\n\t}\r\n\r\n\treturn nil\r\n}\r","start_line":1,"end_line":131,"total_lines":131,"truncated":false}
{"path": "internal/forgejo/webhook_test.go", "start_line": 1, "end_line": 250}
{"path":"internal/forgejo/webhook_test.go","content":"package forgejo\r\n\r\nimport (\r\n\t\"testing\"\r\n\t\"time\"\r\n)\r\n\r\nconst issueOpenedPayload = `{\r\n \"action\": \"opened\",\r\n \"issue\": {\r\n \"id\": 101,\r\n \"number\": 5,\r\n \"title\": \"Something is broken\",\r\n \"body\": \"Steps to reproduce...\",\r\n \"user\": {\"login\": \"alice\"},\r\n \"assignees\": []\r\n },\r\n \"repository\": {\r\n \"name\": \"widgets\",\r\n \"owner\": {\"login\": \"acme\"}\r\n }\r\n}`\r\n\r\nconst issueAssignedPayload = `{\r\n \"action\": \"assigned\",\r\n \"issue\": {\r\n \"id\": 101,\r\n \"number\": 5,\r\n \"title\": \"Something is broken\",\r\n \"updated_at\": \"2026-08-20T10:00:00Z\",\r\n \"user\": {\"login\": \"alice\"},\r\n \"assignees\": [{\"login\": \"leon\"}]\r\n },\r\n \"repository\": {\r\n \"name\": \"widgets\",\r\n \"owner\": {\"login\": \"acme\"}\r\n }\r\n}`\r\n\r\n// The same issue assigned to the same agent a second time, later.\r\nconst issueReassignedPayload = `{\r\n \"action\": \"assigned\",\r\n \"issue\": {\r\n \"id\": 101,\r\n \"number\": 5,\r\n \"title\": \"Something is broken\",\r\n \"updated_at\": \"2026-08-20T11:30:00Z\",\r\n \"user\": {\"login\": \"alice\"},\r\n \"assignees\": [{\"login\": \"leon\"}]\r\n },\r\n \"repository\": {\r\n \"name\": \"widgets\",\r\n \"owner\": {\"login\": \"acme\"}\r\n }\r\n}`\r\n\r\nconst issueCommentCreatedPayload = `{\r\n \"action\": \"created\",\r\n \"issue\": {\r\n \"id\": 101,\r\n \"number\": 5,\r\n \"title\": \"Something is broken\",\r\n \"user\": {\"login\": \"alice\"}\r\n },\r\n \"comment\": {\r\n \"id\": 55,\r\n \"body\": \"any update?\",\r\n \"user\": {\"login\": \"bob\"}\r\n },\r\n \"repository\": {\r\n \"name\": \"widgets\",\r\n \"owner\": {\"login\": \"acme\"}\r\n }\r\n}`\r\n\r\nconst pullRequestOpenedPayload = `{\r\n \"action\": \"opened\",\r\n \"pull_request\": {\r\n \"id\": 202,\r\n \"number\": 9,\r\n \"title\": \"Fix the thing\",\r\n \"body\": \"This fixes it\",\r\n \"user\": {\"login\": \"greg\"}\r\n },\r\n \"repository\": {\r\n \"name\": \"widgets\",\r\n \"owner\": {\"login\": \"acme\"}\r\n }\r\n}`\r\n\r\nfunc TestDecodeIssueOpened(t *testing.T) {\r\n\tev, ok, err := decodeWebhookEvent(\"issues\", []byte(issueOpenedPayload))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif !ok {\r\n\t\tt.Fatal(\"expected an event\")\r\n\t}\r\n\tif ev.Kind != EventIssueNew || ev.Owner != \"acme\" || ev.Repo != \"widgets\" || ev.Index != 5 || ev.Author != \"alice\" {\r\n\t\tt.Fatalf(\"unexpected event: %+v\", ev)\r\n\t}\r\n\tif ev.ID != \"issue-new-101\" {\r\n\t\tt.Fatalf(\"unexpected dedup id: %q\", ev.ID)\r\n\t}\r\n}\r\n\r\nfunc TestDecodeIssueAssigned(t *testing.T) {\r\n\tev, ok, err := decodeWebhookEvent(\"issues\", []byte(issueAssignedPayload))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif !ok {\r\n\t\tt.Fatal(\"expected an event\")\r\n\t}\r\n\tif ev.Kind != EventIssueAssigned || ev.Assignee != \"leon\" {\r\n\t\tt.Fatalf(\"unexpected event: %+v\", ev)\r\n\t}\r\n}\r\n\r\n// Assigning an issue to an agent that already held it once has to\r\n// produce a distinct dedup id, or the watcher swallows it as already\r\n// seen and the agent never picks the issue up again.\r\nfunc TestDecodeIssueReassignedToSameAgentIsNotDeduped(t *testing.T) {\r\n\tfirst, _, err := decodeWebhookEvent(\"issues\", []byte(issueAssignedPayload))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\tsecond, _, err := decodeWebhookEvent(\"issues\", []byte(issueReassignedPayload))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\tif second.Assignee != \"leon\" {\r\n\t\tt.Fatalf(\"unexpected assignee: %q\", second.Assignee)\r\n\t}\r\n\tif first.ID == second.ID {\r\n\t\tt.Fatalf(\"re-assignment reused the dedup id %q, so it would be dropped\", first.ID)\r\n\t}\r\n}\r\n\r\n// The webhook delivery and the poller's later sighting of that one\r\n// assignment must still collapse onto a single id, so it runs once.\r\nfunc TestAssignedIDMatchesAcrossWebhookAndPoll(t *testing.T) {\r\n\tev, _, err := decodeWebhookEvent(\"issues\", []byte(issueAssignedPayload))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\tupdated, err := time.Parse(time.RFC3339, \"2026-08-20T10:00:00Z\")\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\tif polled := issueAssignedID(101, \"leon\", updated); polled != ev.ID {\r\n\t\tt.Fatalf(\"poll id %q does not match webhook id %q\", polled, ev.ID)\r\n\t}\r\n}\r\n\r\nfunc TestDecodeIssueCommentCreated(t *testing.T) {\r\n\tev, ok, err := decodeWebhookEvent(\"issue_comment\", []byte(issueCommentCreatedPayload))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif !ok {\r\n\t\tt.Fatal(\"expected an event\")\r\n\t}\r\n\tif ev.Kind != EventIssueComment || ev.Comment != \"any update?\" || ev.Author != \"bob\" {\r\n\t\tt.Fatalf(\"unexpected event: %+v\", ev)\r\n\t}\r\n}\r\n\r\nfunc TestDecodePullRequestOpened(t *testing.T) {\r\n\tev, ok, err := decodeWebhookEvent(\"pull_request\", []byte(pullRequestOpenedPayload))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif !ok {\r\n\t\tt.Fatal(\"expected an event\")\r\n\t}\r\n\tif ev.Kind != EventPRNew || ev.Index != 9 || ev.Author != \"greg\" {\r\n\t\tt.Fatalf(\"unexpected event: %+v\", ev)\r\n\t}\r\n}\r\n\r\nfunc TestDecodeIgnoresUnknownAction(t *testing.T) {\r\n\t_, ok, err := decodeWebhookEvent(\"issues\", []byte(`{\"action\": \"closed\", \"issue\": {\"id\": 1}, \"repository\": {\"name\": \"x\", \"owner\": {\"login\": \"y\"}}}`))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif ok {\r\n\t\tt.Fatal(\"expected closed action to be ignored\")\r\n\t}\r\n}\r\n\r\nfunc TestDecodeIgnoresUnknownEventKind(t *testing.T) {\r\n\t_, ok, err := decodeWebhookEvent(\"release\", []byte(`{}`))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\tif ok {\r\n\t\tt.Fatal(\"expected unknown event kind to be ignored\")\r\n\t}\r\n}\r","start_line":1,"end_line":204,"total_lines":204,"truncated":false}
{"path": "internal/config/config_test.go", "start_line": 1, "end_line": 300}
{"path":"internal/config/config_test.go","content":"package config\r\n\r\nimport (\r\n\t\"os\"\r\n\t\"path/filepath\"\r\n\t\"testing\"\r\n)\r\n\r\nfunc load(t *testing.T, hcl string) (*Config, error) {\r\n\tt.Helper()\r\n\r\n\tpath := filepath.Join(t.TempDir(), \"zoo.hcl\")\r\n\tif err := os.WriteFile(path, []byte(hcl), 0o600); err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\treturn Load(path)\r\n}\r\n\r\nconst validConfig = `\r\nllm \"qwen\" {\r\n openai = \"https://example.com\"\r\n token = \"tok\"\r\n model = \"qwen3\"\r\n}\r\n\r\nforgejo {\r\n url = \"https://example.com\"\r\n token = \"tok\"\r\n}\r\n\r\nenvironment {\r\n docker_image = \"debian:unstable\"\r\n max_live_agents = 2\r\n}\r\n\r\nagent \"leon\" {\r\n llm = \"qwen\"\r\n}\r\n\r\nevent \"issue:new\" {\r\n agent = \"leon\"\r\n}\r\n\r\nevent \"issue:assigned\" {\r\n instructions = \"Please handle this issue.\"\r\n}\r\n\r\nevent \"issue:comment\" {\r\n agent = \"leon\"\r\n instructions = \"Please review the comment and respond appropriately.\"\r\n}\r\n\r\nevent \"pr:new\" {\r\n agent = \"leon\"\r\n}\r\n`\r\n\r\nfunc TestLoadValid(t *testing.T) {\r\n\tcfg, err := load(t, validConfig)\r\n\tif err != nil {\r\n\t\tt.Fatalf(\"unexpected error: %v\", err)\r\n\t}\r\n\r\n\tif len(cfg.LLMs) != 1 || cfg.LLMs[0].Name != \"qwen\" {\r\n\t\tt.Fatalf(\"unexpected llms: %+v\", cfg.LLMs)\r\n\t}\r\n\r\n\tif agent, ok := cfg.EventAgent(EventIssueNew); !ok || agent != \"leon\" {\r\n\t\tt.Fatalf(\"expected issue:new -\u003e leon, got %q, %v\", agent, ok)\r\n\t}\r\n\r\n\tif _, ok := cfg.EventAgent(EventIssueAssigned); ok {\r\n\t\tt.Fatalf(\"issue:assigned should have no static agent\")\r\n\t}\r\n\r\n\tif got := cfg.EventInstructions(EventIssueAssigned); got != \"Please handle this issue.\" {\r\n\t\tt.Fatalf(\"unexpected issue:assigned instructions: %q\", got)\r\n\t}\r\n\tif got := cfg.EventInstructions(EventIssueComment); got != \"Please review the comment and respond appropriately.\" {\r\n\t\tt.Fatalf(\"unexpected issue:comment instructions: %q\", got)\r\n\t}\r\n\tif got := cfg.EventInstructions(EventPRNew); got != \"\" {\r\n\t\tt.Fatalf(\"expected no instructions for pr:new, got %q\", got)\r\n\t}\r\n}\r\n\r\nfunc TestValidateUndeclaredLLM(t *testing.T) {\r\n\t_, err := load(t, `\r\nforgejo { url = \"https://example.com\" token = \"tok\" }\r\nenvironment { docker_image = \"debian:unstable\" max_live_agents = 1 }\r\nagent \"leon\" { llm = \"missing\" }\r\n`)\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error for undeclared llm reference\")\r\n\t}\r\n}\r\n\r\nfunc TestValidateUndeclaredEventAgent(t *testing.T) {\r\n\t_, err := load(t, `\r\nllm \"qwen\" { openai = \"https://example.com\" token = \"tok\" model = \"qwen3\" }\r\nforgejo { url = \"https://example.com\" token = \"tok\" }\r\nenvironment { docker_image = \"debian:unstable\" max_live_agents = 1 }\r\nagent \"leon\" { llm = \"qwen\" }\r\nevent \"issue:new\" { agent = \"missing\" }\r\n`)\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error for undeclared agent reference\")\r\n\t}\r\n}\r\n\r\nfunc TestValidateIssueAssignedRejectsAgent(t *testing.T) {\r\n\t_, err := load(t, `\r\nllm \"qwen\" { openai = \"https://example.com\" token = \"tok\" model = \"qwen3\" }\r\nforgejo { url = \"https://example.com\" token = \"tok\" }\r\nenvironment { docker_image = \"debian:unstable\" max_live_agents = 1 }\r\nagent \"leon\" { llm = \"qwen\" }\r\nevent \"issue:assigned\" { agent = \"leon\" }\r\n`)\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error: issue:assigned must not set agent\")\r\n\t}\r\n}\r\n\r\nfunc TestValidateMaxLiveAgents(t *testing.T) {\r\n\t_, err := load(t, `\r\nllm \"qwen\" { openai = \"https://example.com\" token = \"tok\" model = \"qwen3\" }\r\nforgejo { url = \"https://example.com\" token = \"tok\" }\r\nenvironment { docker_image = \"debian:unstable\" max_live_agents = 0 }\r\n`)\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error for max_live_agents \u003c 1\")\r\n\t}\r\n}\r\n\r\nfunc TestValidateMissingDockerImage(t *testing.T) {\r\n\t_, err := load(t, `\r\nllm \"qwen\" { openai = \"https://example.com\" token = \"tok\" model = \"qwen3\" }\r\nforgejo { url = \"https://example.com\" token = \"tok\" }\r\nenvironment { docker_image = \"\" max_live_agents = 1 }\r\n`)\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error for missing docker_image\")\r\n\t}\r\n}\r\n\r\nfunc TestValidateUnknownEventKind(t *testing.T) {\r\n\t_, err := load(t, `\r\nllm \"qwen\" { openai = \"https://example.com\" token = \"tok\" model = \"qwen3\" }\r\nforgejo { url = \"https://example.com\" token = \"tok\" }\r\nenvironment { docker_image = \"debian:unstable\" max_live_agents = 1 }\r\nagent \"leon\" { llm = \"qwen\" }\r\nevent \"pr:sync\" { agent = \"leon\" }\r\n`)\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error for unknown event kind\")\r\n\t}\r\n}\r","start_line":1,"end_line":158,"total_lines":158,"truncated":false}
{"path": "internal/scheduler/scheduler.go", "start_line": 1, "end_line": 200}
{"path":"internal/scheduler/scheduler.go","content":"// Package scheduler resolves incoming Forgejo events to configured\r\n// agents and runs them, bounded by max_live_agents.\r\npackage scheduler\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"errors\"\r\n\t\"fmt\"\r\n\t\"log/slog\"\r\n\t\"sync\"\r\n\r\n\t\"github.com/google/uuid\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n\t\"github.com/abrander/zoo/internal/forgejo\"\r\n\t\"github.com/abrander/zoo/internal/livelog\"\r\n\t\"github.com/abrander/zoo/internal/store\"\r\n)\r\n\r\n// forgejoActions is the narrow slice of Client the scheduler needs for\r\n// its own failure-reporting side effects (defined here, not in\r\n// internal/forgejo, so tests can inject a fake).\r\ntype forgejoActions interface {\r\n\tCreateIssueComment(owner, repo string, index int64, body string) error\r\n\tAddLabel(owner, repo string, index int64, name string) error\r\n}\r\n\r\n// FailureLabel is applied to the triggering issue/PR, alongside a\r\n// comment, whenever an agent run fails or times out.\r\nconst FailureLabel = \"zoo:failed\"\r\n\r\n// Runner runs a single agent invocation to completion. Implemented by\r\n// internal/agentrun.Run; a narrow interface here so the scheduler is\r\n// testable without Docker.\r\ntype Runner interface {\r\n\tRun(ctx context.Context, jobID string, agent config.AgentConfig, llm config.LLM, dockerImage string, ev forgejo.Event) error\r\n}\r\n\r\ntype Scheduler struct {\r\n\tcfg *config.Config\r\n\tstore *store.Store\r\n\tforgejo forgejoActions\r\n\trunner Runner\r\n\thub *livelog.Hub\r\n\tlogger *slog.Logger\r\n\r\n\tsem chan struct{}\r\n\twg sync.WaitGroup\r\n}\r\n\r\nfunc New(cfg *config.Config, st *store.Store, fg forgejoActions, runner Runner, hub *livelog.Hub, logger *slog.Logger) *Scheduler {\r\n\treturn \u0026Scheduler{\r\n\t\tcfg: cfg,\r\n\t\tstore: st,\r\n\t\tforgejo: fg,\r\n\t\trunner: runner,\r\n\t\thub: hub,\r\n\t\tlogger: logger,\r\n\t\tsem: make(chan struct{}, cfg.Environment.MaxLive),\r\n\t}\r\n}\r\n\r\n// resolveAgent returns the name of the agent that should handle ev, if\r\n// any. issue:assigned resolves dynamically: the agent whose config label\r\n// matches the Forgejo assignee's username. Every other event kind uses\r\n// the static event-\u003eagent mapping from config.\r\nfunc resolveAgent(cfg *config.Config, ev forgejo.Event) (string, bool) {\r\n\tif ev.Kind == config.EventIssueAssigned {\r\n\t\tif _, ok := cfg.AgentByName(ev.Assignee); ok {\r\n\t\t\treturn ev.Assignee, true\r\n\t\t}\r\n\r\n\t\treturn \"\", false\r\n\t}\r\n\r\n\treturn cfg.EventAgent(ev.Kind)\r\n}\r\n\r\n// Run consumes events until ctx is canceled or the channel closes,\r\n// dispatching each to its resolved agent and blocking on the\r\n// max_live_agents semaphore before starting a run.\r\nfunc (s *Scheduler) Run(ctx context.Context, events \u003c-chan forgejo.Event) {\r\n\tfor {\r\n\t\tselect {\r\n\t\tcase \u003c-ctx.Done():\r\n\t\t\treturn\r\n\r\n\t\tcase ev, ok := \u003c-events:\r\n\t\t\tif !ok {\r\n\t\t\t\treturn\r\n\t\t\t}\r\n\r\n\t\t\ts.handle(ctx, ev)\r\n\t\t}\r\n\t}\r\n}\r\n\r\nfunc (s *Scheduler) handle(ctx context.Context, ev forgejo.Event) {\r\n\tagentName, ok := resolveAgent(s.cfg, ev)\r\n\tif !ok {\r\n\t\ts.logger.Debug(\"no agent resolved for event, dropping\", \"kind\", ev.Kind, \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index)\r\n\t\treturn\r\n\t}\r\n\r\n\t// An agent's own actions (e.g. a comment posted via the `comment`\r\n\t// tool, authenticated with its own per-agent token) can themselves\r\n\t// show up as new events. Don't let an agent trigger itself off its\r\n\t// own activity β that's a self-reinforcing loop, not new work.\r\n\tif ev.Author != \"\" \u0026\u0026 ev.Author == agentName {\r\n\t\ts.logger.Debug(\"dropping event authored by the agent it would trigger\", \"kind\", ev.Kind, \"agent\", agentName, \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index)\r\n\t\treturn\r\n\t}\r\n\r\n\tagent, ok := s.cfg.AgentByName(agentName)\r\n\tif !ok {\r\n\t\ts.logger.Error(\"resolved agent not declared in config\", \"agent\", agentName)\r\n\t\treturn\r\n\t}\r\n\r\n\tllm, ok := s.cfg.LLMByName(agent.LLM)\r\n\tif !ok {\r\n\t\ts.logger.Error(\"agent references undeclared llm\", \"agent\", agentName, \"llm\", agent.LLM)\r\n\t\treturn\r\n\t}\r\n\r\n\tjobID := uuid.NewString()\r\n\r\n\tif err := s.store.CreateJob(ctx, store.Job{\r\n\t\tID: jobID,\r\n\t\tEventKind: ev.Kind,\r\n\t\tAgent: agentName,\r\n\t\tOwner: ev.Owner,\r\n\t\tRepo: ev.Repo,\r\n\t\tIssueIndex: ev.Index,\r\n\t\tTitle: ev.Title,\r\n\t}); err != nil {\r\n\t\ts.logger.Error(\"failed to record job\", \"job\", jobID, \"error\", err)\r\n\t\treturn\r\n\t}\r\n\r\n\tselect {\r\n\tcase s.sem \u003c- struct{}{}:\r\n\r\n\tcase \u003c-ctx.Done():\r\n\t\treturn\r\n\t}\r\n\r\n\ts.wg.Add(1)\r\n\r\n\tgo func() {\r\n\t\tdefer s.wg.Done()\r\n\t\tdefer func() { \u003c-s.sem }()\r\n\r\n\t\ts.run(ctx, jobID, agent, llm, ev)\r\n\t}()\r\n}\r\n\r\nfunc (s *Scheduler) run(ctx context.Context, jobID string, agent config.AgentConfig, llm config.LLM, ev forgejo.Event) {\r\n\tlogger := s.logger.With(\"job\", jobID, \"agent\", agent.Name, \"event\", ev.Kind, \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index)\r\n\r\n\t// Job status writes use a context detached from ctx, not ctx itself:\r\n\t// ctx is canceled on daemon shutdown to unwind the in-flight run, and\r\n\t// an already-canceled ctx would make these UPDATEs fail instantly,\r\n\t// leaving the job stuck at \"running\" forever even though the process\r\n\t// has exited.\r\n\tif err := s.store.MarkJobStarted(context.Background(), jobID); err != nil {\r\n\t\tlogger.Error(\"failed to mark job started\", \"error\", err)\r\n\t}\r\n\r\n\tlogger.Info(\"agent run starting\")\r\n\r\n\terr := s.runner.Run(ctx, jobID, agent, llm, s.cfg.Environment.DockerImage, ev)\r\n\r\n\tstatus := store.JobSucceeded\r\n\terrMsg := \"\"\r\n\r\n\tif err != nil {\r\n\t\terrMsg = err.Error()\r\n\t\t// The daemon-wide ctx passed in here isn't what times a run out\r\n\t\t// (agentrun.Runner.Run applies its own per-run deadline\r\n\t\t// internally); a timed-out run surfaces as a wrapped\r\n\t\t// context.DeadlineExceeded in the returned error instead.\r\n\t\tif errors.Is(err, context.DeadlineExceeded) {\r\n\t\t\tstatus = store.JobTimedOut\r\n\t\t} else {\r\n\t\t\tstatus = store.JobFailed\r\n\t\t}\r\n\r\n\t\tlogger.Error(\"agent run failed\", \"status\", status, \"error\", err)\r\n\t\ts.reportFailure(ev, status, err)\r\n\t} else {\r\n\t\tlogger.Info(\"agent run succeeded\")\r\n\t}\r\n\r\n\tif err := s.store.MarkJobFinished(context.Background(), jobID, status, errMsg); err != nil {\r\n\t\tlogger.Error(\"failed to mark job finished\", \"error\", err)\r\n\t}\r\n\r\n\t// Closing the hub here, only after the terminal status is durably\r\n\t// written, is what lets a dashboard tab's auto-reload (triggered by\r","start_line":1,"end_line":200,"total_lines":241,"truncated":true}
{"query": "NewWatcher"}
/project/cmd/zoo/main.go:69: watcher := forgejo.NewWatcher(fgClient, st, cfg.Forgejo.WebhookSecret, logger.With("component", "forgejo"))
/project/internal/forgejo/watcher.go:25:func NewWatcher(client *Client, st *store.Store, webhookSecret string, logger *slog.Logger) *Watcher {
{"path": "internal/store/store.go", "start_line": 1, "end_line": 120}
{"path":"internal/store/store.go","content":"// Package store provides zoo's embedded persistence: the Forgejo\r\n// poll watermark, event dedup, and job/log history for the dashboard.\r\npackage store\r\n\r\nimport (\r\n\t\"context\"\r\n\t\"database/sql\"\r\n\t_ \"embed\"\r\n\t\"fmt\"\r\n\t\"time\"\r\n\r\n\t_ \"modernc.org/sqlite\"\r\n)\r\n\r\n//go:embed schema.sql\r\nvar schema string\r\n\r\ntype Store struct {\r\n\tdb *sql.DB\r\n}\r\n\r\n// Open opens (creating if necessary) the SQLite database at path and\r\n// applies the schema.\r\nfunc Open(path string) (*Store, error) {\r\n\tdb, err := sql.Open(\"sqlite\", path)\r\n\tif err != nil {\r\n\t\treturn nil, fmt.Errorf(\"open database: %w\", err)\r\n\t}\r\n\r\n\t// SQLite only supports one writer at a time; serialize access rather\r\n\t// than fighting SQLITE_BUSY errors under concurrent agent runs.\r\n\tdb.SetMaxOpenConns(1)\r\n\r\n\tif _, err := db.Exec(schema); err != nil {\r\n\t\tdb.Close()\r\n\r\n\t\treturn nil, fmt.Errorf(\"apply schema: %w\", err)\r\n\t}\r\n\r\n\treturn \u0026Store{db: db}, nil\r\n}\r\n\r\nfunc (s *Store) Close() error {\r\n\treturn s.db.Close()\r\n}\r\n\r\n// MarkSeen records that event id has been processed. It returns false if\r\n// the event was already seen (by webhook or poll), so callers can dedupe\r\n// regardless of source.\r\nfunc (s *Store) MarkSeen(ctx context.Context, id string) (isNew bool, err error) {\r\n\tres, err := s.db.ExecContext(ctx,\r\n\t\t`INSERT OR IGNORE INTO seen_events (id, seen_at) VALUES (?, ?)`,\r\n\t\tid, time.Now().UTC())\r\n\tif err != nil {\r\n\t\treturn false, fmt.Errorf(\"mark seen: %w\", err)\r\n\t}\r\n\r\n\tn, err := res.RowsAffected()\r\n\tif err != nil {\r\n\t\treturn false, fmt.Errorf(\"mark seen: %w\", err)\r\n\t}\r\n\r\n\treturn n \u003e 0, nil\r\n}\r\n\r\n// SyncAssignees records assignees as the current assignee set for the\r\n// issue and returns the subset that wasn't already recorded β i.e. the\r\n// assignments that happened since the last call. Assignees that have\r\n// gone away are forgotten, so assigning the same user again later counts\r\n// as a fresh assignment rather than being swallowed as already-known.\r\n//\r\n// The poller uses this because listing issues only ever shows current\r\n// state: without it, every unrelated update to an assigned issue (a\r\n// comment, an edit) looks exactly like a new assignment.\r\nfunc (s *Store) SyncAssignees(ctx context.Context, issueID int64, assignees []string) (added []string, err error) {\r\n\ttx, err := s.db.BeginTx(ctx, nil)\r\n\tif err != nil {\r\n\t\treturn nil, fmt.Errorf(\"sync assignees: %w\", err)\r\n\t}\r\n\tdefer tx.Rollback()\r\n\r\n\trows, err := tx.QueryContext(ctx, `SELECT assignee FROM issue_assignees WHERE issue_id = ?`, issueID)\r\n\tif err != nil {\r\n\t\treturn nil, fmt.Errorf(\"sync assignees: %w\", err)\r\n\t}\r\n\r\n\tknown := map[string]bool{}\r\n\r\n\tfor rows.Next() {\r\n\t\tvar name string\r\n\r\n\t\tif err := rows.Scan(\u0026name); err != nil {\r\n\t\t\trows.Close()\r\n\r\n\t\t\treturn nil, fmt.Errorf(\"sync assignees: %w\", err)\r\n\t\t}\r\n\r\n\t\tknown[name] = true\r\n\t}\r\n\r\n\trows.Close()\r\n\r\n\tif err := rows.Err(); err != nil {\r\n\t\treturn nil, fmt.Errorf(\"sync assignees: %w\", err)\r\n\t}\r\n\r\n\tcurrent := map[string]bool{}\r\n\r\n\tfor _, name := range assignees {\r\n\t\tif current[name] {\r\n\t\t\tcontinue\r\n\t\t}\r\n\r\n\t\tcurrent[name] = true\r\n\r\n\t\tif !known[name] {\r\n\t\t\tadded = append(added, name)\r\n\t\t}\r\n\t}\r\n\r","start_line":1,"end_line":120,"total_lines":360,"truncated":true}
{"path": "go.mod", "start_line": 1, "end_line": 40}
{"path":"go.mod","content":"module github.com/abrander/zoo\r\n\r\ngo 1.26.4\r\n\r\nrequire (\r\n\tcodeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0\r\n\tgithub.com/docker/docker v28.5.2+incompatible\r\n\tgithub.com/google/uuid v1.6.0\r\n\tgithub.com/hashicorp/hcl/v2 v2.24.0\r\n\tmodernc.org/sqlite v1.56.0\r\n)\r\n\r\nrequire (\r\n\tgithub.com/42wim/httpsig v1.2.3 // indirect\r\n\tgithub.com/Microsoft/go-winio v0.6.2 // indirect\r\n\tgithub.com/agext/levenshtein v1.2.1 // indirect\r\n\tgithub.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect\r\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\r\n\tgithub.com/containerd/errdefs v1.0.0 // indirect\r\n\tgithub.com/containerd/errdefs/pkg v0.3.0 // indirect\r\n\tgithub.com/containerd/log v0.1.0 // indirect\r\n\tgithub.com/davidmz/go-pageant v1.0.2 // indirect\r\n\tgithub.com/distribution/reference v0.6.0 // indirect\r\n\tgithub.com/docker/go-connections v0.8.1 // indirect\r\n\tgithub.com/docker/go-units v0.5.0 // indirect\r\n\tgithub.com/dustin/go-humanize v1.0.1 // indirect\r\n\tgithub.com/felixge/httpsnoop v1.1.0 // indirect\r\n\tgithub.com/go-fed/httpsig v1.1.0 // indirect\r\n\tgithub.com/go-logr/logr v1.4.4 // indirect\r\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\r\n\tgithub.com/google/go-cmp v0.7.0 // indirect\r\n\tgithub.com/hashicorp/go-version v1.7.0 // indirect\r\n\tgithub.com/mattn/go-isatty v0.0.24 // indirect\r\n\tgithub.com/mitchellh/go-wordwrap v1.0.1 // indirect\r\n\tgithub.com/moby/docker-image-spec v1.3.1 // indirect\r\n\tgithub.com/moby/sys/atomicwriter v0.1.0 // indirect\r\n\tgithub.com/moby/term v0.5.2 // indirect\r\n\tgithub.com/morikuni/aec v1.1.0 // indirect\r\n\tgithub.com/ncruces/go-strftime v1.0.0 // indirect\r\n\tgithub.com/opencontainers/go-digest v1.0.0 // indirect\r","start_line":1,"end_line":40,"total_lines":62,"truncated":true}
{"command": "cd /project && go build ./... && go test ./... 2>&1 | tail -20", "timeout": 300}
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 go: downloading golang.org/x/crypto v0.39.0 go: downloading github.com/felixge/httpsnoop v1.1.0 go: downloading go.opentelemetry.io/otel/metric v1.45.0 go: downloading github.com/agext/levenshtein v1.2.1 go: downloading github.com/apparentlymart/go-textseg/v15 v15.0.0 go: downloading github.com/mitchellh/go-wordwrap v1.0.1 go: downloading github.com/zclconf/go-cty v1.16.3 go: downloading github.com/go-logr/logr v1.4.4 go: downloading golang.org/x/sys v0.47.0 go: downloading github.com/google/go-cmp v0.7.0 go: downloading github.com/go-logr/stdr v1.2.2 go: downloading go.opentelemetry.io/auto/sdk v1.2.1 go: downloading github.com/cespare/xxhash/v2 v2.3.0 go: downloading golang.org/x/text v0.40.0 go: downloading modernc.org/libc v1.74.4 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 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec ? github.com/abrander/zoo/cmd/zoo [no test files] ok github.com/abrander/zoo/internal/agentrun 0.004s ok github.com/abrander/zoo/internal/config 0.004s ok github.com/abrander/zoo/internal/forgejo 0.005s ok github.com/abrander/zoo/internal/livelog 0.052s ok github.com/abrander/zoo/internal/llm 0.003s ok github.com/abrander/zoo/internal/scheduler 2.171s ok github.com/abrander/zoo/internal/store 1.842s ok github.com/abrander/zoo/internal/tools 0.003s ok github.com/abrander/zoo/internal/web 0.821s
{"path": "internal/forgejo/repo_filter.go", "content": "package forgejo\n\nimport (\n\t\"fmt\"\n\t\"path\"\n\t\"strings\"\n)\n\n// RepoFilter decides which repositories zoo watches, based on the\n// `repos` list in the forgejo config block. It is the single place both\n// the webhook receiver and the polling fallback consult before an event\n// is deduped and dispatched, so unwatched repositories never reach the\n// scheduler.\n//\n// Pattern syntax (each entry is an \"owner/repo\" pair, with globs):\n//\n// - \"*\" matches every repository on the instance\n// - \"org/repo\" one exact repository (owner compared\n// case-insensitively, like Forgejo usernames; repo\n// name compared case-sensitively)\n// - \"org/*\" every repository in org\n// - \"org\" shorthand for \"org/*\"\n//\n// The owner and repo segments each support the glob syntax of\n// path.Match, so \"org/web*\" or \"org/repo-?\" also work. A nil filter, or\n// one built from an empty list, matches everything β omitting `repos`\n// from the config keeps the historical \"watch everything the token can\n// see\" behavior.\ntype RepoFilter struct {\n\tmatchers []repoMatcher\n}\n\n// repoMatcher is one compiled pattern: a glob for the owner (matched\n// case-insensitively) and one for the repo name (case-sensitively).\ntype repoMatcher struct {\n\towner string\n\trepo string\n}\n\n// NewRepoFilter compiles the given repository patterns. It returns an\n// error for patterns whose glob syntax is malformed, so the daemon fails\n// fast at startup rather than silently not matching anything.\nfunc NewRepoFilter(patterns []string) (*RepoFilter, error) {\n\tf := &RepoFilter{}\n\n\tfor _, p := range patterns {\n\t\towner, repo, hasSlash := strings.Cut(p, \"/\")\n\n\t\tif !hasSlash {\n\t\t\t// A bare name can only name an owner, since repositories\n\t\t\t// are always \"owner/repo\"; treat it as \"name/*\".\n\t\t\towner, repo = p, \"*\"\n\t\t}\n\n\t\tfor _, seg := range []string{owner, repo} {\n\t\t\tif _, err := path.Match(seg, \"x\"); err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"repo pattern %q: %w\", p, err)\n\t\t\t}\n\t\t}\n\n\t\tf.matchers = append(f.matchers, repoMatcher{\n\t\t\towner: strings.ToLower(owner),\n\t\t\trepo: repo,\n\t\t})\n\t}\n\n\treturn f, nil\n}\n\n// Matches reports whether the repository owner/repo is watched.\nfunc (f *RepoFilter) Matches(owner, repo string) bool {\n\tif len(f.matchers) == 0 {\n\t\treturn true\n\t}\n\n\tfor _, m := range f.matchers {\n\t\tif m.matches(owner, repo) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc (m repoMatcher) matches(owner, repo string) bool {\n\townerOK, err := path.Match(m.owner, strings.ToLower(owner))\n\tif err != nil || !ownerOK {\n\t\treturn false\n\t}\n\n\trepoOK, err := path.Match(m.repo, repo)\n\treturn err == nil && repoOK\n}\n"}
OK
{"path": "internal/config/config.go", "find": "type Forgejo struct {\n\tURL string `hcl:\"url\"`\n\tToken string `hcl:\"token\"`\n\tWebhookSecret string `hcl:\"webhook_secret,optional\"`\n}", "replace": "type 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}"}
1 occurrences replaced
{"find": "import (\n\t\"fmt\"\n\n\t\"github.com/hashicorp/hcl/v2/hclsimple\"\n)", "path": "internal/config/config.go", "replace": "import (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/hashicorp/hcl/v2/hclsimple\"\n)"}
1 occurrences replaced
{"find": "\tif c.Forgejo.URL == \"\" || c.Forgejo.Token == \"\" {\n\t\treturn fmt.Errorf(\"forgejo: url and token are required\")\n\t}", "path": "internal/config/config.go", "replace": "\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}"}
1 occurrences replaced
{"find": "// AgentConfig holds an agent's full configuration including its\n// Forgejo token (if configured).", "path": "internal/config/config.go", "replace": "// 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, \"/\") > 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)."}
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\tlogger *slog.Logger\n\n\tevents chan Event\n}\n\nfunc NewWatcher(client *Client, st *store.Store, webhookSecret string, logger *slog.Logger) *Watcher {\n\treturn &Watcher{\n\t\tclient: client,\n\t\tstore: st,\n\t\tsecret: webhookSecret,\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\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}"}
1 occurrences replaced
{"find": "// dispatch dedupes ev against the store (regardless of whether it came\n// from the webhook or the poller) and, if new, forwards it to Events().\nfunc (w *Watcher) dispatch(ev Event) {\n\tisNew, err := w.store.MarkSeen(context.Background(), ev.ID)", "path": "internal/forgejo/watcher.go", "replace": "// 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)"}
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\twatcher := forgejo.NewWatcher(fgClient, st, cfg.Forgejo.WebhookSecret, logger.With(\"component\", \"forgejo\"))", "path": "cmd/zoo/main.go", "replace": "\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\"))"}
1 occurrences replaced
{"find": "forgejo {\n url = \"https://code.stdio.dk\"\n token = \"ZOO_SERVICE_TOKEN\"\n # Shared secret configured on the Forgejo webhook. Required to accept\n # webhook deliveries; polling works without it.\n webhook_secret = \"EXAMPLE_WEBHOOK_SECRET\"\n}", "path": "zoo.hcl.example", "replace": "forgejo {\n url = \"https://code.stdio.dk\"\n token = \"ZOO_SERVICE_TOKEN\"\n # Shared secret configured on the Forgejo webhook. Required to accept\n # webhook deliveries; polling works without it.\n webhook_secret = \"EXAMPLE_WEBHOOK_SECRET\"\n\n # Which repositories to watch. Events from every other repository\n # are ignored. Each entry is an \"owner/repo\" pair with glob\n # wildcards:\n #\n # repos = [\"acme/*\"] # everything in the acme org\n # repos = [\"acme/widgets\"] # just that one repository\n # repos = [\"acme/web*\"] # repos in acme whose name starts with \"web\"\n # repos = [\"acme\", \"bob\"] # bare names mean \"everything in that org\"\n # repos = [\"*\"] # everything on the instance\n #\n # Owner names match case-insensitively (like Forgejo usernames);\n # repository names match case-sensitively. Leave the list out (or\n # empty) to watch everything the token can see.\n # repos = [\"abrander/*\", \"acme/widgets\"]\n}"}
1 occurrences replaced
{"content": "package forgejo\n\nimport (\n\t\"testing\"\n)\n\nfunc mustFilter(t *testing.T, patterns ...string) *RepoFilter {\n\tt.Helper()\n\n\tf, err := NewRepoFilter(patterns)\n\tif err != nil {\n\t\tt.Fatalf(\"NewRepoFilter(%q): %v\", patterns, err)\n\t}\n\n\treturn f\n}\n\nfunc TestRepoFilterEmptyMatchesEverything(t *testing.T) {\n\tf := mustFilter(t)\n\n\tif !f.Matches(\"acme\", \"widgets\") {\n\t\tt.Fatal(\"empty filter should match everything\")\n\t}\n}\n\nfunc TestRepoFilterStarMatchesEverything(t *testing.T) {\n\tf := mustFilter(t, \"*\")\n\n\tfor _, tc := range [][2]string{{\"acme\", \"widgets\"}, {\"bob\", \"anything\"}, {\"\", \"\"}} {\n\t\tif !f.Matches(tc[0], tc[1]) {\n\t\t\tt.Fatalf(\"pattern * should match %q/%q\", tc[0], tc[1])\n\t\t}\n\t}\n}\n\nfunc TestRepoFilterExactRepo(t *testing.T) {\n\tf := mustFilter(t, \"acme/widgets\")\n\n\tif !f.Matches(\"acme\", \"widgets\") {\n\t\tt.Fatal(\"expected acme/widgets to match\")\n\t}\n\tif f.Matches(\"acme\", \"other\") {\n\t\tt.Fatal(\"acme/other should not match acme/widgets\")\n\t}\n\tif f.Matches(\"bob\", \"widgets\") {\n\t\tt.Fatal(\"bob/widgets should not match acme/widgets\")\n\t}\n}\n\nfunc TestRepoFilterOrgWildcard(t *testing.T) {\n\tf := mustFilter(t, \"acme/*\")\n\n\tif !f.Matches(\"acme\", \"widgets\") {\n\t\tt.Fatal(\"expected acme/widgets to match acme/*\")\n\t}\n\tif f.Matches(\"bob\", \"widgets\") {\n\t\tt.Fatal(\"bob/widgets should not match acme/*\")\n\t}\n}\n\nfunc TestRepoFilterBareOwnerMeansOrgWildcard(t *testing.T) {\n\tf := mustFilter(t, \"acme\")\n\n\tif !f.Matches(\"acme\", \"widgets\") {\n\t\tt.Fatal(\"bare owner should match every repo in the org\")\n\t}\n\tif f.Matches(\"bob\", \"widgets\") {\n\t\tt.Fatal(\"bob/widgets should not match bare owner acme\")\n\t}\n}\n\nfunc TestRepoFilterOwnerCaseInsensitive(t *testing.T) {\n\tf := mustFilter(t, \"acme/*\")\n\n\tif !f.Matches(\"ACME\", \"widgets\") {\n\t\tt.Fatal(\"owner names should match case-insensitively, like Forgejo usernames\")\n\t}\n}\n\nfunc TestRepoFilterRepoCaseSensitive(t *testing.T) {\n\tf := mustFilter(t, \"acme/widgets\")\n\n\tif f.Matches(\"acme\", \"Widgets\") {\n\t\tt.Fatal(\"repo names should match case-sensitively\")\n\t}\n}\n\nfunc TestRepoFilterRepoGlob(t *testing.T) {\n\tf := mustFilter(t, \"acme/web*\")\n\n\tif !f.Matches(\"acme\", \"webapp\") {\n\t\tt.Fatal(\"expected acme/webapp to match acme/web*\")\n\t}\n\tif f.Matches(\"acme\", \"mobile\") {\n\t\tt.Fatal(\"acme/mobile should not match acme/web*\")\n\t}\n}\n\nfunc TestRepoFilterMultiplePatterns(t *testing.T) {\n\tf := mustFilter(t, \"acme/widgets\", \"bob/*\")\n\n\tif !f.Matches(\"acme\", \"widgets\") || !f.Matches(\"bob\", \"anything\") {\n\t\tt.Fatal(\"either pattern should match\")\n\t}\n\tif f.Matches(\"acme\", \"other\") {\n\t\tt.Fatal(\"acme/other should not match\")\n\t}\n}\n\nfunc TestRepoFilterMalformedGlob(t *testing.T) {\n\tif _, err := NewRepoFilter([]string{\"acme/[\"}); err == nil {\n\t\tt.Fatal(\"expected error for malformed glob pattern\")\n\t}\n}\n", "path": "internal/forgejo/repo_filter_test.go"}
OK
{"content": "package forgejo\n\nimport (\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 := <-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(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", "path": "internal/forgejo/watcher_test.go"}
OK
{"find": "func TestValidateUnknownEventKind(t *testing.T) {", "path": "internal/config/config_test.go", "replace": "func TestLoadRepos(t *testing.T) {\n\tcfg, err := load(t, validConfig+`\nforgejo {\n url = \"https://example.com\"\n token = \"tok\"\n repos = [\"acme/*\", \"bob/widgets\"]\n}\n`)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tif len(cfg.Forgejo.Repos) != 2 || cfg.Forgejo.Repos[0] != \"acme/*\" || cfg.Forgejo.Repos[1] != \"bob/widgets\" {\n\t\tt.Fatalf(\"unexpected repos: %q\", cfg.Forgejo.Repos)\n\t}\n}\n\nfunc TestValidateReposEmptyList(t *testing.T) {\n\tcfg, err := load(t, validConfig+`\nforgejo {\n url = \"https://example.com\"\n token = \"tok\"\n repos = []\n}\n`)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tif len(cfg.Forgejo.Repos) != 0 {\n\t\tt.Fatalf(\"expected empty repos, got %q\", cfg.Forgejo.Repos)\n\t}\n}\n\nfunc TestValidateReposRejectsMalformedPatterns(t *testing.T) {\n\tfor _, bad := range []string{`\"\"`, `\"acme/widgets/extra\"`, `\"/acme\"`, `\"acme/\"`, `\"acme//widgets\"`} {\n\t\t_, err := load(t, validConfig+`\nforgejo {\n url = \"https://example.com\"\n token = \"tok\"\n repos = [`+bad+`]\n}\n`)\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for repos pattern %s\", bad)\n\t\t}\n\t}\n}\n\nfunc TestValidateUnknownEventKind(t *testing.T) {"}
1 occurrences replaced
{"find": "func TestLoadRepos(t *testing.T) {\n\tcfg, err := load(t, validConfig+`\nforgejo {\n url = \"https://example.com\"\n token = \"tok\"\n repos = [\"acme/*\", \"bob/widgets\"]\n}\n`)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tif len(cfg.Forgejo.Repos) != 2 || cfg.Forgejo.Repos[0] != \"acme/*\" || cfg.Forgejo.Repos[1] != \"bob/widgets\" {\n\t\tt.Fatalf(\"unexpected repos: %q\", cfg.Forgejo.Repos)\n\t}\n}\n\nfunc TestValidateReposEmptyList(t *testing.T) {\n\tcfg, err := load(t, validConfig+`\nforgejo {\n url = \"https://example.com\"\n token = \"tok\"\n repos = []\n}\n`)\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tif len(cfg.Forgejo.Repos) != 0 {\n\t\tt.Fatalf(\"expected empty repos, got %q\", cfg.Forgejo.Repos)\n\t}\n}\n\nfunc TestValidateReposRejectsMalformedPatterns(t *testing.T) {\n\tfor _, bad := range []string{`\"\"`, `\"acme/widgets/extra\"`, `\"/acme\"`, `\"acme/\"`, `\"acme//widgets\"`} {\n\t\t_, err := load(t, validConfig+`\nforgejo {\n url = \"https://example.com\"\n token = \"tok\"\n repos = [`+bad+`]\n}\n`)\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for repos pattern %s\", bad)\n\t\t}\n\t}\n}", "path": "internal/config/config_test.go", "replace": "const reposConfigTemplate = `\nllm \"qwen\" {\n openai = \"https://example.com\"\n token = \"tok\"\n model = \"qwen3\"\n}\n\nforgejo {\n url = \"https://example.com\"\n token = \"tok\"\n repos = %s\n}\n\nenvironment {\n docker_image = \"debian:unstable\"\n max_live_agents = 1\n}\n\nagent \"leon\" {\n llm = \"qwen\"\n}\n\nevent \"issue:new\" {\n agent = \"leon\"\n}\n`\n\nfunc TestLoadRepos(t *testing.T) {\n\tcfg, err := load(t, fmt.Sprintf(reposConfigTemplate, `[\"acme/*\", \"bob/widgets\"]`))\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tif len(cfg.Forgejo.Repos) != 2 || cfg.Forgejo.Repos[0] != \"acme/*\" || cfg.Forgejo.Repos[1] != \"bob/widgets\" {\n\t\tt.Fatalf(\"unexpected repos: %q\", cfg.Forgejo.Repos)\n\t}\n}\n\nfunc TestValidateReposEmptyList(t *testing.T) {\n\tcfg, err := load(t, fmt.Sprintf(reposConfigTemplate, `[]`))\n\tif err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tif len(cfg.Forgejo.Repos) != 0 {\n\t\tt.Fatalf(\"expected empty repos, got %q\", cfg.Forgejo.Repos)\n\t}\n}\n\nfunc TestValidateReposRejectsMalformedPatterns(t *testing.T) {\n\tfor _, bad := range []string{`\"\"`, `\"acme/widgets/extra\"`, `\"/acme\"`, `\"acme/\"`, `\"acme//widgets\"`} {\n\t\t_, err := load(t, fmt.Sprintf(reposConfigTemplate, `[`+bad+`]`))\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"expected error for repos pattern %s\", bad)\n\t\t}\n\t}\n}"}
1 occurrences replaced
{"find": "import (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)", "path": "internal/config/config_test.go", "replace": "import (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)"}
1 occurrences replaced
{"command": "cd /project && go build ./... && go vet ./... && go test ./... 2>&1 | tail -15", "timeout": 300}
ERROR CALLING TOOL: command failed: command exited with status 1
{"command": "cd /project && go build ./... 2>&1; echo \"---build rc=$?\"; go vet ./... 2>&1; echo \"---vet rc=$?\"; go test ./... 2>&1 | tail -30", "timeout": 300}
---build rc=0 # github.com/abrander/zoo/internal/forgejo # [github.com/abrander/zoo/internal/forgejo] vet: internal/forgejo/watcher_test.go:73:66: not enough arguments in call to w.store.MarkSeen have (string) want (context.Context, string) ---vet rc=1 ? github.com/abrander/zoo/cmd/zoo [no test files] # github.com/abrander/zoo/internal/forgejo [github.com/abrander/zoo/internal/forgejo.test] internal/forgejo/watcher_test.go:73:36: not enough arguments in call to w.store.MarkSeen have (string) want (context.Context, string) ok github.com/abrander/zoo/internal/agentrun 0.004s ok github.com/abrander/zoo/internal/config 0.019s FAIL github.com/abrander/zoo/internal/forgejo [build failed] ok github.com/abrander/zoo/internal/livelog (cached) ok github.com/abrander/zoo/internal/llm 0.003s ok github.com/abrander/zoo/internal/scheduler 1.848s ok github.com/abrander/zoo/internal/store (cached) ok github.com/abrander/zoo/internal/tools 0.003s ok github.com/abrander/zoo/internal/web 1.329s FAIL
{"find": "import (\n\t\"io\"\n\t\"log/slog\"\n\t\"testing\"\n\n\t\"github.com/abrander/zoo/internal/store\"\n)", "path": "internal/forgejo/watcher_test.go", "replace": "import (\n\t\"context\"\n\t\"io\"\n\t\"log/slog\"\n\t\"testing\"\n\n\t\"github.com/abrander/zoo/internal/store\"\n)"}
1 occurrences replaced
{"find": "\tif isNew, err := w.store.MarkSeen(testEvent(\"bob\", \"widgets\").ID); err != nil {", "path": "internal/forgejo/watcher_test.go", "replace": "\tif isNew, err := w.store.MarkSeen(context.Background(), testEvent(\"bob\", \"widgets\").ID); err != nil {"}
1 occurrences replaced
{"command": "cd /project && go build ./... && go vet ./... && go test ./... 2>&1 | tail -15", "timeout": 300}
? github.com/abrander/zoo/cmd/zoo [no test files] ok github.com/abrander/zoo/internal/agentrun (cached) ok github.com/abrander/zoo/internal/config (cached) ok github.com/abrander/zoo/internal/forgejo 0.430s ok github.com/abrander/zoo/internal/livelog (cached) ok github.com/abrander/zoo/internal/llm (cached) ok github.com/abrander/zoo/internal/scheduler (cached) ok github.com/abrander/zoo/internal/store (cached) ok github.com/abrander/zoo/internal/tools (cached) ok github.com/abrander/zoo/internal/web (cached)
{"command": "cd /project && gofmt -l . && go test ./internal/forgejo/ ./internal/config/ -run 'RepoFilter|WatcherDispatch|Repos' -v 2>&1 | grep -E '^(=== RUN|--- (PASS|FAIL)|PASS|FAIL|ok)' | head -40", "timeout": 120}
internal/forgejo/repo_filter.go internal/scheduler/scheduler_test.go === RUN TestRepoFilterEmptyMatchesEverything --- PASS: TestRepoFilterEmptyMatchesEverything (0.00s) === RUN TestRepoFilterStarMatchesEverything --- PASS: TestRepoFilterStarMatchesEverything (0.00s) === RUN TestRepoFilterExactRepo --- PASS: TestRepoFilterExactRepo (0.00s) === RUN TestRepoFilterOrgWildcard --- PASS: TestRepoFilterOrgWildcard (0.00s) === RUN TestRepoFilterBareOwnerMeansOrgWildcard --- PASS: TestRepoFilterBareOwnerMeansOrgWildcard (0.00s) === RUN TestRepoFilterOwnerCaseInsensitive --- PASS: TestRepoFilterOwnerCaseInsensitive (0.00s) === RUN TestRepoFilterRepoCaseSensitive --- PASS: TestRepoFilterRepoCaseSensitive (0.00s) === RUN TestRepoFilterRepoGlob --- PASS: TestRepoFilterRepoGlob (0.00s) === RUN TestRepoFilterMultiplePatterns --- PASS: TestRepoFilterMultiplePatterns (0.00s) === RUN TestRepoFilterMalformedGlob --- PASS: TestRepoFilterMalformedGlob (0.00s) === RUN TestWatcherDispatchForwardsWatchedRepo --- PASS: TestWatcherDispatchForwardsWatchedRepo (0.14s) === RUN TestWatcherDispatchDropsUnwatchedRepo --- PASS: TestWatcherDispatchDropsUnwatchedRepo (0.14s) === RUN TestWatcherDispatchEmptyFilterWatchesEverything --- PASS: TestWatcherDispatchEmptyFilterWatchesEverything (0.14s) PASS ok github.com/abrander/zoo/internal/forgejo 0.420s === RUN TestLoadRepos --- PASS: TestLoadRepos (0.00s) === RUN TestValidateReposEmptyList --- PASS: TestValidateReposEmptyList (0.00s) === RUN TestValidateReposRejectsMalformedPatterns --- PASS: TestValidateReposRejectsMalformedPatterns (0.00s) PASS ok github.com/abrander/zoo/internal/config 0.004s
{"command": "cd /project && gofmt -w internal/forgejo/repo_filter.go && gofmt -l internal/forgejo/ internal/config/ cmd/ && git diff --stat", "timeout": 60}
cmd/zoo/main.go | 7 ++++- internal/config/config.go | 34 ++++++++++++++++++++++++ internal/config/config_test.go | 59 ++++++++++++++++++++++++++++++++++++++++++ internal/forgejo/watcher.go | 17 +++++++++--- zoo.hcl.example | 15 +++++++++++ 5 files changed, 128 insertions(+), 4 deletions(-)
{"command": "cd /project && git status --short && git diff", "timeout": 60}
M cmd/zoo/main.go
M internal/config/config.go
M internal/config/config_test.go
M internal/forgejo/watcher.go
M zoo.hcl.example
?? internal/forgejo/repo_filter.go
?? internal/forgejo/repo_filter_test.go
?? internal/forgejo/watcher_test.go
diff --git a/cmd/zoo/main.go b/cmd/zoo/main.go
index 91cf963..06a533b 100644
--- a/cmd/zoo/main.go
+++ b/cmd/zoo/main.go
@@ -66,7 +66,12 @@ func run() error {
return fmt.Errorf("forgejo client: %w", err)
}
- watcher := forgejo.NewWatcher(fgClient, st, cfg.Forgejo.WebhookSecret, logger.With("component", "forgejo"))
+ 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"))
hub := livelog.NewHub()
diff --git a/internal/config/config.go b/internal/config/config.go
index 7966de7..b8d1e1c 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -3,6 +3,7 @@ package config
import (
"fmt"
+ "strings"
"github.com/hashicorp/hcl/v2/hclsimple"
)
@@ -50,6 +51,13 @@ type Forgejo struct {
URL string `hcl:"url"`
Token string `hcl:"token"`
WebhookSecret string `hcl:"webhook_secret,optional"`
+
+ // Repos is the allowlist of repository patterns to watch, e.g.
+ // ["acme/*", "acme/widgets"]. Patterns are "owner/repo" pairs with
+ // glob wildcards; "*" watches everything on the instance. An empty
+ // list keeps the historical behavior of watching every repository
+ // the token can see.
+ Repos []string `hcl:"repos,optional"`
}
type Environment struct {
@@ -99,6 +107,12 @@ func (c *Config) Validate() error {
return fmt.Errorf("forgejo: url and token are required")
}
+ for _, p := range c.Forgejo.Repos {
+ if err := validRepoPattern(p); err != nil {
+ return fmt.Errorf("forgejo: %w", err)
+ }
+ }
+
if c.Environment.MaxLive < 1 {
return fmt.Errorf("environment: max_live_agents must be >= 1, got %d", c.Environment.MaxLive)
}
@@ -144,6 +158,26 @@ func (c *Config) Validate() error {
return nil
}
+// validRepoPattern checks the shape of a forgejo `repos` entry. It must
+// be a non-empty "owner/repo" pair (or a bare owner name, or "*") β at
+// most one slash, none at either end. Glob syntax within the segments
+// is validated later by forgejo.NewRepoFilter.
+func validRepoPattern(p string) error {
+ if p == "" {
+ return fmt.Errorf("repos: pattern must not be empty")
+ }
+
+ if strings.Count(p, "/") > 1 {
+ return fmt.Errorf("repos: pattern %q must be \"owner/repo\" (at most one \"/\")", p)
+ }
+
+ if strings.HasPrefix(p, "/") || strings.HasSuffix(p, "/") {
+ return fmt.Errorf("repos: pattern %q must not start or end with \"/\"", p)
+ }
+
+ return nil
+}
+
// AgentConfig holds an agent's full configuration including its
// Forgejo token (if configured).
type AgentConfig struct {
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index c63b0c5..494b807 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -1,6 +1,7 @@
package config
import (
+ "fmt"
"os"
"path/filepath"
"testing"
@@ -144,6 +145,64 @@ environment { docker_image = "" max_live_agents = 1 }
}
}
+const reposConfigTemplate = `
+llm "qwen" {
+ openai = "https://example.com"
+ token = "tok"
+ model = "qwen3"
+}
+
+forgejo {
+ url = "https://example.com"
+ token = "tok"
+ repos = %s
+}
+
+environment {
+ docker_image = "debian:unstable"
+ max_live_agents = 1
+}
+
+agent "leon" {
+ llm = "qwen"
+}
+
+event "issue:new" {
+ agent = "leon"
+}
+`
+
+func TestLoadRepos(t *testing.T) {
+ cfg, err := load(t, fmt.Sprintf(reposConfigTemplate, `["acme/*", "bob/widgets"]`))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(cfg.Forgejo.Repos) != 2 || cfg.Forgejo.Repos[0] != "acme/*" || cfg.Forgejo.Repos[1] != "bob/widgets" {
+ t.Fatalf("unexpected repos: %q", cfg.Forgejo.Repos)
+ }
+}
+
+func TestValidateReposEmptyList(t *testing.T) {
+ cfg, err := load(t, fmt.Sprintf(reposConfigTemplate, `[]`))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(cfg.Forgejo.Repos) != 0 {
+ t.Fatalf("expected empty repos, got %q", cfg.Forgejo.Repos)
+ }
+}
+
+func TestValidateReposRejectsMalformedPatterns(t *testing.T) {
+ for _, bad := range []string{`""`, `"acme/widgets/extra"`, `"/acme"`, `"acme/"`, `"acme//widgets"`} {
+ _, err := load(t, fmt.Sprintf(reposConfigTemplate, `[`+bad+`]`))
+ if err == nil {
+ t.Fatalf("expected error for repos pattern %s", bad)
+ }
+ }
+}
+
func TestValidateUnknownEventKind(t *testing.T) {
_, err := load(t, `
llm "qwen" { openai = "https://example.com" token = "tok" model = "qwen3" }
diff --git a/internal/forgejo/watcher.go b/internal/forgejo/watcher.go
index dbe64e3..f220f3c 100644
--- a/internal/forgejo/watcher.go
+++ b/internal/forgejo/watcher.go
@@ -17,16 +17,18 @@ type Watcher struct {
client *Client
store *store.Store
secret string
+ repos *RepoFilter
logger *slog.Logger
events chan Event
}
-func NewWatcher(client *Client, st *store.Store, webhookSecret string, logger *slog.Logger) *Watcher {
+func NewWatcher(client *Client, st *store.Store, webhookSecret string, repos *RepoFilter, logger *slog.Logger) *Watcher {
return &Watcher{
client: client,
store: st,
secret: webhookSecret,
+ repos: repos,
logger: logger,
events: make(chan Event, 64),
}
@@ -60,9 +62,18 @@ func (w *Watcher) Run(ctx context.Context) {
}
}
-// dispatch dedupes ev against the store (regardless of whether it came
-// from the webhook or the poller) and, if new, forwards it to Events().
+// dispatch drops ev if its repository isn't watched, dedupes it against
+// the store (regardless of whether it came from the webhook or the
+// poller) and, if new, forwards it to Events(). The repo filter runs
+// before MarkSeen on purpose: events from unwatched repositories must
+// 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) {
+ 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
+ }
+
isNew, err := w.store.MarkSeen(context.Background(), ev.ID)
if err != nil {
w.logger.Error("dedup check failed, dropping event", "id", ev.ID, "error", err)
diff --git a/zoo.hcl.example b/zoo.hcl.example
index 51a0f01..b3ceb3f 100644
--- a/zoo.hcl.example
+++ b/zoo.hcl.example
@@ -20,6 +20,21 @@ forgejo {
# Shared secret configured on the Forgejo webhook. Required to accept
# webhook deliveries; polling works without it.
webhook_secret = "EXAMPLE_WEBHOOK_SECRET"
+
+ # Which repositories to watch. Events from every other repository
+ # are ignored. Each entry is an "owner/repo" pair with glob
+ # wildcards:
+ #
+ # repos = ["acme/*"] # everything in the acme org
+ # repos = ["acme/widgets"] # just that one repository
+ # repos = ["acme/web*"] # repos in acme whose name starts with "web"
+ # repos = ["acme", "bob"] # bare names mean "everything in that org"
+ # repos = ["*"] # everything on the instance
+ #
+ # Owner names match case-insensitively (like Forgejo usernames);
+ # repository names match case-sensitively. Leave the list out (or
+ # empty) to watch everything the token can see.
+ # repos = ["abrander/*", "acme/widgets"]
}
environment {
{"find": "// Matches reports whether the repository owner/repo is watched.\nfunc (f *RepoFilter) Matches(owner, repo string) bool {\n\tif len(f.matchers) == 0 {\n\t\treturn true\n\t}", "path": "internal/forgejo/repo_filter.go", "replace": "// Matches reports whether the repository owner/repo is watched. A nil\n// filter matches everything.\nfunc (f *RepoFilter) Matches(owner, repo string) bool {\n\tif f == nil || len(f.matchers) == 0 {\n\t\treturn true\n\t}"}
1 occurrences replaced
{"find": "func TestRepoFilterEmptyMatchesEverything(t *testing.T) {\n\tf := mustFilter(t)\n\n\tif !f.Matches(\"acme\", \"widgets\") {\n\t\tt.Fatal(\"empty filter should match everything\")\n\t}\n}", "path": "internal/forgejo/repo_filter_test.go", "replace": "func TestRepoFilterEmptyMatchesEverything(t *testing.T) {\n\tf := mustFilter(t)\n\n\tif !f.Matches(\"acme\", \"widgets\") {\n\t\tt.Fatal(\"empty filter should match everything\")\n\t}\n}\n\nfunc TestRepoFilterNilMatchesEverything(t *testing.T) {\n\tvar f *RepoFilter\n\n\tif !f.Matches(\"acme\", \"widgets\") {\n\t\tt.Fatal(\"nil filter should match everything\")\n\t}\n}"}
1 occurrences replaced
{"command": "cd /project && go build ./... && go vet ./... && go test ./... 2>&1 | tail -15", "timeout": 300}
? github.com/abrander/zoo/cmd/zoo [no test files] ok github.com/abrander/zoo/internal/agentrun (cached) ok github.com/abrander/zoo/internal/config (cached) ok github.com/abrander/zoo/internal/forgejo 0.482s ok github.com/abrander/zoo/internal/livelog (cached) ok github.com/abrander/zoo/internal/llm (cached) ok github.com/abrander/zoo/internal/scheduler (cached) ok github.com/abrander/zoo/internal/store (cached) ok github.com/abrander/zoo/internal/tools (cached) ok github.com/abrander/zoo/internal/web (cached)
{"command": "cd /project && git add -A && git commit -m \"forgejo: add repository allowlist with wildcard patterns\n\nAdd a repos list to the forgejo config block that filters which\nrepositories zoo watches. Events from every other repository are\ndropped before dedup, in the single choke point (Watcher.dispatch)\nshared by both the webhook receiver and the polling fallback.\n\nPattern syntax:\n * everything on the instance\n org/repo one exact repository (owner case-insensitive, repo\n case-sensitive, matching Forgejo's own semantics)\n org/* every repository in org\n org shorthand for org/*\nGlob syntax (path.Match) is supported within each segment.\n\nAn empty/omitted list keeps the historical behavior of watching\neverything the token can see, so existing configs are unaffected.\nMalformed patterns fail fast at startup (config shape check in\nconfig.Validate, glob syntax check in forgejo.NewRepoFilter).\n\nCloses #39.\" && git log --oneline -2", "timeout": 60}
[issue-39-greg 6c2615c] forgejo: add repository allowlist with wildcard patterns 8 files changed, 436 insertions(+), 4 deletions(-) create mode 100644 internal/forgejo/repo_filter.go create mode 100644 internal/forgejo/repo_filter_test.go create mode 100644 internal/forgejo/watcher_test.go 6c2615c (HEAD -> issue-39-greg) forgejo: add repository allowlist with wildcard patterns 42fe9d9 (origin/main, origin/HEAD, main) Merge pull request 'Include full issue comment thread in the agent's seed message' (#34) from issue-33-greg into main
{"subcommand": "push", "args": ["origin", "issue-39-greg"]}
To https://code.stdio.dk/abrander/zoo.git * [new branch] issue-39-greg -> issue-39-greg
{"head": "issue-39-greg", "base": "main", "title": "forgejo: repository allowlist with wildcard patterns", "body": "Closes #39.\n\n## What\n\nAdds a `repos` list to the `forgejo` config block. Events from every repository that doesn't match the list are ignored.\n\n```hcl\nforgejo {\n url = \"https://code.stdio.dk\"\n token = \"ZOO_SERVICE_TOKEN\"\n\n repos = [\"abrander/*\", \"acme/widgets\"]\n}\n```\n\n## Design decisions\n\n**Where to filter.** The filter lives in `Watcher.dispatch` β the single choke point that *both* the webhook receiver and the polling fallback pass through. One check, both paths covered, no duplication of logic (answering leon's \"webhook handler, poller, or both\": neither specifically β the merge point).\n\n**Filter before dedup, not after.** Dropped events are not marked seen. If you later add a repository to the list, its new events still flow β they weren't silently consumed by the dedup table.\n\n**Semantics.**\n\n| Pattern | Matches |\n|---|---|\n| `*` | everything on the instance |\n| `org/repo` | that one repository |\n| `org/*` | everything in `org` |\n| `org` | shorthand for `org/*` |\n| `org/web*` | glob within a segment (`path.Match` syntax, so `?`/`[...]` also work) |\n\n- Owner names match **case-insensitively** (Forgejo usernames are), repo names **case-sensitively** (they're real filesystem-ish identifiers in Forgejo).\n- **Empty/omitted list = watch everything** (today's behavior), so existing configs are unaffected. `*` is the explicit way to say the same thing.\n- Malformed patterns fail fast at startup: shape (`owner/repo`, at most one `/`) is validated in `config.Validate`, glob syntax in `forgejo.NewRepoFilter`.\n\n## Files\n\n- `internal/forgejo/repo_filter.go` β new `RepoFilter` (compiled patterns + `Matches`)\n- `internal/forgejo/watcher.go` β filter applied in `dispatch`\n- `internal/config/config.go` β `repos` attribute + shape validation\n- `cmd/zoo/main.go` β wiring\n- `zoo.hcl.example` β documented\n- Tests: filter unit tests, watcher dispatch tests (real sqlite store), config validation tests\n\n`go build`, `go vet`, and the full test suite pass."}
Pull request opened
{"body": "Implemented in PR #40 (branch `issue-39-greg`).\n\n**Config** β new `repos` list in the `forgejo` block:\n\n```hcl\nforgejo {\n url = \"https://code.stdio.dk\"\n token = \"ZOO_SERVICE_TOKEN\"\n repos = [\"abrander/*\", \"acme/widgets\"]\n}\n```\n\n**Semantics:**\n- `*` β everything on the instance\n- `org/repo` β one exact repository\n- `org/*` β everything in `org` (bare `org` works as shorthand)\n- globs within a segment (`org/web*`, via `path.Match`)\n- owner names match case-insensitively (like Forgejo usernames), repo names case-sensitively\n- empty/omitted list keeps today's behavior (watch everything the token can see), so existing configs are unaffected\n\n**Where it's enforced:** `Watcher.dispatch` β the single merge point of the webhook receiver and the polling fallback, so one check covers both paths. It runs *before* dedup, so events from unwatched repos don't occupy dedup ids: if you add a repo to the list later, its new events still flow. Malformed patterns fail fast at startup.\n\nTests added for the matcher, the dispatch path (with a real sqlite store), and config validation; full suite is green."}
Comment posted