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 }