Add Github Webhook data
Change-Id: I69417685de1264b10f60fc4c74e290890cdb0a67
diff --git a/server/git/webhook.go b/server/git/webhook.go
new file mode 100644
index 0000000..df12492
--- /dev/null
+++ b/server/git/webhook.go
@@ -0,0 +1,362 @@
+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}" or "subtasks/{task-id}"
+func ExtractTaskID(branchName string) (string, error) {
+ // Match patterns like "solution/task-123", "subtasks/task-456", 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} or subtasks/{task-id})", 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
+}