mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2026-03-21 09:24:55 +01:00
## Summary Fixes #5590 Fixes #5713 This PR fixes an issue where webhook handling fails with "failure to parse hook" error when the user's OAuth access token has expired. The root cause is that the Bitbucket and GitHub forge implementations make API calls during webhook processing without first refreshing the OAuth token. ## Problem When a webhook arrives from Bitbucket or GitHub, the `Hook()` function (and its helper functions) make API calls to fetch additional data (changed files, repo info, etc.). These API calls use the stored OAuth access token, which may have expired. **Before this fix:** 1. Webhook arrives 2. `Hook()` makes API calls with potentially expired token 3. API call fails with "OAuth2 access token expired" 4. Error bubbles up as HTTP 400 "failure to parse hook" 5. `forge.Refresh()` is called later in `PostHook()` - but it's too late **Example error from logs:** `failure to parse hook error="OAuth2 access token expired. Use your refresh token to obtain a new access token."` ## Solution Add `forge.Refresh()` calls before making API calls in the webhook handling code paths. This follows the same pattern already used by: - Bitbucket Data Center forge (`server/forge/bitbucketdatacenter/bitbucketdatacenter.go`) - Other code paths like `pipeline.Create()`, `cron.go`, etc. ### Changes **Bitbucket** (`server/forge/bitbucket/bitbucket.go`): - Added `forge.Refresh()` in `Hook()` before API calls **GitHub** (`server/forge/github/github.go`): - Added `forge.Refresh()` in `loadChangedFilesFromPullRequest()` - Added `forge.Refresh()` in `getTagCommitSHA()` - Added `forge.Refresh()` in `loadChangedFilesFromCommits()` ## Testing - All existing Bitbucket and GitHub forge tests pass - Tested in production environment with Bitbucket (waited for token expiry, webhook succeeded after fix)
842 lines
24 KiB
Go
842 lines
24 KiB
Go
// Copyright 2022 Woodpecker Authors
|
|
// Copyright 2018 Drone.IO Inc.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package github
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/go-github/v82/github"
|
|
"github.com/rs/zerolog/log"
|
|
"golang.org/x/oauth2"
|
|
|
|
"go.woodpecker-ci.org/woodpecker/v3/server"
|
|
"go.woodpecker-ci.org/woodpecker/v3/server/forge"
|
|
"go.woodpecker-ci.org/woodpecker/v3/server/forge/common"
|
|
forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types"
|
|
"go.woodpecker-ci.org/woodpecker/v3/server/model"
|
|
"go.woodpecker-ci.org/woodpecker/v3/server/store"
|
|
"go.woodpecker-ci.org/woodpecker/v3/shared/httputil"
|
|
"go.woodpecker-ci.org/woodpecker/v3/shared/utils"
|
|
)
|
|
|
|
type contextKey string
|
|
|
|
const (
|
|
defaultURL = "https://github.com" // Default GitHub URL
|
|
defaultAPI = "https://api.github.com/" // Default GitHub API URL
|
|
defaultPageSize = 100
|
|
githubClientKey contextKey = "github_client"
|
|
)
|
|
|
|
// Opts defines configuration options.
|
|
type Opts struct {
|
|
URL string // GitHub server url.
|
|
OAuthClientID string // GitHub oauth client id.
|
|
OAuthClientSecret string // GitHub oauth client secret.
|
|
SkipVerify bool // Skip ssl verification.
|
|
MergeRef bool // Clone pull requests using the merge ref.
|
|
OnlyPublic bool // Only obtain OAuth tokens with access to public repos.
|
|
OAuthHost string // Public url for oauth if different from url.
|
|
}
|
|
|
|
// New returns a Forge implementation that integrates with a GitHub Cloud or
|
|
// GitHub Enterprise version control hosting provider.
|
|
func New(id int64, opts Opts) (forge.Forge, error) {
|
|
r := &client{
|
|
id: id,
|
|
API: defaultAPI,
|
|
url: defaultURL,
|
|
Client: opts.OAuthClientID,
|
|
Secret: opts.OAuthClientSecret,
|
|
oAuthHost: opts.OAuthHost,
|
|
SkipVerify: opts.SkipVerify,
|
|
MergeRef: opts.MergeRef,
|
|
OnlyPublic: opts.OnlyPublic,
|
|
}
|
|
if opts.URL != defaultURL {
|
|
r.url = strings.TrimSuffix(opts.URL, "/")
|
|
r.API = r.url + "/api/v3/"
|
|
}
|
|
|
|
return r, nil
|
|
}
|
|
|
|
type client struct {
|
|
id int64
|
|
url string
|
|
API string
|
|
Client string
|
|
Secret string
|
|
SkipVerify bool
|
|
MergeRef bool
|
|
OnlyPublic bool
|
|
oAuthHost string
|
|
}
|
|
|
|
// Name returns the string name of this driver.
|
|
func (c *client) Name() string {
|
|
return "github"
|
|
}
|
|
|
|
// URL returns the root url of a configured forge.
|
|
func (c *client) URL() string {
|
|
return c.url
|
|
}
|
|
|
|
// Login authenticates the session and returns the forge user details.
|
|
func (c *client) Login(ctx context.Context, req *forge_types.OAuthRequest) (*model.User, string, error) {
|
|
config := c.newConfig()
|
|
redirectURL := config.AuthCodeURL(req.State)
|
|
|
|
// check the OAuth code
|
|
if len(req.Code) == 0 {
|
|
// TODO(bradrydzewski) we really should be using a random value here and
|
|
// storing in a cookie for verification in the next stage of the workflow.
|
|
|
|
return nil, redirectURL, nil
|
|
}
|
|
|
|
token, err := config.Exchange(c.newContext(ctx), req.Code)
|
|
if err != nil {
|
|
return nil, redirectURL, err
|
|
}
|
|
|
|
client := c.newClientToken(ctx, token.AccessToken)
|
|
user, _, err := client.Users.Get(ctx, "")
|
|
if err != nil {
|
|
return nil, redirectURL, err
|
|
}
|
|
|
|
emails, _, err := client.Users.ListEmails(ctx, nil)
|
|
if err != nil {
|
|
return nil, redirectURL, err
|
|
}
|
|
email := matchingEmail(emails, c.API)
|
|
if email == nil {
|
|
return nil, redirectURL, fmt.Errorf("no verified Email address for GitHub account")
|
|
}
|
|
|
|
return &model.User{
|
|
Login: user.GetLogin(),
|
|
Email: email.GetEmail(),
|
|
AccessToken: token.AccessToken,
|
|
RefreshToken: token.RefreshToken,
|
|
Expiry: token.Expiry.UTC().Unix(),
|
|
Avatar: user.GetAvatarURL(),
|
|
ForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(user.GetID())),
|
|
}, redirectURL, nil
|
|
}
|
|
|
|
// Auth returns the GitHub user login for the given access token.
|
|
func (c *client) Auth(ctx context.Context, token, _ string) (string, error) {
|
|
client := c.newClientToken(ctx, token)
|
|
user, _, err := client.Users.Get(ctx, "")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return *user.Login, nil
|
|
}
|
|
|
|
// Refresh refreshes the Gitlab oauth2 access token. If the token is
|
|
// refreshed the user is updated and a true value is returned.
|
|
func (c *client) Refresh(ctx context.Context, user *model.User) (bool, error) {
|
|
// when using Github oAuth app no refresh token is provided
|
|
if user.RefreshToken == "" {
|
|
return false, nil
|
|
}
|
|
|
|
config := c.newConfig()
|
|
|
|
source := config.TokenSource(ctx, &oauth2.Token{
|
|
AccessToken: user.AccessToken,
|
|
RefreshToken: user.RefreshToken,
|
|
Expiry: time.Unix(user.Expiry, 0),
|
|
})
|
|
|
|
token, err := source.Token()
|
|
if err != nil || len(token.AccessToken) == 0 {
|
|
return false, err
|
|
}
|
|
|
|
user.AccessToken = token.AccessToken
|
|
user.RefreshToken = token.RefreshToken
|
|
user.Expiry = token.Expiry.UTC().Unix()
|
|
return true, nil
|
|
}
|
|
|
|
// Teams returns a list of all team membership for the GitHub account.
|
|
func (c *client) Teams(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Team, error) {
|
|
client := c.newClientToken(ctx, u.AccessToken)
|
|
|
|
list, _, err := client.Organizations.List(ctx, "", &github.ListOptions{
|
|
Page: p.Page,
|
|
PerPage: perPage(p.PerPage),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return convertTeamList(list), nil
|
|
}
|
|
|
|
// Repo returns the GitHub repository.
|
|
func (c *client) Repo(ctx context.Context, u *model.User, id model.ForgeRemoteID, owner, name string) (*model.Repo, error) {
|
|
client := c.newClientToken(ctx, u.AccessToken)
|
|
|
|
if id.IsValid() {
|
|
intID, err := strconv.ParseInt(string(id), 10, 64)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
repo, resp, err := client.Repositories.GetByID(ctx, intID)
|
|
if err != nil {
|
|
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
|
return nil, errors.Join(err, forge_types.ErrRepoNotFound)
|
|
}
|
|
return nil, err
|
|
}
|
|
return convertRepo(repo), nil
|
|
}
|
|
|
|
repo, resp, err := client.Repositories.Get(ctx, owner, name)
|
|
if err != nil {
|
|
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
|
return nil, errors.Join(err, forge_types.ErrRepoNotFound)
|
|
}
|
|
return nil, err
|
|
}
|
|
return convertRepo(repo), nil
|
|
}
|
|
|
|
// Repos returns a list of all repositories for GitHub account, including
|
|
// organization repositories.
|
|
func (c *client) Repos(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Repo, error) {
|
|
// we paginate internally (https://github.com/woodpecker-ci/woodpecker/issues/5667)
|
|
if p.Page != 1 {
|
|
return nil, nil
|
|
}
|
|
|
|
client := c.newClientToken(ctx, u.AccessToken)
|
|
|
|
opts := new(github.RepositoryListByAuthenticatedUserOptions)
|
|
opts.PerPage = 100
|
|
opts.Page = 1
|
|
|
|
var repos []*model.Repo
|
|
for opts.Page > 0 {
|
|
list, resp, err := client.Repositories.ListByAuthenticatedUser(ctx, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, repo := range list {
|
|
if repo.GetArchived() {
|
|
continue
|
|
}
|
|
repos = append(repos, convertRepo(repo))
|
|
}
|
|
opts.Page = resp.NextPage
|
|
}
|
|
return repos, nil
|
|
}
|
|
|
|
// File fetches the file from the GitHub repository and returns its contents.
|
|
func (c *client) File(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]byte, error) {
|
|
client := c.newClientToken(ctx, u.AccessToken)
|
|
|
|
opts := new(github.RepositoryContentGetOptions)
|
|
opts.Ref = b.Commit
|
|
content, _, resp, err := client.Repositories.GetContents(ctx, r.Owner, r.Name, f, opts)
|
|
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
|
return nil, errors.Join(err, &forge_types.ErrConfigNotFound{Configs: []string{f}})
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if content == nil {
|
|
return nil, fmt.Errorf("%s is a folder not a file use Dir(..)", f)
|
|
}
|
|
data, err := content.GetContent()
|
|
return []byte(data), err
|
|
}
|
|
|
|
func (c *client) Dir(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]*forge_types.FileMeta, error) {
|
|
client := c.newClientToken(ctx, u.AccessToken)
|
|
|
|
opts := new(github.RepositoryContentGetOptions)
|
|
opts.Ref = b.Commit
|
|
_, data, resp, err := client.Repositories.GetContents(ctx, r.Owner, r.Name, f, opts)
|
|
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
|
return nil, errors.Join(err, &forge_types.ErrConfigNotFound{Configs: []string{f}})
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fc := make(chan *forge_types.FileMeta)
|
|
errChan := make(chan error)
|
|
|
|
for _, file := range data {
|
|
go func(path string) {
|
|
content, err := c.File(ctx, u, r, b, path)
|
|
if err != nil {
|
|
if errors.Is(err, &forge_types.ErrConfigNotFound{}) {
|
|
err = fmt.Errorf("git tree reported existence of file but we got: %s", err.Error())
|
|
}
|
|
errChan <- err
|
|
} else {
|
|
fc <- &forge_types.FileMeta{
|
|
Name: path,
|
|
Data: content,
|
|
}
|
|
}
|
|
}(f + "/" + *file.Name)
|
|
}
|
|
|
|
var files []*forge_types.FileMeta
|
|
|
|
for range data {
|
|
select {
|
|
case err := <-errChan:
|
|
return nil, err
|
|
case fileMeta := <-fc:
|
|
files = append(files, fileMeta)
|
|
}
|
|
}
|
|
|
|
close(fc)
|
|
close(errChan)
|
|
|
|
return files, nil
|
|
}
|
|
|
|
func (c *client) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) {
|
|
token := common.UserToken(ctx, r, u)
|
|
client := c.newClientToken(ctx, token)
|
|
|
|
pullRequests, _, err := client.PullRequests.List(ctx, r.Owner, r.Name, &github.PullRequestListOptions{
|
|
ListOptions: github.ListOptions{
|
|
Page: p.Page,
|
|
PerPage: perPage(p.PerPage),
|
|
},
|
|
State: "open",
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]*model.PullRequest, len(pullRequests))
|
|
for i := range pullRequests {
|
|
result[i] = &model.PullRequest{
|
|
Index: model.ForgeRemoteID(strconv.Itoa(pullRequests[i].GetNumber())),
|
|
Title: pullRequests[i].GetTitle(),
|
|
}
|
|
}
|
|
return result, err
|
|
}
|
|
|
|
// Netrc returns a netrc file capable of authenticating GitHub requests and
|
|
// cloning GitHub repositories. The netrc will use the global machine account
|
|
// when configured.
|
|
func (c *client) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) {
|
|
login := ""
|
|
token := ""
|
|
|
|
if u != nil {
|
|
login = u.AccessToken
|
|
token = "x-oauth-basic"
|
|
}
|
|
|
|
host, err := common.ExtractHostFromCloneURL(r.Clone)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.Netrc{
|
|
Login: login,
|
|
Password: token,
|
|
Machine: host,
|
|
Type: model.ForgeTypeGithub,
|
|
}, nil
|
|
}
|
|
|
|
// Deactivate deactivates the repository be removing registered push hooks from
|
|
// the GitHub repository.
|
|
func (c *client) Deactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error {
|
|
client := c.newClientToken(ctx, u.AccessToken)
|
|
|
|
// make sure a repo rename does not trick us
|
|
forgeRepo, err := c.Repo(ctx, u, r.ForgeRemoteID, r.Owner, r.Name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
hooks, _, err := client.Repositories.ListHooks(ctx, forgeRepo.Owner, forgeRepo.Name, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
match := matchingHooks(hooks, link)
|
|
if match == nil {
|
|
return nil
|
|
}
|
|
_, err = client.Repositories.DeleteHook(ctx, forgeRepo.Owner, forgeRepo.Name, *match.ID)
|
|
return err
|
|
}
|
|
|
|
// OrgMembership returns if user is member of organization and if user
|
|
// is admin/owner in this organization.
|
|
func (c *client) OrgMembership(ctx context.Context, u *model.User, owner string) (*model.OrgPerm, error) {
|
|
client := c.newClientToken(ctx, u.AccessToken)
|
|
org, _, err := client.Organizations.GetOrgMembership(ctx, u.Login, owner)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.OrgPerm{Member: org.GetState() == "active", Admin: org.GetRole() == "admin"}, nil
|
|
}
|
|
|
|
func (c *client) Org(ctx context.Context, u *model.User, owner string) (*model.Org, error) {
|
|
client := c.newClientToken(ctx, u.AccessToken)
|
|
|
|
org, _, err := client.Organizations.Get(ctx, owner)
|
|
log.Trace().Msgf("GitHub organization for owner %s = %v", owner, org)
|
|
if org != nil && err == nil {
|
|
return &model.Org{
|
|
Name: org.GetLogin(),
|
|
IsUser: false,
|
|
}, nil
|
|
}
|
|
|
|
user, _, err := client.Users.Get(ctx, owner)
|
|
log.Trace().Msgf("GitHub user for owner %s = %v", owner, user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.Org{
|
|
Name: user.GetLogin(),
|
|
IsUser: true,
|
|
}, nil
|
|
}
|
|
|
|
// newContext returns the GitHub oauth2 context using an HTTPClient that
|
|
// disables TLS verification if disabled in the forge settings.
|
|
func (c *client) newContext(ctx context.Context) context.Context {
|
|
if !c.SkipVerify {
|
|
return ctx
|
|
}
|
|
return context.WithValue(ctx, oauth2.HTTPClient, &http.Client{
|
|
Transport: &http.Transport{
|
|
Proxy: http.ProxyFromEnvironment,
|
|
TLSClientConfig: &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// newConfig returns the GitHub oauth2 config.
|
|
func (c *client) newConfig() *oauth2.Config {
|
|
scopes := []string{"user:email", "read:org"}
|
|
if c.OnlyPublic {
|
|
scopes = append(scopes, []string{"admin:repo_hook", "repo:status"}...)
|
|
} else {
|
|
scopes = append(scopes, "repo")
|
|
}
|
|
|
|
publicOAuthURL := c.oAuthHost
|
|
if publicOAuthURL == "" {
|
|
publicOAuthURL = c.url
|
|
}
|
|
|
|
return &oauth2.Config{
|
|
ClientID: c.Client,
|
|
ClientSecret: c.Secret,
|
|
Scopes: scopes,
|
|
Endpoint: oauth2.Endpoint{
|
|
AuthURL: fmt.Sprintf("%s/login/oauth/authorize", publicOAuthURL),
|
|
TokenURL: fmt.Sprintf("%s/login/oauth/access_token", c.url),
|
|
},
|
|
RedirectURL: fmt.Sprintf("%s/authorize", server.Config.Server.OAuthHost),
|
|
}
|
|
}
|
|
|
|
// newClientToken returns the GitHub oauth2 client.
|
|
// It first checks if a client is available in the context, otherwise creates a new one.
|
|
func (c *client) newClientToken(ctx context.Context, token string) *github.Client {
|
|
// Check if a client is already in the context
|
|
if ctxClient, ok := ctx.Value(githubClientKey).(*github.Client); ok {
|
|
return ctxClient
|
|
}
|
|
|
|
ts := oauth2.StaticTokenSource(
|
|
&oauth2.Token{AccessToken: token},
|
|
)
|
|
tc := oauth2.NewClient(ctx, ts)
|
|
|
|
// Get the oauth2 transport to set custom base
|
|
tp, _ := tc.Transport.(*oauth2.Transport)
|
|
|
|
baseTransport := &http.Transport{
|
|
Proxy: http.ProxyFromEnvironment,
|
|
}
|
|
if c.SkipVerify {
|
|
baseTransport.TLSClientConfig = &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
}
|
|
}
|
|
|
|
// Wrap the base transport with User-Agent support
|
|
tp.Base = httputil.NewUserAgentRoundTripper(baseTransport, "forge-github")
|
|
|
|
client := github.NewClient(tc)
|
|
client.BaseURL, _ = url.Parse(c.API)
|
|
return client
|
|
}
|
|
|
|
// matchingEmail returns matching user email.
|
|
func matchingEmail(emails []*github.UserEmail, rawURL string) *github.UserEmail {
|
|
for _, email := range emails {
|
|
if email.Email == nil || email.Primary == nil || email.Verified == nil {
|
|
continue
|
|
}
|
|
if *email.Primary && *email.Verified {
|
|
return email
|
|
}
|
|
}
|
|
// github enterprise does not support verified email addresses so instead
|
|
// we'll return the first email address in the list.
|
|
if len(emails) != 0 && rawURL != defaultAPI {
|
|
return emails[0]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// matchingHooks returns matching hook.
|
|
func matchingHooks(hooks []*github.Hook, rawURL string) *github.Hook {
|
|
link, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
for _, hook := range hooks {
|
|
if hook.ID == nil {
|
|
continue
|
|
}
|
|
hookURL, err := url.Parse(hook.Config.GetURL())
|
|
if err == nil && hookURL.Host == link.Host {
|
|
return hook
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var reDeploy = regexp.MustCompile(`.+/deployments/(\d+)`)
|
|
|
|
// Status sends the commit status to the forge.
|
|
// An example would be the GitHub pull request status.
|
|
func (c *client) Status(ctx context.Context, user *model.User, repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) error {
|
|
client := c.newClientToken(ctx, user.AccessToken)
|
|
|
|
if pipeline.Event == model.EventDeploy {
|
|
// Get id from url. If not found, skip.
|
|
matches := reDeploy.FindStringSubmatch(pipeline.ForgeURL)
|
|
//nolint:mnd
|
|
if len(matches) != 2 {
|
|
return nil
|
|
}
|
|
id, _ := strconv.Atoi(matches[1])
|
|
|
|
_, _, err := client.Repositories.CreateDeploymentStatus(ctx, repo.Owner, repo.Name, int64(id), &github.DeploymentStatusRequest{
|
|
State: github.Ptr(convertStatus(pipeline.Status)),
|
|
Description: github.Ptr(common.GetPipelineStatusDescription(pipeline.Status)),
|
|
LogURL: github.Ptr(common.GetPipelineStatusURL(repo, pipeline, nil)),
|
|
})
|
|
return err
|
|
}
|
|
|
|
_, _, err := client.Repositories.CreateStatus(ctx, repo.Owner, repo.Name, pipeline.Commit, github.RepoStatus{
|
|
Context: github.Ptr(common.GetPipelineStatusContext(repo, pipeline, workflow)),
|
|
State: github.Ptr(convertStatus(workflow.State)),
|
|
Description: github.Ptr(common.GetPipelineStatusDescription(workflow.State)),
|
|
TargetURL: github.Ptr(common.GetPipelineStatusURL(repo, pipeline, workflow)),
|
|
})
|
|
return err
|
|
}
|
|
|
|
// Activate activates a repository by creating the post-commit hook and
|
|
// adding the SSH deploy key, if applicable.
|
|
func (c *client) Activate(ctx context.Context, u *model.User, r *model.Repo, link string) error {
|
|
if err := c.Deactivate(ctx, u, r, link); err != nil {
|
|
return err
|
|
}
|
|
client := c.newClientToken(ctx, u.AccessToken)
|
|
hook := &github.Hook{
|
|
Name: github.Ptr("web"),
|
|
Events: []string{
|
|
"push",
|
|
"pull_request",
|
|
"pull_request_review",
|
|
"deployment",
|
|
},
|
|
Config: &github.HookConfig{
|
|
URL: &link,
|
|
ContentType: github.Ptr("form"),
|
|
},
|
|
}
|
|
_, _, err := client.Repositories.CreateHook(ctx, r.Owner, r.Name, hook)
|
|
return err
|
|
}
|
|
|
|
// Branches returns the names of all branches for the named repository.
|
|
func (c *client) Branches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) {
|
|
token := common.UserToken(ctx, r, u)
|
|
client := c.newClientToken(ctx, token)
|
|
|
|
githubBranches, _, err := client.Repositories.ListBranches(ctx, r.Owner, r.Name, &github.BranchListOptions{
|
|
ListOptions: github.ListOptions{
|
|
Page: p.Page,
|
|
PerPage: perPage(p.PerPage),
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
branches := make([]string, 0)
|
|
for _, branch := range githubBranches {
|
|
branches = append(branches, *branch.Name)
|
|
}
|
|
return branches, nil
|
|
}
|
|
|
|
// BranchHead returns the sha of the head (latest commit) of the specified branch.
|
|
func (c *client) BranchHead(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error) {
|
|
token := common.UserToken(ctx, r, u)
|
|
b, _, err := c.newClientToken(ctx, token).Repositories.GetBranch(ctx, r.Owner, r.Name, branch, 1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &model.Commit{
|
|
SHA: b.GetCommit().GetSHA(),
|
|
ForgeURL: b.GetCommit().GetHTMLURL(),
|
|
}, nil
|
|
}
|
|
|
|
// Hook parses the post-commit hook from the Request body
|
|
// and returns the required data in a standard format.
|
|
func (c *client) Hook(ctx context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error) {
|
|
pull, repo, pipeline, currCommit, prevCommit, err := parseHook(r, c.MergeRef)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if pipeline != nil && pipeline.Event == model.EventRelease && pipeline.Commit == "" {
|
|
tagName := strings.Split(pipeline.Ref, "/")[2]
|
|
sha, err := c.getTagCommitSHA(ctx, repo, tagName)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
pipeline.Commit = sha
|
|
}
|
|
|
|
if pull != nil {
|
|
pipeline, err = c.loadChangedFilesFromPullRequest(ctx, pull, repo, pipeline)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
} else if pipeline != nil && pipeline.Event == model.EventPush {
|
|
// GitHub has removed commit summaries from Events API payloads from 7th October 2025 onwards.
|
|
pipeline, err = c.loadChangedFilesFromCommits(ctx, repo, pipeline, currCommit, prevCommit)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
|
|
return repo, pipeline, nil
|
|
}
|
|
|
|
func (c *client) loadChangedFilesFromPullRequest(ctx context.Context, pull *github.PullRequest, tmpRepo *model.Repo, pipeline *model.Pipeline) (*model.Pipeline, error) {
|
|
_store, ok := store.TryFromContext(ctx)
|
|
if !ok {
|
|
log.Error().Msg("could not get store from context")
|
|
return pipeline, nil
|
|
}
|
|
|
|
repo, err := _store.GetRepoNameFallback(c.id, tmpRepo.ForgeRemoteID, tmpRepo.FullName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
user, err := _store.GetUser(repo.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Refresh the OAuth token before making API calls.
|
|
// The token may be expired, and without this refresh the API calls below
|
|
// would fail with an authentication error.
|
|
forge.Refresh(ctx, c, _store, user)
|
|
|
|
gh := c.newClientToken(ctx, user.AccessToken)
|
|
fileList := make([]string, 0, 16)
|
|
|
|
opts := &github.ListOptions{Page: 1}
|
|
for opts.Page > 0 {
|
|
files, resp, err := gh.PullRequests.ListFiles(ctx, repo.Owner, repo.Name, pull.GetNumber(), opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, file := range files {
|
|
fileList = append(fileList, file.GetFilename(), file.GetPreviousFilename())
|
|
}
|
|
|
|
opts.Page = resp.NextPage
|
|
}
|
|
|
|
pipeline.ChangedFiles = utils.DeduplicateStrings(fileList)
|
|
return pipeline, err
|
|
}
|
|
|
|
func (c *client) getTagCommitSHA(ctx context.Context, repo *model.Repo, tagName string) (string, error) {
|
|
_store, ok := store.TryFromContext(ctx)
|
|
if !ok {
|
|
log.Error().Msg("could not get store from context")
|
|
return "", nil
|
|
}
|
|
|
|
repo, err := _store.GetRepoNameFallback(c.id, repo.ForgeRemoteID, repo.FullName)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
user, err := _store.GetUser(repo.UserID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Refresh the OAuth token before making API calls.
|
|
// The token may be expired, and without this refresh the API calls below
|
|
// would fail with an authentication error.
|
|
forge.Refresh(ctx, c, _store, user)
|
|
|
|
gh := c.newClientToken(ctx, user.AccessToken)
|
|
|
|
page := 1
|
|
var tag *github.RepositoryTag
|
|
for {
|
|
tags, _, err := gh.Repositories.ListTags(ctx, repo.Owner, repo.Name, &github.ListOptions{Page: page})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
for _, t := range tags {
|
|
if t.GetName() == tagName {
|
|
tag = t
|
|
break
|
|
}
|
|
}
|
|
if tag != nil {
|
|
break
|
|
}
|
|
}
|
|
if tag == nil {
|
|
return "", fmt.Errorf("could not find tag %s", tagName)
|
|
}
|
|
return tag.GetCommit().GetSHA(), nil
|
|
}
|
|
|
|
func (c *client) loadChangedFilesFromCommits(ctx context.Context, tmpRepo *model.Repo, pipeline *model.Pipeline, curr, prev string) (*model.Pipeline, error) {
|
|
_store, ok := store.TryFromContext(ctx)
|
|
if !ok {
|
|
log.Error().Msg("could not get store from context")
|
|
return pipeline, nil
|
|
}
|
|
|
|
switch prev {
|
|
case curr:
|
|
log.Error().Msg("GitHub push event contains the same commit before and after, no changes detected")
|
|
return pipeline, nil
|
|
case "0000000000000000000000000000000000000000":
|
|
prev = ""
|
|
fallthrough
|
|
case "":
|
|
// For tag events, prev is empty, but we can still fetch the changed files using the current commit
|
|
log.Trace().Msg("GitHub tag event, fetching changed files using current commit")
|
|
}
|
|
|
|
repo, err := _store.GetRepoNameFallback(c.id, tmpRepo.ForgeRemoteID, tmpRepo.FullName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
user, err := _store.GetUser(repo.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Refresh the OAuth token before making API calls.
|
|
// The token may be expired, and without this refresh the API calls below
|
|
// would fail with an authentication error.
|
|
forge.Refresh(ctx, c, _store, user)
|
|
|
|
gh := c.newClientToken(ctx, user.AccessToken)
|
|
fileList := make([]string, 0, 16)
|
|
|
|
if prev == "" {
|
|
opts := &github.ListOptions{Page: 1}
|
|
for opts.Page > 0 {
|
|
commit, resp, err := gh.Repositories.GetCommit(ctx, repo.Owner, repo.Name, curr, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, file := range commit.Files {
|
|
fileList = append(fileList, file.GetFilename(), file.GetPreviousFilename())
|
|
}
|
|
opts.Page = resp.NextPage
|
|
}
|
|
} else {
|
|
opts := &github.ListOptions{Page: 1}
|
|
for opts.Page > 0 {
|
|
comp, resp, err := gh.Repositories.CompareCommits(ctx, repo.Owner, repo.Name, prev, curr, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, file := range comp.Files {
|
|
fileList = append(fileList, file.GetFilename(), file.GetPreviousFilename())
|
|
}
|
|
opts.Page = resp.NextPage
|
|
}
|
|
}
|
|
|
|
pipeline.ChangedFiles = utils.DeduplicateStrings(fileList)
|
|
return pipeline, err
|
|
}
|
|
|
|
func perPage(custom int) int {
|
|
if custom < 1 || custom > defaultPageSize {
|
|
return defaultPageSize
|
|
}
|
|
return custom
|
|
}
|