tools/version-tracker/pkg/github/github.go (358 lines of code) (raw):

package github import ( "context" "encoding/base64" "fmt" "math" "net/http" "os" "path/filepath" "regexp" "slices" "strconv" "strings" "github.com/google/go-github/v53/github" "github.com/aws/eks-anywhere-build-tooling/tools/version-tracker/pkg/constants" "github.com/aws/eks-anywhere-build-tooling/tools/version-tracker/pkg/util/file" "github.com/aws/eks-anywhere-build-tooling/tools/version-tracker/pkg/util/logger" "github.com/aws/eks-anywhere-build-tooling/tools/version-tracker/pkg/util/semver" "github.com/aws/eks-anywhere-build-tooling/tools/version-tracker/pkg/util/tar" "github.com/aws/eks-anywhere-build-tooling/tools/version-tracker/pkg/util/version" ) // getTagsForRepo retrieves the list of tags for the given GitHub repository. func getTagsForRepo(client *github.Client, org, repo string) ([]*github.RepositoryTag, error) { logger.V(6).Info(fmt.Sprintf("Getting tags for [%s/%s] repository", org, repo)) var allTags []*github.RepositoryTag listTagOptions := &github.ListOptions{ PerPage: constants.GithubPerPage, } for { tags, resp, err := client.Repositories.ListTags(context.Background(), org, repo, listTagOptions) if err != nil { return nil, fmt.Errorf("calling ListTags API for [%s/%s] repository: %v", org, repo, err) } allTags = append(allTags, tags...) if resp.NextPage == 0 { break } listTagOptions.Page = resp.NextPage } return allTags, nil } // getCommitsForRepo retrieves the list of commits for the given GitHub repository. func getCommitsForRepo(client *github.Client, org, repo string) ([]*github.RepositoryCommit, error) { logger.V(6).Info(fmt.Sprintf("Getting commits for [%s/%s] repository", org, repo)) var allCommits []*github.RepositoryCommit listCommitOptions := &github.CommitsListOptions{ ListOptions: github.ListOptions{ PerPage: constants.GithubPerPage, }, } for { commits, resp, err := client.Repositories.ListCommits(context.Background(), org, repo, listCommitOptions) if err != nil { return nil, fmt.Errorf("calling ListCommits API for [%s/%s] repository: %v", org, repo, err) } allCommits = append(allCommits, commits...) if resp.NextPage == 0 { break } listCommitOptions.ListOptions.Page = resp.NextPage } return allCommits, nil } // getCommitDateEpoch gets the Unix epoch time equivalent of a given Github commit's date. func getCommitDateEpoch(client *github.Client, org, repo, commitSHA string) (int64, error) { logger.V(6).Info(fmt.Sprintf("Getting date for commit %s in [%s/%s] repository", commitSHA, org, repo)) commit, _, err := client.Repositories.GetCommit(context.Background(), org, repo, commitSHA, nil) if err != nil { return 0, fmt.Errorf("getting date for commit %s in [%s/%s] repository: %v", commitSHA, org, repo, err) } return (*commit.Commit.Author.Date).Unix(), nil } func GetFileContents(client *github.Client, org, repo, filePath, ref string) ([]byte, error) { contents, _, _, err := client.Repositories.GetContents(context.Background(), org, repo, filePath, &github.RepositoryContentGetOptions{Ref: ref}) if err != nil { return nil, fmt.Errorf("getting contents of file [%s]: %v", filePath, err) } contentsDecoded, err := base64.StdEncoding.DecodeString(*contents.Content) if err != nil { return nil, fmt.Errorf("decoding contents of file [%s]: %v", filePath, err) } return contentsDecoded, nil } // GetLatestRevision returns the latest revision (GitHub release or tag) for a given GitHub repository. func GetLatestRevision(client *github.Client, org, repo, currentRevision, branchName string, isTrackedUsingCommitHash, releaseBranched bool) (string, bool, error) { logger.V(6).Info(fmt.Sprintf("Getting latest revision for [%s/%s] repository", org, repo)) var currentRevisionCommit, latestRevision string var needsUpgrade bool projectFullName := fmt.Sprintf("%s/%s", org, repo) // Get all GitHub tags for this project. allTags, err := getTagsForRepo(client, org, repo) if err != nil { return "", false, fmt.Errorf("getting all tags for [%s/%s] repository: %v", org, repo, err) } // Get commit hash corresponding to current revision tag. if isTrackedUsingCommitHash { currentRevisionCommit = currentRevision } else { currentRevisionCommit = getCommitForTag(allTags, currentRevision) } // Get Unix timestamp for current revision's commit. currentRevisionCommitEpoch, err := getCommitDateEpoch(client, org, repo, currentRevisionCommit) if err != nil { return "", false, fmt.Errorf("getting epoch time corresponding to current revision commit: %v", err) } // If the project is tracked using a commit hash, upgrade to the latest commit. if isTrackedUsingCommitHash { // If the project does not have Github tags, pick the latest commit. allCommits, err := getCommitsForRepo(client, org, repo) if err != nil { return "", false, fmt.Errorf("getting all commits for [%s/%s] repository: %v", org, repo, err) } latestRevision = *allCommits[0].SHA needsUpgrade = true } else { semverRegex := regexp.MustCompile(constants.SemverRegex) currentRevisionForSemver := version.EnsurePatchVersion(semverRegex.FindString(currentRevision)) // Get SemVer construct corresponding to the current revision tag. currentRevisionSemver, err := semver.New(currentRevisionForSemver) if err != nil { return "", false, fmt.Errorf("getting semver for current version %s: %v", currentRevisionForSemver, err) } for _, tag := range allTags { tagName := *tag.Name if strings.Contains(tagName, "chart") || strings.Contains(tagName, "helm") { continue } if org == "kubernetes" && repo == "autoscaler" { if !strings.HasPrefix(tagName, "cluster-autoscaler-") { continue } } detectedSemver := semverRegex.FindString(tagName) if detectedSemver == "" { continue } tagNameForSemver := version.EnsurePatchVersion(detectedSemver) if releaseBranched { releaseBranch := os.Getenv(constants.ReleaseBranchEnvvar) releaseNumber := strings.Split(releaseBranch, "-")[1] tagRegex := regexp.MustCompile(fmt.Sprintf(`^v?1(\.|_)%s(\.|_)\d+$`, releaseNumber)) if !tagRegex.MatchString(tagNameForSemver) { continue } } if branchName != constants.MainBranchName { tagRegex := regexp.MustCompile(fmt.Sprintf(`^v?%d(\.|_)%d(\.|_)\d+`, currentRevisionSemver.Major, currentRevisionSemver.Minor)) if !tagRegex.MatchString(tagNameForSemver) { continue } } revisionSemver, err := semver.New(tagNameForSemver) if err != nil { return "", false, fmt.Errorf("getting semver for the version under consideration: %v", err) } if !slices.Contains(constants.ProjectsSupportingPrereleaseTags, projectFullName) && revisionSemver.Prerelease != "" { continue } if _, ok := constants.ProjectMaximumSemvers[projectFullName]; ok { maximumAllowedVersion := constants.ProjectMaximumSemvers[projectFullName] numDots := strings.Count(maximumAllowedVersion, ".") for range 2 - numDots { maximumAllowedVersion += fmt.Sprintf(".%s", strconv.Itoa(math.MaxInt)) } maximumSemver, err := semver.New(maximumAllowedVersion) if err != nil { return "", false, fmt.Errorf("getting semver for the maximum allowed version: %v", err) } if revisionSemver.GreaterThan(maximumSemver) { continue } } latestRevision = tagName // Determine if upgrade is required based on current and latest revisions upgradeRequired, shouldBreak, err := isUpgradeRequired(client, org, repo, latestRevision, currentRevisionCommitEpoch, currentRevisionSemver, allTags) if err != nil { return "", false, fmt.Errorf("determining if upgrade is required for project: %v", err) } if shouldBreak { needsUpgrade = upgradeRequired break } } } return latestRevision, needsUpgrade, nil } // isUpgradeRequired determines if the project requires an upgrade by comparing the current revision to the latest revision. func isUpgradeRequired(client *github.Client, org, repo, latestRevision string, currentRevisionCommitEpoch int64, currentRevisionSemver *semver.Version, allTags []*github.RepositoryTag) (bool, bool, error) { var needsUpgrade, shouldBreak bool // Get commit hash corresponding to latest revision tag. latestRevisionCommit := getCommitForTag(allTags, latestRevision) if latestRevisionCommit == "" { return false, false, fmt.Errorf("empty commit hash for latest revision: %s", latestRevision) } // Get Unix timestamp for latest revision's commit. latestRevisionCommitEpoch, err := getCommitDateEpoch(client, org, repo, latestRevisionCommit) if err != nil { return false, false, fmt.Errorf("getting epoch time corresponding to latest revision commit: %v", err) } semverRegex := regexp.MustCompile(constants.SemverRegex) latestRevisionForSemver := version.EnsurePatchVersion(semverRegex.FindString(latestRevision)) // Get SemVer construct corresponding to the latest revision tag. latestRevisionSemver, err := semver.New(latestRevisionForSemver) if err != nil { return false, false, fmt.Errorf("getting semver for latest version: %v", err) } // If the latest revision comes after the current revision both chronologically and semantically, then declare that // an upgrade is required if latestRevisionSemver.GreaterThan(currentRevisionSemver) || latestRevisionCommitEpoch > currentRevisionCommitEpoch { needsUpgrade = true shouldBreak = true } else if latestRevisionSemver.Equal(currentRevisionSemver) { needsUpgrade = false shouldBreak = true } return needsUpgrade, shouldBreak, nil } // getCommitForTag returns the commit hash corresponding to the given tag. func getCommitForTag(allTags []*github.RepositoryTag, searchTag string) string { for _, tag := range allTags { if searchTag == *tag.Name { return *tag.Commit.SHA } } return "" } // GetGoVersionForLatestRevision gets the Go version used to build the latest revision of the project. func GetGoVersionForLatestRevision(client *github.Client, org, repo, latestRevision string) (string, error) { logger.V(6).Info(fmt.Sprintf("Getting Go version corresponding to latest revision %s for [%s/%s] repository", latestRevision, org, repo)) var goVersion string var err error projectFullName := fmt.Sprintf("%s/%s", org, repo) if _, ok := constants.ProjectReleaseAssets[projectFullName]; ok { release, response, err := client.Repositories.GetReleaseByTag(context.Background(), org, repo, latestRevision) if err != nil { if response.StatusCode == http.StatusNotFound { logger.V(6).Info(fmt.Sprintf("GitHub release for tag %s not found. Falling back to GitHub source of truth file for Go version", latestRevision)) goVersion, err = getGoVersionFromGitHubFile(client, org, repo, projectFullName, latestRevision) if err != nil { return "", fmt.Errorf("getting Go version from GitHub source of truth file: %v", err) } } else { return "", fmt.Errorf("calling GetReleaseByTag API for tag %s in [%s/%s] repository: %v", latestRevision, org, repo, err) } } else { goVersion, err = getGoVersionFromGitHubRelease(release, projectFullName, latestRevision) if err != nil { return "", fmt.Errorf("getting Go version from GitHub release assets: %v", err) } } } else if _, ok := constants.ProjectGoVersionSourceOfTruth[projectFullName]; ok { goVersion, err = getGoVersionFromGitHubFile(client, org, repo, projectFullName, latestRevision) if err != nil { return "", fmt.Errorf("getting Go version from GitHub source of truth file: %v", err) } } return goVersion, nil } // CreatePullRequest creates a pull request from the head branch to the base branch on the base repository. func CreatePullRequest(client *github.Client, org, repo, title, body, baseRepoOwner, baseBranch, headRepoOwner, headBranch, currentRevision, latestRevision string) (*github.PullRequest, error) { var pullRequest *github.PullRequest // Check if there is already a pull request from the head branch to the base branch. pullRequests, _, err := client.PullRequests.List(context.Background(), baseRepoOwner, constants.BuildToolingRepoName, &github.PullRequestListOptions{ Base: baseBranch, Head: fmt.Sprintf("%s:%s", headRepoOwner, headBranch), }) if err != nil { return nil, fmt.Errorf("listing pull requests from %s:%s -> %s:%s: %v", headRepoOwner, headBranch, baseRepoOwner, baseBranch, err) } if len(pullRequests) > 0 { pullRequest = pullRequests[0] logger.Info(fmt.Sprintf("A pull request already exists for %s:%s", headRepoOwner, headBranch), "Pull request", *pullRequest.HTMLURL) pullRequest.Body = github.String(body) pullRequest, _, err = client.PullRequests.Edit(context.Background(), baseRepoOwner, constants.BuildToolingRepoName, *pullRequest.Number, pullRequest) if err != nil { return nil, fmt.Errorf("editing existing pull request [%s]: %v", *pullRequest.HTMLURL, err) } } else { logger.V(6).Info(fmt.Sprintf("Creating pull request with updated versions for [%s/%s] repository", org, repo)) newPR := &github.NewPullRequest{ Title: github.String(title), Head: github.String(fmt.Sprintf("%s:%s", headRepoOwner, headBranch)), Base: github.String(baseBranch), Body: github.String(body), MaintainerCanModify: github.Bool(true), } pullRequest, _, err = client.PullRequests.Create(context.Background(), baseRepoOwner, constants.BuildToolingRepoName, newPR) if err != nil { return nil, fmt.Errorf("creating pull request with updated versions from %s to %s: %v", headBranch, baseBranch, err) } logger.Info(fmt.Sprintf("Created pull request: %s", *pullRequest.HTMLURL)) } return pullRequest, nil } func AddCommentOnPR(client *github.Client, baseRepoOwner, comment string, pullRequest *github.PullRequest) error { prComment := &github.IssueComment{ Body: github.String(comment), } _, _, err := client.Issues.CreateComment(context.Background(), baseRepoOwner, constants.BuildToolingRepoName, *pullRequest.Number, prComment) if err != nil { return fmt.Errorf("commenting on pull request [%s]: %v", *pullRequest.HTMLURL, err) } return nil } func getGoVersionFromGitHubRelease(release *github.RepositoryRelease, projectFullName, latestRevision string) (string, error) { var tarballName, tarballUrl string projectReleaseAsset := constants.ProjectReleaseAssets[projectFullName] searchAssetName := projectReleaseAsset.AssetName assetVersionReplacement := latestRevision if constants.ProjectReleaseAssets[projectFullName].TrimLeadingVersionPrefix { assetVersionReplacement = latestRevision[1:] } if strings.Count(searchAssetName, "%s") > 0 { searchAssetName = fmt.Sprintf(searchAssetName, assetVersionReplacement) } if projectReleaseAsset.OverrideAssetURL != "" { tarballName = searchAssetName tarballUrl = projectReleaseAsset.OverrideAssetURL if strings.Count(tarballUrl, "%s") > 0 { tarballUrl = fmt.Sprintf(tarballUrl, assetVersionReplacement) } } else { for _, asset := range release.Assets { if *asset.Name == searchAssetName { tarballName = *asset.Name tarballUrl = *asset.BrowserDownloadURL break } } } cwd, err := os.Getwd() if err != nil { return "", fmt.Errorf("retrieving current working directory: %v", err) } tarballDownloadPath := filepath.Join(cwd, "github-release-downloads") err = os.MkdirAll(tarballDownloadPath, 0o755) if err != nil { return "", fmt.Errorf("failed to create GitHub release downloads folder: %v", err) } tarballFilePath := filepath.Join(tarballDownloadPath, tarballName) err = file.Download(tarballUrl, tarballFilePath) if err != nil { return "", fmt.Errorf("downloading release tarball from URL [%s]: %v", tarballUrl, err) } binaryName := projectReleaseAsset.BinaryName if strings.Count(binaryName, "%s") > 0 { binaryName = fmt.Sprintf(binaryName, assetVersionReplacement) } if projectReleaseAsset.Extract { tarballFile, err := os.Open(tarballFilePath) if err != nil { return "", fmt.Errorf("opening tarball filepath: %v", err) } err = tar.ExtractFileFromTarball(tarballDownloadPath, tarballFile, binaryName) if err != nil { return "", fmt.Errorf("extracting tarball file: %v", err) } } binaryFilePath := filepath.Join(tarballDownloadPath, binaryName) goVersion, err := version.GetGoVersion(binaryFilePath) if err != nil { return "", fmt.Errorf("getting Go version embedded in binary [%s]: %v", binaryFilePath, err) } err = os.RemoveAll(tarballDownloadPath) if err != nil { return "", fmt.Errorf("removing tarball download path: %v", err) } return goVersion, nil } func getGoVersionFromGitHubFile(client *github.Client, org, repo, projectFullName, latestRevision string) (string, error) { projectGoVersionSourceOfTruthFile := constants.ProjectGoVersionSourceOfTruth[projectFullName].SourceOfTruthFile workflowContents, err := GetFileContents(client, org, repo, projectGoVersionSourceOfTruthFile, latestRevision) if err != nil { return "", fmt.Errorf("getting contents of file [%s]: %v", projectGoVersionSourceOfTruthFile, err) } pattern := regexp.MustCompile(constants.ProjectGoVersionSourceOfTruth[projectFullName].GoVersionSearchString) matches := pattern.FindStringSubmatch(string(workflowContents)) return matches[1], nil }