forked from sneak/upaas
## Summary Adds GitHub and GitLab push webhook support alongside the existing Gitea support. closes #68 ## What Changed ### Auto-detection of webhook source The webhook handler now auto-detects which platform sent the webhook by examining HTTP headers: - **Gitea**: `X-Gitea-Event` - **GitHub**: `X-GitHub-Event` - **GitLab**: `X-Gitlab-Event` Existing Gitea webhooks continue to work unchanged. Unknown sources fall back to Gitea format for backward compatibility. ### Normalized push event All three payload formats are parsed into a unified `PushEvent` struct containing: - Source platform, ref, branch, commit SHA - Repository name, clone URL, HTML URL - Commit URL (with per-platform fallback logic) - Pusher username/name ### New files - **`internal/service/webhook/payloads.go`**: Source-specific payload structs (`GiteaPushPayload`, `GitHubPushPayload`, `GitLabPushPayload`), `ParsePushPayload()` dispatcher, per-platform parsers, branch extraction, and commit URL extraction functions. ### Modified files - **`internal/service/webhook/types.go`**: Added `Source` type (gitea/github/gitlab/unknown), `DetectWebhookSource()`, `DetectEventType()`, and `PushEvent` normalized type. Moved `GiteaPushPayload` to payloads.go. - **`internal/service/webhook/webhook.go`**: `HandleWebhook` now accepts a `Source` parameter and uses `ParsePushPayload()` for unified parsing instead of directly unmarshaling Gitea payloads. - **`internal/handlers/webhook.go`**: Calls `DetectWebhookSource()` and `DetectEventType()` to auto-detect the platform before delegating to the webhook service. - **`internal/service/webhook/webhook_test.go`**: Comprehensive tests for source detection, event type extraction, payload parsing (all 3 platforms), commit URL fallback paths, and integration tests through `HandleWebhook` for GitHub and GitLab sources. - **`README.md`**: Updated description, features, non-goals, and architecture to reflect multi-platform webhook support. ## Test coverage Webhook package: **96.9%** statement coverage. Tests cover: - `DetectWebhookSource` with all header combinations and precedence - `DetectEventType` for each platform - `ParsePushPayload` for Gitea, GitHub, GitLab, unknown source, invalid JSON, empty payloads - Commit URL extraction fallback paths for GitHub and GitLab - Direct struct deserialization for all three payload types - Full `HandleWebhook` integration tests with GitHub and GitLab sources Co-authored-by: user <user@Mac.lan guest wan> Reviewed-on: sneak/upaas#170 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
249 lines
6.9 KiB
Go
249 lines
6.9 KiB
Go
package webhook
|
|
|
|
import "encoding/json"
|
|
|
|
// GiteaPushPayload represents a Gitea push webhook payload.
|
|
//
|
|
//nolint:tagliatelle // Field names match Gitea API (snake_case)
|
|
type GiteaPushPayload struct {
|
|
Ref string `json:"ref"`
|
|
Before string `json:"before"`
|
|
After string `json:"after"`
|
|
CompareURL UnparsedURL `json:"compare_url"`
|
|
Repository struct {
|
|
FullName string `json:"full_name"`
|
|
CloneURL UnparsedURL `json:"clone_url"`
|
|
SSHURL string `json:"ssh_url"`
|
|
HTMLURL UnparsedURL `json:"html_url"`
|
|
} `json:"repository"`
|
|
Pusher struct {
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
} `json:"pusher"`
|
|
Commits []struct {
|
|
ID string `json:"id"`
|
|
URL UnparsedURL `json:"url"`
|
|
Message string `json:"message"`
|
|
Author struct {
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
} `json:"author"`
|
|
} `json:"commits"`
|
|
}
|
|
|
|
// GitHubPushPayload represents a GitHub push webhook payload.
|
|
//
|
|
//nolint:tagliatelle // Field names match GitHub API (snake_case)
|
|
type GitHubPushPayload struct {
|
|
Ref string `json:"ref"`
|
|
Before string `json:"before"`
|
|
After string `json:"after"`
|
|
CompareURL string `json:"compare"`
|
|
Repository struct {
|
|
FullName string `json:"full_name"`
|
|
CloneURL UnparsedURL `json:"clone_url"`
|
|
SSHURL string `json:"ssh_url"`
|
|
HTMLURL UnparsedURL `json:"html_url"`
|
|
} `json:"repository"`
|
|
Pusher struct {
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
} `json:"pusher"`
|
|
HeadCommit *struct {
|
|
ID string `json:"id"`
|
|
URL UnparsedURL `json:"url"`
|
|
Message string `json:"message"`
|
|
} `json:"head_commit"`
|
|
Commits []struct {
|
|
ID string `json:"id"`
|
|
URL UnparsedURL `json:"url"`
|
|
Message string `json:"message"`
|
|
Author struct {
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
} `json:"author"`
|
|
} `json:"commits"`
|
|
}
|
|
|
|
// GitLabPushPayload represents a GitLab push webhook payload.
|
|
//
|
|
//nolint:tagliatelle // Field names match GitLab API (snake_case)
|
|
type GitLabPushPayload struct {
|
|
Ref string `json:"ref"`
|
|
Before string `json:"before"`
|
|
After string `json:"after"`
|
|
UserName string `json:"user_name"`
|
|
UserEmail string `json:"user_email"`
|
|
Project struct {
|
|
PathWithNamespace string `json:"path_with_namespace"`
|
|
GitHTTPURL UnparsedURL `json:"git_http_url"`
|
|
GitSSHURL string `json:"git_ssh_url"`
|
|
WebURL UnparsedURL `json:"web_url"`
|
|
} `json:"project"`
|
|
Commits []struct {
|
|
ID string `json:"id"`
|
|
URL UnparsedURL `json:"url"`
|
|
Message string `json:"message"`
|
|
Author struct {
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
} `json:"author"`
|
|
} `json:"commits"`
|
|
}
|
|
|
|
// ParsePushPayload parses a raw webhook payload into a normalized PushEvent
|
|
// based on the detected webhook source. Returns an error if JSON unmarshaling
|
|
// fails. For SourceUnknown, falls back to Gitea format for backward
|
|
// compatibility.
|
|
func ParsePushPayload(source Source, payload []byte) (*PushEvent, error) {
|
|
switch source {
|
|
case SourceGitHub:
|
|
return parseGitHubPush(payload)
|
|
case SourceGitLab:
|
|
return parseGitLabPush(payload)
|
|
case SourceGitea, SourceUnknown:
|
|
// Gitea and unknown both use Gitea format for backward compatibility.
|
|
return parseGiteaPush(payload)
|
|
}
|
|
|
|
// Unreachable for known source values, but satisfies exhaustive checker.
|
|
return parseGiteaPush(payload)
|
|
}
|
|
|
|
func parseGiteaPush(payload []byte) (*PushEvent, error) {
|
|
var p GiteaPushPayload
|
|
|
|
unmarshalErr := json.Unmarshal(payload, &p)
|
|
if unmarshalErr != nil {
|
|
return nil, unmarshalErr
|
|
}
|
|
|
|
commitURL := extractGiteaCommitURL(p)
|
|
|
|
return &PushEvent{
|
|
Source: SourceGitea,
|
|
Ref: p.Ref,
|
|
Before: p.Before,
|
|
After: p.After,
|
|
Branch: extractBranch(p.Ref),
|
|
RepoName: p.Repository.FullName,
|
|
CloneURL: p.Repository.CloneURL,
|
|
HTMLURL: p.Repository.HTMLURL,
|
|
CommitURL: commitURL,
|
|
Pusher: p.Pusher.Username,
|
|
}, nil
|
|
}
|
|
|
|
func parseGitHubPush(payload []byte) (*PushEvent, error) {
|
|
var p GitHubPushPayload
|
|
|
|
unmarshalErr := json.Unmarshal(payload, &p)
|
|
if unmarshalErr != nil {
|
|
return nil, unmarshalErr
|
|
}
|
|
|
|
commitURL := extractGitHubCommitURL(p)
|
|
|
|
return &PushEvent{
|
|
Source: SourceGitHub,
|
|
Ref: p.Ref,
|
|
Before: p.Before,
|
|
After: p.After,
|
|
Branch: extractBranch(p.Ref),
|
|
RepoName: p.Repository.FullName,
|
|
CloneURL: p.Repository.CloneURL,
|
|
HTMLURL: p.Repository.HTMLURL,
|
|
CommitURL: commitURL,
|
|
Pusher: p.Pusher.Name,
|
|
}, nil
|
|
}
|
|
|
|
func parseGitLabPush(payload []byte) (*PushEvent, error) {
|
|
var p GitLabPushPayload
|
|
|
|
unmarshalErr := json.Unmarshal(payload, &p)
|
|
if unmarshalErr != nil {
|
|
return nil, unmarshalErr
|
|
}
|
|
|
|
commitURL := extractGitLabCommitURL(p)
|
|
|
|
return &PushEvent{
|
|
Source: SourceGitLab,
|
|
Ref: p.Ref,
|
|
Before: p.Before,
|
|
After: p.After,
|
|
Branch: extractBranch(p.Ref),
|
|
RepoName: p.Project.PathWithNamespace,
|
|
CloneURL: p.Project.GitHTTPURL,
|
|
HTMLURL: p.Project.WebURL,
|
|
CommitURL: commitURL,
|
|
Pusher: p.UserName,
|
|
}, nil
|
|
}
|
|
|
|
// extractBranch extracts the branch name from a git ref.
|
|
func extractBranch(ref string) string {
|
|
// refs/heads/main -> main
|
|
const prefix = "refs/heads/"
|
|
|
|
if len(ref) >= len(prefix) && ref[:len(prefix)] == prefix {
|
|
return ref[len(prefix):]
|
|
}
|
|
|
|
return ref
|
|
}
|
|
|
|
// extractGiteaCommitURL extracts the commit URL from a Gitea push payload.
|
|
// Prefers the URL from the head commit, falls back to constructing from repo URL.
|
|
func extractGiteaCommitURL(payload GiteaPushPayload) UnparsedURL {
|
|
for _, commit := range payload.Commits {
|
|
if commit.ID == payload.After && commit.URL != "" {
|
|
return commit.URL
|
|
}
|
|
}
|
|
|
|
if payload.Repository.HTMLURL != "" && payload.After != "" {
|
|
return UnparsedURL(payload.Repository.HTMLURL.String() + "/commit/" + payload.After)
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// extractGitHubCommitURL extracts the commit URL from a GitHub push payload.
|
|
// Prefers head_commit.url, then searches commits, then constructs from repo URL.
|
|
func extractGitHubCommitURL(payload GitHubPushPayload) UnparsedURL {
|
|
if payload.HeadCommit != nil && payload.HeadCommit.URL != "" {
|
|
return payload.HeadCommit.URL
|
|
}
|
|
|
|
for _, commit := range payload.Commits {
|
|
if commit.ID == payload.After && commit.URL != "" {
|
|
return commit.URL
|
|
}
|
|
}
|
|
|
|
if payload.Repository.HTMLURL != "" && payload.After != "" {
|
|
return UnparsedURL(payload.Repository.HTMLURL.String() + "/commit/" + payload.After)
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// extractGitLabCommitURL extracts the commit URL from a GitLab push payload.
|
|
// Prefers commit URL from the commits list, falls back to constructing from
|
|
// project web URL.
|
|
func extractGitLabCommitURL(payload GitLabPushPayload) UnparsedURL {
|
|
for _, commit := range payload.Commits {
|
|
if commit.ID == payload.After && commit.URL != "" {
|
|
return commit.URL
|
|
}
|
|
}
|
|
|
|
if payload.Project.WebURL != "" && payload.After != "" {
|
|
return UnparsedURL(payload.Project.WebURL.String() + "/-/commit/" + payload.After)
|
|
}
|
|
|
|
return ""
|
|
}
|