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

Log

Thinking
First, let's start by exploring the project to understand the codebase.
Tool πŸ”§ list_files {"path": ".", "recursive": true}
{"path": ".", "recursive": true}
/project/cmd
/project/cmd/zoo
/project/cmd/zoo/main.go
/project/go.mod
/project/go.sum
/project/internal
/project/internal/agentrun
/project/internal/agentrun/clone.go
/project/internal/agentrun/context.go
/project/internal/agentrun/docker.go
/project/internal/agentrun/docker_integration_test.go
/project/internal/agentrun/forgejo.go
/project/internal/agentrun/loop.go
/project/internal/agentrun/roster.go
/project/internal/agentrun/roster_test.go
/project/internal/agentrun/run.go
/project/internal/agentrun/seed_test.go
/project/internal/agentrun/system.md
/project/internal/config
/project/internal/config/config.go
/project/internal/config/config_test.go
/project/internal/forgejo
/project/internal/forgejo/client.go
/project/internal/forgejo/client_test.go
/project/internal/forgejo/event.go
/project/internal/forgejo/poll.go
/project/internal/forgejo/watcher.go
/project/internal/forgejo/webhook.go
/project/internal/forgejo/webhook_test.go
/project/internal/livelog
/project/internal/livelog/hub.go
/project/internal/livelog/hub_test.go
/project/internal/llm
/project/internal/llm/client.go
/project/internal/llm/streamer.go
/project/internal/llm/streamer_test.go
/project/internal/llm/types.go
/project/internal/scheduler
/project/internal/scheduler/scheduler.go
/project/internal/scheduler/scheduler_test.go
/project/internal/store
/project/internal/store/schema.sql
/project/internal/store/store.go
/project/internal/store/store_test.go
/project/internal/tools
/project/internal/tools/bash.go
/project/internal/tools/context.go
/project/internal/tools/edit-file.go
/project/internal/tools/forgejo.go
/project/internal/tools/git.go
/project/internal/tools/grep_search.go
/project/internal/tools/list_files.go
/project/internal/tools/move_file.go
/project/internal/tools/read_file.go
/project/internal/tools/registry.go
/project/internal/tools/remove_file.go
/project/internal/tools/shell.go
/project/internal/tools/tools_test.go
/project/internal/tools/write_file.go
/project/internal/web
/project/internal/web/templates.go
/project/internal/web/web.go
/project/internal/web/web_test.go
/project/zoo.hcl.example
Tool πŸ”§ read_file {"path": "/event", "start_line": 1, "end_line": 50}
{"path": "/event", "start_line": 1, "end_line": 50}
{"path":"/event","content":"{\"id\":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}
Thinking
Let's take a look at the main files: config, forgejo's watcher/webhook/poll, and the sample hcl.
Tool πŸ”§ read_file {"path": "internal/config/config.go", "start_line": 1, "end_line": 400}
{"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}
Tool πŸ”§ read_file {"path": "zoo.hcl.example", "start_line": 1, "end_line": 200}
{"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}
Thinking
Next, let's take a look at the forgejo package: watcher.go, webhook.go, poll.go, event.go, client.go.
Tool πŸ”§ read_file {"path": "internal/forgejo/watcher.go", "start_line": 1, "end_line": 300}
{"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}
Tool πŸ”§ read_file {"path": "internal/forgejo/webhook.go", "start_line": 1, "end_line": 300}
{"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}
Thinking
Next, let's take a look at poll.go, event.go, and main.go to see how the Watcher is wired up.
Tool πŸ”§ read_file {"path": "internal/forgejo/poll.go", "start_line": 1, "end_line": 250}
{"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}
Tool πŸ”§ read_file {"path": "internal/forgejo/event.go", "start_line": 1, "end_line": 120}
{"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}
Thinking
Next, let's look at main.go and the scheduler to see how everything is wired up, and also check webhook_test.go to understand the test patterns.
Tool πŸ”§ read_file {"path": "cmd/zoo/main.go", "start_line": 1, "end_line": 250}
{"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}
Tool πŸ”§ read_file {"path": "internal/forgejo/webhook_test.go", "start_line": 1, "end_line": 250}
{"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}
Thinking
Alright, let's look at config_test.go to check the test patterns, and also check the scheduler to see where events get dispatched.
Tool πŸ”§ read_file {"path": "internal/config/config_test.go", "start_line": 1, "end_line": 300}
{"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}
Tool πŸ”§ read_file {"path": "internal/scheduler/scheduler.go", "start_line": 1, "end_line": 200}
{"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}
Thinking
Now I have a solid understanding of the architecture: 1. `config.Config` loads `zoo.hcl`. 2. `forgejo.Watcher` merges the webhook receiver and the polling fallback, deduplicates, and emits `Event` on a channel. 3. `scheduler.Scheduler` consumes events, resolves agents, and executes. Task: Add a list of repositories of interest to the config. Support wildcards like `organisation/*` or `*`. Ignore all others. Design decisions: **Where to place the filter:** - Option A: Inside the Watcher (both the webhook dispatch and the poller). This is the single choke point where all events (both webhook and poll) pass through `dispatch`. Filtering here means events from non-matching repositories are discarded before deduplication and before the scheduler. This is the cleanest: one place, both paths covered. - Option B: Inside the scheduler. But then events would still get marked as "seen" in the store, and the scheduler would need to know about the filter. Also, the webhook handler's emit β†’ dispatch β†’ MarkSeen would mark the event as seen even if it's filtered out β€” which is fine in itself, but filtering in the scheduler is a later stage. Leon's comment says "filter in the webhook handler, poller, or both" β€” the best architectural answer is: filter in the Watcher's `dispatch` (or just before it). That's the single choke point for both paths. In fact, `dispatch` is called from both the webhook handler (via `w.dispatch`) and the poller. So filtering in `dispatch` covers both. Hmm, but should the filter be applied before or after dedup? If we filter before MarkSeen, then a repository that was previously not watched and later added to the config would… well, events would only be marked as seen if they passed the filter. If we filter after MarkSeen, then events from unwatched repos would be marked as seen and discarded β€” and if the user later adds that repo to the config, old events would be lost (already marked as seen). Filtering before MarkSeen is better: unwatched repos don't pollute the seen table, and if the user later adds the repo, new events will flow through. **Config shape:** Inside the `forgejo` block: ```hcl forgejo { url = "..." token = "..." repos = ["abrander/*", "acme/widgets"] } ``` Or a top-level `repos` block. I think putting it in the `forgejo` block makes sense, since it's about which repositories on that Forgejo instance to watch. But `repos` could also be top-level. Hmm. The issue says "in the config, there should be a list of repositories of interest." Either works. I'll put it in the `forgejo` block as `repos = [...]` (a list of strings). **Semantics:** - Empty list (or block omitted): what should the default be? The issue says "then all others should be ignored." If the user doesn't specify any repos, do we watch nothing, or everything? A safe default is… hmm. For a v1 tool, the safest default is probably to watch nothing if no repos are configured? That could be a footgun (user configures everything else but forgets repos and nothing happens). Or watch everything (current behavior). Let me think about what's most useful and least surprising: the issue is asking for a filter. If the user doesn't configure it, preserving current behavior (watch everything the token can see) is least surprising for existing users. But "ignore all others" suggests an allowlist. I'll make the default (empty list) = watch everything (backward compatible), and document it clearly. Actually wait β€” let me reconsider. An allowlist that defaults to "everything" is a common pattern (like firewall allowlists with default-allow). The issue explicitly says `*` means "watch everything on the Forgejo instance," which suggests `*` is the explicit way to say "everything." If the default is also everything, then `*` is redundant but harmless. Hmm, but there's a real argument for default-deny: the point of this feature is to not trigger agents on random repos. If a user's token can see many repos (e.g., an org token), they'd be surprised by events from unrelated repos. But if we default-deny, existing users upgrading would suddenly have nothing happen β€” a worse footgun. I'll go with: empty list = watch everything (backward compatible, matches current behavior), document it clearly. And `*` is the explicit way to say the same thing. No wait, let me think again… The issue title is "List of repositories," and the body says "there should be a list of repositories of interest. Then all others should be ignored." The user (abrander) is the project owner. I think the intent is: when configured, only watch those repos. When not configured… I think default-allow is the right call for backward compatibility, and I'll document it. I'll mention this decision in the PR/issue comment so it can be adjusted. **Wildcard matching:** Patterns: - `org/repo` β€” exact match - `org/*` β€” all repos in org - `*` β€” everything - `*/*` β€” everything (same as `*`) - What about `org/repo*`? The issue only asks for `organisation/*` and `*`. I could implement a simple glob where `*` matches any sequence of characters within a segment (not crossing `/`), or a full glob. Let's keep it simple and predictable: Implement a matcher where patterns are split on `/` into owner and repo parts: - `*` (single segment, no slash) matches everything. - `owner/repo`: each part is a glob where `*` matches any sequence of characters not including `/`. So `org/*` matches all repos in org, `org/web*` matches repos starting with "web". - Case sensitivity: Forgejo usernames are case-insensitive for matching (login is case-insensitive). Repo names are case-sensitive, but org names are case-insensitive. For simplicity and predictability, I'll do case-insensitive matching on the owner part and case-sensitive on the repo part? That's over-engineering. Hmm. Gitea/Forgejo: usernames are case-insensitive, repo names are case-sensitive. Let's do case-insensitive matching for owner and case-sensitive for repo. Actually, to keep it simple and testable, I might just do case-insensitive matching on the whole "owner/repo" string. Hmm, but that could match `ACME/Widgets` when the user wrote `acme/widgets`… repo names are case-sensitive in Forgejo, so `acme/widgets` and `acme/Widgets` are different repos. Case-insensitive matching on the repo part would be wrong. Decision: owner part is case-insensitive (Forgejo logins are case-insensitive), repo part is case-sensitive. This is a defensible, documented choice. Actually, let me reconsider β€” simpler: exact case-sensitive matching on the full `owner/repo`, except `*` wildcards. But then `ACME/widgets` wouldn't match `acme/*`. Forgejo's API returns the actual username casing, and users might write the pattern in a different case. Since Forgejo treats usernames case-insensitively, case-insensitive owner matching is more correct. I'll go with that. **Implementation plan:** 1. `internal/config/config.go`: - Add `Repos []string hcl:"repos,optional"` to the `Forgejo` struct. - Validation: reject empty strings, reject patterns containing more than one `/` (i.e., must be `name` or `owner/name`). Maybe also reject `//`. Keep it light: non-empty, at most one slash. - Add a method to build the matcher? No β€” config shouldn't import forgejo. The forgejo package can build a `RepoFilter` from `cfg.Forgejo.Repos`. 2. New file `internal/forgejo/repo_filter.go`: - `type RepoFilter struct { patterns []string }` β€” precompiled. - `func NewRepoFilter(patterns []string) (*RepoFilter, error)` β€” compiles patterns into glob matchers. - `func (f *RepoFilter) Matches(owner, repo string) bool`. - Empty patterns β†’ matches everything. - Uses `path.Match`-style semantics. Go's `path.Match` handles `*` (doesn't cross `/`), `?`, `[...]`. Since we split owner/repo into separate segments, `path.Match` per segment works: `path.Match(pattern, name)` where pattern `*` matches any string without `/`. This gives us glob semantics for free, including `?` and character classes. But do we want to document `?` and `[...]`? path.Match supports them, so supporting them for free is fine, and I'll document that `*` is the primary wildcard. Wait, one subtlety: `path.Match` with pattern `*` matches any string (since the name has no `/`). Good. 3. `internal/forgejo/watcher.go`: - Add `repos *RepoFilter` (or `repoFilter RepoFilter`) to the Watcher. - `NewWatcher` needs the patterns. Change the signature: `NewWatcher(client, st, webhookSecret, repos []string, logger)` β€” or pass a `RepoFilter`. I'll pass `[]string` and construct the filter inside, or construct it in main and pass the filter. Cleaner: main constructs `forgejo.NewRepoFilter(cfg.Forgejo.Repos)` and passes it to `NewWatcher`. But then NewWatcher's signature takes a `*RepoFilter`. Hmm, let's keep it simple: pass `repos []string` to NewWatcher and construct the filter internally. Actually, constructing in main lets us fail fast at startup with a clear error. Let me look at how NewClient handles errors… `forgejo.NewClient(cfg.Forgejo)` returns an error. I'll do the same: `NewRepoFilter(patterns)` returns `(*RepoFilter, error)` and main calls it, passing the result to NewWatcher. - Inside `dispatch`: first check `w.repos.Matches(ev.Owner, ev.Repo)`. If it doesn't match, log at debug/info and return (before MarkSeen). 4. `internal/forgejo/repo_filter_test.go` β€” tests for the matcher. 5. `internal/forgejo/watcher_test.go` β€” tests that dispatch filters out non-matching repos and lets matching ones through. I need to check what store methods are available for the test… dispatch uses `w.store.MarkSeen`. Let me check the store to see if I can open a real store in tests (store_test.go exists, so probably possible). Let me check store.Open's signature. 6. `internal/config/config_test.go` β€” add tests for repos validation. 7. `zoo.hcl.example` β€” document the new `repos` attribute. 8. Maybe update the README too? There's no README in the file listing. OK. Also, check the scheduler's tests and the web dashboard β€” the dashboard might be listing jobs. No changes needed. Wait β€” one more consideration: the poller. `pollIssues` lists issues across all repos the token can see. With a filter, we could optimize by listing only matching repos, but that requires knowing the repo list up front (API call to list repos). The filter at dispatch time is sufficient and simpler. Polling still fetches all repos' issues from the API, but discards non-matching ones. That's acceptable for v1. I'll note it. Also, the webhook handler: webhooks are per-repo or instance-wide. If instance-wide, the filter at dispatch handles it. Now, about the filter position relative to dedup: I've decided to filter before MarkSeen. Let me double-check `dispatch`: ```go func (w *Watcher) dispatch(ev Event) { if !w.repos.Matches(ev.Owner, ev.Repo) { w.logger.Debug("dropping event from unwatched repository", ...) return } isNew, err := w.store.MarkSeen(...) ... } ``` Good. Edge case: `ev.Owner` might be empty (repoOwner returns "" if owner is nil). In that case, the pattern `*` matches (owner "" matches `*`). `org/repo` pattern: `path.Match("org", "")` is false. Good. Now, config validation for patterns: - Non-empty. - No leading/trailing slash. - At most one slash. - No empty segments (e.g., `org/` or `/repo` or `//`). I'll write a `validateRepoPattern` helper in config.go: ```go func validRepoPattern(p string) error { if p == "" { return fmt.Errorf("repo pattern must not be empty") } if strings.Count(p, "/") > 1 { return fmt.Errorf("repo pattern %q: at most one '/' allowed (owner/repo)", p) } if strings.HasPrefix(p, "/") || strings.HasSuffix(p, "/") { return fmt.Errorf("repo pattern %q: must not start or end with '/'", p) } return nil } ``` This covers `org/`, `/repo`, `//`. Now the RepoFilter: ```go // Package forgejo ... // RepoFilter decides which repositories zoo watches. It is built from // the `repos` list in the forgejo config block... type RepoFilter struct { // patterns are the raw patterns, for logging/errors. patterns []string // compiled owner/repo matchers. matchers []repoMatcher } type repoMatcher struct { owner string // glob, case-insensitive repo string // glob, case-sensitive ownerCI bool repoCI bool } ``` Hmm, let me simplify: precompute lowercased owner glob and lowercased owner for matching. ```go func NewRepoFilter(patterns []string) (*RepoFilter, error) { f := &RepoFilter{patterns: append([]string(nil), patterns...)} for _, p := range patterns { owner, repo, hasSlash := strings.Cut(p, "/") if !hasSlash { owner, repo = "*", "*" } f.matchers = append(f.matchers, repoMatcher{ owner: strings.ToLower(owner), repo: repo, }) } return f, nil } ``` Wait β€” `strings.Cut(p, "/")` on `"*"` gives `("*", "", false)`. On `"org/*"` it gives `("org", "*", true)`. Good. But wait: should a bare `org` (no slash) mean "all repos in org," or an exact repo name `org`? Ambiguous! In Forgejo, a repo is always `owner/repo`. A bare name can't be a repo. So a bare `org` is more naturally read as "all repos in org" β€” but the issue's example uses `organisation/*`. Hmm. Two options: 1. Bare `org` = all repos in that org (treat as `org/*`). 2. Bare `org` = invalid, require `org/*`. I think treating bare `org` as `org/*` is convenient and matches user intuition ("I care about this org"). But it could be surprising. Alternatively, require the explicit form. Hmm. The issue says: "support wildcards. E.g., `organisation/*`, or just `*` if the user wants to watch everything." So the two required forms are `org/*` and `*`. I'll support: - `*` β†’ everything - `owner/repo` exact match - `owner/*` (and generally `owner/glob`, `glob/repo`) - Bare `owner` β†’ treat as `owner/*` (documented). This is a convenience. Hmm, or should I reject it? Let me think about what's least surprising. If a user writes `repos = ["acme"]`, they almost certainly mean "the acme org." I'll treat a bare name as `name/*` and document it. That's a defensible, documented choice. Matching: ```go func (f *RepoFilter) Matches(owner, repo string) bool { if len(f.matchers) == 0 { return true } for _, m := range f.matchers { if m.matches(owner, repo) { return true } } return false } func (m repoMatcher) matches(owner, repo string) bool { ownerOK, err := path.Match(m.owner, strings.ToLower(owner)) if err != nil || !ownerOK { return false } repoOK, err := path.Match(m.repo, repo) if err != nil || !repoOK { return false } return true } ``` `path.Match` returns an error for malformed patterns (e.g., a trailing `[`). Since config validation already restricts the shape, the only source of errors is a malformed bracket expression like `org/[`. Should I validate at config time? That's overkill. I'll let path.Match's error β†’ no match, and log at debug. Actually, for better UX, I could validate patterns in NewRepoFilter by dry-running `path.Match(pattern, "")`… hmm, that would also catch malformed brackets. Let me do that in NewRepoFilter: for each segment, call `path.Match(seg, "x")` and return an error if err != nil. Wait, `path.Match("[", "x")` returns an error "syntax error in pattern." Good. But `path.Match("a[", "x")` too. Good. This gives us fail-fast at startup. Hmm wait, but `path.Match` has a subtlety: the pattern `*` matches any string. The pattern `a*b` is fine. OK. Now, is `path.Match`'s `*` "matches any sequence of non-Separator characters"? Yes. Since we split on `/` first, the segments don't contain `/`, so `*` matches the whole segment. Now, the Watcher changes: ```go 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), } } ``` And in dispatch: ```go 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) return } ... } ``` main.go: ```go 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")) ``` Config: ```go type Forgejo struct { URL string `hcl:"url"` Token string `hcl:"token"` WebhookSecret string `hcl:"webhook_secret,optional"` // Repos is the list of repository patterns to watch... Repos []string `hcl:"repos,optional"` } ``` Validation in `Validate()`: ```go for _, p := range c.Forgejo.Repos { if err := validateRepoPattern(p); err != nil { return fmt.Errorf("forgejo: %w", err) } } ``` Where to put validateRepoPattern? In config.go. But NewRepoFilter also validates glob syntax. Two layers: config validates the shape (owner/repo), forgejo validates the glob syntax. That's fine. Hmm, actually β€” should the glob syntax validation live in config? The config package doesn't know about path.Match semantics. I'll keep the shape validation in config and the glob syntax validation in NewRepoFilter. Good separation. Now, tests: 1. `repo_filter_test.go`: - Empty filter matches everything. - `*` matches everything. - `org/*` matches `org/repo`, doesn't match `other/repo`. - `org/repo` exact match. - Case-insensitive owner: `ACME/widgets` matches `acme/*`. - Case-sensitive repo: `acme/Widgets` doesn't match `acme/widgets`. - Bare `org` matches `org/repo`. - `org/web*` matches `org/webapp`. - Malformed pattern `org/[` β†’ error from NewRepoFilter. 2. Watcher test: need a store. Let me check the store package's test helpers. Let me read store.go and store_test.go. 3. Config tests: repos are parsed. Invalid patterns are rejected. Let me also check whether the dashboard (web.go) or anything else references NewWatcher. Let me grep for NewWatcher and check the forgejo client tests.
Now let me look at the store (for test helpers) and check all usages of `NewWatcher`:
Tool πŸ”§ grep_search {"query": "NewWatcher"}
{"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 {
Tool πŸ”§ read_file {"path": "internal/store/store.go", "start_line": 1, "end_line": 120}
{"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}
Thinking
Alright. `store.Open` works with a temp path. So let me check the Go version in go.mod, and verify that the build works before making any changes.
Tool πŸ”§ read_file {"path": "go.mod", "start_line": 1, "end_line": 40}
{"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}
Tool πŸ”§ bash {"command": "cd /project && go build ./... && go test ./... 2>&1 | tail -20", "timeout": 300}
{"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
Thinking
The baseline is green. Now let's implement the design: **Architecture decision** (to be documented in the PR): 1. **Config**: Add `repos = [...]` to the `forgejo` block β€” an allowlist of repository patterns. 2. **Filtering**: Apply it in `Watcher.dispatch` β€” the single chokepoint through which both webhook and poller events pass, *before* deduplication (so unwatched repos don't pollute the seen table, and if a repo is added later, its new events will flow). 3. **Semantics**: - Empty/omitted `repos` β†’ watch everything (backward-compatible with current behavior). - `*` β†’ everything. - `owner/repo` β†’ exact match (owner is case-insensitive, repo is case-sensitive β€” matching Forgejo's own semantics). - `owner/*` β†’ all repos in the org. - Bare `owner` β†’ shorthand for `owner/*`. - Glob syntax via `path.Match` (i.e., `?` and `[...]` also work). 4. **Validation**: config validates the shape (non-empty, at most one `/`, no leading/trailing `/`); `NewRepoFilter` validates glob syntax at startup. Now let's write the code. First, `internal/forgejo/repo_filter.go`:
Baseline is green. Now I'll implement the feature. My design: 1. **Config**: Add `repos = [...]` to the `forgejo` block β€” an allowlist of repository patterns. 2. **Filtering**: Apply it in `Watcher.dispatch` β€” the single chokepoint that both the webhook and poller events pass through, *before* dedup (so that unwatched repos don't pollute the seen table, and events will flow if a repo is added later). 3. **Semantics**: empty list = watch everything (backward compatible); `*` = everything; `owner/repo` is an exact match (owner is case-insensitive, repo is case-sensitive, matching Forgejo's own semantics); `owner/*` = the whole org; bare `owner` = shorthand for `owner/*`; glob syntax via `path.Match`. Let's start with the new filter package file:
Tool πŸ”§ write_file {"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"}
{"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
Thinking
Next, I'll update the config: add `Repos []string` to the `Forgejo` struct, and perform validation.
Now the config changes:
Tool πŸ”§ edit_file {"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}"}
{"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
Thinking
Next, I'll add validation to `Validate()` and add a `validateRepoPattern` helper. Also, I need to import `strings` in config.go.
Tool πŸ”§ edit_file {"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)"}
{"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
Tool πŸ”§ edit_file {"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}"}
{"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
Thinking
Next, I'll add the `validRepoPattern` helper. I'll add it after `Validate()`, or near the end. Let's add it after the Validate function:
Tool πŸ”§ edit_file {"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)."}
{"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
Thinking
Next, I'll update the Watcher to use the filter. I'll modify `watcher.go`:
Now wire the filter into the Watcher:
Tool πŸ”§ edit_file {"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}"}
{"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
Tool πŸ”§ edit_file {"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)"}
{"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
Thinking
Next, I'll update main.go to build the filter and pass it to NewWatcher:
Now update `main.go`:
Tool πŸ”§ edit_file {"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\"))"}
{"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
Thinking
Next, I'll update `zoo.hcl.example` to document the new attribute:
Now document the new option in `zoo.hcl.example`:
Tool πŸ”§ edit_file {"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}"}
{"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
Thinking
Alright, let's write the tests: 1. `internal/forgejo/repo_filter_test.go` 2. Watcher dispatch tests β€” a new `internal/forgejo/watcher_test.go` 3. Config tests for the repos validation Let's write repo_filter_test.go:
Now the tests. First, the filter itself:
Tool πŸ”§ write_file {"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"}
{"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
Thinking
Next up is the watcher's dispatch test. I need a real store (sqlite in a temp directory) and a Watcher. `dispatch` is unexported, but since the test is in the same package, that's fine. ```go package forgejo import ( "log/slog" "testing" "github.com/abrander/zoo/internal/store" ) func newTestWatcher(t *testing.T, patterns ...string) *Watcher { t.Helper() st, err := store.Open(t.TempDir() + "/zoo.db") if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) repos, err := NewRepoFilter(patterns) if err != nil { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) return NewWatcher(nil, st, "", repos, logger) } ``` Wait, `NewWatcher(client *Client, ...)` β€” the client can be nil, since dispatch doesn't use the client. That's fine. Tests: 1. An event from a watched repo β†’ gets forwarded to the Events channel, and marked as seen. 2. An event from an unwatched repo β†’ not forwarded, and not marked as seen (verify that MarkSeen returns true when called again β€” i.e., the id isn't in the store). 3. Empty filter β†’ everything gets forwarded. Let's write this.
Now a watcher-level test proving the filter applies to the shared dispatch path:
Tool πŸ”§ write_file {"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"}
{"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
Thinking
Next, I'll add config tests for the `repos` attribute. I'll append to config_test.go:
Now config tests for the new attribute:
Tool πŸ”§ edit_file {"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) {"}
{"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
Thinking
Wait, there's a problem: `validConfig` already contains a `forgejo { ... }` block, and HCL doesn't allow duplicate blocks for the same label… actually, in the case of a single block (not a list), if you declare `forgejo` twice, hclsimple.DecodeFile will error out with "Block forgejo already declared". Let me verify: HCL v2's body decoding β€” for a single block (not `block` with a list), duplicate blocks cause an error. Yes, `hcl:"forgejo,block"` β€” declaring it twice is an error. Therefore, my test approach of appending a second forgejo block to validConfig will fail. I need a different approach: either create a separate complete config string that includes repos, or restructure validConfig so that the forgejo block contains repos. Better approach: write a standalone config string for these tests. Let me fix it.
I made a mistake β€” `validConfig` already declares a `forgejo` block, and HCL rejects duplicate single blocks. Let me fix those tests to use standalone configs:
Tool πŸ”§ edit_file {"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}"}
{"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
Thinking
I need to add `fmt` to the imports in config_test.go.
Tool πŸ”§ edit_file {"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)"}
{"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
Thinking
Wait, `"acme//widgets"` β€” let me check my `validRepoPattern`: `strings.Count("acme//widgets", "/")` = 2 > 1 β†’ error. Good. `"/acme"` β†’ HasPrefix "/" β†’ error. `"acme/"` β†’ HasSuffix β†’ error. `""` β†’ empty β†’ error. `"acme/widgets/extra"` β†’ count 2 β†’ error. Good. Alright, let's build and run the tests.
Now build and run all tests:
Tool πŸ”§ bash {"command": "cd /project && go build ./... && go vet ./... && go test ./... 2>&1 | tail -15", "timeout": 300}
{"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
Thinking
Something failed. Let's check the output in more detail.
Tool πŸ”§ bash {"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}
{"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
Thinking
I forgot the context argument in the test. Fix: `w.store.MarkSeen(context.Background(), ...)`.
Missing `context` arg in the test β€” fixing:
Tool πŸ”§ edit_file {"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)"}
{"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
Tool πŸ”§ edit_file {"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 {"}
{"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
Thinking
Wait, `w.store` β€” the store field is lowercase, but since the test is in the same package, it's accessible. OK. Let me run it again.
Tool πŸ”§ bash {"command": "cd /project && go build ./... && go vet ./... && go test ./... 2>&1 | tail -15", "timeout": 300}
{"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)
Thinking
All green. Let's run the new tests in verbose mode to confirm they actually ran, and also double-check gofmt.
All green. Let me verify the new tests actually ran and check formatting:
Tool πŸ”§ bash {"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}
{"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
Thinking
All tests pass. `gofmt -l` is flagging two files: - `internal/forgejo/repo_filter.go` β€” mine, needs formatting. - `internal/scheduler/scheduler_test.go` β€” an existing one (not mine; let me confirm it was already unformatted before my changes β€” I haven't touched it, so it's pre-existing. Shouldn't touch it, to keep changes surgical). Let me format only my file.
All tests pass. `gofmt` flags my `repo_filter.go` (and a pre-existing issue in `scheduler_test.go` which I haven't touched β€” leaving that alone). Formatting my file:
Tool πŸ”§ bash {"command": "cd /project && gofmt -w internal/forgejo/repo_filter.go && gofmt -l internal/forgejo/ internal/config/ cmd/ && git diff --stat", "timeout": 60}
{"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(-)
Thinking
gofmt is currently clean (no output from gofmt -l against the directories I touched). The diff stat shows the modified files. New files (repo_filter.go, repo_filter_test.go, watcher_test.go) are untracked, so they don't show up in diff --stat. Let me do a final review of the complete diff, then commit and push.
Clean. Let me do a final review of the full diff:
Tool πŸ”§ bash {"command": "cd /project && git status --short && git diff", "timeout": 60}
{"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 {
Thinking
The diff looks good. One more thing to double-check: the new file repo_filter.go after gofmt. Let me quickly verify that it's formatted, and run the full test suite one more time at the end. After that, commit and push. One more thing to consider β€” the webhook handler: should the filter also be applied at the HTTP level (e.g., returning 200 without processing)? It's already being applied in dispatch, which is called from the handler via emit. No problem. Another consideration: `w.repos` could be nil if someone constructs a Watcher with a nil filter β€” `Matches` on a nil *RepoFilter: `len(f.matchers)` on a nil pointer... `f.matchers` on a nil struct pointer β†’ nil dereference panic! Let me check: `func (f *RepoFilter) Matches(...)` β€” if f is nil, accessing `f.matchers` panics. In main.go we always pass a non-nil one (NewRepoFilter always returns a non-nil one). In tests we pass the result of mustFilter. So in practice it's fine, but for robustness we could add a nil check. It's a cheap safeguard: ```go func (f *RepoFilter) Matches(owner, repo string) bool { if f == nil || len(f.matchers) == 0 { return true } ... } ``` This is good defensive coding. Let me add it. After that, commit, push, and open the PR.
The diff looks fine. One defensive improvement: `Matches` should be safe even when called on a nil filter (since it's a pointer receiver):
Tool πŸ”§ edit_file {"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}"}
{"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
Thinking
Let's add a test for the nil filter too, and then run the full suite one more time at the end.
Tool πŸ”§ edit_file {"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}"}
{"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
Thinking
Let's run the full test suite one more time to confirm that everything passes, including the new nil filter test.
Let me run the full suite once more to confirm everything passes:
Tool πŸ”§ bash {"command": "cd /project && go build ./... && go vet ./... && go test ./... 2>&1 | tail -15", "timeout": 300}
{"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)
Thinking
Everything's fine. Next, I'll commit and push.
All green. Committing and pushing:
Tool πŸ”§ bash {"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}
{"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
Thinking
Wait, the commit shows "8 files changed, 436 insertions(+)", but I only added 3 new files + 5 modified files = 8 files. OK, that matches. Next, push the branch and open a PR.
Tool πŸ”§ git {"subcommand": "push", "args": ["origin", "issue-39-greg"]}
{"subcommand": "push", "args": ["origin", "issue-39-greg"]}
To https://code.stdio.dk/abrander/zoo.git
 * [new branch]      issue-39-greg -> issue-39-greg
Thinking
Push complete. Next, I'll open a pull request.
Tool πŸ”§ open_pull_request {"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."}
{"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
Thinking
The PR has been opened. Next, I'll post a comment on the issue summarizing what was done (the guidelines say to always leave a comment). Since the PR references the issue, a brief comment on the issue is appropriate.
Tool πŸ”§ comment {"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."}
{"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
Thinking
Everything is complete: 1. βœ… Implemented the feature (config + filter + integration into the watcher) 2. βœ… Added tests, all passing 3. βœ… Committed and pushed 4. βœ… Opened a PR 5. βœ… Left a comment on the issue Done. Let me summarize for the final message.
Done. Summary of what I shipped for issue #39: **Feature** β€” a `repos` allowlist in the `forgejo` config block with wildcard support: - `*` β†’ watch everything on the instance - `org/repo` β†’ one exact repository - `org/*` β†’ everything in an org (bare `org` is shorthand) - glob syntax per segment (`org/web*`, `?`, `[...]` via `path.Match`) - owner names case-insensitive (matching Forgejo username semantics), repo names case-sensitive - empty/omitted list preserves current behavior (watch everything), so existing configs keep working **Architecture** β€” the filter is a new `RepoFilter` in `internal/forgejo`, applied in `Watcher.dispatch`: the single choke point shared by both the webhook receiver and the polling fallback (answering leon's "webhook, poller, or both" with "the merge point"). It runs *before* dedup so unwatched repos don't consume dedup ids β€” a repo added to the config later still gets its new events. Malformed patterns fail fast at startup (shape check in `config.Validate`, glob syntax in `NewRepoFilter`). **Verification** β€” `go build`, `go vet`, and the full test suite pass, including 16 new tests (matcher unit tests, watcher dispatch tests against a real sqlite store, config validation tests). **Delivered** β€” committed on `issue-39-greg`, pushed, PR opened against `main`, and the issue commented with the design rationale.