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