mirror of
https://github.com/apricote/releaser-pleaser.git
synced 2026-01-13 13:21:00 +00:00
In a timing issue, the release pull request may be created but the releaser-pleaser labels not added. On the next run releaser-pleaser then creates a second release pull request. We try to reduce the chance of this happening by checking the context cancellation at the top, and if its not cancelled we run both API requests without passing along any cancellations from the parent context. Closes #215
544 lines
14 KiB
Go
544 lines
14 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/blang/semver/v4"
|
|
"github.com/go-git/go-git/v5/plumbing/transport"
|
|
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
|
"github.com/google/go-github/v72/github"
|
|
|
|
"github.com/apricote/releaser-pleaser/internal/forge"
|
|
"github.com/apricote/releaser-pleaser/internal/git"
|
|
"github.com/apricote/releaser-pleaser/internal/pointer"
|
|
"github.com/apricote/releaser-pleaser/internal/releasepr"
|
|
)
|
|
|
|
const (
|
|
PerPageMax = 100
|
|
PRStateOpen = "open"
|
|
PRStateClosed = "closed"
|
|
EnvAPIToken = "GITHUB_TOKEN" // nolint:gosec // Not actually a hardcoded credential
|
|
EnvUsername = "GITHUB_USER"
|
|
EnvRepository = "GITHUB_REPOSITORY"
|
|
)
|
|
|
|
var (
|
|
gitHubActionsBotAuthor = git.Author{
|
|
Name: "github-actions[bot]",
|
|
Email: "41898282+github-actions[bot]@users.noreply.github.com",
|
|
}
|
|
)
|
|
|
|
var _ forge.Forge = &GitHub{}
|
|
|
|
type GitHub struct {
|
|
options *Options
|
|
|
|
client *github.Client
|
|
log *slog.Logger
|
|
}
|
|
|
|
func (g *GitHub) RepoURL() string {
|
|
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) PullRequestURL(id int) string {
|
|
return fmt.Sprintf("https://github.com/%s/%s/pull/%d", g.options.Owner, g.options.Repo, id)
|
|
}
|
|
|
|
func (g *GitHub) GitAuth() transport.AuthMethod {
|
|
return &http.BasicAuth{
|
|
Username: g.options.Username,
|
|
Password: g.options.APIToken,
|
|
}
|
|
}
|
|
|
|
func (g *GitHub) CommitAuthor(ctx context.Context) (git.Author, error) {
|
|
g.log.DebugContext(ctx, "getting commit author from current token user")
|
|
|
|
user, _, err := g.client.Users.Get(ctx, "")
|
|
if err != nil {
|
|
g.log.WarnContext(ctx, "failed to get commit author from API, using default github-actions[bot] user", "error", err)
|
|
|
|
return gitHubActionsBotAuthor, nil
|
|
}
|
|
|
|
return git.Author{
|
|
Name: user.GetName(),
|
|
Email: user.GetEmail(),
|
|
}, nil
|
|
}
|
|
|
|
func (g *GitHub) LatestTags(ctx context.Context) (git.Releases, error) {
|
|
g.log.DebugContext(ctx, "listing all tags in github repository")
|
|
|
|
tags, err := all(func(listOptions github.ListOptions) ([]*github.RepositoryTag, *github.Response, error) {
|
|
return g.client.Repositories.ListTags(
|
|
ctx, g.options.Owner, g.options.Repo,
|
|
&listOptions,
|
|
)
|
|
})
|
|
if err != nil {
|
|
return git.Releases{}, err
|
|
}
|
|
|
|
var releases git.Releases
|
|
|
|
for _, ghTag := range tags {
|
|
tag := &git.Tag{
|
|
Hash: ghTag.GetCommit().GetSHA(),
|
|
Name: ghTag.GetName(),
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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.
|
|
releases.Stable = tag
|
|
break
|
|
}
|
|
}
|
|
|
|
return releases, nil
|
|
}
|
|
|
|
func (g *GitHub) CommitsSince(ctx context.Context, tag *git.Tag) ([]git.Commit, error) {
|
|
var repositoryCommits []*github.RepositoryCommit
|
|
var err error
|
|
if tag != nil {
|
|
repositoryCommits, err = g.commitsSinceTag(ctx, tag)
|
|
} else {
|
|
repositoryCommits, err = g.commitsSinceInit(ctx)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var commits = make([]git.Commit, 0, len(repositoryCommits))
|
|
for _, ghCommit := range repositoryCommits {
|
|
commit := git.Commit{
|
|
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)
|
|
}
|
|
|
|
return commits, nil
|
|
}
|
|
|
|
func (g *GitHub) commitsSinceTag(ctx context.Context, tag *git.Tag) ([]*github.RepositoryCommit, error) {
|
|
head := g.options.BaseBranch
|
|
log := g.log.With("base", tag.Hash, "head", head)
|
|
log.Debug("comparing commits")
|
|
|
|
repositoryCommits, err := all(
|
|
func(listOptions github.ListOptions) ([]*github.RepositoryCommit, *github.Response, error) {
|
|
comparison, resp, err := g.client.Repositories.CompareCommits(
|
|
ctx, g.options.Owner, g.options.Repo,
|
|
tag.Hash, head, &listOptions)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return comparison.Commits, resp, err
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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")
|
|
|
|
repositoryCommits, err := all(
|
|
func(listOptions github.ListOptions) ([]*github.RepositoryCommit, *github.Response, error) {
|
|
return g.client.Repositories.ListCommits(
|
|
ctx, g.options.Owner, g.options.Repo,
|
|
&github.CommitsListOptions{
|
|
SHA: head,
|
|
ListOptions: listOptions,
|
|
})
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return repositoryCommits, nil
|
|
}
|
|
|
|
func (g *GitHub) prForCommit(ctx context.Context, commit git.Commit) (*git.PullRequest, error) {
|
|
// 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.
|
|
|
|
g.log.Debug("fetching pull requests associated with commit", "commit.hash", commit.Hash)
|
|
|
|
associatedPRs, err := all(
|
|
func(listOptions github.ListOptions) ([]*github.PullRequest, *github.Response, error) {
|
|
return g.client.PullRequests.ListPullRequestsWithCommit(
|
|
ctx, g.options.Owner, g.options.Repo,
|
|
commit.Hash, &listOptions)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
if pullRequest == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
return gitHubPRToPullRequest(pullRequest), nil
|
|
}
|
|
|
|
func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []releasepr.Label) error {
|
|
g.log.Debug("fetching labels on repo")
|
|
ghLabels, err := all(func(listOptions github.ListOptions) ([]*github.Label, *github.Response, error) {
|
|
return g.client.Issues.ListLabels(
|
|
ctx, g.options.Owner, g.options.Repo,
|
|
&listOptions)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, label := range labels {
|
|
if !slices.ContainsFunc(ghLabels, func(ghLabel *github.Label) bool {
|
|
return ghLabel.GetName() == label.Name
|
|
}) {
|
|
g.log.Info("creating label in repository", "label.name", label.Name)
|
|
_, _, err = g.client.Issues.CreateLabel(
|
|
ctx, g.options.Owner, g.options.Repo,
|
|
&github.Label{
|
|
Name: pointer.Pointer(label.Name),
|
|
Color: pointer.Pointer(label.Color),
|
|
Description: pointer.Pointer(label.Description),
|
|
},
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*releasepr.ReleasePullRequest, error) {
|
|
|
|
prs, err := all(
|
|
func(listOptions github.ListOptions) ([]*github.PullRequest, *github.Response, error) {
|
|
return g.client.PullRequests.ListPullRequestsWithCommit(ctx, g.options.Owner, g.options.Repo, branch, &listOptions)
|
|
})
|
|
if err != nil {
|
|
var ghErr *github.ErrorResponse
|
|
if errors.As(err, &ghErr) {
|
|
if ghErr.Message == fmt.Sprintf("No commit found for SHA: %s", branch) {
|
|
return nil, nil
|
|
}
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
for _, pr := range prs {
|
|
if pr.GetBase().GetRef() == g.options.BaseBranch && pr.GetHead().GetRef() == branch && pr.GetState() == PRStateOpen {
|
|
return gitHubPRToReleasePullRequest(pr), nil
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func (g *GitHub) CreatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
|
|
// If the Pull Request is created without the labels releaser-pleaser will create a new PR in the run. The user may merge both and have duplicate entries in the changelog.
|
|
// We try to avoid this situation by checking for a cancelled context first, and then running both API calls without passing along any cancellations.
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
ctx = context.WithoutCancel(ctx)
|
|
|
|
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
|
|
}
|
|
|
|
pr.ID = ghPR.GetNumber()
|
|
|
|
err = g.SetPullRequestLabels(ctx, pr, []releasepr.Label{}, pr.Labels)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *GitHub) UpdatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
|
|
_, _, err := g.client.PullRequests.Edit(
|
|
ctx, g.options.Owner, g.options.Repo,
|
|
pr.ID, &github.PullRequest{
|
|
Title: &pr.Title,
|
|
Body: &pr.Description,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *GitHub) SetPullRequestLabels(ctx context.Context, pr *releasepr.ReleasePullRequest, remove, add []releasepr.Label) error {
|
|
for _, label := range remove {
|
|
_, err := g.client.Issues.RemoveLabelForIssue(
|
|
ctx, g.options.Owner, g.options.Repo,
|
|
pr.ID, label.Name,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
addString := make([]string, 0, len(add))
|
|
for _, label := range add {
|
|
addString = append(addString, label.Name)
|
|
}
|
|
|
|
_, _, err := g.client.Issues.AddLabelsToIssue(
|
|
ctx, g.options.Owner, g.options.Repo,
|
|
pr.ID, addString,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *GitHub) ClosePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
|
|
_, _, err := g.client.PullRequests.Edit(
|
|
ctx, g.options.Owner, g.options.Repo,
|
|
pr.ID, &github.PullRequest{
|
|
State: pointer.Pointer(PRStateClosed),
|
|
},
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *GitHub) PendingReleases(ctx context.Context, pendingLabel releasepr.Label) ([]*releasepr.ReleasePullRequest, error) {
|
|
ghPRs, err := all(func(listOptions github.ListOptions) ([]*github.PullRequest, *github.Response, error) {
|
|
return g.client.PullRequests.List(
|
|
ctx, g.options.Owner, g.options.Repo,
|
|
&github.PullRequestListOptions{
|
|
State: PRStateClosed,
|
|
Base: g.options.BaseBranch,
|
|
ListOptions: listOptions,
|
|
})
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
prs := make([]*releasepr.ReleasePullRequest, 0, len(ghPRs))
|
|
|
|
for _, pr := range ghPRs {
|
|
pending := slices.ContainsFunc(pr.Labels, func(l *github.Label) bool {
|
|
return l.GetName() == pendingLabel.Name
|
|
})
|
|
if !pending {
|
|
continue
|
|
}
|
|
|
|
// pr.Merged is always nil :(
|
|
if pr.MergedAt == nil {
|
|
// Closed and not merged
|
|
continue
|
|
}
|
|
|
|
prs = append(prs, gitHubPRToReleasePullRequest(pr))
|
|
}
|
|
|
|
return prs, nil
|
|
}
|
|
|
|
func (g *GitHub) CreateRelease(ctx context.Context, commit git.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 all[T any](f func(listOptions github.ListOptions) ([]T, *github.Response, error)) ([]T, error) {
|
|
results := make([]T, 0)
|
|
page := 1
|
|
|
|
for {
|
|
pageResults, resp, err := f(github.ListOptions{Page: page, PerPage: PerPageMax})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
results = append(results, pageResults...)
|
|
|
|
if page == resp.LastPage || resp.LastPage == 0 {
|
|
return results, nil
|
|
}
|
|
page = resp.NextPage
|
|
}
|
|
}
|
|
|
|
func gitHubPRToPullRequest(pr *github.PullRequest) *git.PullRequest {
|
|
return &git.PullRequest{
|
|
ID: pr.GetNumber(),
|
|
Title: pr.GetTitle(),
|
|
Description: pr.GetBody(),
|
|
}
|
|
}
|
|
|
|
func gitHubPRToReleasePullRequest(pr *github.PullRequest) *releasepr.ReleasePullRequest {
|
|
labels := make([]releasepr.Label, 0, len(pr.Labels))
|
|
for _, label := range pr.Labels {
|
|
labelName := label.GetName()
|
|
if i := slices.IndexFunc(releasepr.KnownLabels, func(label releasepr.Label) bool {
|
|
return label.Name == labelName
|
|
}); i >= 0 {
|
|
labels = append(labels, releasepr.KnownLabels[i])
|
|
}
|
|
}
|
|
|
|
var releaseCommit *git.Commit
|
|
if pr.MergeCommitSHA != nil {
|
|
releaseCommit = &git.Commit{Hash: pr.GetMergeCommitSHA()}
|
|
}
|
|
|
|
return &releasepr.ReleasePullRequest{
|
|
PullRequest: *gitHubPRToPullRequest(pr),
|
|
Labels: labels,
|
|
|
|
Head: pr.GetHead().GetRef(),
|
|
ReleaseCommit: releaseCommit,
|
|
}
|
|
}
|
|
|
|
func (g *Options) autodiscover() {
|
|
if apiToken := os.Getenv(EnvAPIToken); apiToken != "" {
|
|
g.APIToken = apiToken
|
|
}
|
|
// TODO: Check if there is a better solution for cloning/pushing locally
|
|
if username := os.Getenv(EnvUsername); username != "" {
|
|
g.Username = username
|
|
}
|
|
|
|
if envRepository := os.Getenv(EnvRepository); 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
|
|
}
|
|
}
|
|
}
|
|
|
|
type Options struct {
|
|
forge.Options
|
|
|
|
Owner string
|
|
Repo string
|
|
|
|
APIToken string
|
|
Username string
|
|
}
|
|
|
|
func New(log *slog.Logger, options *Options) *GitHub {
|
|
options.autodiscover()
|
|
|
|
client := github.NewClient(nil)
|
|
if options.APIToken != "" {
|
|
client = client.WithAuthToken(options.APIToken)
|
|
}
|
|
|
|
gh := &GitHub{
|
|
options: options,
|
|
|
|
client: client,
|
|
log: log.With("forge", "github"),
|
|
}
|
|
|
|
return gh
|
|
}
|