blob: 285ea2898da798a995170e73bd18bd3089c5af36 [file] [log] [blame]
package git
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
)
// GitHubWebhook represents the GitHub pull request webhook payload
type GitHubWebhook struct {
Action string `json:"action"`
Number int `json:"number"`
PullRequest *GitHubPullRequest `json:"pull_request"`
Repository *Repository `json:"repository"`
Sender *User `json:"sender"`
Changes *Changes `json:"changes,omitempty"`
}
// GitHubPullRequest contains the pull request data from GitHub webhook
type GitHubPullRequest struct {
ID int `json:"id"`
Number int `json:"number"`
State string `json:"state"`
Locked bool `json:"locked"`
Title string `json:"title"`
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClosedAt *time.Time `json:"closed_at"`
MergedAt *time.Time `json:"merged_at"`
MergeCommitSHA *string `json:"merge_commit_sha"`
Assignee *User `json:"assignee"`
Assignees []*User `json:"assignees"`
RequestedReviewers []*User `json:"requested_reviewers"`
Labels []*Label `json:"labels"`
Milestone *Milestone `json:"milestone"`
Draft bool `json:"draft"`
Merged bool `json:"merged"`
Mergeable *bool `json:"mergeable"`
MergeableState string `json:"mergeable_state"`
MergedBy *User `json:"merged_by"`
Comments int `json:"comments"`
ReviewComments int `json:"review_comments"`
Commits int `json:"commits"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
ChangedFiles int `json:"changed_files"`
Head *PullRequestBranch `json:"head"`
Base *PullRequestBranch `json:"base"`
User *User `json:"user"`
}
// PullRequestBranch contains branch information
type PullRequestBranch struct {
Label string `json:"label"`
Ref string `json:"ref"`
SHA string `json:"sha"`
User *User `json:"user"`
Repo *Repository `json:"repo"`
}
// Repository contains repository information
type Repository struct {
ID int `json:"id"`
NodeID string `json:"node_id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Private bool `json:"private"`
Owner *User `json:"owner"`
Description *string `json:"description"`
Fork bool `json:"fork"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PushedAt time.Time `json:"pushed_at"`
GitURL string `json:"git_url"`
SSHURL string `json:"ssh_url"`
CloneURL string `json:"clone_url"`
SvnURL string `json:"svn_url"`
Homepage *string `json:"homepage"`
Size int `json:"size"`
StargazersCount int `json:"stargazers_count"`
WatchersCount int `json:"watchers_count"`
Language *string `json:"language"`
HasIssues bool `json:"has_issues"`
HasProjects bool `json:"has_projects"`
HasDownloads bool `json:"has_downloads"`
HasWiki bool `json:"has_wiki"`
HasPages bool `json:"has_pages"`
HasDiscussions bool `json:"has_discussions"`
ForksCount int `json:"forks_count"`
Archived bool `json:"archived"`
Disabled bool `json:"disabled"`
License *License `json:"license"`
AllowForking bool `json:"allow_forking"`
IsTemplate bool `json:"is_template"`
WebCommitSignoffRequired bool `json:"web_commit_signoff_required"`
Topics []string `json:"topics"`
Visibility string `json:"visibility"`
DefaultBranch string `json:"default_branch"`
AllowSquashMerge bool `json:"allow_squash_merge"`
AllowMergeCommit bool `json:"allow_merge_commit"`
AllowRebaseMerge bool `json:"allow_rebase_merge"`
AllowAutoMerge bool `json:"allow_auto_merge"`
DeleteBranchOnMerge bool `json:"delete_branch_on_merge"`
AllowUpdateBranch bool `json:"allow_update_branch"`
UseSquashPrTitleAsDefault bool `json:"use_squash_pr_title_as_default"`
SquashMergeCommitMessage string `json:"squash_merge_commit_message"`
SquashMergeCommitTitle string `json:"squash_merge_commit_title"`
MergeCommitMessage string `json:"merge_commit_message"`
MergeCommitTitle string `json:"merge_commit_title"`
SecurityAndAnalysis *SecurityAndAnalysis `json:"security_and_analysis"`
}
// User contains user information
type User struct {
Login string `json:"login"`
ID int `json:"id"`
NodeID string `json:"node_id"`
AvatarURL string `json:"avatar_url"`
GravatarID string `json:"gravatar_id"`
URL string `json:"url"`
HTMLURL string `json:"html_url"`
FollowersURL string `json:"followers_url"`
FollowingURL string `json:"following_url"`
GistsURL string `json:"gists_url"`
StarredURL string `json:"starred_url"`
SubscriptionsURL string `json:"subscriptions_url"`
OrganizationsURL string `json:"organizations_url"`
ReposURL string `json:"repos_url"`
EventsURL string `json:"events_url"`
ReceivedEventsURL string `json:"received_events_url"`
Type string `json:"type"`
SiteAdmin bool `json:"site_admin"`
Name *string `json:"name"`
Company *string `json:"company"`
Blog *string `json:"blog"`
Location *string `json:"location"`
Email *string `json:"email"`
Hireable *bool `json:"hireable"`
Bio *string `json:"bio"`
TwitterUsername *string `json:"twitter_username"`
PublicRepos int `json:"public_repos"`
PublicGists int `json:"public_gists"`
Followers int `json:"followers"`
Following int `json:"following"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PrivateGists int `json:"private_gists"`
TotalPrivateRepos int `json:"total_private_repos"`
OwnedPrivateRepos int `json:"owned_private_repos"`
DiskUsage int `json:"disk_usage"`
Collaborators int `json:"collaborators"`
TwoFactorAuthentication bool `json:"two_factor_authentication"`
Plan *Plan `json:"plan"`
SuspendedAt *time.Time `json:"suspended_at"`
BusinessPlus bool `json:"business_plus"`
LdapDn *string `json:"ldap_dn"`
}
// Label contains label information
type Label struct {
ID int `json:"id"`
NodeID string `json:"node_id"`
URL string `json:"url"`
Name string `json:"name"`
Description *string `json:"description"`
Color string `json:"color"`
Default bool `json:"default"`
}
// Milestone contains milestone information
type Milestone struct {
URL string `json:"url"`
HTMLURL string `json:"html_url"`
LabelsURL string `json:"labels_url"`
ID int `json:"id"`
NodeID string `json:"node_id"`
Number int `json:"number"`
State string `json:"state"`
Title string `json:"title"`
Description *string `json:"description"`
Creator *User `json:"creator"`
OpenIssues int `json:"open_issues"`
ClosedIssues int `json:"closed_issues"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DueOn *time.Time `json:"due_on"`
ClosedAt *time.Time `json:"closed_at"`
}
// License contains license information
type License struct {
Key string `json:"key"`
Name string `json:"name"`
URL string `json:"url"`
SpdxID string `json:"spdx_id"`
NodeID string `json:"node_id"`
HTMLURL string `json:"html_url"`
}
// SecurityAndAnalysis contains security and analysis information
type SecurityAndAnalysis struct {
AdvancedSecurity *SecurityFeature `json:"advanced_security"`
SecretScanning *SecurityFeature `json:"secret_scanning"`
SecretScanningPushProtection *SecurityFeature `json:"secret_scanning_push_protection"`
DependabotSecurityUpdates *SecurityFeature `json:"dependabot_security_updates"`
DependencyGraph *SecurityFeature `json:"dependency_graph"`
}
// SecurityFeature contains security feature information
type SecurityFeature struct {
Status string `json:"status"`
}
// Plan contains plan information
type Plan struct {
Name string `json:"name"`
Space int `json:"space"`
Collaborators int `json:"collaborators"`
PrivateRepos int `json:"private_repos"`
}
// Changes contains information about what changed in the webhook
type Changes struct {
Title *Change `json:"title,omitempty"`
Body *Change `json:"body,omitempty"`
Base *Change `json:"base,omitempty"`
}
// Change contains information about a specific change
type Change struct {
From string `json:"from"`
}
// ValidateSignature validates GitHub webhook signature using SHA-256 HMAC
func ValidateSignature(payload []byte, signature, secret string) error {
if secret == "" {
return errors.New("webhook secret not configured")
}
if !strings.HasPrefix(signature, "sha256=") {
return errors.New("signature must use sha256")
}
expectedMAC, err := hex.DecodeString(signature[7:])
if err != nil {
return fmt.Errorf("invalid signature format: %w", err)
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
computedMAC := mac.Sum(nil)
if !hmac.Equal(expectedMAC, computedMAC) {
return errors.New("signature verification failed")
}
return nil
}
// ParseWebhook parses GitHub webhook payload
func ParseWebhook(payload []byte) (*GitHubWebhook, error) {
var webhook GitHubWebhook
if err := json.Unmarshal(payload, &webhook); err != nil {
return nil, fmt.Errorf("failed to parse webhook: %w", err)
}
return &webhook, nil
}
// IsValidMergeEvent checks if webhook represents a merged PR
func IsValidMergeEvent(webhook *GitHubWebhook) bool {
return webhook.Action == "closed" &&
webhook.PullRequest != nil &&
webhook.PullRequest.Merged &&
webhook.PullRequest.Head != nil
}
// ExtractTaskID extracts task ID from branch name like "solution/[task-id]-title" or "subtasks/[task-id]-title"
func ExtractTaskID(branchName string) (string, error) {
// Match patterns like "solution/[task-123]-title", "subtasks/[task-456]-description", etc.
re := regexp.MustCompile(`^(?:solution|subtasks)/\[([^\]]+)\]-.+$`)
matches := re.FindStringSubmatch(branchName)
if len(matches) != 2 {
return "", fmt.Errorf("branch name '%s' does not match expected pattern (solution/[task-id]-title or subtasks/[task-id]-title)", branchName)
}
return matches[1], nil
}
// ProcessMergeWebhook processes a GitHub PR merge webhook and returns the task ID
// This function handles the complete GitHub webhook payload structure
func ProcessMergeWebhook(payload []byte, signature, secret string) (string, error) {
// Validate signature
if err := ValidateSignature(payload, signature, secret); err != nil {
return "", fmt.Errorf("signature validation failed: %w", err)
}
// Parse webhook
webhook, err := ParseWebhook(payload)
if err != nil {
return "", fmt.Errorf("webhook parsing failed: %w", err)
}
// Check if it's a merge event
if !IsValidMergeEvent(webhook) {
return "", errors.New("not a valid merge event")
}
// Extract task ID from branch name
taskID, err := ExtractTaskID(webhook.PullRequest.Head.Ref)
if err != nil {
return "", fmt.Errorf("task ID extraction failed: %w", err)
}
return taskID, nil
}
// GetWebhookInfo returns comprehensive information about the webhook for debugging/logging
func GetWebhookInfo(webhook *GitHubWebhook) map[string]interface{} {
info := map[string]interface{}{
"action": webhook.Action,
"number": webhook.Number,
}
if webhook.PullRequest != nil {
info["pr_id"] = webhook.PullRequest.ID
info["pr_title"] = webhook.PullRequest.Title
info["pr_state"] = webhook.PullRequest.State
info["pr_merged"] = webhook.PullRequest.Merged
info["pr_merged_at"] = webhook.PullRequest.MergedAt
info["pr_merged_by"] = webhook.PullRequest.MergedBy
if webhook.PullRequest.Head != nil {
info["head_ref"] = webhook.PullRequest.Head.Ref
info["head_sha"] = webhook.PullRequest.Head.SHA
}
if webhook.PullRequest.Base != nil {
info["base_ref"] = webhook.PullRequest.Base.Ref
info["base_sha"] = webhook.PullRequest.Base.SHA
}
}
if webhook.Repository != nil {
info["repo_name"] = webhook.Repository.Name
info["repo_full_name"] = webhook.Repository.FullName
}
if webhook.Sender != nil {
info["sender_login"] = webhook.Sender.Login
info["sender_id"] = webhook.Sender.ID
}
return info
}