mirror of
https://github.com/apricote/releaser-pleaser.git
synced 2026-01-13 13:21:00 +00:00
feat: add support for GitLab repositories (#49)
This commit is contained in:
parent
5ea41654a7
commit
48d9ede0a2
5 changed files with 472 additions and 19 deletions
|
|
@ -140,7 +140,7 @@ func (g *GitHub) CommitsSince(ctx context.Context, tag *git.Tag) ([]git.Commit,
|
|||
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", "base", tag.Hash, "head", head)
|
||||
log.Debug("comparing commits")
|
||||
|
||||
repositoryCommits, err := all(
|
||||
func(listOptions github.ListOptions) ([]*github.RepositoryCommit, *github.Response, error) {
|
||||
|
|
|
|||
|
|
@ -1,31 +1,430 @@
|
|||
package gitlab
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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/xanzy/go-gitlab"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// var _ forge.Forge = &GitLab{}
|
||||
const (
|
||||
PerPageMax = 100
|
||||
PRStateOpen = "opened"
|
||||
PRStateMerged = "merged"
|
||||
PRStateEventClose = "close"
|
||||
EnvAPIToken = "GITLAB_TOKEN" // nolint:gosec // Not actually a hardcoded credential
|
||||
)
|
||||
|
||||
type GitLab struct {
|
||||
options forge.Options
|
||||
}
|
||||
options *Options
|
||||
|
||||
func (g *GitLab) autodiscover() {
|
||||
// Read settings from GitLab-CI env vars
|
||||
}
|
||||
|
||||
func New(options forge.Options) *GitLab {
|
||||
gl := &GitLab{
|
||||
options: options,
|
||||
}
|
||||
|
||||
gl.autodiscover()
|
||||
|
||||
return gl
|
||||
client *gitlab.Client
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func (g *GitLab) RepoURL() string {
|
||||
return fmt.Sprintf("https://gitlab.com/%s", g.options.Repository)
|
||||
}
|
||||
|
||||
func (g *GitLab) CloneURL() string {
|
||||
return fmt.Sprintf("https://gitlab.com/%s/%s.git", g.options.Path, g.options.Repo)
|
||||
}
|
||||
|
||||
func (g *GitLab) ReleaseURL(version string) string {
|
||||
return fmt.Sprintf("https://gitlab.com/%s/%s/-/releases/%s", g.options.Path, g.options.Repo, version)
|
||||
}
|
||||
|
||||
func (g *GitLab) PullRequestURL(id int) string {
|
||||
return fmt.Sprintf("https://gitlab.com/%s/%s/-/merge_requests/%d", g.options.Path, g.options.Repo, id)
|
||||
}
|
||||
|
||||
func (g *GitLab) GitAuth() transport.AuthMethod {
|
||||
return &http.BasicAuth{
|
||||
// Username just needs to be any non-blank value
|
||||
Username: "api-token",
|
||||
Password: g.options.APIToken,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GitLab) LatestTags(ctx context.Context) (git.Releases, error) {
|
||||
g.log.DebugContext(ctx, "listing all tags in gitlab repository")
|
||||
|
||||
tags, err := all(func(listOptions gitlab.ListOptions) ([]*gitlab.Tag, *gitlab.Response, error) {
|
||||
return g.client.Tags.ListTags(g.options.ProjectID, &gitlab.ListTagsOptions{
|
||||
OrderBy: pointer.Pointer("updated"),
|
||||
ListOptions: listOptions,
|
||||
}, gitlab.WithContext(ctx))
|
||||
})
|
||||
if err != nil {
|
||||
return git.Releases{}, err
|
||||
}
|
||||
|
||||
var releases git.Releases
|
||||
for _, glTag := range tags {
|
||||
tag := &git.Tag{
|
||||
Hash: glTag.Commit.ID,
|
||||
Name: glTag.Name,
|
||||
}
|
||||
|
||||
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 *GitLab) CommitsSince(ctx context.Context, tag *git.Tag) ([]git.Commit, error) {
|
||||
var err error
|
||||
|
||||
head := g.options.BaseBranch
|
||||
log := g.log.With("head", head)
|
||||
|
||||
refName := ""
|
||||
if tag != nil {
|
||||
log = log.With("base", tag.Hash)
|
||||
refName = fmt.Sprintf("%s..%s", tag.Hash, head)
|
||||
} else {
|
||||
refName = head
|
||||
}
|
||||
log.Debug("listing commits", "ref.name", refName)
|
||||
|
||||
gitLabCommits, err := all(func(listOptions gitlab.ListOptions) ([]*gitlab.Commit, *gitlab.Response, error) {
|
||||
return g.client.Commits.ListCommits(g.options.ProjectID, &gitlab.ListCommitsOptions{
|
||||
RefName: &refName,
|
||||
ListOptions: listOptions,
|
||||
}, gitlab.WithContext(ctx))
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var commits = make([]git.Commit, 0, len(gitLabCommits))
|
||||
for _, ghCommit := range gitLabCommits {
|
||||
commit := git.Commit{
|
||||
Hash: ghCommit.ID,
|
||||
Message: ghCommit.Message,
|
||||
}
|
||||
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 *GitLab) prForCommit(ctx context.Context, commit git.Commit) (*git.PullRequest, error) {
|
||||
// We naively look up the associated MR for each commit through the "List merge requests associated with a commit"
|
||||
// endpoint. This requires len(commits) requests.
|
||||
// Using the "List merge requests" endpoint might be faster, as it allows us to fetch 100 arbitrary MRs per request,
|
||||
// but worst case we need to look up all MRs made in the repository ever.
|
||||
|
||||
log := g.log.With("commit.hash", commit.Hash)
|
||||
|
||||
log.Debug("fetching pull requests associated with commit")
|
||||
associatedMRs, _, err := g.client.Commits.ListMergeRequestsByCommit(
|
||||
g.options.ProjectID, commit.Hash,
|
||||
gitlab.WithContext(ctx),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var mergeRequest *gitlab.MergeRequest
|
||||
for _, mr := range associatedMRs {
|
||||
// We only look for the MR that has this commit set as the "merge commit" => The result of squashing this branch onto main
|
||||
if mr.MergeCommitSHA == commit.Hash {
|
||||
mergeRequest = mr
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if mergeRequest == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return gitlabMRToPullRequest(mergeRequest), nil
|
||||
}
|
||||
|
||||
func (g *GitLab) EnsureLabelsExist(ctx context.Context, labels []releasepr.Label) error {
|
||||
g.log.Debug("fetching labels on repo")
|
||||
glLabels, err := all(func(listOptions gitlab.ListOptions) ([]*gitlab.Label, *gitlab.Response, error) {
|
||||
return g.client.Labels.ListLabels(g.options.ProjectID, &gitlab.ListLabelsOptions{
|
||||
ListOptions: listOptions,
|
||||
}, gitlab.WithContext(ctx))
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, label := range labels {
|
||||
if !slices.ContainsFunc(glLabels, func(glLabel *gitlab.Label) bool {
|
||||
return glLabel.Name == label.Name
|
||||
}) {
|
||||
g.log.Info("creating label in repository", "label.name", label)
|
||||
_, _, err := g.client.Labels.CreateLabel(g.options.ProjectID, &gitlab.CreateLabelOptions{
|
||||
Name: pointer.Pointer(label.Name),
|
||||
Color: pointer.Pointer("#" + label.Color),
|
||||
Description: pointer.Pointer(label.Description),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *GitLab) PullRequestForBranch(ctx context.Context, branch string) (*releasepr.ReleasePullRequest, error) {
|
||||
// There should only be a single open merge request from branch into g.options.BaseBranch at any given moment.
|
||||
// We can skip pagination and just return the first result.
|
||||
mrs, _, err := g.client.MergeRequests.ListProjectMergeRequests(g.options.ProjectID, &gitlab.ListProjectMergeRequestsOptions{
|
||||
State: pointer.Pointer(PRStateOpen),
|
||||
SourceBranch: pointer.Pointer(branch),
|
||||
TargetBranch: pointer.Pointer(g.options.BaseBranch),
|
||||
ListOptions: gitlab.ListOptions{
|
||||
Page: 1,
|
||||
PerPage: PerPageMax,
|
||||
},
|
||||
}, gitlab.WithContext(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(mrs) >= 1 {
|
||||
return gitlabMRToReleasePullRequest(mrs[0]), nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (g *GitLab) CreatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
|
||||
labels := make(gitlab.LabelOptions, 0, len(pr.Labels))
|
||||
for _, label := range pr.Labels {
|
||||
labels = append(labels, label.Name)
|
||||
}
|
||||
|
||||
glMR, _, err := g.client.MergeRequests.CreateMergeRequest(g.options.ProjectID, &gitlab.CreateMergeRequestOptions{
|
||||
Title: &pr.Title,
|
||||
Description: &pr.Description,
|
||||
SourceBranch: &pr.Head,
|
||||
TargetBranch: &g.options.BaseBranch,
|
||||
Labels: &labels,
|
||||
}, gitlab.WithContext(ctx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pr.ID = glMR.IID
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *GitLab) UpdatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
|
||||
_, _, err := g.client.MergeRequests.UpdateMergeRequest(g.options.ProjectID, pr.ID, &gitlab.UpdateMergeRequestOptions{
|
||||
Title: &pr.Title,
|
||||
Description: &pr.Description,
|
||||
}, gitlab.WithContext(ctx))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *GitLab) SetPullRequestLabels(ctx context.Context, pr *releasepr.ReleasePullRequest, remove, add []releasepr.Label) error {
|
||||
removeLabels := make(gitlab.LabelOptions, 0, len(remove))
|
||||
for _, label := range remove {
|
||||
removeLabels = append(removeLabels, label.Name)
|
||||
}
|
||||
|
||||
addLabels := make(gitlab.LabelOptions, 0, len(add))
|
||||
for _, label := range add {
|
||||
addLabels = append(addLabels, label.Name)
|
||||
}
|
||||
|
||||
_, _, err := g.client.MergeRequests.UpdateMergeRequest(g.options.ProjectID, pr.ID, &gitlab.UpdateMergeRequestOptions{
|
||||
RemoveLabels: &removeLabels,
|
||||
AddLabels: &addLabels,
|
||||
}, gitlab.WithContext(ctx))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *GitLab) ClosePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
|
||||
_, _, err := g.client.MergeRequests.UpdateMergeRequest(g.options.ProjectID, pr.ID, &gitlab.UpdateMergeRequestOptions{
|
||||
StateEvent: pointer.Pointer(PRStateEventClose),
|
||||
}, gitlab.WithContext(ctx))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *GitLab) PendingReleases(ctx context.Context, pendingLabel releasepr.Label) ([]*releasepr.ReleasePullRequest, error) {
|
||||
glMRs, err := all(func(listOptions gitlab.ListOptions) ([]*gitlab.MergeRequest, *gitlab.Response, error) {
|
||||
return g.client.MergeRequests.ListMergeRequests(&gitlab.ListMergeRequestsOptions{
|
||||
State: pointer.Pointer(PRStateMerged),
|
||||
Labels: &gitlab.LabelOptions{pendingLabel.Name},
|
||||
TargetBranch: pointer.Pointer(g.options.BaseBranch),
|
||||
ListOptions: listOptions,
|
||||
}, gitlab.WithContext(ctx))
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prs := make([]*releasepr.ReleasePullRequest, 0, len(glMRs))
|
||||
|
||||
for _, mr := range glMRs {
|
||||
prs = append(prs, gitlabMRToReleasePullRequest(mr))
|
||||
}
|
||||
|
||||
return prs, nil
|
||||
}
|
||||
|
||||
func (g *GitLab) CreateRelease(ctx context.Context, commit git.Commit, title, changelog string, _, _ bool) error {
|
||||
_, _, err := g.client.Releases.CreateRelease(g.options.ProjectID, &gitlab.CreateReleaseOptions{
|
||||
Name: &title,
|
||||
TagName: &title,
|
||||
Description: &changelog,
|
||||
Ref: &commit.Hash,
|
||||
}, gitlab.WithContext(ctx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func all[T any](f func(listOptions gitlab.ListOptions) ([]T, *gitlab.Response, error)) ([]T, error) {
|
||||
results := make([]T, 0)
|
||||
page := 1
|
||||
|
||||
for {
|
||||
pageResults, resp, err := f(gitlab.ListOptions{Page: page, PerPage: PerPageMax})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results = append(results, pageResults...)
|
||||
|
||||
if page == resp.TotalPages || resp.TotalPages == 0 {
|
||||
return results, nil
|
||||
}
|
||||
page = resp.NextPage
|
||||
}
|
||||
}
|
||||
|
||||
func gitlabMRToPullRequest(pr *gitlab.MergeRequest) *git.PullRequest {
|
||||
return &git.PullRequest{
|
||||
ID: pr.IID,
|
||||
Title: pr.Title,
|
||||
Description: pr.Description,
|
||||
}
|
||||
}
|
||||
|
||||
func gitlabMRToReleasePullRequest(pr *gitlab.MergeRequest) *releasepr.ReleasePullRequest {
|
||||
labels := make([]releasepr.Label, 0, len(pr.Labels))
|
||||
for _, labelName := range pr.Labels {
|
||||
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 != "" {
|
||||
releaseCommit = &git.Commit{Hash: pr.MergeCommitSHA}
|
||||
}
|
||||
|
||||
return &releasepr.ReleasePullRequest{
|
||||
PullRequest: *gitlabMRToPullRequest(pr),
|
||||
Labels: labels,
|
||||
|
||||
Head: pr.SHA,
|
||||
ReleaseCommit: releaseCommit,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Options) autodiscover() {
|
||||
// Read settings from GitLab-CI env vars
|
||||
if apiToken := os.Getenv(EnvAPIToken); apiToken != "" {
|
||||
g.APIToken = apiToken
|
||||
}
|
||||
|
||||
// TODO: Replace hardcode project-id with a better alternative
|
||||
g.ProjectID = 60698565
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
forge.Options
|
||||
|
||||
Path string
|
||||
Repo string
|
||||
ProjectID int
|
||||
|
||||
APIToken string
|
||||
}
|
||||
|
||||
func New(log *slog.Logger, options *Options) (*GitLab, error) {
|
||||
log = log.With("forge", "gitlab")
|
||||
options.autodiscover()
|
||||
|
||||
client, err := gitlab.NewClient(options.APIToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gl := &GitLab{
|
||||
options: options,
|
||||
|
||||
client: client,
|
||||
log: log,
|
||||
}
|
||||
|
||||
return gl, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue