package webhook import ( "encoding/json" "fmt" "net/http" "strings" "github.com/alexei/tinyforge/internal/workload/plugin" ) // Vendor headers we recognize for short-circuit dispatch. The header // presence (not just value) is the trigger — vendors version event names // over time so matching on value alone is brittle. const ( headerGiteaEvent = "X-Gitea-Event" headerGiteaDelivery = "X-Gitea-Delivery" headerGitHubEvent = "X-GitHub-Event" headerGitLabEvent = "X-Gitlab-Event" ) // vendorParseResult bundles the parsed event with an "ok" flag distinct // from the error: ok=false means "this parser doesn't apply to the // request, try the next one"; ok=true with err!=nil means "this parser // applied but the payload was malformed." type vendorParseResult struct { event plugin.InboundEvent ok bool err error } // tryVendorParsers runs each vendor-specific parser in order and returns // the first one that recognizes the payload. ok=false means no vendor // parser claimed the request — caller should fall back to the generic // simple-body parser. func tryVendorParsers(body []byte, headers http.Header) vendorParseResult { for _, fn := range []func([]byte, http.Header) vendorParseResult{ parseGiteaPackageEvent, parseGitHubPackageEvent, parseGitHubPushEvent, parseGiteaPushEvent, parseGitLabPushEvent, } { res := fn(body, headers) if res.ok { return res } } return vendorParseResult{} } // parseGiteaPackageEvent recognizes Gitea container-registry package // pushes. Distinguishing fingerprint: X-Gitea-Event: package and a body // with package.type == "container". // // Reference: https://docs.gitea.com/usage/webhooks // // Body excerpt: // // { // "action": "created", // "package": { // "name": "my-app", // "type": "container", // "version": "v1.2.3", // "owner": {"login": "alexei"}, // "repository": {"full_name": "alexei/my-app"} // } // } func parseGiteaPackageEvent(body []byte, headers http.Header) vendorParseResult { if headers.Get(headerGiteaEvent) != "package" { return vendorParseResult{} } var probe struct { Action string `json:"action"` Package struct { Name string `json:"name"` Type string `json:"type"` Version string `json:"version"` Owner struct { Login string `json:"login"` Username string `json:"username"` } `json:"owner"` Repository struct { FullName string `json:"full_name"` HTMLURL string `json:"html_url"` } `json:"repository"` } `json:"package"` Sender struct { Login string `json:"login"` Username string `json:"username"` } `json:"sender"` } if err := json.Unmarshal(body, &probe); err != nil { return vendorParseResult{ok: true, err: fmt.Errorf("invalid Gitea package JSON")} } if !strings.EqualFold(probe.Package.Type, "container") { // Non-container package (npm, maven, generic, etc.) — Tinyforge // only deploys containers today, so drop these as not-applicable. // Return ok=true so we don't fall through to the simple-body // parser and accidentally pick up the "version" field as a ref. return vendorParseResult{ok: true, err: fmt.Errorf("Gitea package type %q is not a container", probe.Package.Type)} } if probe.Package.Name == "" || probe.Package.Version == "" { return vendorParseResult{ok: true, err: fmt.Errorf("Gitea package event missing name/version")} } owner := probe.Package.Owner.Login if owner == "" { owner = probe.Package.Owner.Username } repo := probe.Package.Name if owner != "" { repo = owner + "/" + probe.Package.Name } registry := registryHostFromGiteaRepoURL(probe.Package.Repository.HTMLURL) evt := plugin.InboundEvent{ Kind: "image-push", Image: &plugin.ImagePushEvent{ Registry: registry, Repo: repo, Tag: probe.Package.Version, }, } return vendorParseResult{event: evt, ok: true} } // registryHostFromGiteaRepoURL extracts the host portion of a Gitea // repository URL. Gitea container registry lives on the same host, so // the repo URL host is a reliable proxy for the registry host. func registryHostFromGiteaRepoURL(repoURL string) string { if repoURL == "" { return "" } // Strip scheme. if i := strings.Index(repoURL, "://"); i >= 0 { repoURL = repoURL[i+3:] } if i := strings.Index(repoURL, "/"); i >= 0 { repoURL = repoURL[:i] } return repoURL } // parseGitHubPackageEvent recognizes GitHub Container Registry (ghcr.io) // publish events. Two event names exist depending on the year the webhook // was registered: "package" and "registry_package". Both share the same // body shape under registry_package. // // Reference: // - https://docs.github.com/en/webhooks/webhook-events-and-payloads#package // - https://docs.github.com/en/webhooks/webhook-events-and-payloads#registry_package // // Body excerpt: // // { // "action": "published", // "registry_package": { // "name": "my-app", // "namespace": "owner", // "package_type": "CONTAINER", // "package_version": { // "container_metadata": { "tag": { "name": "v1.2.3" } }, // "name": "sha256:..." // }, // "registry": { "url": "https://ghcr.io/..." } // } // } func parseGitHubPackageEvent(body []byte, headers http.Header) vendorParseResult { ev := headers.Get(headerGitHubEvent) if ev != "package" && ev != "registry_package" { return vendorParseResult{} } // GitHub sends the body under either "package" or "registry_package" // depending on event name; tolerate both by probing for whichever is // populated. var probe struct { Action string `json:"action"` Package json.RawMessage `json:"package"` RegistryPackage json.RawMessage `json:"registry_package"` } if err := json.Unmarshal(body, &probe); err != nil { return vendorParseResult{ok: true, err: fmt.Errorf("invalid GitHub package JSON")} } raw := probe.RegistryPackage if len(raw) == 0 { raw = probe.Package } if len(raw) == 0 { return vendorParseResult{ok: true, err: fmt.Errorf("GitHub package event missing package body")} } var pkg struct { Name string `json:"name"` Namespace string `json:"namespace"` PackageType string `json:"package_type"` PackageVersion struct { Name string `json:"name"` Version string `json:"version"` ContainerMetadata struct { Tag struct { Name string `json:"name"` } `json:"tag"` } `json:"container_metadata"` } `json:"package_version"` Registry struct { URL string `json:"url"` } `json:"registry"` } if err := json.Unmarshal(raw, &pkg); err != nil { return vendorParseResult{ok: true, err: fmt.Errorf("invalid GitHub package payload")} } if !strings.EqualFold(pkg.PackageType, "CONTAINER") && !strings.EqualFold(pkg.PackageType, "container") { return vendorParseResult{ok: true, err: fmt.Errorf("GitHub package type %q is not a container", pkg.PackageType)} } tag := pkg.PackageVersion.ContainerMetadata.Tag.Name digest := "" // PackageVersion.Name is "sha256:..." for container versions. if strings.HasPrefix(pkg.PackageVersion.Name, "sha256:") { digest = pkg.PackageVersion.Name } if tag == "" && digest == "" { return vendorParseResult{ok: true, err: fmt.Errorf("GitHub package event missing tag and digest")} } repo := pkg.Name if pkg.Namespace != "" { repo = pkg.Namespace + "/" + pkg.Name } evt := plugin.InboundEvent{ Kind: "image-push", Image: &plugin.ImagePushEvent{ Registry: registryHostFromGitHubRegistry(pkg.Registry.URL), Repo: repo, Tag: tag, Digest: digest, }, } return vendorParseResult{event: evt, ok: true} } // registryHostFromGitHubRegistry pulls the host from a GitHub registry // URL ("https://ghcr.io/..." → "ghcr.io"). Defaults to "ghcr.io" since // that's the only host GitHub uses for container packages today. func registryHostFromGitHubRegistry(rawURL string) string { host := registryHostFromGiteaRepoURL(rawURL) // same scheme/host stripping if host == "" { return "ghcr.io" } return host } // parseGitHubPushEvent handles the standard GitHub push event with a // vendor annotation. The generic parser already covers the body shape // (ref / after / repository.full_name / pusher.name) — this exists so // the resulting GitEvent.Vendor is populated and trigger plugins can // distinguish GitHub from Gitea without re-parsing headers. func parseGitHubPushEvent(body []byte, headers http.Header) vendorParseResult { if headers.Get(headerGitHubEvent) != "push" { return vendorParseResult{} } evt, err := parseGenericGitPush(body) if err != nil { return vendorParseResult{ok: true, err: err} } if evt.Git != nil { evt.Git.Vendor = "github" } return vendorParseResult{event: evt, ok: true} } // parseGiteaPushEvent — same idea, for Gitea. Gitea push events share // shape with GitHub's (Gitea explicitly modeled them). func parseGiteaPushEvent(body []byte, headers http.Header) vendorParseResult { if headers.Get(headerGiteaEvent) != "push" { return vendorParseResult{} } evt, err := parseGenericGitPush(body) if err != nil { return vendorParseResult{ok: true, err: err} } if evt.Git != nil { evt.Git.Vendor = "gitea" } return vendorParseResult{event: evt, ok: true} } // parseGitLabPushEvent handles GitLab's push event, which uses a // different body shape than GitHub/Gitea: // // { // "ref": "refs/heads/main", // "after": "abc123", // "user_username": "alice", // "project": { "path_with_namespace": "owner/repo" } // } // // Reference: https://docs.gitlab.com/user/project/integrations/webhook_events/ func parseGitLabPushEvent(body []byte, headers http.Header) vendorParseResult { ev := headers.Get(headerGitLabEvent) if ev != "Push Hook" && ev != "Tag Push Hook" { return vendorParseResult{} } var probe struct { Ref string `json:"ref"` After string `json:"after"` UserName string `json:"user_username"` UserNameAlt string `json:"user_name"` Project struct { PathWithNamespace string `json:"path_with_namespace"` GitHTTPURL string `json:"git_http_url"` } `json:"project"` } if err := json.Unmarshal(body, &probe); err != nil { return vendorParseResult{ok: true, err: fmt.Errorf("invalid GitLab push JSON")} } if probe.Ref == "" { return vendorParseResult{ok: true, err: fmt.Errorf("GitLab push event missing ref")} } pusher := probe.UserName if pusher == "" { pusher = probe.UserNameAlt } evt := plugin.InboundEvent{ Kind: "git-push", Git: &plugin.GitEvent{ Vendor: "gitlab", Repo: probe.Project.PathWithNamespace, Ref: probe.Ref, CommitSHA: probe.After, Pusher: pusher, }, } if strings.HasPrefix(probe.Ref, "refs/heads/") { evt.Git.Branch = strings.TrimPrefix(probe.Ref, "refs/heads/") } if strings.HasPrefix(probe.Ref, "refs/tags/") { evt.Git.Tag = strings.TrimPrefix(probe.Ref, "refs/tags/") evt.Kind = "git-tag" } return vendorParseResult{event: evt, ok: true} } // parseGenericGitPush extracts a GitEvent from a GitHub/Gitea-style push // body. Shared by the GitHub and Gitea vendor wrappers and by the // generic fallback in buildInboundEvent. func parseGenericGitPush(body []byte) (plugin.InboundEvent, error) { var probe struct { Ref string `json:"ref"` After string `json:"after"` Repository struct { FullName string `json:"full_name"` CloneURL string `json:"clone_url"` } `json:"repository"` Repo string `json:"repo"` Pusher struct { Name string `json:"name"` Username string `json:"username"` } `json:"pusher"` } if err := json.Unmarshal(body, &probe); err != nil { return plugin.InboundEvent{}, fmt.Errorf("invalid git push JSON") } if probe.Ref == "" { return plugin.InboundEvent{}, fmt.Errorf("git push event missing ref") } repo := probe.Repository.FullName if repo == "" { repo = probe.Repo } pusher := probe.Pusher.Name if pusher == "" { pusher = probe.Pusher.Username } evt := plugin.InboundEvent{ Kind: "git-push", Git: &plugin.GitEvent{ Repo: repo, Ref: probe.Ref, CommitSHA: probe.After, Pusher: pusher, }, } if strings.HasPrefix(probe.Ref, "refs/heads/") { evt.Git.Branch = strings.TrimPrefix(probe.Ref, "refs/heads/") } if strings.HasPrefix(probe.Ref, "refs/tags/") { evt.Git.Tag = strings.TrimPrefix(probe.Ref, "refs/tags/") evt.Kind = "git-tag" } return evt, nil }