feat: add GitHub and GitLab webhook support (#170)
All checks were successful
Check / check (push) Successful in 3m17s
All checks were successful
Check / check (push) Successful in 3m17s
## 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: #170 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #170.
This commit is contained in:
248
internal/service/webhook/payloads.go
Normal file
248
internal/service/webhook/payloads.go
Normal file
@@ -0,0 +1,248 @@
|
||||
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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user