github.com/ActiveState/cli@v0.0.0-20240508170324-6801f60cd051/scripts/internal/workflow-helpers/github.go (about) 1 package workflow_helpers 2 3 import ( 4 "fmt" 5 "net/http" 6 "os" 7 "strings" 8 "time" 9 10 "github.com/ActiveState/cli/internal/constants" 11 "github.com/ActiveState/cli/internal/errs" 12 "github.com/ActiveState/cli/internal/logging" 13 "github.com/ActiveState/cli/internal/rtutils/ptr" 14 "github.com/ActiveState/cli/internal/testhelpers/secrethelper" 15 "github.com/andygrunwald/go-jira" 16 "github.com/blang/semver" 17 "github.com/google/go-github/v45/github" 18 "github.com/thoas/go-funk" 19 "golang.org/x/net/context" 20 ) 21 22 func InitGHClient() *github.Client { 23 token := secrethelper.GetSecretIfEmpty(os.Getenv("GITHUB_TOKEN"), "user.GITHUB_TOKEN") 24 25 return github.NewClient(&http.Client{ 26 Transport: NewRateLimitTransport(http.DefaultTransport, token), 27 }) 28 } 29 30 // ExtractJiraIssueID tries to extract the jira issue ID from the branch name 31 func ExtractJiraIssueID(pr *github.PullRequest) (string, error) { 32 if pr.Head == nil || pr.Head.Ref == nil { 33 panic(fmt.Sprintf("Head or head ref is nil: %#v", pr)) 34 } 35 36 v, err := ParseJiraKey(*pr.Head.Ref) 37 if err != nil { 38 return "", errs.New("Please ensure your branch name is valid") 39 } 40 return v, nil 41 } 42 43 // FetchPRs fetches all PRs and iterates over all available pages 44 func FetchPRs(ghClient *github.Client, cutoff time.Time, opts *github.PullRequestListOptions) ([]*github.PullRequest, error) { 45 result := []*github.PullRequest{} 46 47 if opts == nil { 48 opts = &github.PullRequestListOptions{ 49 State: "closed", 50 Base: "master", 51 } 52 } 53 54 opts.Sort = "updated" 55 opts.Direction = "desc" 56 57 nextPage := 1 58 59 for x := 0; x < 10; x++ { // Hard limit of 1000 most recent PRs 60 opts.ListOptions = github.ListOptions{ 61 Page: nextPage, 62 PerPage: 100, 63 } 64 // Grab github PRs to compare against jira stories, cause Jira's API does not tell us what the linker PR is 65 prs, v, err := ghClient.PullRequests.List(context.Background(), "ActiveState", "cli", opts) 66 if err != nil { 67 return nil, errs.Wrap(err, "Could not find PRs") 68 } 69 nextPage = v.NextPage 70 if len(prs) > 0 && prs[0].UpdatedAt.Before(cutoff) { 71 break // The rest of the PRs are too old to care about 72 } 73 result = append(result, prs...) 74 if nextPage == 0 { 75 break // Last page 76 } 77 } 78 79 return result, nil 80 } 81 82 func FetchCommitsByShaRange(ghClient *github.Client, startSha string, stopSha string) ([]*github.RepositoryCommit, error) { 83 return FetchCommitsByRef(ghClient, startSha, func(commit *github.RepositoryCommit) bool { 84 return commit.GetSHA() == stopSha 85 }) 86 } 87 88 func FetchCommitsByRef(ghClient *github.Client, ref string, stop func(commit *github.RepositoryCommit) bool) ([]*github.RepositoryCommit, error) { 89 result := []*github.RepositoryCommit{} 90 perPage := 100 91 nextPage := 1 92 93 for x := 0; x < 100; x++ { // hard limit of 100,000 commits 94 commits, v, err := ghClient.Repositories.ListCommits(context.Background(), "ActiveState", "cli", &github.CommitsListOptions{ 95 SHA: ref, 96 ListOptions: github.ListOptions{ 97 Page: nextPage, 98 PerPage: perPage, 99 }, 100 }) 101 if err != nil { 102 return nil, errs.Wrap(err, "ListCommits failed") 103 } 104 nextPage = v.NextPage 105 106 for _, commit := range commits { 107 if stop != nil && stop(commit) { 108 return result, nil 109 } 110 result = append(result, commit) 111 } 112 113 if nextPage == 0 { 114 break // Last page 115 } 116 117 if x == 99 { 118 fmt.Println("WARNING: Hard limit of 100,000 commits reached") 119 } 120 } 121 122 return result, nil 123 } 124 125 func SearchGithubIssues(client *github.Client, term string) ([]*github.Issue, error) { 126 issues := []*github.Issue{} 127 perPage := 100 128 nextPage := 1 129 130 for x := 0; x < 10; x++ { // hard limit of 1,000 issues 131 result, v, err := client.Search.Issues(context.Background(), "repo:ActiveState/cli "+term, &github.SearchOptions{ 132 ListOptions: github.ListOptions{ 133 Page: nextPage, 134 PerPage: perPage, 135 }, 136 }) 137 if err != nil { 138 return nil, errs.Wrap(err, "Search.Issues failed") 139 } 140 nextPage = v.NextPage 141 issues = append(issues, result.Issues...) 142 if nextPage == 0 { 143 break // Last page 144 } 145 } 146 147 return issues, nil 148 } 149 150 func FetchPRByTitle(ghClient *github.Client, title string) (*github.PullRequest, error) { 151 var targetIssue *github.Issue 152 issues, _, err := ghClient.Search.Issues(context.Background(), fmt.Sprintf("repo:ActiveState/cli in:title is:pr %s", title), nil) 153 if err != nil { 154 return nil, errs.Wrap(err, "failed to search for issues") 155 } 156 157 for _, issue := range issues.Issues { 158 if strings.TrimSpace(issue.GetTitle()) == strings.TrimSpace(title) { 159 targetIssue = issue 160 break 161 } 162 } 163 164 if targetIssue != nil { 165 targetPR, err := FetchPR(ghClient, *targetIssue.Number) 166 if err != nil { 167 return nil, errs.Wrap(err, "failed to get PR") 168 } 169 return targetPR, nil 170 } 171 172 return nil, nil 173 } 174 175 func FetchPR(ghClient *github.Client, number int) (*github.PullRequest, error) { 176 pr, _, err := ghClient.PullRequests.Get(context.Background(), "ActiveState", "cli", number) 177 if err != nil { 178 return nil, errs.Wrap(err, "failed to get PR") 179 } 180 return pr, nil 181 } 182 183 func CreatePR(ghClient *github.Client, prName, branchName, baseBranch, body string) (*github.PullRequest, error) { 184 payload := &github.NewPullRequest{ 185 Title: &prName, 186 Head: &branchName, 187 Base: ptr.To(baseBranch), 188 Body: ptr.To(body), 189 } 190 191 pr, _, err := ghClient.PullRequests.Create(context.Background(), "ActiveState", "cli", payload) 192 if err != nil { 193 return nil, errs.Wrap(err, "failed to create PR") 194 } 195 196 return pr, nil 197 } 198 199 func LabelPR(ghClient *github.Client, prnumber int, labels []string) error { 200 if _, _, err := ghClient.Issues.AddLabelsToIssue( 201 context.Background(), "ActiveState", "cli", prnumber, labels, 202 ); err != nil { 203 return errs.Wrap(err, "failed to add label") 204 } 205 return nil 206 } 207 208 type Assertion string 209 210 const ( 211 AssertLT Assertion = "less than" 212 AssertGT = "greater than" 213 ) 214 215 func FetchVersionPRs(ghClient *github.Client, assert Assertion, versionToCompare semver.Version, limit int) ([]*github.PullRequest, error) { 216 issues, err := SearchGithubIssues(ghClient, "is:pr in:title "+VersionedPRPrefix) 217 if err != nil { 218 return nil, errs.Wrap(err, "failed to search for PRs") 219 } 220 221 filtered := issuesWithVersionAssert(issues, assert, versionToCompare) 222 result := []*github.PullRequest{} 223 for n, issue := range filtered { 224 if !strings.HasPrefix(issue.GetTitle(), VersionedPRPrefix) { 225 // The search above matches the whole title, and is very forgiving, which we don't want to be 226 continue 227 } 228 pr, err := FetchPR(ghClient, issue.GetNumber()) 229 if err != nil { 230 return nil, errs.Wrap(err, "failed to get PR") 231 } 232 result = append(result, pr) 233 if limit != -1 && n+1 == limit { 234 break 235 } 236 } 237 238 return result, nil 239 } 240 241 func FetchVersionPR(ghClient *github.Client, assert Assertion, versionToCompare semver.Version) (*github.PullRequest, error) { 242 prs, err := FetchVersionPRs(ghClient, assert, versionToCompare, 1) 243 if err != nil { 244 return nil, err 245 } 246 if len(prs) == 0 { 247 return nil, errs.New("Could not find issue with version %s %s", assert, versionToCompare.String()) 248 } 249 return prs[0], nil 250 } 251 252 func BranchHasVersionsGT(client *github.Client, jiraClient *jira.Client, branchName string, version semver.Version) (bool, error) { 253 versions, err := ActiveVersionsOnBranch(client, jiraClient, branchName, time.Now().AddDate(0, -6, 0)) 254 if err != nil { 255 return false, errs.Wrap(err, "failed to get versions on master") 256 } 257 258 for _, v := range versions { 259 if v.GT(version) { 260 // Master has commits on it intended for versions greater than the one being targeted 261 return true, nil 262 } 263 } 264 265 return false, nil 266 } 267 268 func ActiveVersionsOnBranch(ghClient *github.Client, jiraClient *jira.Client, branchName string, dateCutoff time.Time) ([]semver.Version, error) { 269 commits, err := FetchCommitsByRef(ghClient, branchName, func(commit *github.RepositoryCommit) bool { 270 return commit.Commit.Committer.Date.Before(dateCutoff) 271 }) 272 if err != nil { 273 return nil, errs.Wrap(err, "failed to fetch commits") 274 } 275 jiraIDs := []string{} 276 for _, commit := range commits { 277 jiraID, err := ParseJiraKey(commit.Commit.GetMessage()) 278 if err != nil { 279 // no match 280 continue 281 } 282 jiraIDs = append(jiraIDs, jiraID) 283 } 284 285 jiraIDs = funk.Uniq(jiraIDs).([]string) 286 issues, err := JqlUnpaged(jiraClient, fmt.Sprintf(`project = "DX" AND id IN(%s)`, strings.Join(jiraIDs, ","))) 287 if err != nil { 288 return nil, errs.Wrap(err, "failed to fetch issues") 289 } 290 291 seen := map[string]struct{}{} 292 result := []semver.Version{} 293 for _, issue := range issues { 294 if issue.Fields.FixVersions == nil || len(issue.Fields.FixVersions) == 0 { 295 continue 296 } 297 versionValue := issue.Fields.FixVersions[0].Name 298 if _, ok := seen[versionValue]; ok { 299 continue 300 } 301 seen[versionValue] = struct{}{} 302 version, err := ParseJiraVersion(versionValue) 303 if err != nil { 304 logging.Debug("Failed to parse version %s: %v", versionValue, err) 305 continue 306 } 307 result = append(result, version) 308 } 309 310 return result, nil 311 } 312 313 func UpdatePRTargetBranch(client *github.Client, prnumber int, targetBranch string) error { 314 _, _, err := client.PullRequests.Edit(context.Background(), "ActiveState", "cli", prnumber, &github.PullRequest{ 315 Base: &github.PullRequestBranch{ 316 Ref: github.String(targetBranch), 317 }, 318 }) 319 if err != nil { 320 return errs.Wrap(err, "failed to update PR target branch") 321 } 322 return nil 323 } 324 325 func GetCommitsBehind(client *github.Client, base, head string) ([]*github.RepositoryCommit, error) { 326 // Note we're swapping base and head when doing this because github responds with the commits that are ahead, rather than behind. 327 commits, _, err := client.Repositories.CompareCommits(context.Background(), "ActiveState", "cli", head, base, nil) 328 if err != nil { 329 return nil, errs.Wrap(err, "failed to compare commits") 330 } 331 result := []*github.RepositoryCommit{} 332 for _, commit := range commits.Commits { 333 msg := strings.Split(commit.GetCommit().GetMessage(), "\n")[0] // first line only 334 msgWords := strings.Split(msg, " ") 335 if msg == UpdateVersionCommitMessage { 336 // Updates to version.txt are not meant to be inherited 337 continue 338 } 339 suffix := strings.TrimPrefix(msgWords[len(msgWords)-1], "ActiveState/") 340 if (strings.HasPrefix(msg, "Merge pull request") && IsVersionBranch(suffix)) || 341 (strings.HasPrefix(msg, "Merge branch '"+constants.BetaChannel+"'") && IsVersionBranch(suffix)) { 342 // Git's compare commits is not smart enough to consider merge commits from other version branches equal 343 // This matches the following types of messages: 344 // Merge pull request #2531 from ActiveState/version/0-38-1-RC1 345 // Merge branch 'beta' into version/0-40-0-RC1 346 continue 347 } 348 result = append(result, commit) 349 } 350 return result, nil 351 } 352 353 func SetPRBody(client *github.Client, prnumber int, body string) error { 354 _, _, err := client.PullRequests.Edit(context.Background(), "ActiveState", "cli", prnumber, &github.PullRequest{ 355 Body: &body, 356 }) 357 if err != nil { 358 return errs.Wrap(err, "failed to set PR body") 359 } 360 return nil 361 } 362 363 func CreateBranch(ghClient *github.Client, branchName string, SHA string) error { 364 _, _, err := ghClient.Git.CreateRef(context.Background(), "ActiveState", "cli", &github.Reference{ 365 Ref: github.String(fmt.Sprintf("refs/heads/%s", branchName)), 366 Object: &github.GitObject{ 367 SHA: ptr.To(SHA), 368 }, 369 }) 370 if err != nil { 371 return errs.Wrap(err, "failed to create ref") 372 } 373 return nil 374 } 375 376 func CreateFileUpdateCommit(ghClient *github.Client, branchName string, path string, contents string, message string) (string, error) { 377 fileContents, _, _, err := ghClient.Repositories.GetContents(context.Background(), "ActiveState", "cli", path, &github.RepositoryContentGetOptions{ 378 Ref: branchName, 379 }) 380 if err != nil { 381 return "", errs.Wrap(err, "failed to get file contents for %s on branch %s", path, branchName) 382 } 383 384 resp, _, err := ghClient.Repositories.UpdateFile(context.Background(), "ActiveState", "cli", path, &github.RepositoryContentFileOptions{ 385 Author: &github.CommitAuthor{ 386 Name: ptr.To("ActiveState CLI Automation"), 387 Email: ptr.To("support@activestate.com"), 388 }, 389 Branch: &branchName, 390 Message: ptr.To(message), 391 Content: []byte(contents), 392 SHA: fileContents.SHA, 393 }) 394 if err != nil { 395 return "", errs.Wrap(err, "failed to update file") 396 } 397 398 return resp.GetSHA(), nil 399 }