github.com/munnerz/test-infra@v0.0.0-20190108210205-ce3d181dc989/prow/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  	gogithub "github.com/google/go-github/github"
    29  	"github.com/gorilla/sessions"
    30  	githubql "github.com/shurcooL/githubv4"
    31  	"github.com/sirupsen/logrus"
    32  	"golang.org/x/oauth2"
    33  
    34  	"k8s.io/test-infra/pkg/ghclient"
    35  	"k8s.io/test-infra/prow/config"
    36  	"k8s.io/test-infra/prow/github"
    37  )
    38  
    39  const (
    40  	loginSession   = "github_login"
    41  	githubEndpoint = "https://api.github.com"
    42  	tokenSession   = "access-token-session"
    43  	tokenKey       = "access-token"
    44  	loginKey       = "login"
    45  )
    46  
    47  type githubClient interface {
    48  	Query(context.Context, interface{}, map[string]interface{}) error
    49  	GetCombinedStatus(org, repo, ref string) (*github.CombinedStatus, error)
    50  }
    51  
    52  // PullRequestQueryHandler defines an interface that query handlers should implement.
    53  type PullRequestQueryHandler interface {
    54  	QueryPullRequests(context.Context, githubClient, string) ([]PullRequest, error)
    55  	GetHeadContexts(ghc githubClient, pr PullRequest) ([]Context, error)
    56  	GetUser(*ghclient.Client) (*gogithub.User, error)
    57  }
    58  
    59  // UserData represents data returned to client request to the endpoint. It has a flag that indicates
    60  // whether the user has logged in his github or not and list of open pull requests owned by the
    61  // user.
    62  type UserData struct {
    63  	Login                    bool
    64  	PullRequestsWithContexts []PullRequestWithContexts
    65  }
    66  
    67  // PullRequestWithContexts contains a pull request with its latest commit contexts.
    68  type PullRequestWithContexts struct {
    69  	Contexts    []Context
    70  	PullRequest PullRequest
    71  }
    72  
    73  // DashboardAgent is responsible for handling request to /pr-status endpoint.
    74  // It will serve a list of open pull requests owned by the user.
    75  type DashboardAgent struct {
    76  	repos []string
    77  	goac  *config.GithubOAuthConfig
    78  
    79  	log *logrus.Entry
    80  }
    81  
    82  // Label represents a Github label.
    83  type Label struct {
    84  	ID   githubql.ID
    85  	Name githubql.String
    86  }
    87  
    88  // Context represent a Github status check context.
    89  type Context struct {
    90  	Context     string
    91  	Description string
    92  	State       string
    93  }
    94  
    95  // PullRequest holds the GraphQL response data for a Github pull request.
    96  type PullRequest struct {
    97  	Number githubql.Int
    98  	Merged githubql.Boolean
    99  	Title  githubql.String
   100  	Author struct {
   101  		Login githubql.String
   102  	}
   103  	BaseRef struct {
   104  		Name   githubql.String
   105  		Prefix githubql.String
   106  	}
   107  	HeadRefOID githubql.String `graphql:"headRefOid"`
   108  	Repository struct {
   109  		Name          githubql.String
   110  		NameWithOwner githubql.String
   111  		Owner         struct {
   112  			Login githubql.String
   113  		}
   114  	}
   115  	Labels struct {
   116  		Nodes []struct {
   117  			Label Label `graphql:"... on Label"`
   118  		}
   119  	} `graphql:"labels(first: 100)"`
   120  	Milestone struct {
   121  		Title githubql.String
   122  	}
   123  	Mergeable githubql.MergeableState
   124  }
   125  
   126  // UserLoginQuery holds the GraphQL query for the currently authenticated user.
   127  type UserLoginQuery struct {
   128  	Viewer struct {
   129  		Login githubql.String
   130  	}
   131  }
   132  
   133  type searchQuery struct {
   134  	RateLimit struct {
   135  		Cost      githubql.Int
   136  		Remaining githubql.Int
   137  	}
   138  	Search struct {
   139  		PageInfo struct {
   140  			HasNextPage githubql.Boolean
   141  			EndCursor   githubql.String
   142  		}
   143  		Nodes []struct {
   144  			PullRequest PullRequest `graphql:"... on PullRequest"`
   145  		}
   146  	} `graphql:"search(type: ISSUE, first: 100, after: $searchCursor, query: $query)"`
   147  }
   148  
   149  // NewDashboardAgent creates a new user dashboard agent .
   150  func NewDashboardAgent(repos []string, config *config.GithubOAuthConfig, log *logrus.Entry) *DashboardAgent {
   151  	return &DashboardAgent{
   152  		repos: repos,
   153  		goac:  config,
   154  		log:   log,
   155  	}
   156  }
   157  
   158  func invalidateGitHubSession(w http.ResponseWriter, r *http.Request, session *sessions.Session) error {
   159  	// Invalidate github login session
   160  	http.SetCookie(w, &http.Cookie{
   161  		Name:    loginSession,
   162  		Path:    "/",
   163  		Expires: time.Now().Add(-time.Hour * 24),
   164  		MaxAge:  -1,
   165  		Secure:  true,
   166  	})
   167  
   168  	// Invalidate access token session
   169  	session.Options.MaxAge = -1
   170  	return session.Save(r, w)
   171  }
   172  
   173  // HandlePrStatus returns a http handler function that handles request to /pr-status
   174  // endpoint. The handler takes user access token stored in the cookie to query to Github on behalf
   175  // of the user and serve the data in return. The Query handler is passed to the method so as it
   176  // can be mocked in the unit test..
   177  func (da *DashboardAgent) HandlePrStatus(queryHandler PullRequestQueryHandler) http.HandlerFunc {
   178  	return func(w http.ResponseWriter, r *http.Request) {
   179  		serverError := func(action string, err error) {
   180  			da.log.WithError(err).Errorf("Error %s.", action)
   181  			msg := fmt.Sprintf("500 Internal server error %s: %v", action, err)
   182  			http.Error(w, msg, http.StatusInternalServerError)
   183  		}
   184  
   185  		data := UserData{
   186  			Login: false,
   187  		}
   188  
   189  		// Get existing session. Invalidate everything if we fail and continue as
   190  		// if not logged in.
   191  		session, err := da.goac.CookieStore.Get(r, tokenSession)
   192  		if err != nil {
   193  			da.log.WithError(err).Info("Failed to get existing session, invalidating GitHub login session")
   194  			if err := invalidateGitHubSession(w, r, session); err != nil {
   195  				serverError("Failed to invalidate GitHub session", err)
   196  				return
   197  			}
   198  		}
   199  
   200  		// If access token exists, get user login using the access token. This is a
   201  		// chance to validate whether the access token is consumable or not. If
   202  		// not, we invalidate the sessions and continue as if not logged in.
   203  		token, ok := session.Values[tokenKey].(*oauth2.Token)
   204  		var user *gogithub.User
   205  		if ok && token.Valid() {
   206  			goGithubClient := ghclient.NewClient(token.AccessToken, false)
   207  			var err error
   208  			user, err = queryHandler.GetUser(goGithubClient)
   209  			if err != nil {
   210  				if strings.Contains(err.Error(), "401") {
   211  					da.log.Info("Failed to access GitHub with existing access token, invalidating GitHub login session")
   212  					if err := invalidateGitHubSession(w, r, session); err != nil {
   213  						serverError("Failed to invalidate GitHub session", err)
   214  						return
   215  					}
   216  				} else {
   217  					serverError("Error with getting user login", err)
   218  					return
   219  				}
   220  			}
   221  		}
   222  
   223  		if user != nil {
   224  			login := *user.Login
   225  			data.Login = true
   226  			// Saves login. We save the login under 2 cookies. One for the use of client to render the
   227  			// data and one encoded for server to verify the identity of the authenticated user.
   228  			http.SetCookie(w, &http.Cookie{
   229  				Name:    loginSession,
   230  				Value:   login,
   231  				Path:    "/",
   232  				Expires: time.Now().Add(time.Hour * 24 * 30),
   233  				Secure:  true,
   234  			})
   235  			session.Values[loginKey] = login
   236  			if err := session.Save(r, w); err != nil {
   237  				serverError("Save oauth session", err)
   238  				return
   239  			}
   240  
   241  			// Construct query
   242  			ghc := github.NewClient(func() []byte { return []byte(token.AccessToken) }, githubEndpoint)
   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(), ghc, 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(ghc, 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  // QueryPullRequests is a query function that returns a list of open pull requests owned by the user whose access token
   293  // is consumed by the github client.
   294  func (da *DashboardAgent) QueryPullRequests(ctx context.Context, ghc githubClient, query string) ([]PullRequest, error) {
   295  	var prs []PullRequest
   296  	vars := map[string]interface{}{
   297  		"query":        (githubql.String)(query),
   298  		"searchCursor": (*githubql.String)(nil),
   299  	}
   300  	var totalCost int
   301  	var remaining int
   302  	for {
   303  		sq := searchQuery{}
   304  		if err := ghc.Query(ctx, &sq, vars); err != nil {
   305  			return nil, err
   306  		}
   307  		totalCost += int(sq.RateLimit.Cost)
   308  		remaining = int(sq.RateLimit.Remaining)
   309  		for _, n := range sq.Search.Nodes {
   310  			prs = append(prs, n.PullRequest)
   311  		}
   312  		if !sq.Search.PageInfo.HasNextPage {
   313  			break
   314  		}
   315  		vars["searchCursor"] = githubql.NewString(sq.Search.PageInfo.EndCursor)
   316  	}
   317  	da.log.Infof("Search for query \"%s\" cost %d point(s). %d remaining.", query, totalCost, remaining)
   318  	return prs, nil
   319  }
   320  
   321  // GetHeadContexts returns the status checks' contexts of the head commit of the PR.
   322  func (da *DashboardAgent) GetHeadContexts(ghc githubClient, pr PullRequest) ([]Context, error) {
   323  	org := string(pr.Repository.Owner.Login)
   324  	repo := string(pr.Repository.Name)
   325  	combined, err := ghc.GetCombinedStatus(org, repo, string(pr.HeadRefOID))
   326  	if err != nil {
   327  		return nil, fmt.Errorf("failed to get the combined status: %v", err)
   328  	}
   329  	contexts := make([]Context, 0, len(combined.Statuses))
   330  	for _, status := range combined.Statuses {
   331  		contexts = append(
   332  			contexts,
   333  			Context{
   334  				Context:     status.Context,
   335  				Description: status.Description,
   336  				State:       strings.ToUpper(status.State),
   337  			},
   338  		)
   339  	}
   340  	return contexts, nil
   341  }
   342  
   343  // GetUser attempts to get the currently authenticated Github user.
   344  func (da *DashboardAgent) GetUser(client *ghclient.Client) (*gogithub.User, error) {
   345  	return client.GetUser("")
   346  }
   347  
   348  // ConstructSearchQuery returns the Github search query string for PRs that are open and authored
   349  // by the user passed. The search is scoped to repositories that are configured with either Prow or
   350  // Tide.
   351  func (da *DashboardAgent) ConstructSearchQuery(login string) string {
   352  	tokens := []string{"is:pr", "state:open", "author:" + login}
   353  	for i := range da.repos {
   354  		tokens = append(tokens, fmt.Sprintf("repo:\"%s\"", da.repos[i]))
   355  	}
   356  	return strings.Join(tokens, " ")
   357  }
   358  
   359  func queryConstrainsRepos(q string) bool {
   360  	tkns := strings.Split(q, " ")
   361  	for _, tkn := range tkns {
   362  		if strings.HasPrefix(tkn, "org:") || strings.HasPrefix(tkn, "repo:") {
   363  			return true
   364  		}
   365  	}
   366  	return false
   367  }