| 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 |
| } |