tatara-operator M2 (webhook server: push -> main-filtered incremental re-ingest) Implementation Plan¶
For agentic workers: execute tasks top to bottom. Each task is one TDD cycle: write the failing test (full code), run it and SEE it fail, write the minimal implementation (full code), run it and SEE it pass, then commit. Do not skip the "see it fail" step. Do not batch tasks. Implementation subagents run sonnet (rule 7); plan/review/merge run opus. All work happens in a worktree off
main(rule 10); never build or deploy from the worktree. Use the superpowers skills:test-driven-developmentper task,requesting-code-reviewbefore the final merge,verification-before-completionbefore claiming done.
Goal: Stand up the operator's webhook server. A POST /operator/webhooks/{project} endpoint on HTTP_ADDR receives GitHub/GitLab events, auto-detects the provider from headers, HMAC-verifies the payload against the Project's webhookSecret, and: on a push to a Repository's defaultBranch sets the tatara.dev/reingest-requested RFC3339 annotation on that Repository (re-using the M1 re-ingest trigger contract); on an issue/mr carrying the Project's triggerLabel, logs + increments a metric + returns 202 as a deliberate stub that M5 completes (no Task creation in M2). Unknown project or bad signature returns 404/401 with no CRD mutation. Every event increments operator_webhook_events_total{provider,kind,result}.
Architecture: The internal/scm package owns provider-agnostic webhook parsing behind the pinned scm.Client interface; M2 implements only DetectAndVerify for both providers (the OpenChange/Comment methods exist but return errors.New("not implemented"), completed in M5). A registry selects the right scm.Client from request headers. The internal/webhook package wraps that with a chi router that loads the Project + secret via the controller-runtime client, dispatches by event kind, and mutates the matching Repository. The server runs as a separate goroutine in cmd/manager/main.go, sharing the manager's client and HTTP_ADDR listener (the REST API in M3 mounts on the same router under different path prefixes). HTTP plumbing mirrors tatara-chat/internal/httpapi (chi v5 router, httptest table tests, JSON error envelope).
Tech Stack: Go 1.25.x (rule 1; pinned exact minor in go.mod), kubebuilder / controller-runtime, github.com/go-chi/chi/v5, sigs.k8s.io/controller-runtime/pkg/client (+ pkg/client/fake for tests), github.com/prometheus/client_golang, stdlib crypto/hmac/crypto/sha256/crypto/subtle, log/slog, net/http/httptest, github.com/stretchr/testify/require. Metrics come from internal/obs (M0). Config from internal/config (M0).
Assumptions (stated picks, rule: act + note): M0 (internal/obs with the operator_webhook_events_total{provider,kind,result} CounterVec already registered, internal/config with HTTP_ADDR, the four CRD types + deepcopy under api/v1alpha1, cmd/manager/main.go with a controller-runtime manager) and M1 (Repository reconciler honoring the tatara.dev/reingest-requested annotation) are merged on main before M2 starts. The annotation constant is owned by M1; M2 imports it (Task 5 picks the exact symbol). Remote-URL matching normalizes by stripping a trailing .git and a trailing /, lowercasing the host, and comparing case-insensitively (a single helper, unit-tested) - this is the one non-obvious choice and it is pinned in Task 4.
PRECONDITIONS - read before editing (do not re-read once in context, grep for symbols instead, per context economy): - docs/superpowers/specs/2026-06-06-tatara-operator-design.md (Webhook server section). - docs/superpowers/plans/_tatara-operator-shared-contracts.md (PIN SET - scm.Client, WebhookEvent, webhook-server contract, re-ingest trigger contract, metric labels). Obey exactly. - api/v1alpha1/{project_types,repository_types}.go (M0) - confirm field names Project.Spec.ScmSecretRef, Project.Spec.TriggerLabel, Repository.Spec.URL, Repository.Spec.DefaultBranch, Repository.Spec.ProjectRef. - internal/config/config.go (M0) - confirm HTTP_ADDR accessor. - internal/obs/metrics.go (M0) - confirm the operator_webhook_events_total CounterVec accessor and label order {provider,kind,result}. - M1 re-ingest annotation constant (grep reingest-requested under internal/ and api/). - Mirror HTTP/test conventions from ~/Documents/tatara/tatara-chat/internal/httpapi/{router,router_test,errors,middleware}.go (chi router, httptest, table tests, JSON error envelope).
WORKTREE: superpowers:using-git-worktrees -> branch feat/m2-webhook-push off main. All commits land there; merge to tatara-operator main at the end via superpowers:finishing-a-development-branch.
Task 1: scm types + interface + not-implemented stubs¶
Files: internal/scm/scm.go, internal/scm/scm_test.go
- Failing test - assert the
WebhookEventstruct shape and that the github/gitlab stub structs satisfyscm.ClientwithOpenChange/Commentreturning the not-implemented error. Writeinternal/scm/scm_test.goin full:package scm import ( "context" "net/http" "testing" "github.com/stretchr/testify/require" ) func TestClientsSatisfyInterface(t *testing.T) { var _ Client = (*GitHub)(nil) var _ Client = (*GitLab)(nil) } func TestProviders(t *testing.T) { require.Equal(t, "github", (&GitHub{}).Provider()) require.Equal(t, "gitlab", (&GitLab{}).Provider()) } func TestM5MethodsNotImplemented(t *testing.T) { ctx := context.Background() for _, c := range []Client{&GitHub{}, &GitLab{}} { _, err := c.OpenChange(ctx, "https://x/r", "tok", "src", "dst", "t", "b") require.ErrorContains(t, err, "not implemented") require.ErrorContains(t, c.Comment(ctx, "tok", "o/r#1", "b"), "not implemented") } } func TestWebhookEventZeroValue(t *testing.T) { var e WebhookEvent require.Equal(t, "", e.Kind) require.Nil(t, e.Labels) _ = http.Header{} } - Run
go test ./internal/scm/...-> EXPECT FAIL (compile error: undefinedClient,WebhookEvent,GitHub,GitLab). - Minimal impl - write
internal/scm/scm.goin full (interface verbatim from the pin set; stubs for M5 methods):package scm import ( "context" "errors" "net/http" ) // WebhookEvent is the provider-agnostic parse of an inbound SCM webhook. type WebhookEvent struct { Kind string // "push" | "issue" | "mr" | "other" Repo string // remote URL Branch string // for push Labels []string Title string Body string IssueRef string // owner/repo#123 (github) or group/proj!iid (gitlab) URL string } // Client is the per-provider SCM adapter. M2 implements DetectAndVerify; // OpenChange and Comment are implemented in M5. type Client interface { Provider() string DetectAndVerify(h http.Header, payload []byte, secret string) (WebhookEvent, error) OpenChange(ctx context.Context, repoURL, token, sourceBranch, targetBranch, title, body string) (url string, err error) Comment(ctx context.Context, token, issueRef, body string) error } var errNotImplemented = errors.New("not implemented: M5") // GitHub implements Client for GitHub. type GitHub struct{} // GitLab implements Client for GitLab. type GitLab struct{} func (*GitHub) Provider() string { return "github" } func (*GitLab) Provider() string { return "gitlab" } // OpenChange is implemented in M5 (SCM write-back). func (*GitHub) OpenChange(context.Context, string, string, string, string, string, string) (string, error) { return "", errNotImplemented } // Comment is implemented in M5 (SCM write-back). func (*GitHub) Comment(context.Context, string, string, string) error { return errNotImplemented } // OpenChange is implemented in M5 (SCM write-back). func (*GitLab) OpenChange(context.Context, string, string, string, string, string, string) (string, error) { return "", errNotImplemented } // Comment is implemented in M5 (SCM write-back). func (*GitLab) Comment(context.Context, string, string, string) error { return errNotImplemented } - Run
go test ./internal/scm/...-> EXPECT FAIL still (DetectAndVerify not yet declared on the structs, soClientis not satisfied - it is declared in Tasks 2 and 3). To make Task 1 self-contained and green, add temporaryDetectAndVerifyplaceholders now and replace their bodies in Tasks ⅔. Add toscm.go:Then re-run// DetectAndVerify is implemented per provider in github.go / gitlab.go. func (*GitHub) DetectAndVerify(http.Header, []byte, string) (WebhookEvent, error) { return WebhookEvent{}, errNotImplemented } func (*GitLab) DetectAndVerify(http.Header, []byte, string) (WebhookEvent, error) { return WebhookEvent{}, errNotImplemented }go test ./internal/scm/...-> EXPECT PASS. - Note for Tasks ⅔: move each
DetectAndVerifyintogithub.go/gitlab.goand delete the placeholder fromscm.goin the same task (one declaration only; a duplicate method is a compile error - this is the signal the move happened correctly). - Commit:
feat(scm): Client interface, WebhookEvent, M5 not-implemented stubs.
Task 2: github DetectAndVerify (signature + push/issue/PR parsing)¶
Files: internal/scm/github.go, internal/scm/github_test.go
- Failing test - real signed fixtures, HMAC computed in the test. Write
internal/scm/github_test.goin full:package scm import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "net/http" "testing" "github.com/stretchr/testify/require" ) func ghSig(secret string, body []byte) string { m := hmac.New(sha256.New, []byte(secret)) m.Write(body) return "sha256=" + hex.EncodeToString(m.Sum(nil)) } func ghHeader(event, secret string, body []byte) http.Header { h := http.Header{} h.Set("X-GitHub-Event", event) h.Set("X-Hub-Signature-256", ghSig(secret, body)) return h } func TestGitHubDetectAndVerify(t *testing.T) { const secret = "s3cr3t" pushBody := []byte(`{"ref":"refs/heads/main","after":"abc123","repository":{"clone_url":"https://github.com/o/r.git"}}`) issueBody := []byte(`{"action":"opened","issue":{"number":7,"title":"Fix bug","body":"do it","labels":[{"name":"tatara"},{"name":"bug"}],"html_url":"https://github.com/o/r/issues/7"},"repository":{"clone_url":"https://github.com/o/r.git","full_name":"o/r"}}`) prBody := []byte(`{"action":"opened","pull_request":{"number":9,"title":"PR title","body":"pr body","labels":[{"name":"tatara"}],"html_url":"https://github.com/o/r/pull/9"},"repository":{"clone_url":"https://github.com/o/r.git","full_name":"o/r"}}`) tests := []struct { name string event string body []byte want WebhookEvent }{ {"push", "push", pushBody, WebhookEvent{Kind: "push", Repo: "https://github.com/o/r.git", Branch: "main"}}, {"issue", "issues", issueBody, WebhookEvent{Kind: "issue", Repo: "https://github.com/o/r.git", Labels: []string{"tatara", "bug"}, Title: "Fix bug", Body: "do it", IssueRef: "o/r#7", URL: "https://github.com/o/r/issues/7"}}, {"pr", "pull_request", prBody, WebhookEvent{Kind: "mr", Repo: "https://github.com/o/r.git", Labels: []string{"tatara"}, Title: "PR title", Body: "pr body", IssueRef: "o/r#9", URL: "https://github.com/o/r/pull/9"}}, {"other", "ping", []byte(`{}`), WebhookEvent{Kind: "other"}}, } c := &GitHub{} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := c.DetectAndVerify(ghHeader(tt.event, secret, tt.body), tt.body, secret) require.NoError(t, err) require.Equal(t, tt.want, got) }) } } func TestGitHubBadSignature(t *testing.T) { const secret = "s3cr3t" body := []byte(`{"ref":"refs/heads/main","after":"x","repository":{"clone_url":"https://github.com/o/r.git"}}`) h := http.Header{} h.Set("X-GitHub-Event", "push") h.Set("X-Hub-Signature-256", ghSig("wrong", body)) _, err := (&GitHub{}).DetectAndVerify(h, body, secret) require.Error(t, err) } func TestGitHubMissingSignature(t *testing.T) { body := []byte(`{}`) h := http.Header{} h.Set("X-GitHub-Event", "push") _, err := (&GitHub{}).DetectAndVerify(h, body, "s") require.Error(t, err) } - Run
go test ./internal/scm/... -run GitHub-> EXPECT FAIL (DetectAndVerify is the placeholder returning not-implemented). - Minimal impl - move
DetectAndVerifyintointernal/scm/github.goand delete the placeholder fromscm.go. Writeinternal/scm/github.goin full:Delete thepackage scm import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "strings" ) type ghLabel struct { Name string `json:"name"` } type ghWorkItem struct { Number int `json:"number"` Title string `json:"title"` Body string `json:"body"` Labels []ghLabel `json:"labels"` HTMLURL string `json:"html_url"` } type ghPayload struct { Ref string `json:"ref"` After string `json:"after"` Repository struct { CloneURL string `json:"clone_url"` FullName string `json:"full_name"` } `json:"repository"` Issue *ghWorkItem `json:"issue"` PullRequest *ghWorkItem `json:"pull_request"` } // DetectAndVerify verifies the X-Hub-Signature-256 HMAC and parses the payload. func (*GitHub) DetectAndVerify(h http.Header, payload []byte, secret string) (WebhookEvent, error) { if err := verifyGitHubSig(h.Get("X-Hub-Signature-256"), payload, secret); err != nil { return WebhookEvent{}, err } event := h.Get("X-GitHub-Event") var p ghPayload if err := json.Unmarshal(payload, &p); err != nil { return WebhookEvent{}, fmt.Errorf("github: parse payload: %w", err) } switch event { case "push": return WebhookEvent{Kind: "push", Repo: p.Repository.CloneURL, Branch: strings.TrimPrefix(p.Ref, "refs/heads/")}, nil case "issues": return ghWorkItemEvent("issue", p.Repository.FullName, p.Repository.CloneURL, p.Issue), nil case "pull_request": return ghWorkItemEvent("mr", p.Repository.FullName, p.Repository.CloneURL, p.PullRequest), nil default: return WebhookEvent{Kind: "other"}, nil } } func ghWorkItemEvent(kind, fullName, cloneURL string, wi *ghWorkItem) WebhookEvent { if wi == nil { return WebhookEvent{Kind: "other"} } labels := make([]string, 0, len(wi.Labels)) for _, l := range wi.Labels { labels = append(labels, l.Name) } return WebhookEvent{ Kind: kind, Repo: cloneURL, Labels: labels, Title: wi.Title, Body: wi.Body, IssueRef: fmt.Sprintf("%s#%d", fullName, wi.Number), URL: wi.HTMLURL, } } func verifyGitHubSig(header string, payload []byte, secret string) error { if header == "" { return errors.New("github: missing X-Hub-Signature-256") } want, ok := strings.CutPrefix(header, "sha256=") if !ok { return errors.New("github: malformed signature") } m := hmac.New(sha256.New, []byte(secret)) m.Write(payload) got := hex.EncodeToString(m.Sum(nil)) if !hmac.Equal([]byte(got), []byte(want)) { return errors.New("github: signature mismatch") } return nil }func (*GitHub) DetectAndVerify(...)placeholder fromscm.go. - Run
go test ./internal/scm/... -run GitHub-> EXPECT PASS. Rungo test ./internal/scm/...-> EXPECT PASS (Task 1 tests still green). - Commit:
feat(scm): github DetectAndVerify with HMAC-256 verify and push/issue/pr parsing.
Task 3: gitlab DetectAndVerify (token + push/issue/MR parsing)¶
Files: internal/scm/gitlab.go, internal/scm/gitlab_test.go
- Failing test - write
internal/scm/gitlab_test.goin full:package scm import ( "net/http" "testing" "github.com/stretchr/testify/require" ) func glHeader(event, token string) http.Header { h := http.Header{} h.Set("X-Gitlab-Event", event) h.Set("X-Gitlab-Token", token) return h } func TestGitLabDetectAndVerify(t *testing.T) { const secret = "glt0ken" pushBody := []byte(`{"ref":"refs/heads/main","project":{"git_http_url":"https://gitlab.com/g/p.git","path_with_namespace":"g/p"}}`) issueBody := []byte(`{"object_kind":"issue","project":{"git_http_url":"https://gitlab.com/g/p.git","path_with_namespace":"g/p"},"object_attributes":{"iid":12,"title":"An issue","description":"desc","url":"https://gitlab.com/g/p/-/issues/12"},"labels":[{"title":"tatara"},{"title":"ops"}]}`) mrBody := []byte(`{"object_kind":"merge_request","project":{"git_http_url":"https://gitlab.com/g/p.git","path_with_namespace":"g/p"},"object_attributes":{"iid":34,"title":"An MR","description":"mr desc","url":"https://gitlab.com/g/p/-/merge_requests/34"},"labels":[{"title":"tatara"}]}`) tests := []struct { name string event string body []byte want WebhookEvent }{ {"push", "Push Hook", pushBody, WebhookEvent{Kind: "push", Repo: "https://gitlab.com/g/p.git", Branch: "main"}}, {"issue", "Issue Hook", issueBody, WebhookEvent{Kind: "issue", Repo: "https://gitlab.com/g/p.git", Labels: []string{"tatara", "ops"}, Title: "An issue", Body: "desc", IssueRef: "g/p!12", URL: "https://gitlab.com/g/p/-/issues/12"}}, {"mr", "Merge Request Hook", mrBody, WebhookEvent{Kind: "mr", Repo: "https://gitlab.com/g/p.git", Labels: []string{"tatara"}, Title: "An MR", Body: "mr desc", IssueRef: "g/p!34", URL: "https://gitlab.com/g/p/-/merge_requests/34"}}, {"other", "Pipeline Hook", []byte(`{}`), WebhookEvent{Kind: "other"}}, } c := &GitLab{} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := c.DetectAndVerify(glHeader(tt.event, secret), tt.body, secret) require.NoError(t, err) require.Equal(t, tt.want, got) }) } } func TestGitLabBadToken(t *testing.T) { body := []byte(`{"ref":"refs/heads/main","project":{"git_http_url":"https://gitlab.com/g/p.git"}}`) _, err := (&GitLab{}).DetectAndVerify(glHeader("Push Hook", "wrong"), body, "glt0ken") require.Error(t, err) } func TestGitLabMissingToken(t *testing.T) { h := http.Header{} h.Set("X-Gitlab-Event", "Push Hook") _, err := (&GitLab{}).DetectAndVerify(h, []byte(`{}`), "glt0ken") require.Error(t, err) } - Run
go test ./internal/scm/... -run GitLab-> EXPECT FAIL (placeholder). - Minimal impl - move
DetectAndVerifyintointernal/scm/gitlab.goand delete the placeholder fromscm.go. Writeinternal/scm/gitlab.goin full:Delete thepackage scm import ( "crypto/subtle" "encoding/json" "errors" "fmt" "net/http" ) type glLabel struct { Title string `json:"title"` } type glPayload struct { ObjectKind string `json:"object_kind"` Ref string `json:"ref"` Project struct { GitHTTPURL string `json:"git_http_url"` PathWithNamespace string `json:"path_with_namespace"` } `json:"project"` ObjectAttributes struct { IID int `json:"iid"` Title string `json:"title"` Description string `json:"description"` URL string `json:"url"` } `json:"object_attributes"` Labels []glLabel `json:"labels"` } // DetectAndVerify verifies the X-Gitlab-Token and parses the payload. func (*GitLab) DetectAndVerify(h http.Header, payload []byte, secret string) (WebhookEvent, error) { token := h.Get("X-Gitlab-Token") if token == "" { return WebhookEvent{}, errors.New("gitlab: missing X-Gitlab-Token") } if subtle.ConstantTimeCompare([]byte(token), []byte(secret)) != 1 { return WebhookEvent{}, errors.New("gitlab: token mismatch") } var p glPayload if err := json.Unmarshal(payload, &p); err != nil { return WebhookEvent{}, fmt.Errorf("gitlab: parse payload: %w", err) } switch h.Get("X-Gitlab-Event") { case "Push Hook": return WebhookEvent{Kind: "push", Repo: p.Project.GitHTTPURL, Branch: trimGitLabRef(p.Ref)}, nil case "Issue Hook": return glWorkItemEvent("issue", p), nil case "Merge Request Hook": return glWorkItemEvent("mr", p), nil default: return WebhookEvent{Kind: "other"}, nil } } func trimGitLabRef(ref string) string { const prefix = "refs/heads/" if len(ref) > len(prefix) && ref[:len(prefix)] == prefix { return ref[len(prefix):] } return ref } func glWorkItemEvent(kind string, p glPayload) WebhookEvent { labels := make([]string, 0, len(p.Labels)) for _, l := range p.Labels { labels = append(labels, l.Title) } return WebhookEvent{ Kind: kind, Repo: p.Project.GitHTTPURL, Labels: labels, Title: p.ObjectAttributes.Title, Body: p.ObjectAttributes.Description, IssueRef: fmt.Sprintf("%s!%d", p.Project.PathWithNamespace, p.ObjectAttributes.IID), URL: p.ObjectAttributes.URL, } }func (*GitLab) DetectAndVerify(...)placeholder fromscm.go. - Run
go test ./internal/scm/... -run GitLab-> EXPECT PASS. Rungo test ./internal/scm/...-> EXPECT PASS. - Commit:
feat(scm): gitlab DetectAndVerify with token verify and push/issue/mr parsing.
Task 4: registry (provider selection by header) + remote-URL match helper¶
Files: internal/scm/registry.go, internal/scm/registry_test.go
- Failing test - write
internal/scm/registry_test.goin full:package scm import ( "net/http" "testing" "github.com/stretchr/testify/require" ) func TestSelectByHeader(t *testing.T) { gh := http.Header{} gh.Set("X-GitHub-Event", "push") c, err := Select(gh) require.NoError(t, err) require.Equal(t, "github", c.Provider()) gl := http.Header{} gl.Set("X-Gitlab-Event", "Push Hook") c, err = Select(gl) require.NoError(t, err) require.Equal(t, "gitlab", c.Provider()) _, err = Select(http.Header{}) require.Error(t, err) } func TestSameRemote(t *testing.T) { tests := []struct { a, b string want bool }{ {"https://github.com/o/r.git", "https://github.com/o/r", true}, {"https://github.com/o/r/", "https://github.com/o/r.git", true}, {"https://GitHub.com/o/r.git", "https://github.com/o/r", true}, {"https://github.com/o/r.git", "https://github.com/o/other.git", false}, {"https://gitlab.com/g/p.git", "https://gitlab.com/g/p", true}, } for _, tt := range tests { require.Equal(t, tt.want, SameRemote(tt.a, tt.b), "%s vs %s", tt.a, tt.b) } } - Run
go test ./internal/scm/... -run "TestSelectByHeader|TestSameRemote"-> EXPECT FAIL (undefinedSelect,SameRemote). - Minimal impl - write
internal/scm/registry.goin full:package scm import ( "errors" "net/http" "net/url" "strings" ) // Select returns the Client for the provider indicated by request headers. func Select(h http.Header) (Client, error) { switch { case h.Get("X-GitHub-Event") != "": return &GitHub{}, nil case h.Get("X-Gitlab-Event") != "": return &GitLab{}, nil default: return nil, errors.New("scm: unrecognized provider headers") } } // SameRemote reports whether two git remote URLs refer to the same repository, // ignoring a trailing .git or /, and lowercasing the host. func SameRemote(a, b string) bool { na, ok1 := normalizeRemote(a) nb, ok2 := normalizeRemote(b) if !ok1 || !ok2 { return false } return na == nb } func normalizeRemote(raw string) (string, bool) { u, err := url.Parse(raw) if err != nil { return "", false } path := strings.TrimSuffix(u.Path, "/") path = strings.TrimSuffix(path, ".git") return strings.ToLower(u.Host) + path, true } - Run
go test ./internal/scm/...-> EXPECT PASS. - Commit:
feat(scm): provider registry Select + SameRemote URL matcher.
Task 5: webhook server (handler: load Project, verify, dispatch)¶
Files: internal/webhook/server.go, internal/webhook/server_test.go
This is the integration core. The handler depends on a controller-runtime client (fake in tests), the obs webhook counter, and internal/scm. It reads the M1 re-ingest annotation constant - grep reingest-requested in the repo and import the existing constant; do NOT redefine it. If M1 did not export one, the boy-scout fix (rule 3) is to add const ReingestRequestedAnnotation = "tatara.dev/reingest-requested" to api/v1alpha1 and use it from both M1 and here; note that in MEMORY.md.
- Failing test - signed payloads built in the test, fake client seeded with Project + Repository, assertions on annotation mutation and status codes. Write
internal/webhook/server_test.goin full:package webhook_test import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" tatarav1 "github.com/szymonrychu/tatara-operator/api/v1alpha1" "github.com/szymonrychu/tatara-operator/internal/webhook" ) const ns = "tatara" func ghSign(secret string, body []byte) string { m := hmac.New(sha256.New, []byte(secret)) m.Write(body) return "sha256=" + hex.EncodeToString(m.Sum(nil)) } func newScheme(t *testing.T) *runtime.Scheme { s := runtime.NewScheme() require.NoError(t, corev1.AddToScheme(s)) require.NoError(t, tatarav1.AddToScheme(s)) return s } func seedClient(t *testing.T, objs ...client.Object) client.Client { return fake.NewClientBuilder().WithScheme(newScheme(t)).WithObjects(objs...).Build() } func project(name, secretRef, trigger string) *tatarav1.Project { return &tatarav1.Project{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, Spec: tatarav1.ProjectSpec{ScmSecretRef: secretRef, TriggerLabel: trigger}, } } func secret(name, webhookSecret string) *corev1.Secret { return &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, Data: map[string][]byte{"webhookSecret": []byte(webhookSecret), "token": []byte("pat")}, } } func repository(name, projectRef, url, branch string) *tatarav1.Repository { return &tatarav1.Repository{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, Spec: tatarav1.RepositorySpec{ProjectRef: projectRef, URL: url, DefaultBranch: branch}, } } func newServer(t *testing.T, c client.Client) (http.Handler, *prometheus.Registry) { reg := prometheus.NewRegistry() srv := webhook.NewServer(webhook.Config{ Client: c, Namespace: ns, Metrics: obs.NewMetrics(reg), }) return srv.Handler(), reg } func post(t *testing.T, h http.Handler, project string, hdr http.Header, body []byte) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/operator/webhooks/"+project, strings.NewReader(string(body))) for k, vs := range hdr { for _, v := range vs { req.Header.Add(k, v) } } w := httptest.NewRecorder() h.ServeHTTP(w, req) return w } func TestPushSetsReingestAnnotation(t *testing.T) { const secretVal = "whsec" c := seedClient(t, project("proj1", "proj1-scm", "tatara"), secret("proj1-scm", secretVal), repository("repo1", "proj1", "https://github.com/o/r.git", "main"), ) h, reg := newServer(t, c) body := []byte(`{"ref":"refs/heads/main","after":"sha1","repository":{"clone_url":"https://github.com/o/r.git"}}`) hdr := http.Header{} hdr.Set("X-GitHub-Event", "push") hdr.Set("X-Hub-Signature-256", ghSign(secretVal, body)) w := post(t, h, "proj1", hdr, body) require.Equal(t, http.StatusAccepted, w.Code) var got tatarav1.Repository require.NoError(t, c.Get(context.Background(), client.ObjectKey{Namespace: ns, Name: "repo1"}, &got)) ts := got.Annotations[tatarav1.ReingestRequestedAnnotation] require.NotEmpty(t, ts) _, err := time.Parse(time.RFC3339, ts) require.NoError(t, err) require.Equal(t, 1.0, counterValue(t, reg, "operator_webhook_events_total", map[string]string{"provider": "github", "kind": "push", "result": "accepted"})) } func TestPushNonDefaultBranchNoMutation(t *testing.T) { const secretVal = "whsec" c := seedClient(t, project("proj1", "proj1-scm", "tatara"), secret("proj1-scm", secretVal), repository("repo1", "proj1", "https://github.com/o/r.git", "main"), ) h, _ := newServer(t, c) body := []byte(`{"ref":"refs/heads/feature","after":"sha2","repository":{"clone_url":"https://github.com/o/r.git"}}`) hdr := http.Header{} hdr.Set("X-GitHub-Event", "push") hdr.Set("X-Hub-Signature-256", ghSign(secretVal, body)) w := post(t, h, "proj1", hdr, body) require.Equal(t, http.StatusAccepted, w.Code) var got tatarav1.Repository require.NoError(t, c.Get(context.Background(), client.ObjectKey{Namespace: ns, Name: "repo1"}, &got)) require.Empty(t, got.Annotations[tatarav1.ReingestRequestedAnnotation]) } func TestIssueWithTriggerLabelStubbed(t *testing.T) { const secretVal = "whsec" c := seedClient(t, project("proj1", "proj1-scm", "tatara"), secret("proj1-scm", secretVal), repository("repo1", "proj1", "https://github.com/o/r.git", "main"), ) h, reg := newServer(t, c) body := []byte(`{"action":"opened","issue":{"number":7,"title":"t","body":"b","labels":[{"name":"tatara"}],"html_url":"https://github.com/o/r/issues/7"},"repository":{"clone_url":"https://github.com/o/r.git","full_name":"o/r"}}`) hdr := http.Header{} hdr.Set("X-GitHub-Event", "issues") hdr.Set("X-Hub-Signature-256", ghSign(secretVal, body)) w := post(t, h, "proj1", hdr, body) require.Equal(t, http.StatusAccepted, w.Code) // M2 stub: no Task created. Verify no Task objects exist. var tasks tatarav1.TaskList require.NoError(t, c.List(context.Background(), &tasks, client.InNamespace(ns))) require.Empty(t, tasks.Items) require.Equal(t, 1.0, counterValue(t, reg, "operator_webhook_events_total", map[string]string{"provider": "github", "kind": "issue", "result": "accepted"})) } func TestUnknownProject404(t *testing.T) { c := seedClient(t) h, reg := newServer(t, c) body := []byte(`{"ref":"refs/heads/main","after":"x","repository":{"clone_url":"https://github.com/o/r.git"}}`) hdr := http.Header{} hdr.Set("X-GitHub-Event", "push") hdr.Set("X-Hub-Signature-256", ghSign("whatever", body)) w := post(t, h, "ghost", hdr, body) require.Equal(t, http.StatusNotFound, w.Code) require.Equal(t, 1.0, counterValue(t, reg, "operator_webhook_events_total", map[string]string{"provider": "github", "kind": "other", "result": "unknown_project"})) } func TestBadSignature401NoMutation(t *testing.T) { const secretVal = "whsec" c := seedClient(t, project("proj1", "proj1-scm", "tatara"), secret("proj1-scm", secretVal), repository("repo1", "proj1", "https://github.com/o/r.git", "main"), ) h, reg := newServer(t, c) body := []byte(`{"ref":"refs/heads/main","after":"x","repository":{"clone_url":"https://github.com/o/r.git"}}`) hdr := http.Header{} hdr.Set("X-GitHub-Event", "push") hdr.Set("X-Hub-Signature-256", ghSign("wrong", body)) w := post(t, h, "proj1", hdr, body) require.Equal(t, http.StatusUnauthorized, w.Code) var got tatarav1.Repository require.NoError(t, c.Get(context.Background(), client.ObjectKey{Namespace: ns, Name: "repo1"}, &got)) require.Empty(t, got.Annotations[tatarav1.ReingestRequestedAnnotation]) require.Equal(t, 1.0, counterValue(t, reg, "operator_webhook_events_total", map[string]string{"provider": "github", "kind": "other", "result": "bad_signature"})) } - Add the missing test imports at the top of the file (the writer of this test added them while iterating; the final import block must include):
"context","k8s.io/apimachinery/pkg/runtime","github.com/szymonrychu/tatara-operator/internal/obs", plus acounterValuehelper. Add the helper tointernal/webhook/server_test.go:func counterValue(t *testing.T, reg *prometheus.Registry, name string, labels map[string]string) float64 { mfs, err := reg.Gather() require.NoError(t, err) for _, mf := range mfs { if mf.GetName() != name { continue } for _, m := range mf.GetMetric() { match := true for _, lp := range m.GetLabel() { if want, ok := labels[lp.GetName()]; ok && want != lp.GetValue() { match = false } } if match && len(m.GetLabel()) == len(labels) { return m.GetCounter().GetValue() } } } return 0 } - Run
go test ./internal/webhook/...-> EXPECT FAIL (undefinedwebhook.NewServer,webhook.Config,obs.NewMetrics/its webhook counter accessor - confirm the M0 obs API name and adjust the test'sMetrics:field andcounterValuemetric name to match the registered CounterVec). Resolve any obs API mismatch in the test FIRST (the metric nameoperator_webhook_events_totaland labels{provider,kind,result}are pinned and must not change). - Minimal impl - write
internal/webhook/server.goin full. The handler flow: parse{project}->GetProject (404 if missing) ->GetthescmSecretRefSecret + readwebhookSecret->scm.Select(headers)(other/400 if unrecognized) ->DetectAndVerify(401 on error) -> dispatch onev.Kind. Forpush: list Repositories in the namespace whoseSpec.ProjectRef == project, find one whereSameRemote(repo.Spec.URL, ev.Repo)ANDev.Branch == repo.Spec.DefaultBranch; if found, set the annotation totime.Now().UTC().Format(time.RFC3339)andUpdate. Forissue/mr: ifev.Labelscontainsproject.Spec.TriggerLabel, log + count (M5 will create the Task here - explicit stub); else count as ignored. Always increment the counter with the final result label. Return 202 for all accepted paths.package webhook import ( "errors" "log/slog" "net/http" "slices" "time" "github.com/go-chi/chi/v5" apierrors "k8s.io/apimachinery/pkg/api/errors" corev1 "k8s.io/api/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" tatarav1 "github.com/szymonrychu/tatara-operator/api/v1alpha1" "github.com/szymonrychu/tatara-operator/internal/obs" "github.com/szymonrychu/tatara-operator/internal/scm" ) // Config holds webhook server dependencies. type Config struct { Client client.Client Namespace string Metrics *obs.Metrics Logger *slog.Logger } // Server serves the SCM webhook endpoint. type Server struct { cfg Config log *slog.Logger } // NewServer constructs a webhook Server. func NewServer(cfg Config) *Server { if cfg.Logger == nil { cfg.Logger = slog.Default() } return &Server{cfg: cfg, log: cfg.Logger} } // Handler returns the chi router mounting the webhook endpoint. func (s *Server) Handler() http.Handler { r := chi.NewRouter() r.Post("/operator/webhooks/{project}", s.handle) return r } func (s *Server) handle(w http.ResponseWriter, r *http.Request) { ctx := r.Context() projectName := chi.URLParam(r, "project") body, err := readBody(r) if err != nil { s.count("unknown", "other", "bad_request") http.Error(w, "read body", http.StatusBadRequest) return } client, err := scm.Select(r.Header) if err != nil { s.count("unknown", "other", "bad_request") http.Error(w, "unrecognized provider", http.StatusBadRequest) return } provider := client.Provider() var proj tatarav1.Project if err := s.cfg.Client.Get(ctx, key(s.cfg.Namespace, projectName), &proj); err != nil { if apierrors.IsNotFound(err) { s.count(provider, "other", "unknown_project") http.Error(w, "unknown project", http.StatusNotFound) return } s.count(provider, "other", "error") http.Error(w, "lookup project", http.StatusInternalServerError) return } secret, err := s.webhookSecret(ctx, proj.Spec.ScmSecretRef) if err != nil { s.count(provider, "other", "error") http.Error(w, "secret", http.StatusInternalServerError) return } ev, err := client.DetectAndVerify(r.Header, body, secret) if err != nil { s.count(provider, "other", "bad_signature") http.Error(w, "verification failed", http.StatusUnauthorized) return } switch ev.Kind { case "push": s.handlePush(ctx, w, provider, projectName, ev) case "issue", "mr": s.handleWorkItem(w, provider, proj, ev) default: s.count(provider, "other", "ignored") w.WriteHeader(http.StatusAccepted) } } func (s *Server) handlePush(ctx context.Context, w http.ResponseWriter, provider, projectName string, ev scm.WebhookEvent) { var repos tatarav1.RepositoryList if err := s.cfg.Client.List(ctx, &repos, client.InNamespace(s.cfg.Namespace)); err != nil { s.count(provider, "push", "error") http.Error(w, "list repositories", http.StatusInternalServerError) return } for i := range repos.Items { repo := &repos.Items[i] if repo.Spec.ProjectRef != projectName { continue } if !scm.SameRemote(repo.Spec.URL, ev.Repo) || ev.Branch != repo.Spec.DefaultBranch { continue } if repo.Annotations == nil { repo.Annotations = map[string]string{} } repo.Annotations[tatarav1.ReingestRequestedAnnotation] = time.Now().UTC().Format(time.RFC3339) if err := s.cfg.Client.Update(ctx, repo); err != nil { s.count(provider, "push", "error") http.Error(w, "annotate repository", http.StatusInternalServerError) return } s.log.InfoContext(ctx, "webhook push re-ingest requested", "provider", provider, "project", projectName, "repository", repo.Name, "branch", ev.Branch) s.count(provider, "push", "accepted") w.WriteHeader(http.StatusAccepted) return } s.count(provider, "push", "ignored") w.WriteHeader(http.StatusAccepted) } func (s *Server) handleWorkItem(w http.ResponseWriter, provider string, proj tatarav1.Project, ev scm.WebhookEvent) { if !slices.Contains(ev.Labels, proj.Spec.TriggerLabel) { s.count(provider, ev.Kind, "ignored") w.WriteHeader(http.StatusAccepted) return } // M2 stub: a labeled work item is accepted and logged, but Task creation // is deliberately NOT performed here. M5 (work-item -> Task) completes this // branch by creating a Task with goal=ev.Body and source from ev. s.log.Info("webhook work item with trigger label (M2 stub, Task creation in M5)", "provider", provider, "project", proj.Name, "kind", ev.Kind, "issue_ref", ev.IssueRef) s.count(provider, ev.Kind, "accepted") w.WriteHeader(http.StatusAccepted) } func (s *Server) webhookSecret(ctx context.Context, ref string) (string, error) { var sec corev1.Secret if err := s.cfg.Client.Get(ctx, key(s.cfg.Namespace, ref), &sec); err != nil { return "", err } v, ok := sec.Data["webhookSecret"] if !ok { return "", errors.New("secret missing webhookSecret key") } return string(v), nil } func (s *Server) count(provider, kind, result string) { s.cfg.Metrics.WebhookEvents.WithLabelValues(provider, kind, result).Inc() } func key(ns, name string) client.ObjectKey { return client.ObjectKey{Namespace: ns, Name: name} } - Add the remaining imports the impl needs (
"context","io"forreadBody) and a smallreadBodyhelper inserver.go: - Reconcile the obs accessor: the impl uses
s.cfg.Metrics.WebhookEvents(a*prometheus.CounterVec). Confirm M0'sobs.Metricsexposes the webhook counter as a field; if the field name differs, use M0's actual name in bothserver.goand the test (do not rename the metric or its labels). If M0 exposes only a registry + private metrics, the boy-scout fix is to export the field; note it inMEMORY.md. - Run
go test ./internal/webhook/...-> EXPECT PASS (all six cases). Rungo test ./internal/scm/...-> EXPECT PASS. - Commit:
feat(webhook): server handler - verify, push annotation, work-item M5 stub, metrics.
Task 6: wire webhook server into cmd/manager/main.go¶
Files: cmd/manager/main.go (modify), internal/webhook/server_run_test.go
The manager already builds a controller-runtime manager.Manager. Add the webhook HTTP server as a goroutine bound to HTTP_ADDR, sharing mgr.GetClient(). controller-runtime exposes mgr.Add(manager.Runnable); prefer registering the HTTP server as a Runnable so it shares the manager's lifecycle and graceful shutdown rather than a raw go (cleaner, no leaked goroutine on shutdown - this is the pick).
- Failing test - assert that
NewServer(...).Handler()returns a non-nil handler and that aRunnablewrapper starts/stops on context cancel. Writeinternal/webhook/server_run_test.goin full:package webhook_test import ( "context" "net/http" "net/http/httptest" "testing" "time" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" "github.com/szymonrychu/tatara-operator/internal/obs" "github.com/szymonrychu/tatara-operator/internal/webhook" ) func TestRunnableStartStop(t *testing.T) { c := seedClient(t) srv := webhook.NewServer(webhook.Config{Client: c, Namespace: ns, Metrics: obs.NewMetrics(prometheus.NewRegistry())}) r := webhook.NewRunnable(srv, "127.0.0.1:0") ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done <- r.Start(ctx) }() time.Sleep(50 * time.Millisecond) cancel() select { case err := <-done: require.NoError(t, err) case <-time.After(2 * time.Second): t.Fatal("runnable did not stop on context cancel") } // sanity: handler still serves w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/operator/webhooks/x", nil)) require.Equal(t, http.StatusMethodNotAllowed, w.Code) } - Run
go test ./internal/webhook/... -run TestRunnableStartStop-> EXPECT FAIL (undefinedwebhook.NewRunnable). - Minimal impl - add a
Runnabletointernal/webhook/server.go(or a newinternal/webhook/runnable.go) implementing controller-runtime'smanager.Runnable(Start(ctx) error). It starts anhttp.ServerwithHandler()on the given addr and shuts down onctx.Done():// Runnable adapts the webhook Server to controller-runtime's manager.Runnable. type Runnable struct { srv *Server addr string } // NewRunnable wraps a Server so it can be registered with mgr.Add. func NewRunnable(srv *Server, addr string) *Runnable { return &Runnable{srv: srv, addr: addr} } // Start serves HTTP until ctx is cancelled, then gracefully shuts down. func (r *Runnable) Start(ctx context.Context) error { httpSrv := &http.Server{Addr: r.addr, Handler: r.srv.Handler(), ReadHeaderTimeout: 10 * time.Second} errCh := make(chan error, 1) go func() { if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err return } errCh <- nil }() select { case <-ctx.Done(): shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() return httpSrv.Shutdown(shutdownCtx) case err := <-errCh: return err } } - Run
go test ./internal/webhook/... -run TestRunnableStartStop-> EXPECT PASS. - Modify
cmd/manager/main.go: after the manager is built and CRD schemes are registered, construct the metrics (reuse the obs metrics already created in M0 - share the same*obs.Metricswith the reconcilers), buildwebhook.NewServer(webhook.Config{Client: mgr.GetClient(), Namespace: cfg.Namespace, Metrics: metrics, Logger: logger}), and registermgr.Add(webhook.NewRunnable(srv, cfg.HTTPAddr)). Use the actual M0 config accessor forHTTP_ADDRand the namespace. Show only the added lines as a diff in the PR (the file already exists; rule: diffs not whole files). Do NOT duplicate the obs registry; the/metricsendpoint stays owned by the manager's metrics server (M0/M6). - Run
go build ./...-> EXPECT PASS. Rungo vet ./...-> clean. - Commit:
feat(cmd): run webhook server as a manager runnable on HTTP_ADDR.
Task 7: full verification + merge¶
Files: none new (verification + merge only)
-
superpowers:verification-before-completion. Run the full suite and lint from the worktree: go build ./...-> clean.go test ./... -race -count=1-> all green (especially./internal/scm/...and./internal/webhook/...).golangci-lint run ./...-> clean (errors wrapped with%wwhere created, no unused, JSON slog only per rule 11).gofmt -l .-> empty.- Confirm the pinned contract is honored:
scm.Clientsignature matches the pin set byte-for-byte; metric isoperator_webhook_events_total{provider,kind,result}; annotation key istatara.dev/reingest-requested; path isPOST /operator/webhooks/{project}onHTTP_ADDR; bad signature -> 401, unknown project -> 404, both with no CRD mutation; work-item path is a logged + metered 202 stub with NO Task created (M5 completes it). - Update
MEMORY.md(one line, dated 2026-06-06): record any boy-scout exports made (M1 annotation constant, obs webhook-counter field) and the SameRemote normalization rule. UpdateROADMAP.md: mark M2 done, note M5 owns Task creation on the work-item path and thescmwrite half (OpenChange/Comment). -
superpowers:requesting-code-review(opus). Apply critical/high findings, re-rungo test ./... -raceandgolangci-lint run ./..., thenpre-commit run --all-files. -
superpowers:finishing-a-development-branch: mergefeat/m2-webhook-pushintotatara-operatormain, delete the worktree. Do not build or deploy from the worktree (rule 10).