| iomodo | d89df96 | 2025-07-31 12:53:05 +0400 | [diff] [blame] | 1 | package git |
| 2 | |
| 3 | import ( |
| 4 | "crypto/hmac" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "regexp" |
| 11 | "strings" |
| 12 | "time" |
| 13 | ) |
| 14 | |
| 15 | // GitHubWebhook represents the GitHub pull request webhook payload |
| 16 | type GitHubWebhook struct { |
| 17 | Action string `json:"action"` |
| 18 | Number int `json:"number"` |
| 19 | PullRequest *GitHubPullRequest `json:"pull_request"` |
| 20 | Repository *Repository `json:"repository"` |
| 21 | Sender *User `json:"sender"` |
| 22 | Changes *Changes `json:"changes,omitempty"` |
| 23 | } |
| 24 | |
| 25 | // GitHubPullRequest contains the pull request data from GitHub webhook |
| 26 | type GitHubPullRequest struct { |
| 27 | ID int `json:"id"` |
| 28 | Number int `json:"number"` |
| 29 | State string `json:"state"` |
| 30 | Locked bool `json:"locked"` |
| 31 | Title string `json:"title"` |
| 32 | Body string `json:"body"` |
| 33 | CreatedAt time.Time `json:"created_at"` |
| 34 | UpdatedAt time.Time `json:"updated_at"` |
| 35 | ClosedAt *time.Time `json:"closed_at"` |
| 36 | MergedAt *time.Time `json:"merged_at"` |
| 37 | MergeCommitSHA *string `json:"merge_commit_sha"` |
| 38 | Assignee *User `json:"assignee"` |
| 39 | Assignees []*User `json:"assignees"` |
| 40 | RequestedReviewers []*User `json:"requested_reviewers"` |
| 41 | Labels []*Label `json:"labels"` |
| 42 | Milestone *Milestone `json:"milestone"` |
| 43 | Draft bool `json:"draft"` |
| 44 | Merged bool `json:"merged"` |
| 45 | Mergeable *bool `json:"mergeable"` |
| 46 | MergeableState string `json:"mergeable_state"` |
| 47 | MergedBy *User `json:"merged_by"` |
| 48 | Comments int `json:"comments"` |
| 49 | ReviewComments int `json:"review_comments"` |
| 50 | Commits int `json:"commits"` |
| 51 | Additions int `json:"additions"` |
| 52 | Deletions int `json:"deletions"` |
| 53 | ChangedFiles int `json:"changed_files"` |
| 54 | Head *PullRequestBranch `json:"head"` |
| 55 | Base *PullRequestBranch `json:"base"` |
| 56 | User *User `json:"user"` |
| 57 | } |
| 58 | |
| 59 | // PullRequestBranch contains branch information |
| 60 | type PullRequestBranch struct { |
| 61 | Label string `json:"label"` |
| 62 | Ref string `json:"ref"` |
| 63 | SHA string `json:"sha"` |
| 64 | User *User `json:"user"` |
| 65 | Repo *Repository `json:"repo"` |
| 66 | } |
| 67 | |
| 68 | // Repository contains repository information |
| 69 | type Repository struct { |
| 70 | ID int `json:"id"` |
| 71 | NodeID string `json:"node_id"` |
| 72 | Name string `json:"name"` |
| 73 | FullName string `json:"full_name"` |
| 74 | Private bool `json:"private"` |
| 75 | Owner *User `json:"owner"` |
| 76 | Description *string `json:"description"` |
| 77 | Fork bool `json:"fork"` |
| 78 | CreatedAt time.Time `json:"created_at"` |
| 79 | UpdatedAt time.Time `json:"updated_at"` |
| 80 | PushedAt time.Time `json:"pushed_at"` |
| 81 | GitURL string `json:"git_url"` |
| 82 | SSHURL string `json:"ssh_url"` |
| 83 | CloneURL string `json:"clone_url"` |
| 84 | SvnURL string `json:"svn_url"` |
| 85 | Homepage *string `json:"homepage"` |
| 86 | Size int `json:"size"` |
| 87 | StargazersCount int `json:"stargazers_count"` |
| 88 | WatchersCount int `json:"watchers_count"` |
| 89 | Language *string `json:"language"` |
| 90 | HasIssues bool `json:"has_issues"` |
| 91 | HasProjects bool `json:"has_projects"` |
| 92 | HasDownloads bool `json:"has_downloads"` |
| 93 | HasWiki bool `json:"has_wiki"` |
| 94 | HasPages bool `json:"has_pages"` |
| 95 | HasDiscussions bool `json:"has_discussions"` |
| 96 | ForksCount int `json:"forks_count"` |
| 97 | Archived bool `json:"archived"` |
| 98 | Disabled bool `json:"disabled"` |
| 99 | License *License `json:"license"` |
| 100 | AllowForking bool `json:"allow_forking"` |
| 101 | IsTemplate bool `json:"is_template"` |
| 102 | WebCommitSignoffRequired bool `json:"web_commit_signoff_required"` |
| 103 | Topics []string `json:"topics"` |
| 104 | Visibility string `json:"visibility"` |
| 105 | DefaultBranch string `json:"default_branch"` |
| 106 | AllowSquashMerge bool `json:"allow_squash_merge"` |
| 107 | AllowMergeCommit bool `json:"allow_merge_commit"` |
| 108 | AllowRebaseMerge bool `json:"allow_rebase_merge"` |
| 109 | AllowAutoMerge bool `json:"allow_auto_merge"` |
| 110 | DeleteBranchOnMerge bool `json:"delete_branch_on_merge"` |
| 111 | AllowUpdateBranch bool `json:"allow_update_branch"` |
| 112 | UseSquashPrTitleAsDefault bool `json:"use_squash_pr_title_as_default"` |
| 113 | SquashMergeCommitMessage string `json:"squash_merge_commit_message"` |
| 114 | SquashMergeCommitTitle string `json:"squash_merge_commit_title"` |
| 115 | MergeCommitMessage string `json:"merge_commit_message"` |
| 116 | MergeCommitTitle string `json:"merge_commit_title"` |
| 117 | SecurityAndAnalysis *SecurityAndAnalysis `json:"security_and_analysis"` |
| 118 | } |
| 119 | |
| 120 | // User contains user information |
| 121 | type User struct { |
| 122 | Login string `json:"login"` |
| 123 | ID int `json:"id"` |
| 124 | NodeID string `json:"node_id"` |
| 125 | AvatarURL string `json:"avatar_url"` |
| 126 | GravatarID string `json:"gravatar_id"` |
| 127 | URL string `json:"url"` |
| 128 | HTMLURL string `json:"html_url"` |
| 129 | FollowersURL string `json:"followers_url"` |
| 130 | FollowingURL string `json:"following_url"` |
| 131 | GistsURL string `json:"gists_url"` |
| 132 | StarredURL string `json:"starred_url"` |
| 133 | SubscriptionsURL string `json:"subscriptions_url"` |
| 134 | OrganizationsURL string `json:"organizations_url"` |
| 135 | ReposURL string `json:"repos_url"` |
| 136 | EventsURL string `json:"events_url"` |
| 137 | ReceivedEventsURL string `json:"received_events_url"` |
| 138 | Type string `json:"type"` |
| 139 | SiteAdmin bool `json:"site_admin"` |
| 140 | Name *string `json:"name"` |
| 141 | Company *string `json:"company"` |
| 142 | Blog *string `json:"blog"` |
| 143 | Location *string `json:"location"` |
| 144 | Email *string `json:"email"` |
| 145 | Hireable *bool `json:"hireable"` |
| 146 | Bio *string `json:"bio"` |
| 147 | TwitterUsername *string `json:"twitter_username"` |
| 148 | PublicRepos int `json:"public_repos"` |
| 149 | PublicGists int `json:"public_gists"` |
| 150 | Followers int `json:"followers"` |
| 151 | Following int `json:"following"` |
| 152 | CreatedAt time.Time `json:"created_at"` |
| 153 | UpdatedAt time.Time `json:"updated_at"` |
| 154 | PrivateGists int `json:"private_gists"` |
| 155 | TotalPrivateRepos int `json:"total_private_repos"` |
| 156 | OwnedPrivateRepos int `json:"owned_private_repos"` |
| 157 | DiskUsage int `json:"disk_usage"` |
| 158 | Collaborators int `json:"collaborators"` |
| 159 | TwoFactorAuthentication bool `json:"two_factor_authentication"` |
| 160 | Plan *Plan `json:"plan"` |
| 161 | SuspendedAt *time.Time `json:"suspended_at"` |
| 162 | BusinessPlus bool `json:"business_plus"` |
| 163 | LdapDn *string `json:"ldap_dn"` |
| 164 | } |
| 165 | |
| 166 | // Label contains label information |
| 167 | type Label struct { |
| 168 | ID int `json:"id"` |
| 169 | NodeID string `json:"node_id"` |
| 170 | URL string `json:"url"` |
| 171 | Name string `json:"name"` |
| 172 | Description *string `json:"description"` |
| 173 | Color string `json:"color"` |
| 174 | Default bool `json:"default"` |
| 175 | } |
| 176 | |
| 177 | // Milestone contains milestone information |
| 178 | type Milestone struct { |
| 179 | URL string `json:"url"` |
| 180 | HTMLURL string `json:"html_url"` |
| 181 | LabelsURL string `json:"labels_url"` |
| 182 | ID int `json:"id"` |
| 183 | NodeID string `json:"node_id"` |
| 184 | Number int `json:"number"` |
| 185 | State string `json:"state"` |
| 186 | Title string `json:"title"` |
| 187 | Description *string `json:"description"` |
| 188 | Creator *User `json:"creator"` |
| 189 | OpenIssues int `json:"open_issues"` |
| 190 | ClosedIssues int `json:"closed_issues"` |
| 191 | CreatedAt time.Time `json:"created_at"` |
| 192 | UpdatedAt time.Time `json:"updated_at"` |
| 193 | DueOn *time.Time `json:"due_on"` |
| 194 | ClosedAt *time.Time `json:"closed_at"` |
| 195 | } |
| 196 | |
| 197 | // License contains license information |
| 198 | type License struct { |
| 199 | Key string `json:"key"` |
| 200 | Name string `json:"name"` |
| 201 | URL string `json:"url"` |
| 202 | SpdxID string `json:"spdx_id"` |
| 203 | NodeID string `json:"node_id"` |
| 204 | HTMLURL string `json:"html_url"` |
| 205 | } |
| 206 | |
| 207 | // SecurityAndAnalysis contains security and analysis information |
| 208 | type SecurityAndAnalysis struct { |
| 209 | AdvancedSecurity *SecurityFeature `json:"advanced_security"` |
| 210 | SecretScanning *SecurityFeature `json:"secret_scanning"` |
| 211 | SecretScanningPushProtection *SecurityFeature `json:"secret_scanning_push_protection"` |
| 212 | DependabotSecurityUpdates *SecurityFeature `json:"dependabot_security_updates"` |
| 213 | DependencyGraph *SecurityFeature `json:"dependency_graph"` |
| 214 | } |
| 215 | |
| 216 | // SecurityFeature contains security feature information |
| 217 | type SecurityFeature struct { |
| 218 | Status string `json:"status"` |
| 219 | } |
| 220 | |
| 221 | // Plan contains plan information |
| 222 | type Plan struct { |
| 223 | Name string `json:"name"` |
| 224 | Space int `json:"space"` |
| 225 | Collaborators int `json:"collaborators"` |
| 226 | PrivateRepos int `json:"private_repos"` |
| 227 | } |
| 228 | |
| 229 | // Changes contains information about what changed in the webhook |
| 230 | type Changes struct { |
| 231 | Title *Change `json:"title,omitempty"` |
| 232 | Body *Change `json:"body,omitempty"` |
| 233 | Base *Change `json:"base,omitempty"` |
| 234 | } |
| 235 | |
| 236 | // Change contains information about a specific change |
| 237 | type Change struct { |
| 238 | From string `json:"from"` |
| 239 | } |
| 240 | |
| 241 | // ValidateSignature validates GitHub webhook signature using SHA-256 HMAC |
| 242 | func ValidateSignature(payload []byte, signature, secret string) error { |
| 243 | if secret == "" { |
| 244 | return errors.New("webhook secret not configured") |
| 245 | } |
| 246 | |
| 247 | if !strings.HasPrefix(signature, "sha256=") { |
| 248 | return errors.New("signature must use sha256") |
| 249 | } |
| 250 | |
| 251 | expectedMAC, err := hex.DecodeString(signature[7:]) |
| 252 | if err != nil { |
| 253 | return fmt.Errorf("invalid signature format: %w", err) |
| 254 | } |
| 255 | |
| 256 | mac := hmac.New(sha256.New, []byte(secret)) |
| 257 | mac.Write(payload) |
| 258 | computedMAC := mac.Sum(nil) |
| 259 | |
| 260 | if !hmac.Equal(expectedMAC, computedMAC) { |
| 261 | return errors.New("signature verification failed") |
| 262 | } |
| 263 | |
| 264 | return nil |
| 265 | } |
| 266 | |
| 267 | // ParseWebhook parses GitHub webhook payload |
| 268 | func ParseWebhook(payload []byte) (*GitHubWebhook, error) { |
| 269 | var webhook GitHubWebhook |
| 270 | if err := json.Unmarshal(payload, &webhook); err != nil { |
| 271 | return nil, fmt.Errorf("failed to parse webhook: %w", err) |
| 272 | } |
| 273 | return &webhook, nil |
| 274 | } |
| 275 | |
| 276 | // IsValidMergeEvent checks if webhook represents a merged PR |
| 277 | func IsValidMergeEvent(webhook *GitHubWebhook) bool { |
| 278 | return webhook.Action == "closed" && |
| 279 | webhook.PullRequest != nil && |
| 280 | webhook.PullRequest.Merged && |
| 281 | webhook.PullRequest.Head != nil |
| 282 | } |
| 283 | |
| iomodo | e5970ba | 2025-07-31 17:59:52 +0400 | [diff] [blame] | 284 | // ExtractTaskID extracts task ID from branch name like "solution/[task-id]-title" or "subtasks/[task-id]-title" |
| iomodo | d89df96 | 2025-07-31 12:53:05 +0400 | [diff] [blame] | 285 | func ExtractTaskID(branchName string) (string, error) { |
| iomodo | 1409a18 | 2025-07-31 18:07:05 +0400 | [diff] [blame] | 286 | // Match patterns like "solution/(task-123)-title", "subtasks/(task-456)-description", etc. |
| 287 | re := regexp.MustCompile(`^(?:solution|subtasks)/\(([^)]+)\)-.+$`) |
| iomodo | d89df96 | 2025-07-31 12:53:05 +0400 | [diff] [blame] | 288 | matches := re.FindStringSubmatch(branchName) |
| 289 | |
| 290 | if len(matches) != 2 { |
| iomodo | 1409a18 | 2025-07-31 18:07:05 +0400 | [diff] [blame] | 291 | return "", fmt.Errorf("branch name '%s' does not match expected pattern (solution/(task-id)-title or subtasks/(task-id)-title)", branchName) |
| iomodo | d89df96 | 2025-07-31 12:53:05 +0400 | [diff] [blame] | 292 | } |
| 293 | |
| 294 | return matches[1], nil |
| 295 | } |
| 296 | |
| 297 | // ProcessMergeWebhook processes a GitHub PR merge webhook and returns the task ID |
| 298 | // This function handles the complete GitHub webhook payload structure |
| 299 | func ProcessMergeWebhook(payload []byte, signature, secret string) (string, error) { |
| 300 | // Validate signature |
| 301 | if err := ValidateSignature(payload, signature, secret); err != nil { |
| 302 | return "", fmt.Errorf("signature validation failed: %w", err) |
| 303 | } |
| 304 | |
| 305 | // Parse webhook |
| 306 | webhook, err := ParseWebhook(payload) |
| 307 | if err != nil { |
| 308 | return "", fmt.Errorf("webhook parsing failed: %w", err) |
| 309 | } |
| 310 | |
| 311 | // Check if it's a merge event |
| 312 | if !IsValidMergeEvent(webhook) { |
| 313 | return "", errors.New("not a valid merge event") |
| 314 | } |
| 315 | |
| 316 | // Extract task ID from branch name |
| 317 | taskID, err := ExtractTaskID(webhook.PullRequest.Head.Ref) |
| 318 | if err != nil { |
| 319 | return "", fmt.Errorf("task ID extraction failed: %w", err) |
| 320 | } |
| 321 | |
| 322 | return taskID, nil |
| 323 | } |
| 324 | |
| 325 | // GetWebhookInfo returns comprehensive information about the webhook for debugging/logging |
| 326 | func GetWebhookInfo(webhook *GitHubWebhook) map[string]interface{} { |
| 327 | info := map[string]interface{}{ |
| 328 | "action": webhook.Action, |
| 329 | "number": webhook.Number, |
| 330 | } |
| 331 | |
| 332 | if webhook.PullRequest != nil { |
| 333 | info["pr_id"] = webhook.PullRequest.ID |
| 334 | info["pr_title"] = webhook.PullRequest.Title |
| 335 | info["pr_state"] = webhook.PullRequest.State |
| 336 | info["pr_merged"] = webhook.PullRequest.Merged |
| 337 | info["pr_merged_at"] = webhook.PullRequest.MergedAt |
| 338 | info["pr_merged_by"] = webhook.PullRequest.MergedBy |
| 339 | |
| 340 | if webhook.PullRequest.Head != nil { |
| 341 | info["head_ref"] = webhook.PullRequest.Head.Ref |
| 342 | info["head_sha"] = webhook.PullRequest.Head.SHA |
| 343 | } |
| 344 | |
| 345 | if webhook.PullRequest.Base != nil { |
| 346 | info["base_ref"] = webhook.PullRequest.Base.Ref |
| 347 | info["base_sha"] = webhook.PullRequest.Base.SHA |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | if webhook.Repository != nil { |
| 352 | info["repo_name"] = webhook.Repository.Name |
| 353 | info["repo_full_name"] = webhook.Repository.FullName |
| 354 | } |
| 355 | |
| 356 | if webhook.Sender != nil { |
| 357 | info["sender_login"] = webhook.Sender.Login |
| 358 | info["sender_id"] = webhook.Sender.ID |
| 359 | } |
| 360 | |
| 361 | return info |
| 362 | } |