blob: 6ab18a1e2e35ead727c937548f58fd4549c0f600 [file] [log] [blame]
iomodod89df962025-07-31 12:53:05 +04001package git
2
3import (
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
16type 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
26type 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
60type 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
69type 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
121type 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
167type 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
178type 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
198type 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
208type 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
217type SecurityFeature struct {
218 Status string `json:"status"`
219}
220
221// Plan contains plan information
222type 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
230type 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
237type Change struct {
238 From string `json:"from"`
239}
240
241// ValidateSignature validates GitHub webhook signature using SHA-256 HMAC
242func 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
268func 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
277func IsValidMergeEvent(webhook *GitHubWebhook) bool {
278 return webhook.Action == "closed" &&
279 webhook.PullRequest != nil &&
280 webhook.PullRequest.Merged &&
281 webhook.PullRequest.Head != nil
282}
283
iomodoe5970ba2025-07-31 17:59:52 +0400284// ExtractTaskID extracts task ID from branch name like "solution/[task-id]-title" or "subtasks/[task-id]-title"
iomodod89df962025-07-31 12:53:05 +0400285func ExtractTaskID(branchName string) (string, error) {
iomodo1409a182025-07-31 18:07:05 +0400286 // Match patterns like "solution/(task-123)-title", "subtasks/(task-456)-description", etc.
287 re := regexp.MustCompile(`^(?:solution|subtasks)/\(([^)]+)\)-.+$`)
iomodod89df962025-07-31 12:53:05 +0400288 matches := re.FindStringSubmatch(branchName)
289
290 if len(matches) != 2 {
iomodo1409a182025-07-31 18:07:05 +0400291 return "", fmt.Errorf("branch name '%s' does not match expected pattern (solution/(task-id)-title or subtasks/(task-id)-title)", branchName)
iomodod89df962025-07-31 12:53:05 +0400292 }
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
299func 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
326func 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}