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  }