github.com/triarius/goreleaser@v1.12.5/internal/client/github.go (about)

     1  package client
     2  
     3  import (
     4  	"crypto/tls"
     5  	"fmt"
     6  	"net/http"
     7  	"net/url"
     8  	"os"
     9  	"reflect"
    10  	"strconv"
    11  	"strings"
    12  
    13  	"github.com/caarlos0/log"
    14  	"github.com/google/go-github/v48/github"
    15  	"github.com/triarius/goreleaser/internal/artifact"
    16  	"github.com/triarius/goreleaser/internal/tmpl"
    17  	"github.com/triarius/goreleaser/pkg/config"
    18  	"github.com/triarius/goreleaser/pkg/context"
    19  	"golang.org/x/oauth2"
    20  )
    21  
    22  const DefaultGitHubDownloadURL = "https://github.com"
    23  
    24  type githubClient struct {
    25  	client *github.Client
    26  }
    27  
    28  // NewGitHub returns a github client implementation.
    29  func NewGitHub(ctx *context.Context, token string) (GitHubClient, error) {
    30  	ts := oauth2.StaticTokenSource(
    31  		&oauth2.Token{AccessToken: token},
    32  	)
    33  
    34  	httpClient := oauth2.NewClient(ctx, ts)
    35  	base := httpClient.Transport.(*oauth2.Transport).Base
    36  	if base == nil || reflect.ValueOf(base).IsNil() {
    37  		base = http.DefaultTransport
    38  	}
    39  	// nolint: gosec
    40  	base.(*http.Transport).TLSClientConfig = &tls.Config{
    41  		InsecureSkipVerify: ctx.Config.GitHubURLs.SkipTLSVerify,
    42  	}
    43  	base.(*http.Transport).Proxy = http.ProxyFromEnvironment
    44  	httpClient.Transport.(*oauth2.Transport).Base = base
    45  
    46  	client := github.NewClient(httpClient)
    47  	err := overrideGitHubClientAPI(ctx, client)
    48  	if err != nil {
    49  		return &githubClient{}, err
    50  	}
    51  
    52  	return &githubClient{client: client}, nil
    53  }
    54  
    55  func (c *githubClient) GenerateReleaseNotes(ctx *context.Context, repo Repo, prev, current string) (string, error) {
    56  	notes, _, err := c.client.Repositories.GenerateReleaseNotes(ctx, repo.Owner, repo.Name, &github.GenerateNotesOptions{
    57  		TagName:         current,
    58  		PreviousTagName: github.String(prev),
    59  	})
    60  	if err != nil {
    61  		return "", err
    62  	}
    63  	return notes.Body, err
    64  }
    65  
    66  func (c *githubClient) Changelog(ctx *context.Context, repo Repo, prev, current string) (string, error) {
    67  	var log []string
    68  	opts := &github.ListOptions{PerPage: 100}
    69  
    70  	for {
    71  		result, resp, err := c.client.Repositories.CompareCommits(ctx, repo.Owner, repo.Name, prev, current, opts)
    72  		if err != nil {
    73  			return "", err
    74  		}
    75  		for _, commit := range result.Commits {
    76  			log = append(log, fmt.Sprintf(
    77  				"%s: %s (@%s)",
    78  				commit.GetSHA(),
    79  				strings.Split(commit.Commit.GetMessage(), "\n")[0],
    80  				commit.GetAuthor().GetLogin(),
    81  			))
    82  		}
    83  		if resp.NextPage == 0 {
    84  			break
    85  		}
    86  		opts.Page = resp.NextPage
    87  	}
    88  
    89  	return strings.Join(log, "\n"), nil
    90  }
    91  
    92  // GetDefaultBranch returns the default branch of a github repo
    93  func (c *githubClient) GetDefaultBranch(ctx *context.Context, repo Repo) (string, error) {
    94  	p, res, err := c.client.Repositories.Get(ctx, repo.Owner, repo.Name)
    95  	if err != nil {
    96  		log.WithFields(log.Fields{
    97  			"projectID":  repo.String(),
    98  			"statusCode": res.StatusCode,
    99  			"err":        err.Error(),
   100  		}).Warn("error checking for default branch")
   101  		return "", err
   102  	}
   103  	return p.GetDefaultBranch(), nil
   104  }
   105  
   106  // CloseMilestone closes a given milestone.
   107  func (c *githubClient) CloseMilestone(ctx *context.Context, repo Repo, title string) error {
   108  	milestone, err := c.getMilestoneByTitle(ctx, repo, title)
   109  	if err != nil {
   110  		return err
   111  	}
   112  
   113  	if milestone == nil {
   114  		return ErrNoMilestoneFound{Title: title}
   115  	}
   116  
   117  	closedState := "closed"
   118  	milestone.State = &closedState
   119  
   120  	_, _, err = c.client.Issues.EditMilestone(
   121  		ctx,
   122  		repo.Owner,
   123  		repo.Name,
   124  		*milestone.Number,
   125  		milestone,
   126  	)
   127  
   128  	return err
   129  }
   130  
   131  func (c *githubClient) CreateFile(
   132  	ctx *context.Context,
   133  	commitAuthor config.CommitAuthor,
   134  	repo Repo,
   135  	content []byte,
   136  	path,
   137  	message string,
   138  ) error {
   139  	var branch string
   140  	var err error
   141  	if repo.Branch != "" {
   142  		branch = repo.Branch
   143  	} else {
   144  		branch, err = c.GetDefaultBranch(ctx, repo)
   145  		if err != nil {
   146  			// Fall back to sdk default
   147  			log.WithFields(log.Fields{
   148  				"fileName":        path,
   149  				"projectID":       repo.String(),
   150  				"requestedBranch": branch,
   151  				"err":             err.Error(),
   152  			}).Warn("error checking for default branch, using master")
   153  		}
   154  	}
   155  	options := &github.RepositoryContentFileOptions{
   156  		Committer: &github.CommitAuthor{
   157  			Name:  github.String(commitAuthor.Name),
   158  			Email: github.String(commitAuthor.Email),
   159  		},
   160  		Content: content,
   161  		Message: github.String(message),
   162  	}
   163  
   164  	// Set the branch if we got it above...otherwise, just default to
   165  	// whatever the SDK does auto-magically
   166  	if branch != "" {
   167  		options.Branch = &branch
   168  	}
   169  
   170  	file, _, res, err := c.client.Repositories.GetContents(
   171  		ctx,
   172  		repo.Owner,
   173  		repo.Name,
   174  		path,
   175  		&github.RepositoryContentGetOptions{},
   176  	)
   177  	if err != nil && (res == nil || res.StatusCode != 404) {
   178  		return err
   179  	}
   180  
   181  	if res.StatusCode == 404 {
   182  		_, _, err = c.client.Repositories.CreateFile(
   183  			ctx,
   184  			repo.Owner,
   185  			repo.Name,
   186  			path,
   187  			options,
   188  		)
   189  		return err
   190  	}
   191  	options.SHA = file.SHA
   192  	_, _, err = c.client.Repositories.UpdateFile(
   193  		ctx,
   194  		repo.Owner,
   195  		repo.Name,
   196  		path,
   197  		options,
   198  	)
   199  	return err
   200  }
   201  
   202  func (c *githubClient) CreateRelease(ctx *context.Context, body string) (string, error) {
   203  	var release *github.RepositoryRelease
   204  	title, err := tmpl.New(ctx).Apply(ctx.Config.Release.NameTemplate)
   205  	if err != nil {
   206  		return "", err
   207  	}
   208  
   209  	if ctx.Config.Release.Draft && ctx.Config.Release.ReplaceExistingDraft {
   210  		if err := c.deleteExistingDraftRelease(ctx, title); err != nil {
   211  			return "", err
   212  		}
   213  	}
   214  
   215  	// Truncate the release notes if it's too long (github doesn't allow more than 125000 characters)
   216  	body = truncateReleaseBody(body)
   217  
   218  	data := &github.RepositoryRelease{
   219  		Name:       github.String(title),
   220  		TagName:    github.String(ctx.Git.CurrentTag),
   221  		Body:       github.String(body),
   222  		Draft:      github.Bool(ctx.Config.Release.Draft),
   223  		Prerelease: github.Bool(ctx.PreRelease),
   224  	}
   225  
   226  	if ctx.Config.Release.DiscussionCategoryName != "" {
   227  		data.DiscussionCategoryName = github.String(ctx.Config.Release.DiscussionCategoryName)
   228  	}
   229  
   230  	if target := ctx.Config.Release.TargetCommitish; target != "" {
   231  		target, err := tmpl.New(ctx).Apply(target)
   232  		if err != nil {
   233  			return "", err
   234  		}
   235  		if target != "" {
   236  			data.TargetCommitish = github.String(target)
   237  		}
   238  	}
   239  
   240  	release, _, err = c.client.Repositories.GetReleaseByTag(
   241  		ctx,
   242  		ctx.Config.Release.GitHub.Owner,
   243  		ctx.Config.Release.GitHub.Name,
   244  		data.GetTagName(),
   245  	)
   246  	if err != nil {
   247  		release, _, err = c.client.Repositories.CreateRelease(
   248  			ctx,
   249  			ctx.Config.Release.GitHub.Owner,
   250  			ctx.Config.Release.GitHub.Name,
   251  			data,
   252  		)
   253  	} else {
   254  		data.Body = github.String(getReleaseNotes(release.GetBody(), body, ctx.Config.Release.ReleaseNotesMode))
   255  		release, _, err = c.client.Repositories.EditRelease(
   256  			ctx,
   257  			ctx.Config.Release.GitHub.Owner,
   258  			ctx.Config.Release.GitHub.Name,
   259  			release.GetID(),
   260  			data,
   261  		)
   262  	}
   263  	if err != nil {
   264  		log.WithField("url", release.GetHTMLURL()).Info("release updated")
   265  	}
   266  
   267  	githubReleaseID := strconv.FormatInt(release.GetID(), 10)
   268  	return githubReleaseID, err
   269  }
   270  
   271  func (c *githubClient) ReleaseURLTemplate(ctx *context.Context) (string, error) {
   272  	downloadURL, err := tmpl.New(ctx).Apply(ctx.Config.GitHubURLs.Download)
   273  	if err != nil {
   274  		return "", fmt.Errorf("templating GitHub download URL: %w", err)
   275  	}
   276  
   277  	return fmt.Sprintf(
   278  		"%s/%s/%s/releases/download/{{ .Tag }}/{{ .ArtifactName }}",
   279  		downloadURL,
   280  		ctx.Config.Release.GitHub.Owner,
   281  		ctx.Config.Release.GitHub.Name,
   282  	), nil
   283  }
   284  
   285  func (c *githubClient) Upload(
   286  	ctx *context.Context,
   287  	releaseID string,
   288  	artifact *artifact.Artifact,
   289  	file *os.File,
   290  ) error {
   291  	githubReleaseID, err := strconv.ParseInt(releaseID, 10, 64)
   292  	if err != nil {
   293  		return err
   294  	}
   295  	_, resp, err := c.client.Repositories.UploadReleaseAsset(
   296  		ctx,
   297  		ctx.Config.Release.GitHub.Owner,
   298  		ctx.Config.Release.GitHub.Name,
   299  		githubReleaseID,
   300  		&github.UploadOptions{
   301  			Name: artifact.Name,
   302  		},
   303  		file,
   304  	)
   305  	if err == nil {
   306  		return nil
   307  	}
   308  	if resp != nil && resp.StatusCode == 422 {
   309  		return err
   310  	}
   311  	return RetriableError{err}
   312  }
   313  
   314  // getMilestoneByTitle returns a milestone by title.
   315  func (c *githubClient) getMilestoneByTitle(ctx *context.Context, repo Repo, title string) (*github.Milestone, error) {
   316  	// The GitHub API/SDK does not provide lookup by title functionality currently.
   317  	opts := &github.MilestoneListOptions{
   318  		ListOptions: github.ListOptions{PerPage: 100},
   319  	}
   320  
   321  	for {
   322  		milestones, resp, err := c.client.Issues.ListMilestones(
   323  			ctx,
   324  			repo.Owner,
   325  			repo.Name,
   326  			opts,
   327  		)
   328  		if err != nil {
   329  			return nil, err
   330  		}
   331  
   332  		for _, m := range milestones {
   333  			if m != nil && m.Title != nil && *m.Title == title {
   334  				return m, nil
   335  			}
   336  		}
   337  
   338  		if resp.NextPage == 0 {
   339  			break
   340  		}
   341  
   342  		opts.Page = resp.NextPage
   343  	}
   344  
   345  	return nil, nil
   346  }
   347  
   348  func overrideGitHubClientAPI(ctx *context.Context, client *github.Client) error {
   349  	if ctx.Config.GitHubURLs.API == "" {
   350  		return nil
   351  	}
   352  
   353  	apiURL, err := tmpl.New(ctx).Apply(ctx.Config.GitHubURLs.API)
   354  	if err != nil {
   355  		return fmt.Errorf("templating GitHub API URL: %w", err)
   356  	}
   357  	api, err := url.Parse(apiURL)
   358  	if err != nil {
   359  		return err
   360  	}
   361  
   362  	uploadURL, err := tmpl.New(ctx).Apply(ctx.Config.GitHubURLs.Upload)
   363  	if err != nil {
   364  		return fmt.Errorf("templating GitHub upload URL: %w", err)
   365  	}
   366  	upload, err := url.Parse(uploadURL)
   367  	if err != nil {
   368  		return err
   369  	}
   370  
   371  	client.BaseURL = api
   372  	client.UploadURL = upload
   373  
   374  	return nil
   375  }
   376  
   377  func (c *githubClient) deleteExistingDraftRelease(ctx *context.Context, name string) error {
   378  	opt := github.ListOptions{PerPage: 50}
   379  	for {
   380  		releases, resp, err := c.client.Repositories.ListReleases(
   381  			ctx,
   382  			ctx.Config.Release.GitHub.Owner,
   383  			ctx.Config.Release.GitHub.Name,
   384  			&opt,
   385  		)
   386  		if err != nil {
   387  			return fmt.Errorf("could not delete existing drafts: %w", err)
   388  		}
   389  		for _, r := range releases {
   390  			if r.GetDraft() && r.GetName() == name {
   391  				if _, err := c.client.Repositories.DeleteRelease(
   392  					ctx,
   393  					ctx.Config.Release.GitHub.Owner,
   394  					ctx.Config.Release.GitHub.Name,
   395  					r.GetID(),
   396  				); err != nil {
   397  					return fmt.Errorf("could not delete previous draft release: %w", err)
   398  				}
   399  
   400  				log.WithFields(log.Fields{
   401  					"commit": r.GetTargetCommitish(),
   402  					"tag":    r.GetTagName(),
   403  					"name":   r.GetName(),
   404  				}).Info("deleted previous draft release")
   405  
   406  				// in theory, there should be only 1 release matching, so we can just return
   407  				return nil
   408  			}
   409  		}
   410  		if resp.NextPage == 0 {
   411  			return nil
   412  		}
   413  		opt.Page = resp.NextPage
   414  	}
   415  }