sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/prstatus/prstatus.go (about)

     1  /*
     2  Copyright 2018 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package prstatus
    18  
    19  import (
    20  	"context"
    21  	"encoding/json"
    22  	"fmt"
    23  	"io"
    24  	"net/http"
    25  	"strings"
    26  	"time"
    27  
    28  	"github.com/gorilla/sessions"
    29  	githubql "github.com/shurcooL/githubv4"
    30  	"github.com/sirupsen/logrus"
    31  	"golang.org/x/oauth2"
    32  
    33  	"sigs.k8s.io/prow/pkg/github"
    34  	"sigs.k8s.io/prow/pkg/githuboauth"
    35  )
    36  
    37  const (
    38  	loginSession = "github_login"
    39  	tokenSession = "access-token-session"
    40  	tokenKey     = "access-token"
    41  	loginKey     = "login"
    42  )
    43  
    44  // pullRequestQueryHandler defines an interface that query handlers should implement.
    45  type pullRequestQueryHandler interface {
    46  	queryPullRequests(context.Context, githubQuerier, string) ([]PullRequest, error)
    47  	getHeadContexts(ghc githubStatusFetcher, pr PullRequest) ([]Context, error)
    48  }
    49  
    50  // UserData represents data returned to client request to the endpoint. It has a flag that indicates
    51  // whether the user has logged in his github or not and list of open pull requests owned by the
    52  // user.
    53  type UserData struct {
    54  	Login                    bool
    55  	PullRequestsWithContexts []PullRequestWithContexts
    56  }
    57  
    58  // PullRequestWithContexts contains a pull request with its latest commit contexts.
    59  type PullRequestWithContexts struct {
    60  	Contexts    []Context
    61  	PullRequest PullRequest
    62  }
    63  
    64  // DashboardAgent is responsible for handling request to /pr-status endpoint.
    65  // It will serve a list of open pull requests owned by the user.
    66  type DashboardAgent struct {
    67  	repos []string
    68  	goac  *githuboauth.Config
    69  
    70  	log *logrus.Entry
    71  }
    72  
    73  // Label represents a GitHub label.
    74  type Label struct {
    75  	ID   githubql.ID
    76  	Name githubql.String
    77  }
    78  
    79  // Context represent a GitHub status check context.
    80  type Context struct {
    81  	Context     string
    82  	Description string
    83  	State       string
    84  }
    85  
    86  // PullRequest holds the GraphQL response data for a GitHub pull request.
    87  type PullRequest struct {
    88  	Number githubql.Int
    89  	Merged githubql.Boolean
    90  	Title  githubql.String
    91  	Author struct {
    92  		Login githubql.String
    93  	}
    94  	BaseRef struct {
    95  		Name   githubql.String
    96  		Prefix githubql.String
    97  	}
    98  	HeadRefOID githubql.String `graphql:"headRefOid"`
    99  	Repository struct {
   100  		Name          githubql.String
   101  		NameWithOwner githubql.String
   102  		Owner         struct {
   103  			Login githubql.String
   104  		}
   105  	}
   106  	Labels struct {
   107  		Nodes []struct {
   108  			Label Label `graphql:"... on Label"`
   109  		}
   110  	} `graphql:"labels(first: 100)"`
   111  	Milestone struct {
   112  		Title githubql.String
   113  	}
   114  	Mergeable githubql.MergeableState
   115  }
   116  
   117  // UserLoginQuery holds the GraphQL query for the currently authenticated user.
   118  type UserLoginQuery struct {
   119  	Viewer struct {
   120  		Login githubql.String
   121  	}
   122  }
   123  
   124  type searchQuery struct {
   125  	RateLimit struct {
   126  		Cost      githubql.Int
   127  		Remaining githubql.Int
   128  	}
   129  	Search struct {
   130  		PageInfo struct {
   131  			HasNextPage githubql.Boolean
   132  			EndCursor   githubql.String
   133  		}
   134  		Nodes []struct {
   135  			PullRequest PullRequest `graphql:"... on PullRequest"`
   136  		}
   137  	} `graphql:"search(type: ISSUE, first: 100, after: $searchCursor, query: $query)"`
   138  }
   139  
   140  // NewDashboardAgent creates a new user dashboard agent .
   141  func NewDashboardAgent(repos []string, config *githuboauth.Config, log *logrus.Entry) *DashboardAgent {
   142  	return &DashboardAgent{
   143  		repos: repos,
   144  		goac:  config,
   145  		log:   log,
   146  	}
   147  }
   148  
   149  func invalidateGitHubSession(w http.ResponseWriter, r *http.Request, session *sessions.Session) error {
   150  	// Invalidate github login session
   151  	http.SetCookie(w, &http.Cookie{
   152  		Name:    loginSession,
   153  		Path:    "/",
   154  		Expires: time.Now().Add(-time.Hour * 24),
   155  		MaxAge:  -1,
   156  		Secure:  true,
   157  	})
   158  
   159  	// Invalidate access token session
   160  	session.Options.MaxAge = -1
   161  	return session.Save(r, w)
   162  }
   163  
   164  type GitHubClient interface {
   165  	githubQuerier
   166  	githubStatusFetcher
   167  	BotUser() (*github.UserData, error)
   168  }
   169  
   170  type githubClientCreator func(accessToken string) (GitHubClient, error)
   171  
   172  // HandlePrStatus returns a http handler function that handles request to /pr-status
   173  // endpoint. The handler takes user access token stored in the cookie to query to GitHub on behalf
   174  // of the user and serve the data in return. The Query handler is passed to the method so as it
   175  // can be mocked in the unit test..
   176  func (da *DashboardAgent) HandlePrStatus(queryHandler pullRequestQueryHandler, createClient githubClientCreator) http.HandlerFunc {
   177  	return func(w http.ResponseWriter, r *http.Request) {
   178  		serverError := func(action string, err error) {
   179  			da.log.WithError(err).Errorf("Error %s.", action)
   180  			msg := fmt.Sprintf("500 Internal server error %s: %v", action, err)
   181  			http.Error(w, msg, http.StatusInternalServerError)
   182  		}
   183  
   184  		data := UserData{
   185  			Login: false,
   186  		}
   187  
   188  		// Get existing session. Invalidate everything if we fail and continue as
   189  		// if not logged in.
   190  		session, err := da.goac.CookieStore.Get(r, tokenSession)
   191  		if err != nil {
   192  			da.log.WithError(err).Info("Failed to get existing session, invalidating GitHub login session")
   193  			if err := invalidateGitHubSession(w, r, session); err != nil {
   194  				serverError("Failed to invalidate GitHub session", err)
   195  				return
   196  			}
   197  		}
   198  
   199  		// If access token exists, get user login using the access token. This is a
   200  		// chance to validate whether the access token is consumable or not. If
   201  		// not, we invalidate the sessions and continue as if not logged in.
   202  		token, ok := session.Values[tokenKey].(*oauth2.Token) // TODO(fejta): client cache
   203  		var user *github.User
   204  		var botUser *github.UserData
   205  		if ok && token.Valid() {
   206  			githubClient, err := createClient(token.AccessToken)
   207  			if err != nil {
   208  				serverError("creating githubClient", err)
   209  				return
   210  			}
   211  			botUser, err = githubClient.BotUser()
   212  			if err != nil {
   213  				if strings.Contains(err.Error(), "401") {
   214  					da.log.Info("Failed to access GitHub with existing access token, invalidating GitHub login session")
   215  					if err := invalidateGitHubSession(w, r, session); err != nil {
   216  						serverError("Failed to invalidate GitHub session", err)
   217  						return
   218  					}
   219  				} else {
   220  					serverError("Error with getting user login", err)
   221  					return
   222  				}
   223  			}
   224  			user = &github.User{Login: botUser.Login}
   225  
   226  			login := user.Login
   227  			data.Login = true
   228  			// Saves login. We save the login under 2 cookies. One for the use of client to render the
   229  			// data and one encoded for server to verify the identity of the authenticated user.
   230  			http.SetCookie(w, &http.Cookie{
   231  				Name:    loginSession,
   232  				Value:   login,
   233  				Path:    "/",
   234  				Expires: time.Now().Add(time.Hour * 24 * 30),
   235  				Secure:  true,
   236  			})
   237  			session.Values[loginKey] = login
   238  			if err := session.Save(r, w); err != nil {
   239  				serverError("Save oauth session", err)
   240  				return
   241  			}
   242  
   243  			query := da.ConstructSearchQuery(login)
   244  			if err := r.ParseForm(); err == nil {
   245  				if q := r.Form.Get("query"); q != "" {
   246  					query = q
   247  				}
   248  			}
   249  			// If neither repo nor org is specified in the search query. We limit the search to repos that
   250  			// are configured with either Prow or Tide.
   251  			if !queryConstrainsRepos(query) {
   252  				for _, v := range da.repos {
   253  					query += fmt.Sprintf(" repo:\"%s\"", v)
   254  				}
   255  			}
   256  			pullRequests, err := queryHandler.queryPullRequests(context.Background(), githubClient, query)
   257  			if err != nil {
   258  				serverError("Error with querying user data.", err)
   259  				return
   260  			}
   261  			var pullRequestWithContexts []PullRequestWithContexts
   262  			for _, pr := range pullRequests {
   263  				prcontexts, err := queryHandler.getHeadContexts(githubClient, pr)
   264  				if err != nil {
   265  					serverError("Error with getting head context of pr", err)
   266  					continue
   267  				}
   268  				pullRequestWithContexts = append(pullRequestWithContexts, PullRequestWithContexts{
   269  					Contexts:    prcontexts,
   270  					PullRequest: pr,
   271  				})
   272  			}
   273  
   274  			data.PullRequestsWithContexts = pullRequestWithContexts
   275  		}
   276  
   277  		marshaledData, err := json.Marshal(data)
   278  		if err != nil {
   279  			da.log.WithError(err).Error("Error with marshalling user data.")
   280  		}
   281  
   282  		if v := r.URL.Query().Get("var"); v != "" {
   283  			fmt.Fprintf(w, "var %s = ", v)
   284  			w.Write(marshaledData)
   285  			io.WriteString(w, ";")
   286  		} else {
   287  			w.Write(marshaledData)
   288  		}
   289  	}
   290  }
   291  
   292  type githubQuerier interface {
   293  	QueryWithGitHubAppsSupport(ctx context.Context, q interface{}, vars map[string]interface{}, org string) error
   294  }
   295  
   296  // queryPullRequests is a query function that returns a list of open pull requests owned by the user whose access token
   297  // is consumed by the github client.
   298  func (da *DashboardAgent) queryPullRequests(ctx context.Context, ghc githubQuerier, query string) ([]PullRequest, error) {
   299  	var prs []PullRequest
   300  	vars := map[string]interface{}{
   301  		"query":        (githubql.String)(query),
   302  		"searchCursor": (*githubql.String)(nil),
   303  	}
   304  	var totalCost int
   305  	var remaining int
   306  	for {
   307  		sq := searchQuery{}
   308  		if err := ghc.QueryWithGitHubAppsSupport(ctx, &sq, vars, ""); err != nil {
   309  			return nil, err
   310  		}
   311  		totalCost += int(sq.RateLimit.Cost)
   312  		remaining = int(sq.RateLimit.Remaining)
   313  		for _, n := range sq.Search.Nodes {
   314  			org := string(n.PullRequest.Repository.Owner.Login)
   315  			repo := string(n.PullRequest.Repository.Name)
   316  			ref := string(n.PullRequest.HeadRefOID)
   317  			if org == "" || repo == "" || ref == "" {
   318  				da.log.Warningf("Skipped empty pull request returned by query \"%s\": %v", query, n.PullRequest)
   319  				continue
   320  			}
   321  			prs = append(prs, n.PullRequest)
   322  		}
   323  		if !sq.Search.PageInfo.HasNextPage {
   324  			break
   325  		}
   326  		vars["searchCursor"] = githubql.NewString(sq.Search.PageInfo.EndCursor)
   327  	}
   328  	da.log.Infof("Search for query \"%s\" cost %d point(s). %d remaining.", query, totalCost, remaining)
   329  	return prs, nil
   330  }
   331  
   332  type githubStatusFetcher interface {
   333  	GetCombinedStatus(org, repo, ref string) (*github.CombinedStatus, error)
   334  	ListCheckRuns(org, repo, ref string) (*github.CheckRunList, error)
   335  }
   336  
   337  // getHeadContexts returns the status checks' contexts of the head commit of the PR.
   338  func (da *DashboardAgent) getHeadContexts(ghc githubStatusFetcher, pr PullRequest) ([]Context, error) {
   339  	org := string(pr.Repository.Owner.Login)
   340  	repo := string(pr.Repository.Name)
   341  	combined, err := ghc.GetCombinedStatus(org, repo, string(pr.HeadRefOID))
   342  	if err != nil {
   343  		return nil, fmt.Errorf("failed to get the combined status: %w", err)
   344  	}
   345  	checkruns, err := ghc.ListCheckRuns(org, repo, string(pr.HeadRefOID))
   346  	if err != nil {
   347  		return nil, fmt.Errorf("failed to fetch checkruns: %w", err)
   348  	}
   349  	contexts := make([]Context, 0, len(combined.Statuses)+len(checkruns.CheckRuns))
   350  	for _, status := range combined.Statuses {
   351  		contexts = append(contexts, Context{
   352  			Context:     status.Context,
   353  			Description: status.Description,
   354  			State:       strings.ToUpper(status.State),
   355  		})
   356  	}
   357  	for _, checkrun := range checkruns.CheckRuns {
   358  		var state string
   359  		if checkrun.CompletedAt == "" {
   360  			state = "PENDING"
   361  		} else if strings.ToUpper(checkrun.Conclusion) == "NEUTRAL" {
   362  			state = "SUCCESS"
   363  		} else {
   364  			state = strings.ToUpper(checkrun.Conclusion)
   365  		}
   366  		contexts = append(contexts, Context{
   367  			Context:     checkrun.Name,
   368  			Description: checkrun.DetailsURL,
   369  			State:       state,
   370  		})
   371  	}
   372  	return contexts, nil
   373  }
   374  
   375  // ConstructSearchQuery returns the GitHub search query string for PRs that are open and authored
   376  // by the user passed. The search is scoped to repositories that are configured with either Prow or
   377  // Tide.
   378  func (da *DashboardAgent) ConstructSearchQuery(login string) string {
   379  	tokens := []string{"is:pr", "state:open", "author:" + login}
   380  	for i := range da.repos {
   381  		tokens = append(tokens, fmt.Sprintf("repo:\"%s\"", da.repos[i]))
   382  	}
   383  	return strings.Join(tokens, " ")
   384  }
   385  
   386  func queryConstrainsRepos(q string) bool {
   387  	tkns := strings.Split(q, " ")
   388  	for _, tkn := range tkns {
   389  		if strings.HasPrefix(tkn, "org:") || strings.HasPrefix(tkn, "repo:") {
   390  			return true
   391  		}
   392  	}
   393  	return false
   394  }