releaser-pleaser/forge.go

660 lines
16 KiB
Go
Raw Normal View History

2024-07-12 14:51:24 +02:00
package rp
import (
2024-07-27 09:34:22 +02:00
"context"
2024-08-01 23:00:56 +02:00
"errors"
2024-07-12 14:51:24 +02:00
"fmt"
2024-07-27 09:34:22 +02:00
"log/slog"
2024-08-01 23:00:56 +02:00
"os"
2024-08-04 21:22:22 +02:00
"slices"
2024-08-02 23:11:07 +02:00
"strings"
2024-07-27 09:34:22 +02:00
"github.com/blang/semver/v4"
2024-08-01 23:00:56 +02:00
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/go-git/go-git/v5/plumbing/transport/http"
2024-07-27 09:34:22 +02:00
"github.com/google/go-github/v63/github"
2024-07-12 14:51:24 +02:00
)
2024-07-30 20:24:58 +02:00
const (
2024-08-04 21:22:22 +02:00
GitHubPerPageMax = 100
GitHubPRStateOpen = "open"
GitHubPRStateClosed = "closed"
2024-08-23 22:29:09 +02:00
GitHubEnvAPIToken = "GITHUB_TOKEN" // nolint:gosec // Not actually a hardcoded credential
2024-08-04 21:22:22 +02:00
GitHubEnvUsername = "GITHUB_USER"
2024-08-05 23:49:31 +02:00
GitHubEnvRepository = "GITHUB_REPOSITORY"
2024-08-05 01:00:34 +02:00
GitHubLabelColor = "dedede"
2024-07-30 20:24:58 +02:00
)
2024-07-12 14:51:24 +02:00
type Forge interface {
RepoURL() string
2024-08-01 23:00:56 +02:00
CloneURL() string
ReleaseURL(version string) string
GitAuth() transport.AuthMethod
2024-07-27 09:34:22 +02:00
// LatestTags returns the last stable tag created on the main branch. If there is a more recent pre-release tag,
// that is also returned. If no tag is found, it returns nil.
2024-08-02 23:11:07 +02:00
LatestTags(context.Context) (Releases, error)
2024-07-27 09:34:22 +02:00
// CommitsSince returns all commits to main branch after the Tag. The tag can be `nil`, in which case this
// function should return all commits.
CommitsSince(context.Context, *Tag) ([]Commit, error)
2024-08-17 15:45:36 +02:00
// EnsureLabelsExist verifies that all desired labels are available on the repository. If labels are missing, they
// are created them.
EnsureLabelsExist(context.Context, []Label) error
2024-08-05 01:00:34 +02:00
2024-07-30 20:24:58 +02:00
// PullRequestForBranch returns the open pull request between the branch and ForgeOptions.BaseBranch. If no open PR
// exists, it returns nil.
PullRequestForBranch(context.Context, string) (*ReleasePullRequest, error)
2024-08-03 03:00:36 +02:00
2024-08-17 15:45:36 +02:00
// CreatePullRequest opens a new pull/merge request for the ReleasePullRequest.
CreatePullRequest(context.Context, *ReleasePullRequest) error
2024-08-17 15:45:36 +02:00
// UpdatePullRequest updates the pull/merge request identified through the ID of
// the ReleasePullRequest to the current description and title.
UpdatePullRequest(context.Context, *ReleasePullRequest) error
2024-08-17 15:45:36 +02:00
// SetPullRequestLabels updates the pull/merge request identified through the ID of
// the ReleasePullRequest to the current labels.
SetPullRequestLabels(ctx context.Context, pr *ReleasePullRequest, remove, add []Label) error
// ClosePullRequest closes the pull/merge request identified through the ID of
// the ReleasePullRequest, as it is no longer required.
2024-08-04 21:22:22 +02:00
ClosePullRequest(context.Context, *ReleasePullRequest) error
2024-08-17 15:45:36 +02:00
// PendingReleases returns a list of ReleasePullRequest. The list should contain all pull/merge requests that are
// merged and have the matching label.
PendingReleases(context.Context, Label) ([]*ReleasePullRequest, error)
2024-08-04 21:22:22 +02:00
2024-08-17 15:45:36 +02:00
// CreateRelease creates a release on the Forge, pointing at the commit with the passed in details.
CreateRelease(ctx context.Context, commit Commit, title, changelog string, prerelease, latest bool) error
2024-07-12 14:51:24 +02:00
}
type ForgeOptions struct {
Repository string
2024-07-27 09:34:22 +02:00
BaseBranch string
2024-07-12 14:51:24 +02:00
}
var _ Forge = &GitHub{}
2024-07-27 09:34:22 +02:00
// var _ Forge = &GitLab{}
2024-07-12 14:51:24 +02:00
type GitHub struct {
2024-07-27 09:34:22 +02:00
options *GitHubOptions
client *github.Client
log *slog.Logger
2024-07-12 14:51:24 +02:00
}
func (g *GitHub) RepoURL() string {
2024-08-01 23:00:56 +02:00
return fmt.Sprintf("https://github.com/%s/%s", g.options.Owner, g.options.Repo)
}
func (g *GitHub) CloneURL() string {
return fmt.Sprintf("https://github.com/%s/%s.git", g.options.Owner, g.options.Repo)
}
func (g *GitHub) ReleaseURL(version string) string {
return fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s", g.options.Owner, g.options.Repo, version)
}
func (g *GitHub) GitAuth() transport.AuthMethod {
return &http.BasicAuth{
Username: g.options.Username,
Password: g.options.APIToken,
}
2024-07-12 14:51:24 +02:00
}
2024-08-02 23:11:07 +02:00
func (g *GitHub) LatestTags(ctx context.Context) (Releases, error) {
g.log.DebugContext(ctx, "listing all tags in github repository")
page := 1
2024-08-02 23:11:07 +02:00
var releases Releases
for {
tags, resp, err := g.client.Repositories.ListTags(
ctx, g.options.Owner, g.options.Repo,
&github.ListOptions{Page: page, PerPage: GitHubPerPageMax},
)
if err != nil {
2024-08-02 23:11:07 +02:00
return Releases{}, err
}
for _, ghTag := range tags {
tag := &Tag{
Hash: ghTag.GetCommit().GetSHA(),
Name: ghTag.GetName(),
}
2024-08-02 23:11:07 +02:00
version, err := semver.Parse(strings.TrimPrefix(tag.Name, "v"))
if err != nil {
g.log.WarnContext(
ctx, "unable to parse tag as semver, skipping",
"tag.name", tag.Name,
"tag.hash", tag.Hash,
"error", err,
)
continue
}
2024-08-02 23:11:07 +02:00
if releases.Latest == nil {
releases.Latest = tag
}
if len(version.Pre) == 0 {
// Stable version tag
// We return once we have found the latest stable tag, not needed to look at every single tag.
2024-08-02 23:11:07 +02:00
releases.Stable = tag
break
}
}
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
2024-07-27 09:34:22 +02:00
}
2024-08-02 23:11:07 +02:00
return releases, nil
2024-07-27 09:34:22 +02:00
}
func (g *GitHub) CommitsSince(ctx context.Context, tag *Tag) ([]Commit, error) {
var repositoryCommits []*github.RepositoryCommit
var err error
if tag != nil {
repositoryCommits, err = g.commitsSinceTag(ctx, tag)
} else {
repositoryCommits, err = g.commitsSinceInit(ctx)
2024-07-27 09:34:22 +02:00
}
if err != nil {
return nil, err
}
var commits = make([]Commit, 0, len(repositoryCommits))
for _, ghCommit := range repositoryCommits {
commit := Commit{
2024-07-27 09:34:22 +02:00
Hash: ghCommit.GetSHA(),
Message: ghCommit.GetCommit().GetMessage(),
}
commit.PullRequest, err = g.prForCommit(ctx, commit)
if err != nil {
return nil, fmt.Errorf("failed to check for commit pull request: %w", err)
}
commits = append(commits, commit)
2024-07-27 09:34:22 +02:00
}
return commits, nil
}
func (g *GitHub) commitsSinceTag(ctx context.Context, tag *Tag) ([]*github.RepositoryCommit, error) {
head := g.options.BaseBranch
log := g.log.With("base", tag.Hash, "head", head)
log.Debug("comparing commits", "base", tag.Hash, "head", head)
page := 1
var repositoryCommits []*github.RepositoryCommit
for {
log.Debug("fetching page", "page", page)
comparison, resp, err := g.client.Repositories.CompareCommits(
ctx, g.options.Owner, g.options.Repo,
tag.Hash, head, &github.ListOptions{
Page: page,
2024-08-01 23:00:56 +02:00
PerPage: GitHubPerPageMax,
2024-07-27 09:34:22 +02:00
})
if err != nil {
return nil, err
}
if repositoryCommits == nil {
// Pre-initialize slice on first request
log.Debug("found commits", "length", comparison.GetTotalCommits())
repositoryCommits = make([]*github.RepositoryCommit, 0, comparison.GetTotalCommits())
}
repositoryCommits = append(repositoryCommits, comparison.Commits...)
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
}
return repositoryCommits, nil
}
func (g *GitHub) commitsSinceInit(ctx context.Context) ([]*github.RepositoryCommit, error) {
head := g.options.BaseBranch
log := g.log.With("head", head)
log.Debug("listing all commits")
page := 1
var repositoryCommits []*github.RepositoryCommit
for {
log.Debug("fetching page", "page", page)
commits, resp, err := g.client.Repositories.ListCommits(
ctx, g.options.Owner, g.options.Repo,
&github.CommitsListOptions{
SHA: head,
ListOptions: github.ListOptions{
Page: page,
PerPage: GitHubPerPageMax,
},
})
if err != nil {
return nil, err
}
if repositoryCommits == nil && resp.LastPage > 0 {
// Pre-initialize slice on first request
log.Debug("found commits", "pages", resp.LastPage)
repositoryCommits = make([]*github.RepositoryCommit, 0, resp.LastPage*GitHubPerPageMax)
}
repositoryCommits = append(repositoryCommits, commits...)
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
}
return repositoryCommits, nil
}
func (g *GitHub) prForCommit(ctx context.Context, commit Commit) (*PullRequest, error) {
2024-07-27 09:34:22 +02:00
// We naively look up the associated PR for each commit through the "List pull requests associated with a commit"
// endpoint. This requires len(commits) requests.
// Using the "List pull requests" endpoint might be faster, as it allows us to fetch 100 arbitrary PRs per request,
// but worst case we need to look up all PRs made in the repository ever.
log := g.log.With("commit.hash", commit.Hash)
page := 1
var associatedPRs []*github.PullRequest
2024-07-27 09:34:22 +02:00
for {
log.Debug("fetching pull requests associated with commit", "page", page)
prs, resp, err := g.client.PullRequests.ListPullRequestsWithCommit(
ctx, g.options.Owner, g.options.Repo,
commit.Hash, &github.ListOptions{
Page: page,
PerPage: GitHubPerPageMax,
})
if err != nil {
return nil, err
2024-07-27 09:34:22 +02:00
}
associatedPRs = append(associatedPRs, prs...)
2024-07-27 09:34:22 +02:00
if page == resp.LastPage || resp.LastPage == 0 {
break
2024-07-27 09:34:22 +02:00
}
page = resp.NextPage
}
2024-07-27 09:34:22 +02:00
var pullrequest *github.PullRequest
for _, pr := range associatedPRs {
// We only look for the PR that has this commit set as the "merge commit" => The result of squashing this branch onto main
if pr.GetMergeCommitSHA() == commit.Hash {
pullrequest = pr
break
2024-07-27 09:34:22 +02:00
}
}
if pullrequest == nil {
return nil, nil
}
2024-07-27 09:34:22 +02:00
return gitHubPRToPullRequest(pullrequest), nil
2024-07-27 09:34:22 +02:00
}
2024-08-17 15:45:36 +02:00
func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []Label) error {
2024-08-05 01:00:34 +02:00
existingLabels := make([]string, 0, len(labels))
page := 1
for {
g.log.Debug("fetching labels on repo", "page", page)
ghLabels, resp, err := g.client.Issues.ListLabels(
ctx, g.options.Owner, g.options.Repo,
&github.ListOptions{
Page: page,
PerPage: GitHubPerPageMax,
})
if err != nil {
return err
}
for _, label := range ghLabels {
existingLabels = append(existingLabels, label.GetName())
}
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
}
for _, label := range labels {
2024-08-17 15:45:36 +02:00
if !slices.Contains(existingLabels, string(label)) {
2024-08-05 01:00:34 +02:00
g.log.Info("creating label in repository", "label.name", label)
_, _, err := g.client.Issues.CreateLabel(
ctx, g.options.Owner, g.options.Repo,
&github.Label{
2024-08-17 15:45:36 +02:00
Name: Pointer(string(label)),
2024-08-05 01:00:34 +02:00
Color: Pointer(GitHubLabelColor),
},
)
if err != nil {
return err
}
}
}
return nil
}
2024-07-30 20:24:58 +02:00
func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*ReleasePullRequest, error) {
page := 1
for {
prs, resp, err := g.client.PullRequests.ListPullRequestsWithCommit(ctx, g.options.Owner, g.options.Repo, branch, &github.ListOptions{
Page: page,
2024-08-01 23:00:56 +02:00
PerPage: GitHubPerPageMax,
2024-07-30 20:24:58 +02:00
})
if err != nil {
2024-08-01 23:00:56 +02:00
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) {
if ghErr.Message == fmt.Sprintf("No commit found for SHA: %s", branch) {
return nil, nil
}
}
2024-07-30 20:24:58 +02:00
return nil, err
}
for _, pr := range prs {
if pr.GetBase().GetRef() == g.options.BaseBranch && pr.GetHead().GetRef() == branch && pr.GetState() == GitHubPRStateOpen {
2024-08-04 21:22:22 +02:00
return gitHubPRToReleasePullRequest(pr), nil
2024-07-30 20:24:58 +02:00
}
}
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
}
return nil, nil
}
func (g *GitHub) CreatePullRequest(ctx context.Context, pr *ReleasePullRequest) error {
2024-08-03 03:00:36 +02:00
ghPR, _, err := g.client.PullRequests.Create(
ctx, g.options.Owner, g.options.Repo,
&github.NewPullRequest{
Title: &pr.Title,
Head: &pr.Head,
Base: &g.options.BaseBranch,
Body: &pr.Description,
},
)
if err != nil {
return err
}
2024-08-04 21:22:22 +02:00
// TODO: String ID?
pr.ID = ghPR.GetNumber()
2024-08-17 15:45:36 +02:00
err = g.SetPullRequestLabels(ctx, pr, []Label{}, pr.Labels)
if err != nil {
return err
2024-08-03 03:00:36 +02:00
}
return nil
}
func (g *GitHub) UpdatePullRequest(ctx context.Context, pr *ReleasePullRequest) error {
_, _, err := g.client.PullRequests.Edit(
2024-08-04 21:22:22 +02:00
ctx, g.options.Owner, g.options.Repo,
pr.ID, &github.PullRequest{
Title: &pr.Title,
Body: &pr.Description,
},
)
if err != nil {
return err
}
2024-08-03 03:00:36 +02:00
return nil
2024-08-03 03:00:36 +02:00
}
2024-08-17 15:45:36 +02:00
func (g *GitHub) SetPullRequestLabels(ctx context.Context, pr *ReleasePullRequest, remove, add []Label) error {
2024-08-04 21:22:22 +02:00
for _, label := range remove {
_, err := g.client.Issues.RemoveLabelForIssue(
ctx, g.options.Owner, g.options.Repo,
2024-08-17 15:45:36 +02:00
pr.ID, string(label),
2024-08-04 21:22:22 +02:00
)
if err != nil {
return err
}
}
2024-08-17 15:45:36 +02:00
addString := make([]string, 0, len(add))
for _, label := range add {
addString = append(addString, string(label))
}
2024-08-04 21:22:22 +02:00
_, _, err := g.client.Issues.AddLabelsToIssue(
ctx, g.options.Owner, g.options.Repo,
2024-08-17 15:45:36 +02:00
pr.ID, addString,
2024-08-04 21:22:22 +02:00
)
if err != nil {
return err
}
return nil
}
func (g *GitHub) ClosePullRequest(ctx context.Context, pr *ReleasePullRequest) error {
_, _, err := g.client.PullRequests.Edit(
ctx, g.options.Owner, g.options.Repo,
pr.ID, &github.PullRequest{
State: Pointer(GitHubPRStateClosed),
},
)
if err != nil {
return err
}
return nil
}
2024-08-17 15:45:36 +02:00
func (g *GitHub) PendingReleases(ctx context.Context, pendingLabel Label) ([]*ReleasePullRequest, error) {
2024-08-04 21:22:22 +02:00
page := 1
var prs []*ReleasePullRequest
for {
ghPRs, resp, err := g.client.PullRequests.List(
ctx, g.options.Owner, g.options.Repo,
&github.PullRequestListOptions{
State: GitHubPRStateClosed,
Base: g.options.BaseBranch,
ListOptions: github.ListOptions{
Page: page,
PerPage: GitHubPerPageMax,
},
})
if err != nil {
return nil, err
}
if prs == nil && resp.LastPage > 0 {
// Pre-initialize slice on first request
g.log.Debug("found pending releases", "pages", resp.LastPage)
prs = make([]*ReleasePullRequest, 0, (resp.LastPage-1)*GitHubPerPageMax)
}
for _, pr := range ghPRs {
pending := slices.ContainsFunc(pr.Labels, func(l *github.Label) bool {
2024-08-17 15:45:36 +02:00
return l.GetName() == string(pendingLabel)
2024-08-04 21:22:22 +02:00
})
if !pending {
continue
}
// pr.Merged is always nil :(
if pr.MergedAt == nil {
// Closed and not merged
continue
}
prs = append(prs, gitHubPRToReleasePullRequest(pr))
}
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
}
return prs, nil
}
func (g *GitHub) CreateRelease(ctx context.Context, commit Commit, title, changelog string, preRelease, latest bool) error {
makeLatest := ""
if latest {
makeLatest = "true"
} else {
makeLatest = "false"
}
_, _, err := g.client.Repositories.CreateRelease(
ctx, g.options.Owner, g.options.Repo,
&github.RepositoryRelease{
TagName: &title,
TargetCommitish: &commit.Hash,
Name: &title,
Body: &changelog,
Prerelease: &preRelease,
MakeLatest: &makeLatest,
},
)
if err != nil {
return err
}
return nil
}
func gitHubPRToPullRequest(pr *github.PullRequest) *PullRequest {
return &PullRequest{
ID: pr.GetNumber(),
Title: pr.GetTitle(),
Description: pr.GetBody(),
}
}
2024-08-04 21:22:22 +02:00
func gitHubPRToReleasePullRequest(pr *github.PullRequest) *ReleasePullRequest {
2024-08-17 15:45:36 +02:00
labels := make([]Label, 0, len(pr.Labels))
2024-08-04 21:22:22 +02:00
for _, label := range pr.Labels {
2024-08-17 15:45:36 +02:00
labelName := Label(label.GetName())
if slices.Contains(KnownLabels, Label(label.GetName())) {
labels = append(labels, labelName)
}
2024-08-04 21:22:22 +02:00
}
var releaseCommit *Commit
if pr.MergeCommitSHA != nil {
releaseCommit = &Commit{Hash: pr.GetMergeCommitSHA()}
}
return &ReleasePullRequest{
ID: pr.GetNumber(),
Title: pr.GetTitle(),
Description: pr.GetBody(),
Labels: labels,
Head: pr.GetHead().GetRef(),
ReleaseCommit: releaseCommit,
}
}
2024-07-27 09:34:22 +02:00
func (g *GitHubOptions) autodiscover() {
2024-08-01 23:00:56 +02:00
if apiToken := os.Getenv(GitHubEnvAPIToken); apiToken != "" {
g.APIToken = apiToken
}
// TODO: Check if there is a better solution for cloning/pushing locally
if username := os.Getenv(GitHubEnvUsername); username != "" {
g.Username = username
}
2024-08-05 23:49:31 +02:00
if envRepository := os.Getenv(GitHubEnvRepository); envRepository != "" {
// GITHUB_REPOSITORY=apricote/releaser-pleaser
parts := strings.Split(envRepository, "/")
if len(parts) == 2 {
g.Owner = parts[0]
g.Repo = parts[1]
g.Repository = envRepository
}
}
2024-07-27 09:34:22 +02:00
}
type GitHubOptions struct {
ForgeOptions
Owner string
Repo string
APIToken string
2024-08-01 23:00:56 +02:00
Username string
2024-07-12 14:51:24 +02:00
}
2024-07-27 09:34:22 +02:00
func NewGitHub(log *slog.Logger, options *GitHubOptions) *GitHub {
options.autodiscover()
client := github.NewClient(nil)
if options.APIToken != "" {
client = client.WithAuthToken(options.APIToken)
}
2024-07-12 14:51:24 +02:00
gh := &GitHub{
options: options,
2024-07-27 09:34:22 +02:00
client: client,
log: log.With("forge", "github"),
}
2024-07-12 14:51:24 +02:00
return gh
}
type GitLab struct {
options ForgeOptions
}
func (g *GitLab) autodiscover() {
// Read settings from GitLab-CI env vars
}
func NewGitLab(options ForgeOptions) *GitLab {
gl := &GitLab{
options: options,
}
gl.autodiscover()
return gl
}
func (g *GitLab) RepoURL() string {
return fmt.Sprintf("https://gitlab.com/%s", g.options.Repository)
}
2024-08-04 21:22:22 +02:00
func Pointer[T any](value T) *T {
return &value
}