releaser-pleaser/internal/forge/github/github.go
Julian Tölle d259921215
fix(github): duplicate release pr when process is stopped at wrong moment (#236)
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
2025-08-23 23:00:52 +02:00

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
}