github.com/yrj2011/jx-test-infra@v0.0.0-20190529031832-7a2065ee98eb/prow/tide/status.go (about) 1 /* 2 Copyright 2017 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 tide contains a controller for managing a tide pool of PRs. The 18 // controller will automatically retest PRs in the pool and merge them if they 19 // pass tests. 20 package tide 21 22 import ( 23 "context" 24 "fmt" 25 "net/url" 26 "sort" 27 "strings" 28 "sync" 29 "time" 30 31 "github.com/shurcooL/githubv4" 32 "github.com/sirupsen/logrus" 33 34 "k8s.io/apimachinery/pkg/util/sets" 35 "k8s.io/test-infra/prow/config" 36 "k8s.io/test-infra/prow/github" 37 ) 38 39 const ( 40 statusContext string = "tide" 41 statusInPool = "In merge pool." 42 // statusNotInPool is a format string used when a PR is not in a tide pool. 43 // The '%s' field is populated with the reason why the PR is not in a 44 // tide pool or the empty string if the reason is unknown. See requirementDiff. 45 statusNotInPool = "Not mergeable.%s" 46 ) 47 48 type statusController struct { 49 logger *logrus.Entry 50 ca *config.Agent 51 ghc githubClient 52 53 // newPoolPending is a size 1 chan that signals that the main Tide loop has 54 // updated the 'poolPRs' field with a freshly updated pool. 55 newPoolPending chan bool 56 // shutDown is used to signal to the main controller that the statusController 57 // has completed processing after newPoolPending is closed. 58 shutDown chan bool 59 60 // lastSyncStart is used to ensure that the status update period is at least 61 // the minimum status update period. 62 lastSyncStart time.Time 63 // lastSuccessfulQueryStart is used to only list PRs that have changed since 64 // we last successfully listed PRs in order to make status context updates 65 // cheaper. 66 lastSuccessfulQueryStart time.Time 67 68 sync.Mutex 69 poolPRs map[string]PullRequest 70 } 71 72 func (sc *statusController) shutdown() { 73 close(sc.newPoolPending) 74 <-sc.shutDown 75 } 76 77 // requirementDiff calculates the diff between a PR and a TideQuery. 78 // This diff is defined with a string that describes some subset of the 79 // differences and an integer counting the total number of differences. 80 // The diff count should always reflect the total number of differences between 81 // the current state of the PR and the query, but the message returned need not 82 // attempt to convey all of that information if some differences are more severe. 83 // For instance, we need to convey that a PR is open against a forbidden branch 84 // more than we need to detail which status contexts are failed against the PR. 85 // Note: an empty diff can be returned if the reason that the PR does not match 86 // the TideQuery is unknown. This can happen happen if this function's logic 87 // does not match GitHub's and does not indicate that the PR matches the query. 88 func requirementDiff(pr *PullRequest, q *config.TideQuery, cc contextChecker) (string, int) { 89 const maxLabelChars = 50 90 var desc string 91 var diff int 92 // Drops labels if needed to fit the description text area, but keep at least 1. 93 truncate := func(labels []string) []string { 94 i := 1 95 chars := len(labels[0]) 96 for ; i < len(labels); i++ { 97 if chars+len(labels[i]) > maxLabelChars { 98 break 99 } 100 chars += len(labels[i]) + 2 // ", " 101 } 102 return labels[:i] 103 } 104 105 for _, excludedBranch := range q.ExcludedBranches { 106 if string(pr.BaseRef.Name) == excludedBranch { 107 desc = fmt.Sprintf(" Merging to branch %s is forbidden.", pr.BaseRef.Name) 108 diff = 1 109 } 110 } 111 112 // if no whitelist is configured, the target is OK by default 113 targetBranchWhitelisted := len(q.IncludedBranches) == 0 114 for _, includedBranch := range q.IncludedBranches { 115 if string(pr.BaseRef.Name) == includedBranch { 116 targetBranchWhitelisted = true 117 } 118 } 119 120 if !targetBranchWhitelisted { 121 desc = fmt.Sprintf(" Merging to branch %s is forbidden.", pr.BaseRef.Name) 122 diff++ 123 } 124 125 var missingLabels []string 126 for _, l1 := range q.Labels { 127 var found bool 128 for _, l2 := range pr.Labels.Nodes { 129 if string(l2.Name) == l1 { 130 found = true 131 break 132 } 133 } 134 if !found { 135 missingLabels = append(missingLabels, l1) 136 } 137 } 138 diff += len(missingLabels) 139 if desc == "" && len(missingLabels) > 0 { 140 sort.Strings(missingLabels) 141 trunced := truncate(missingLabels) 142 if len(trunced) == 1 { 143 desc = fmt.Sprintf(" Needs %s label.", trunced[0]) 144 } else { 145 desc = fmt.Sprintf(" Needs %s labels.", strings.Join(trunced, ", ")) 146 } 147 } 148 149 var presentLabels []string 150 for _, l1 := range q.MissingLabels { 151 for _, l2 := range pr.Labels.Nodes { 152 if string(l2.Name) == l1 { 153 presentLabels = append(presentLabels, l1) 154 break 155 } 156 } 157 } 158 diff += len(presentLabels) 159 if desc == "" && len(presentLabels) > 0 { 160 sort.Strings(presentLabels) 161 trunced := truncate(presentLabels) 162 if len(trunced) == 1 { 163 desc = fmt.Sprintf(" Should not have %s label.", trunced[0]) 164 } else { 165 desc = fmt.Sprintf(" Should not have %s labels.", strings.Join(trunced, ", ")) 166 } 167 } 168 169 // fixing label issues takes precedence over status contexts 170 var contexts []string 171 for _, commit := range pr.Commits.Nodes { 172 if commit.Commit.OID == pr.HeadRefOID { 173 for _, ctx := range unsuccessfulContexts(commit.Commit.Status.Contexts, cc, logrus.New().WithFields(pr.logFields())) { 174 contexts = append(contexts, string(ctx.Context)) 175 } 176 } 177 } 178 diff += len(contexts) 179 if desc == "" && len(contexts) > 0 { 180 sort.Strings(contexts) 181 trunced := truncate(contexts) 182 if len(trunced) == 1 { 183 desc = fmt.Sprintf(" Job %s has not succeeded.", trunced[0]) 184 } else { 185 desc = fmt.Sprintf(" Jobs %s have not succeeded.", strings.Join(trunced, ", ")) 186 } 187 } 188 189 if q.Milestone != "" && (pr.Milestone == nil || string(pr.Milestone.Title) != q.Milestone) { 190 diff++ 191 if desc == "" { 192 desc = fmt.Sprintf(" Must be in milestone %s.", q.Milestone) 193 } 194 } 195 196 // TODO(cjwagner): List reviews (states:[APPROVED], first: 1) as part of open 197 // PR query. 198 199 return desc, diff 200 } 201 202 // Returns expected status state and description. 203 // If a PR is not mergeable, we have to select a TideQuery to compare it against 204 // in order to generate a diff for the status description. We choose the query 205 // for the repo that the PR is closest to meeting (as determined by the number 206 // of unmet/violated requirements). 207 func expectedStatus(queryMap config.QueryMap, pr *PullRequest, pool map[string]PullRequest, cc contextChecker) (string, string) { 208 if _, ok := pool[prKey(pr)]; !ok { 209 minDiffCount := -1 210 var minDiff string 211 for _, q := range queryMap.ForRepo(string(pr.Repository.Owner.Login), string(pr.Repository.Name)) { 212 diff, diffCount := requirementDiff(pr, &q, cc) 213 if minDiffCount == -1 || diffCount < minDiffCount { 214 minDiffCount = diffCount 215 minDiff = diff 216 } 217 } 218 return github.StatusPending, fmt.Sprintf(statusNotInPool, minDiff) 219 } 220 return github.StatusSuccess, statusInPool 221 } 222 223 // targetURL determines the URL used for more details in the status 224 // context on GitHub. If no PR dashboard is configured, we will use 225 // the administrative Prow overview. 226 func targetURL(c *config.Agent, pr *PullRequest, log *logrus.Entry) string { 227 var link string 228 if tideURL := c.Config().Tide.TargetURL; tideURL != "" { 229 link = tideURL 230 } else if baseURL := c.Config().Tide.PRStatusBaseURL; baseURL != "" { 231 parseURL, err := url.Parse(baseURL) 232 if err != nil { 233 log.WithError(err).Error("Failed to parse PR status base URL") 234 } else { 235 prQuery := fmt.Sprintf("is:pr repo:%s author:%s head:%s", pr.Repository.NameWithOwner, pr.Author.Login, pr.HeadRefName) 236 values := parseURL.Query() 237 values.Set("query", prQuery) 238 parseURL.RawQuery = values.Encode() 239 link = parseURL.String() 240 } 241 } 242 return link 243 } 244 245 func (sc *statusController) setStatuses(all []PullRequest, pool map[string]PullRequest) { 246 queryMap := sc.ca.Config().Tide.Queries.QueryMap() 247 processed := sets.NewString() 248 249 process := func(pr *PullRequest) { 250 processed.Insert(prKey(pr)) 251 log := sc.logger.WithFields(pr.logFields()) 252 contexts, err := headContexts(log, sc.ghc, pr) 253 if err != nil { 254 log.WithError(err).Error("Getting head commit status contexts, skipping...") 255 return 256 } 257 cr, err := sc.ca.Config().GetTideContextPolicy( 258 string(pr.Repository.Owner.Login), 259 string(pr.Repository.Name), 260 string(pr.BaseRef.Name)) 261 if err != nil { 262 log.WithError(err).Error("setting up context register") 263 return 264 } 265 266 wantState, wantDesc := expectedStatus(queryMap, pr, pool, cr) 267 var actualState githubv4.StatusState 268 var actualDesc string 269 for _, ctx := range contexts { 270 if string(ctx.Context) == statusContext { 271 actualState = ctx.State 272 actualDesc = string(ctx.Description) 273 } 274 } 275 if wantState != strings.ToLower(string(actualState)) || wantDesc != actualDesc { 276 if err := sc.ghc.CreateStatus( 277 string(pr.Repository.Owner.Login), 278 string(pr.Repository.Name), 279 string(pr.HeadRefOID), 280 github.Status{ 281 Context: statusContext, 282 State: wantState, 283 Description: wantDesc, 284 TargetURL: targetURL(sc.ca, pr, log), 285 }); err != nil { 286 log.WithError(err).Errorf( 287 "Failed to set status context from %q to %q.", 288 string(actualState), 289 wantState, 290 ) 291 } 292 } 293 } 294 295 for _, pr := range all { 296 process(&pr) 297 } 298 // The list of all open PRs may not contain a PR if it was merged before we 299 // listed all open PRs. To prevent a new PR that starts in the pool and 300 // immediately merges from missing a tide status context we need to ensure that 301 // every PR in the pool is processed even if it doesn't appear in all. 302 // 303 // Note: We could still fail to update a status context if the statusController 304 // falls behind the main Tide sync loop by multiple loops (if we are lapped). 305 // This would be unlikely to occur, could only occur if the status update sync 306 // period is longer than the main sync period, and would only result in a 307 // missing tide status context on a successfully merged PR. 308 for key, poolPR := range pool { 309 if !processed.Has(key) { 310 process(&poolPR) 311 } 312 } 313 } 314 315 func (sc *statusController) run() { 316 for { 317 // wait for a new pool 318 if !<-sc.newPoolPending { 319 // chan was closed 320 break 321 } 322 sc.waitSync() 323 } 324 close(sc.shutDown) 325 } 326 327 // waitSync waits until the minimum status update period has elapsed then syncs, 328 // returning the sync start time. 329 // If newPoolPending is closed while waiting (indicating a shutdown request) 330 // this function returns immediately without syncing. 331 func (sc *statusController) waitSync() { 332 // wait for the min sync period time to elapse if needed. 333 wait := time.After(time.Until(sc.lastSyncStart.Add(sc.ca.Config().Tide.StatusUpdatePeriod))) 334 for { 335 select { 336 case <-wait: 337 sc.Lock() 338 pool := sc.poolPRs 339 sc.Unlock() 340 sc.sync(pool) 341 return 342 case more := <-sc.newPoolPending: 343 if !more { 344 return 345 } 346 } 347 } 348 } 349 350 func (sc *statusController) sync(pool map[string]PullRequest) { 351 sc.lastSyncStart = time.Now() 352 353 sinceTime := sc.lastSuccessfulQueryStart.Add(-10 * time.Second) 354 query := sc.ca.Config().Tide.Queries.AllPRsSince(sinceTime) 355 queryStartTime := time.Now() 356 allPRs, err := search(context.Background(), sc.ghc, sc.logger, query) 357 if err != nil { 358 sc.logger.WithError(err).Errorf("Searching for open PRs.") 359 return 360 } 361 // We were able to find all open PRs so update the last successful query time. 362 sc.lastSuccessfulQueryStart = queryStartTime 363 sc.setStatuses(allPRs, pool) 364 }