Details and log output for this agent run.
{"path": "."}
drwxrwxr-x 5 1000 1000 4096 Aug 24 08:41 . drwxr-xr-x 1 root root 4096 Aug 24 08:41 .. drwxrwxr-x 8 1000 1000 4096 Aug 24 08:41 .git -rw-rw-r-- 1 1000 1000 22 Aug 24 08:41 .gitignore drwxrwxr-x 3 1000 1000 4096 Aug 24 08:41 cmd -rw-rw-r-- 1 1000 1000 2628 Aug 24 08:41 go.mod -rw-rw-r-- 1 1000 1000 16578 Aug 24 08:41 go.sum drwxrwxr-x 11 1000 1000 4096 Aug 24 08:41 internal -rw-rw-r-- 1 1000 1000 1564 Aug 24 08:41 zoo.hcl.example
{"path": "internal"}
drwxrwxr-x 11 1000 1000 4096 Aug 24 08:41 . drwxrwxr-x 5 1000 1000 4096 Aug 24 08:41 .. drwxrwxr-x 2 1000 1000 4096 Aug 24 08:41 agentrun drwxrwxr-x 2 1000 1000 4096 Aug 24 08:41 config drwxrwxr-x 2 1000 1000 4096 Aug 24 08:41 forgejo drwxrwxr-x 2 1000 1000 4096 Aug 24 08:41 livelog drwxrwxr-x 2 1000 1000 4096 Aug 24 08:41 llm drwxrwxr-x 2 1000 1000 4096 Aug 24 08:41 scheduler drwxrwxr-x 2 1000 1000 4096 Aug 24 08:41 store drwxrwxr-x 2 1000 1000 4096 Aug 24 08:41 tools drwxrwxr-x 2 1000 1000 4096 Aug 24 08:41 web
{"end_line": 50, "path": "zoo.hcl.example", "start_line": 1}
{"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","start_line":1,"end_line":50,"total_lines":70,"truncated":true}
{"path": "internal/config"}
drwxrwxr-x 2 1000 1000 4096 Aug 24 08:41 . drwxrwxr-x 11 1000 1000 4096 Aug 24 08:41 .. -rw-rw-r-- 1 1000 1000 5356 Aug 24 08:41 config.go -rw-rw-r-- 1 1000 1000 4075 Aug 24 08:41 config_test.go
{"end_line": 150, "path": "internal/config/config.go", "start_line": 1}
{"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","start_line":1,"end_line":150,"total_lines":204,"truncated":true}
{"end_line": 204, "path": "internal/config/config.go", "start_line": 150}
{"path":"internal/config/config.go","content":"\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":150,"end_line":204,"total_lines":204,"truncated":false}
{"path": "internal/scheduler"}
drwxrwxr-x 2 1000 1000 4096 Aug 24 08:41 . drwxrwxr-x 11 1000 1000 4096 Aug 24 08:41 .. -rw-rw-r-- 1 1000 1000 7442 Aug 24 08:41 scheduler.go -rw-rw-r-- 1 1000 1000 8876 Aug 24 08:41 scheduler_test.go
{"end_line": 100, "path": "internal/scheduler/scheduler.go", "start_line": 1}
{"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","start_line":1,"end_line":100,"total_lines":241,"truncated":true}
{"end_line": 241, "path": "internal/scheduler/scheduler.go", "start_line": 100}
{"path":"internal/scheduler/scheduler.go","content":"\tif !ok {\r\n\t\ts.logger.Debug(\"no agent resolved for event, dropping\", \"kind\", ev.Kind, \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index)\r\n\t\treturn\r\n\t}\r\n\r\n\t// An agent's own actions (e.g. a comment posted via the `comment`\r\n\t// tool, authenticated with its own per-agent token) can themselves\r\n\t// show up as new events. Don't let an agent trigger itself off its\r\n\t// own activity — that's a self-reinforcing loop, not new work.\r\n\tif ev.Author != \"\" \u0026\u0026 ev.Author == agentName {\r\n\t\ts.logger.Debug(\"dropping event authored by the agent it would trigger\", \"kind\", ev.Kind, \"agent\", agentName, \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index)\r\n\t\treturn\r\n\t}\r\n\r\n\tagent, ok := s.cfg.AgentByName(agentName)\r\n\tif !ok {\r\n\t\ts.logger.Error(\"resolved agent not declared in config\", \"agent\", agentName)\r\n\t\treturn\r\n\t}\r\n\r\n\tllm, ok := s.cfg.LLMByName(agent.LLM)\r\n\tif !ok {\r\n\t\ts.logger.Error(\"agent references undeclared llm\", \"agent\", agentName, \"llm\", agent.LLM)\r\n\t\treturn\r\n\t}\r\n\r\n\tjobID := uuid.NewString()\r\n\r\n\tif err := s.store.CreateJob(ctx, store.Job{\r\n\t\tID: jobID,\r\n\t\tEventKind: ev.Kind,\r\n\t\tAgent: agentName,\r\n\t\tOwner: ev.Owner,\r\n\t\tRepo: ev.Repo,\r\n\t\tIssueIndex: ev.Index,\r\n\t\tTitle: ev.Title,\r\n\t}); err != nil {\r\n\t\ts.logger.Error(\"failed to record job\", \"job\", jobID, \"error\", err)\r\n\t\treturn\r\n\t}\r\n\r\n\tselect {\r\n\tcase s.sem \u003c- struct{}{}:\r\n\r\n\tcase \u003c-ctx.Done():\r\n\t\treturn\r\n\t}\r\n\r\n\ts.wg.Add(1)\r\n\r\n\tgo func() {\r\n\t\tdefer s.wg.Done()\r\n\t\tdefer func() { \u003c-s.sem }()\r\n\r\n\t\ts.run(ctx, jobID, agent, llm, ev)\r\n\t}()\r\n}\r\n\r\nfunc (s *Scheduler) run(ctx context.Context, jobID string, agent config.AgentConfig, llm config.LLM, ev forgejo.Event) {\r\n\tlogger := s.logger.With(\"job\", jobID, \"agent\", agent.Name, \"event\", ev.Kind, \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index)\r\n\r\n\t// Job status writes use a context detached from ctx, not ctx itself:\r\n\t// ctx is canceled on daemon shutdown to unwind the in-flight run, and\r\n\t// an already-canceled ctx would make these UPDATEs fail instantly,\r\n\t// leaving the job stuck at \"running\" forever even though the process\r\n\t// has exited.\r\n\tif err := s.store.MarkJobStarted(context.Background(), jobID); err != nil {\r\n\t\tlogger.Error(\"failed to mark job started\", \"error\", err)\r\n\t}\r\n\r\n\tlogger.Info(\"agent run starting\")\r\n\r\n\terr := s.runner.Run(ctx, jobID, agent, llm, s.cfg.Environment.DockerImage, ev)\r\n\r\n\tstatus := store.JobSucceeded\r\n\terrMsg := \"\"\r\n\r\n\tif err != nil {\r\n\t\terrMsg = err.Error()\r\n\t\t// The daemon-wide ctx passed in here isn't what times a run out\r\n\t\t// (agentrun.Runner.Run applies its own per-run deadline\r\n\t\t// internally); a timed-out run surfaces as a wrapped\r\n\t\t// context.DeadlineExceeded in the returned error instead.\r\n\t\tif errors.Is(err, context.DeadlineExceeded) {\r\n\t\t\tstatus = store.JobTimedOut\r\n\t\t} else {\r\n\t\t\tstatus = store.JobFailed\r\n\t\t}\r\n\r\n\t\tlogger.Error(\"agent run failed\", \"status\", status, \"error\", err)\r\n\t\ts.reportFailure(ev, status, err)\r\n\t} else {\r\n\t\tlogger.Info(\"agent run succeeded\")\r\n\t}\r\n\r\n\tif err := s.store.MarkJobFinished(context.Background(), jobID, status, errMsg); err != nil {\r\n\t\tlogger.Error(\"failed to mark job finished\", \"error\", err)\r\n\t}\r\n\r\n\t// Closing the hub here, only after the terminal status is durably\r\n\t// written, is what lets a dashboard tab's auto-reload (triggered by\r\n\t// this Close) see the finished status immediately. Closing it inside\r\n\t// Runner.Run instead — before this status write — raced a fast\r\n\t// reload against MarkJobFinished: the reloaded page could still see\r\n\t// \"running\", open a fresh SSE subscription against a hub entry that\r\n\t// no longer exists, and be stuck showing a stale status forever.\r\n\ts.hub.Close(jobID)\r\n}\r\n\r\n// reportFailure implements the locked-in failure policy: comment on the\r\n// triggering issue/PR with an error summary, and apply FailureLabel.\r\nfunc (s *Scheduler) reportFailure(ev forgejo.Event, status store.JobStatus, runErr error) {\r\n\tbody := fmt.Sprintf(\"zoo agent run %s: %s\", status, runErr.Error())\r\n\r\n\tif err := s.forgejo.CreateIssueComment(ev.Owner, ev.Repo, ev.Index, body); err != nil {\r\n\t\ts.logger.Error(\"failed to post failure comment\", \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index, \"error\", err)\r\n\t}\r\n\r\n\tif err := s.forgejo.AddLabel(ev.Owner, ev.Repo, ev.Index, FailureLabel); err != nil {\r\n\t\ts.logger.Error(\"failed to add failure label\", \"owner\", ev.Owner, \"repo\", ev.Repo, \"index\", ev.Index, \"error\", err)\r\n\t}\r\n}\r\n\r\n// Shutdown stops accepting new work is the caller's responsibility (stop\r\n// feeding Run's channel); Shutdown just waits for in-flight runs, up to\r\n// ctx's deadline.\r\nfunc (s *Scheduler) Shutdown(ctx context.Context) error {\r\n\tdone := make(chan struct{})\r\n\r\n\tgo func() {\r\n\t\ts.wg.Wait()\r\n\t\tclose(done)\r\n\t}()\r\n\r\n\tselect {\r\n\tcase \u003c-done:\r\n\t\treturn nil\r\n\r\n\tcase \u003c-ctx.Done():\r\n\t\treturn ctx.Err()\r\n\t}\r\n}\r","start_line":100,"end_line":241,"total_lines":241,"truncated":false}
{"path": "internal/forgejo"}
drwxrwxr-x 2 1000 1000 4096 Aug 24 08:41 . drwxrwxr-x 11 1000 1000 4096 Aug 24 08:41 .. -rw-rw-r-- 1 1000 1000 9787 Aug 24 08:41 client.go -rw-rw-r-- 1 1000 1000 3356 Aug 24 08:41 client_test.go -rw-rw-r-- 1 1000 1000 1117 Aug 24 08:41 event.go -rw-rw-r-- 1 1000 1000 5921 Aug 24 08:41 poll.go -rw-rw-r-- 1 1000 1000 1784 Aug 24 08:41 watcher.go -rw-rw-r-- 1 1000 1000 6040 Aug 24 08:41 webhook.go -rw-rw-r-- 1 1000 1000 4922 Aug 24 08:41 webhook_test.go
{"end_line": 100, "path": "internal/forgejo/webhook.go", "start_line": 1}
{"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","start_line":1,"end_line":100,"total_lines":237,"truncated":true}
{"end_line": 50, "path": "internal/forgejo/event.go", "start_line": 1}
{"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}
{"agent": "greg"}
Assigned to greg
{"body": "Assigned this to **greg** (Senior Developer & Architect) since this feature requires architectural decisions across multiple packages:\n\n- Adding repository filtering configuration (`zoo.hcl`)\n- Wildcard matching logic (`organisation/*`, `*`)\n- Filtering in the webhook handler, poller, or both\n\nGreg can design the proper solution for how and where to apply the repository filter."}
Comment posted