github.com/munnerz/test-infra@v0.0.0-20190108210205-ce3d181dc989/prow/cmd/deck/static/pr/pr.ts (about) 1 import "dialog-polyfill"; 2 3 import {Context} from '../api/github'; 4 import {PullRequest, Label, UserData} from '../api/pr'; 5 import {Blocker, TideData, TidePool, TideQuery as ITideQuery} from '../api/tide'; 6 import {Job, JobState} from '../api/prow'; 7 8 declare const tideData: TideData; 9 declare const allBuilds: Job[]; 10 declare const dialogPolyfill: { 11 registerDialog(element: HTMLDialogElement): void; 12 }; 13 14 15 type UnifiedState = JobState | "expected" | "error" | "failure" | "pending" | "success"; 16 17 interface UnifiedContext { 18 context: string; 19 description: string; 20 state: UnifiedState; 21 discrepancy: string | null; 22 url?: string; 23 } 24 25 interface ProcessedLabel { 26 name: string; 27 own: boolean; 28 } 29 30 interface ProcessedQuery { 31 score: number; 32 labels: ProcessedLabel[]; 33 missingLabels: ProcessedLabel[]; 34 excludedBranches?: string[]; 35 includedBranches?: string[]; 36 milestone?: string; 37 } 38 39 /** 40 * A Tide Query helper class that checks whether a pr is covered by the query. 41 */ 42 class TideQuery { 43 orgs?: string[]; 44 repos?: string[]; 45 excludedRepos?: string[]; 46 labels?: string[]; 47 missingLabels?: string[]; 48 excludedBranches?: string[]; 49 includedBranches?: string[]; 50 milestone?: string; 51 52 constructor(query: ITideQuery) { 53 this.orgs = query.orgs; 54 this.repos = query.repos; 55 this.excludedRepos = query.excludedRepos; 56 this.labels = query.labels; 57 this.missingLabels = query.missingLabels; 58 this.excludedBranches = query.excludedBranches; 59 this.includedBranches = query.includedBranches; 60 this.milestone = query.milestone; 61 } 62 63 /** 64 * Returns true if the pr is covered by the query. 65 */ 66 matchPr(pr: PullRequest): boolean { 67 const isMatched = 68 (this.repos && this.repos.indexOf(pr.Repository.NameWithOwner) !== -1) || 69 ((this.orgs && this.orgs.indexOf(pr.Repository.Owner.Login) !== -1) && 70 (!this.excludedRepos || this.excludedRepos.indexOf(pr.Repository.NameWithOwner) === -1)); 71 72 if (!isMatched) { 73 return false; 74 } 75 76 if (pr.BaseRef) { 77 if (this.excludedBranches && 78 this.excludedBranches.indexOf(pr.BaseRef.Name) !== -1) { 79 return false; 80 } 81 if (this.includedBranches && 82 this.includedBranches.indexOf(pr.BaseRef.Name) === -1) { 83 return false; 84 } 85 } 86 87 return true; 88 } 89 } 90 91 /** 92 * Submit the query by redirecting the page with the query and let window.onload 93 * sends the request. 94 * @param input query input element 95 */ 96 function submitQuery(input: HTMLInputElement | HTMLTextAreaElement) { 97 const query = getPRQuery(input.value); 98 input.value = query; 99 window.location.search = '?query=' + encodeURIComponent(query); 100 } 101 102 /** 103 * Creates a XMLHTTP request to /pr-data.js. 104 * @param {function} fulfillFn 105 * @param {function} errorHandler 106 */ 107 function createXMLHTTPRequest(fulfillFn: (request: XMLHttpRequest) => any, errorHandler: () => any): XMLHttpRequest { 108 const request = new XMLHttpRequest(); 109 const url = "/pr-data.js"; 110 request.onreadystatechange = () => { 111 if (request.readyState === 4 && request.status === 200) { 112 fulfillFn(request); 113 } 114 }; 115 request.onerror = () => { 116 errorHandler(); 117 }; 118 request.withCredentials = true; 119 request.open("POST", url, true); 120 request.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); 121 122 return request; 123 } 124 125 /** 126 * Makes sure the search query is looking for pull requests by dropping all 127 * is:issue and type:pr tokens and adds is:pr if does not exist. 128 */ 129 function getPRQuery(q: string): string { 130 const tokens = q.replace(/\+/g, " ").split(" "); 131 // Firstly, drop all pr/issue search tokens 132 let result = tokens.filter(tkn => { 133 tkn = tkn.trim(); 134 return !(tkn === "is:issue" || tkn === "type:issue" || tkn === "is:pr" 135 || tkn === "type:pr"); 136 }).join(" "); 137 // Returns the query with is:pr to the start of the query 138 result = "is:pr " + result; 139 return result; 140 } 141 142 /** 143 * Redraw the page 144 */ 145 function redraw(prData: UserData): void { 146 const mainContainer = document.querySelector("#pr-container")!; 147 while (mainContainer.firstChild) { 148 mainContainer.removeChild(mainContainer.firstChild!); 149 } 150 if (prData && prData.Login) { 151 loadPrStatus(prData); 152 } else { 153 forceGithubLogin(); 154 } 155 } 156 157 /** 158 * Enables/disables the progress bar. 159 */ 160 function loadProgress(isStarted: boolean): void { 161 const pg = document.querySelector("#loading-progress")!; 162 if (isStarted) { 163 pg.classList.remove("hidden"); 164 } else { 165 pg.classList.add("hidden"); 166 } 167 } 168 169 /** 170 * Handles the URL query on load event. 171 */ 172 function onLoadQuery(): string { 173 const query = window.location.search.substring(1); 174 const params = query.split("&"); 175 if (!params[0]) { 176 return ""; 177 } 178 const val = params[0].slice("query=".length); 179 if (val && val !== "") { 180 return decodeURIComponent(val.replace(/\+/g, ' ')); 181 } 182 return ""; 183 } 184 185 /** 186 * Gets cookie by its name. 187 */ 188 function getCookieByName(name: string): string { 189 if (!document.cookie) { 190 return ""; 191 } 192 const cookies = decodeURIComponent(document.cookie).split(";"); 193 for (let i = 0; i < cookies.length; i++) { 194 const c = cookies[i].trim(); 195 const pref = name + "="; 196 if (c.indexOf(pref) === 0) { 197 return c.slice(pref.length); 198 } 199 } 200 return ""; 201 } 202 203 /** 204 * Creates an alert for merge blocking issues on tide. 205 */ 206 function createMergeBlockingIssueAlert(tidePool: TidePool, blockers: Blocker[]): HTMLElement { 207 const alert = document.createElement("div"); 208 alert.classList.add("alert"); 209 alert.textContent = `Currently Prow is not merging any PRs to ${tidePool.Org}/${tidePool.Repo} on branch ${tidePool.Branch}. Refer to `; 210 211 for (let j = 0; j < blockers.length; j++) { 212 const issue = blockers[j]; 213 const link = document.createElement("a"); 214 link.href = issue.URL; 215 link.innerText = "#" + issue.Number; 216 if (j + 1 < blockers.length) { 217 link.innerText = link.innerText + ", "; 218 } 219 alert.appendChild(link); 220 } 221 const closeButton = document.createElement("span"); 222 closeButton.textContent = "×"; 223 closeButton.classList.add("closebutton"); 224 closeButton.addEventListener("click", () => { 225 alert.classList.add("hidden"); 226 }); 227 alert.appendChild(closeButton); 228 return alert; 229 } 230 231 /** 232 * Displays any status alerts, e.g: tide pool blocking issues. 233 */ 234 function showAlerts(): void { 235 const alertContainer = document.querySelector("#alert-container")!; 236 const tidePools = tideData.Pools ? tideData.Pools : []; 237 for (let pool of tidePools) { 238 const blockers = pool.Blockers ? pool.Blockers : []; 239 if (blockers.length > 0) { 240 alertContainer.appendChild(createMergeBlockingIssueAlert(pool, blockers)) 241 } 242 } 243 } 244 245 window.onload = () => { 246 const dialogs = document.querySelectorAll("dialog") as NodeListOf<HTMLDialogElement>; 247 dialogs.forEach((dialog) => { 248 dialogPolyfill.registerDialog(dialog); 249 dialog.querySelector('.close')!.addEventListener('click', () => { 250 dialog.close(); 251 }); 252 }); 253 // Check URL, if the search is empty, adds search query by default format 254 // ?is:pr state:open query="author:<user_login>" 255 if (window.location.search === "") { 256 const login = getCookieByName("github_login"); 257 const searchQuery = "is:pr state:open " + "author:" + login; 258 window.location.search = "?query=" + encodeURIComponent(searchQuery); 259 } 260 const request = createXMLHTTPRequest((request) => { 261 const prData = JSON.parse(request.responseText); 262 redraw(prData); 263 loadProgress(false); 264 }, () => { 265 loadProgress(false); 266 const mainContainer = document.querySelector("#pr-container")!; 267 mainContainer.appendChild(createMessage("Something wrongs! We could not fulfill your request")); 268 }); 269 showAlerts(); 270 loadProgress(true); 271 request.send("query=" + onLoadQuery()); 272 }; 273 274 function createSearchCard(): HTMLElement { 275 const searchCard = document.createElement("div"); 276 searchCard.id = "search-card"; 277 searchCard.classList.add("pr-card", "mdl-card"); 278 279 // Input box 280 const input = document.createElement("textarea"); 281 input.id = "search-input"; 282 input.value = decodeURIComponent(window.location.search.slice("query=".length + 1)); 283 input.rows = 1; 284 input.spellcheck = false; 285 input.addEventListener("keydown", (event) => { 286 if (event.keyCode === 13) { 287 event.preventDefault(); 288 submitQuery(input); 289 } else { 290 const el = event.target as HTMLTextAreaElement; 291 el.style.height = "auto"; 292 el.style.height = el.scrollHeight + "px"; 293 } 294 }); 295 input.addEventListener("focus", (event) => { 296 const el = event.target as HTMLTextAreaElement; 297 el.style.height = "auto"; 298 el.style.height = el.scrollHeight + "px"; 299 }); 300 // Refresh button 301 const refBtn = createIcon("refresh", "Reload the query", ["search-button"], true); 302 refBtn.addEventListener("click", () => { 303 submitQuery(input); 304 }, true); 305 const userBtn = createIcon("person", "Show my open pull requests", ["search-button"], true); 306 userBtn.addEventListener("click", () => { 307 const login = getCookieByName("github_login"); 308 const searchQuery = "is:pr state:open " + "author:" + login; 309 window.location.search = "?query=" + encodeURIComponent(searchQuery); 310 }); 311 312 const actionCtn = document.createElement("div"); 313 actionCtn.id = "search-action"; 314 actionCtn.appendChild(userBtn); 315 actionCtn.appendChild(refBtn); 316 317 const inputContainer = document.createElement("div"); 318 inputContainer.id = "search-input-ctn"; 319 inputContainer.appendChild(input); 320 inputContainer.appendChild(actionCtn); 321 322 const title = document.createElement("h6"); 323 title.textContent = "Github search query"; 324 const infoBtn = createIcon("info", "More information about the search query", ["search-info"], true); 325 const titleCtn = document.createElement("div"); 326 titleCtn.appendChild(title); 327 titleCtn.appendChild(infoBtn); 328 titleCtn.classList.add("search-title"); 329 330 const searchDialog = document.querySelector("#search-dialog") as HTMLDialogElement; 331 infoBtn.addEventListener("click", () => { 332 searchDialog.showModal(); 333 }); 334 335 searchCard.appendChild(titleCtn); 336 searchCard.appendChild(inputContainer); 337 return searchCard; 338 } 339 340 /** 341 * GetFullPRContexts gathers build jobs and pr contexts. It firstly takes 342 * all pr contexts and only replaces contexts that have existing Prow Jobs. Tide 343 * context will be omitted from the list. 344 */ 345 function getFullPRContext(builds: Job[], contexts: Context[]): UnifiedContext[] { 346 const contextMap: Map<string, UnifiedContext> = new Map(); 347 if (contexts) { 348 for (let context of contexts) { 349 if (context.Context === "tide") { 350 continue; 351 } 352 contextMap.set(context.Context, { 353 context: context.Context, 354 description: context.Description, 355 state: context.State.toLowerCase() as UnifiedState, 356 discrepancy: null, 357 }); 358 } 359 } 360 if (builds) { 361 for (let build of builds) { 362 let discrepancy = null; 363 // If Github context exits, check if mismatch or not. 364 if (contextMap.has(build.context)) { 365 const githubContext = contextMap.get(build.context)!; 366 // TODO (qhuynh96): ProwJob's states and Github contexts states 367 // are not equivalent in some states. 368 if (githubContext.state !== build.state) { 369 discrepancy = "Github context and Prow Job states mismatch"; 370 } 371 } 372 contextMap.set(build.context, { 373 context: build.context, 374 description: build.description, 375 state: build.state, 376 url: build.url, 377 discrepancy: discrepancy 378 }); 379 } 380 } 381 return Array.from(contextMap.values()); 382 } 383 384 /** 385 * Loads Pr Status 386 */ 387 function loadPrStatus(prData: UserData): void { 388 const tideQueries: TideQuery[] = []; 389 for (let query of tideData.TideQueries) { 390 tideQueries.push(new TideQuery(query)); 391 } 392 393 const container = document.querySelector("#pr-container")!; 394 container.appendChild(createSearchCard()); 395 if (!prData.PullRequestsWithContexts || prData.PullRequestsWithContexts.length === 0) { 396 const msg = createMessage("No open PRs found", ""); 397 container.appendChild(msg); 398 return; 399 } 400 for (let prWithContext of prData.PullRequestsWithContexts) { 401 // There might be multiple runs of jobs for a build. 402 // allBuilds is sorted with the most recent builds first, so 403 // we only need to keep the first build for each job name. 404 let pr = prWithContext.PullRequest; 405 let seenJobs: {[key: string]: boolean} = {}; 406 let builds: Job[] = []; 407 for (let build of allBuilds) { 408 if (build.type === 'presubmit' && 409 build.repo === pr.Repository.NameWithOwner && 410 build.base_ref === pr.BaseRef.Name && 411 build.number === pr.Number && 412 build.pull_sha === pr.HeadRefOID) { 413 if (!seenJobs[build.job]) { // First (latest) build for job. 414 seenJobs[build.job] = true; 415 builds.push(build); 416 } 417 } 418 } 419 const githubContexts = prWithContext.Contexts; 420 const contexts = getFullPRContext(builds, githubContexts); 421 const validQueries: TideQuery[] = []; 422 for (let query of tideQueries) { 423 if (query.matchPr(pr)) { 424 validQueries.push(query); 425 } 426 } 427 container.appendChild(createPRCard(pr, contexts, closestMatchingQueries(pr, validQueries), tideData.Pools)); 428 } 429 } 430 431 /** 432 * Creates Pool labels. 433 */ 434 function createTidePoolLabel(pr: PullRequest, tidePool?: TidePool): HTMLElement | null { 435 if (!tidePool) { 436 return null; 437 } 438 const label = document.createElement("span"); 439 const blockers = tidePool.Blockers ? tidePool.Blockers : []; 440 if (blockers.length > 0) { 441 label.textContent = "Pool is temporarily blocked"; 442 label.classList.add("title-label", "mdl-shadow--2dp", "pending"); 443 return label; 444 } 445 const poolTypes = [tidePool.Target, tidePool.BatchPending, 446 tidePool.SuccessPRs, tidePool.PendingPRs, tidePool.MissingPRs]; 447 const inPoolId = poolTypes.findIndex(poolType => { 448 if (!poolType) { 449 return false; 450 } 451 const index = poolType.findIndex(prInPool => { 452 return prInPool.Number === pr.Number; 453 }); 454 return index !== -1; 455 }); 456 if (inPoolId === -1) { 457 return null; 458 } 459 const labelTitle = ["Merging", "In Batch & Test Pending", 460 "Test Passing & Merge Pending", "Test Pending", 461 "Queued for retest"]; 462 const labelStyle = ["merging", "batching", "passing", "pending", "pending"]; 463 label.textContent = "In Pool - " + labelTitle[inPoolId]; 464 label.classList.add("title-label", "mdl-shadow--2dp", labelStyle[inPoolId]); 465 466 return label; 467 } 468 469 /** 470 * Creates a label for the title. It will prioritise the merge status over the 471 * job status. Saying that, if the pr has jobs failed and does not meet merge 472 * requirements, it will show that the PR needs to resolve labels. 473 */ 474 function createTitleLabel(isMerge: boolean, jobStatus: VagueState, noQuery: boolean, 475 labelConflict: boolean, mergeConflict: boolean, 476 branchConflict: boolean, milestoneConflict: boolean): HTMLElement { 477 const label = document.createElement("span"); 478 label.classList.add("title-label"); 479 480 if (noQuery) { 481 label.textContent = "Unknown Merge Requirements"; 482 label.classList.add("unknown"); 483 } else if (isMerge) { 484 label.textContent = "Merged"; 485 label.classList.add("merging"); 486 } else if (branchConflict) { 487 label.textContent = "Blocked from merging into target branch"; 488 label.classList.add("pending"); 489 } else if (milestoneConflict) { 490 label.textContent = "Blocked from merging by current milestone"; 491 label.classList.add("pending"); 492 } else if (mergeConflict) { 493 label.textContent = "Needs to resolve merge conflicts"; 494 label.classList.add("pending"); 495 } else if (labelConflict) { 496 label.textContent = "Needs to resolve labels"; 497 label.classList.add("pending"); 498 } else { 499 if (jobStatus === "succeeded") { 500 label.textContent = "Good to be merged"; 501 label.classList.add(jobStatus); 502 } else { 503 label.textContent = "Jobs " + jobStatus; 504 label.classList.add(jobStatus); 505 } 506 } 507 508 return label; 509 } 510 511 /** 512 * Creates PR Card title. 513 */ 514 function createPRCardTitle(pr: PullRequest, tidePools: TidePool[], jobStatus: VagueState, 515 noQuery: boolean, labelConflict: boolean, 516 mergeConflict: boolean, branchConflict: boolean, 517 milestoneConflict: boolean): HTMLElement { 518 const prTitle = document.createElement("div"); 519 prTitle.classList.add("mdl-card__title"); 520 521 const title = document.createElement("h4"); 522 title.textContent = "#" + pr.Number; 523 title.classList.add("mdl-card__title-text"); 524 525 const subtitle = document.createElement("h5"); 526 subtitle.textContent = pr.Repository.NameWithOwner + ":" + pr.BaseRef.Name; 527 subtitle.classList.add("mdl-card__subtitle-text"); 528 529 const link = document.createElement("a"); 530 link.href = "https://github.com/" + pr.Repository.NameWithOwner + "/pull/" 531 + pr.Number; 532 link.appendChild(title); 533 534 const prTitleText = document.createElement("div"); 535 prTitleText.appendChild(link); 536 prTitleText.appendChild(subtitle); 537 prTitleText.classList.add("pr-title-text"); 538 prTitle.appendChild(prTitleText); 539 540 const pool = tidePools.filter(pool => { 541 const repo = pool.Org + "/" + pool.Repo; 542 return pr.Repository.NameWithOwner === repo && pr.BaseRef.Name 543 === pool.Branch; 544 }); 545 let tidePoolLabel = createTidePoolLabel(pr, pool[0]); 546 if (!tidePoolLabel) { 547 tidePoolLabel = createTitleLabel(pr.Merged, jobStatus, noQuery, labelConflict, mergeConflict, branchConflict, milestoneConflict); 548 } 549 prTitle.appendChild(tidePoolLabel); 550 551 return prTitle; 552 } 553 554 /** 555 * Creates a list of contexts. 556 */ 557 function createContextList(contexts: UnifiedContext[], itemStyle: string[] = []): HTMLElement { 558 const container = document.createElement("ul"); 559 container.classList.add("mdl-list", "job-list"); 560 const getStateIcon = (state: string): string => { 561 switch (state) { 562 case "success": 563 return "check_circle"; 564 case "failure": 565 return "error"; 566 case "pending": 567 return "watch_later"; 568 case "triggered": 569 return "schedule"; 570 case "aborted": 571 return "remove_circle"; 572 case "error": 573 return "warning"; 574 default: 575 return ""; 576 } 577 }; 578 const getItemContainer = (context: UnifiedContext): HTMLElement => { 579 if (context.url) { 580 const item = document.createElement("a"); 581 item.href = context.url; 582 return item; 583 } else { 584 return document.createElement("div"); 585 } 586 }; 587 contexts.forEach(context => { 588 const elCon = document.createElement("li"); 589 elCon.classList.add("mdl-list__item", "job-list-item", ...itemStyle); 590 const item = getItemContainer(context); 591 item.classList.add("mdl-list__item-primary-content"); 592 item.appendChild(createIcon( 593 getStateIcon(context.state), 594 "", 595 ["state", context.state, "mdl-list__item-icon"])); 596 item.appendChild(document.createTextNode(context.context)); 597 if (context.discrepancy) { 598 item.appendChild(createIcon( 599 "warning", 600 context.discrepancy, 601 ["state", "context-warning", "mdl-list__item-icon"])); 602 } 603 elCon.appendChild(item); 604 if (context.description) { 605 const itemDesc = document.createElement("span"); 606 itemDesc.textContent = context.description; 607 itemDesc.style.color = "grey"; 608 itemDesc.style.fontSize = "14px"; 609 elCon.appendChild(itemDesc); 610 } 611 container.appendChild(elCon); 612 }); 613 return container; 614 } 615 616 /** 617 * Creates Job status. 618 */ 619 function createJobStatus(builds: UnifiedContext[]): HTMLElement { 620 const statusContainer = document.createElement("div"); 621 statusContainer.classList.add("status-container"); 622 const status = document.createElement("div"); 623 const failedJobs = builds.filter(build => { 624 return build.state === "failure"; 625 }); 626 // Job status indicator 627 const state = jobStatus(builds); 628 let statusText = ""; 629 let stateIcon = ""; 630 switch (state) { 631 case "succeeded": 632 statusText = "All tests passed"; 633 stateIcon = "check_circle"; 634 break; 635 case "failed": 636 statusText = failedJobs.length + " test" + (failedJobs.length === 1 ? "" : "s") + " failed"; 637 stateIcon = "error"; 638 break; 639 case "unknown": 640 statusText = "No test found"; 641 break; 642 default: 643 statusText = "Tests are running"; 644 stateIcon = "watch_later"; 645 } 646 const arrowIcon = createIcon("expand_more"); 647 arrowIcon.classList.add("arrow-icon"); 648 if (state === "unknown") { 649 arrowIcon.classList.add("hidden"); 650 const p = document.createElement("P"); 651 p.textContent = "Test results for this PR are not in our record but you can always find them on PR's GitHub page. Sorry for any inconvenience!"; 652 653 status.appendChild(document.createTextNode(statusText)); 654 status.appendChild(createStatusHelp("No test found", [p])); 655 status.classList.add("no-status"); 656 } else { 657 status.appendChild(createIcon(stateIcon, "", ["status-icon", state])); 658 status.appendChild(document.createTextNode(statusText)); 659 } 660 status.classList.add("status", "expandable"); 661 statusContainer.appendChild(status); 662 // Job list 663 let failedJobsList: HTMLElement | undefined; 664 if (failedJobs.length > 0) { 665 failedJobsList = createContextList(failedJobs); 666 statusContainer.appendChild(failedJobsList); 667 } 668 const jobList = createContextList(builds); 669 jobList.classList.add("hidden"); 670 status.addEventListener("click", () => { 671 if (state === "unknown") { 672 return; 673 } 674 if (failedJobsList) { 675 failedJobsList.classList.add("hidden"); 676 } 677 jobList.classList.toggle("hidden"); 678 arrowIcon.textContent = arrowIcon.textContent === "expand_more" 679 ? "expand_less" : "expand_more"; 680 }); 681 682 status.appendChild(arrowIcon); 683 statusContainer.appendChild(jobList); 684 return statusContainer; 685 } 686 687 /** 688 * escapeLabel escaped label name that returns a valid name used for css 689 * selector. 690 */ 691 function escapeLabel(label: string): string { 692 if (label === "") return ""; 693 const toUnicode = function(index: number): string { 694 const h = label.charCodeAt(index).toString(16).split(''); 695 while (h.length < 6) h.splice(0, 0, '0'); 696 697 return 'x' + h.join(''); 698 }; 699 let result = ""; 700 const alphaNum = /^[0-9a-zA-Z]+$/; 701 702 for (let i = 0; i < label.length; i++) { 703 const c = label.charCodeAt(i); 704 if ((i === 0 && c > 47 && c < 58) || !label[i].match(alphaNum)) { 705 result += toUnicode(i); 706 continue; 707 } 708 result += label[i]; 709 } 710 711 return result 712 } 713 714 /** 715 * Creates a HTML element for the label given its name 716 */ 717 function createLabelEl(label: string): HTMLElement { 718 const el = document.createElement("span"); 719 const escapedName = escapeLabel(label); 720 el.classList.add("merge-table-label", "mdl-shadow--2dp", "label", escapedName); 721 el.textContent = label; 722 723 return el; 724 } 725 726 /** 727 * Creates a merge requirement cell. 728 */ 729 function createMergeLabelCell(labels: ProcessedLabel[], notMissingLabel = false): HTMLElement { 730 const cell = document.createElement("td"); 731 labels.forEach(label => { 732 const labelEl = createLabelEl(label.name); 733 const toDisplay = label.own !== notMissingLabel; 734 if (toDisplay) { 735 cell.appendChild(labelEl); 736 } 737 }); 738 739 return cell; 740 } 741 742 /** 743 * Appends labels to a container 744 */ 745 function appendLabelsToContainer(container: HTMLElement, labels: string[]): void { 746 while (container.firstChild) { 747 container.removeChild(container.firstChild); 748 } 749 labels.forEach(label => { 750 const labelEl = createLabelEl(label); 751 container.appendChild(labelEl); 752 }); 753 } 754 755 /** 756 * Fills query details. The details will be either the milestone or 757 * included/excluded branches. 758 * @param selector 759 * @param data 760 */ 761 function fillDetail(selector: string, data?: string[] | string): void { 762 const section = document.querySelector(selector)!; 763 if (!data || (Array.isArray(data) && data.length === 0)) { 764 section.classList.add("hidden"); 765 return; 766 } 767 768 section.classList.remove("hidden"); 769 const container = section.querySelector(".detail-data")!; 770 container.textContent = ""; 771 while (container.firstChild) { 772 container.removeChild(container.firstChild); 773 } 774 775 if (Array.isArray(data)) { 776 for (let branch of data) { 777 const str = document.createElement("SPAN"); 778 str.classList.add("detail-branch"); 779 str.appendChild(document.createTextNode(branch)); 780 container.appendChild(str); 781 } 782 } else if (typeof data === 'string') { 783 container.appendChild(document.createTextNode(data)); 784 } 785 } 786 787 /** 788 * Creates query details btn 789 * @param query 790 * @returns {HTMLElement} 791 */ 792 function createQueryDetailsBtn(query: ProcessedQuery): HTMLTableDataCellElement { 793 const mergeIcon = document.createElement("td"); 794 mergeIcon.classList.add("merge-table-icon"); 795 796 const iconButton = createIcon("information", "Clicks to see query details", [], true); 797 const dialog = document.querySelector("#query-dialog")! as HTMLDialogElement; 798 799 // Query labels 800 const allRequired = document.getElementById("query-all-required")!; 801 const allForbidden = document.getElementById("query-all-forbidden")!; 802 iconButton.addEventListener("click", () => { 803 fillDetail("#query-detail-milestone", query.milestone); 804 fillDetail("#query-detail-exclude", query.excludedBranches); 805 fillDetail("#query-detail-include", query.includedBranches); 806 appendLabelsToContainer(allRequired, query.labels.map(label => { 807 return label.name; 808 })); 809 appendLabelsToContainer(allForbidden, query.missingLabels.map(label => { 810 return label.name; 811 })); 812 dialog.showModal(); 813 }); 814 mergeIcon.appendChild(iconButton); 815 816 return mergeIcon; 817 } 818 819 /** 820 * Creates merge requirement table for queries. 821 */ 822 function createQueriesTable(prLabels: {Label: Label}[], queries: ProcessedQuery[]): HTMLTableElement { 823 const table = document.createElement("table"); 824 table.classList.add("merge-table"); 825 const thead = document.createElement("thead"); 826 const allLabelHeaderRow = document.createElement("tr"); 827 const allLabelHeaderCell = document.createElement("td"); 828 // Creates all pr labels header. 829 allLabelHeaderCell.textContent = "PR's Labels"; 830 allLabelHeaderCell.colSpan = 3; 831 allLabelHeaderRow.appendChild(allLabelHeaderCell); 832 thead.appendChild(allLabelHeaderRow); 833 834 const allLabelRow = document.createElement("tr"); 835 const allLabelCell = document.createElement("td"); 836 allLabelCell.colSpan = 3; 837 appendLabelsToContainer(allLabelCell, prLabels.map(label => { 838 return label.Label.Name; 839 })); 840 allLabelRow.appendChild(allLabelCell); 841 thead.appendChild(allLabelRow); 842 843 const tableRow = document.createElement("tr"); 844 const col1 = document.createElement("td"); 845 col1.textContent = "Required Labels (Missing)"; 846 const col2 = document.createElement("td"); 847 col2.textContent = "Forbidden Labels (Shouldn't have)"; 848 const col3 = document.createElement("td"); 849 850 const body = document.createElement("tbody"); 851 queries.forEach(query => { 852 const row = document.createElement("tr"); 853 row.appendChild(createMergeLabelCell(query.labels, true)); 854 row.appendChild(createMergeLabelCell(query.missingLabels)); 855 row.appendChild(createQueryDetailsBtn(query)); 856 body.appendChild(row); 857 }); 858 859 tableRow.appendChild(col1); 860 tableRow.appendChild(col2); 861 tableRow.appendChild(col3); 862 thead.appendChild(tableRow); 863 table.appendChild(thead); 864 table.appendChild(body); 865 866 return table; 867 } 868 869 /** 870 * Creates the merge label requirement status. 871 */ 872 function createMergeLabelStatus(prLabels: {Label: Label}[] = [], queries: ProcessedQuery[]): HTMLElement { 873 const statusContainer = document.createElement("div"); 874 statusContainer.classList.add("status-container"); 875 const status = document.createElement("div"); 876 statusContainer.appendChild(status); 877 if (queries.length > 0) { 878 const labelConflict = !hasResolvedLabels(queries[0]); 879 if (labelConflict) { 880 status.appendChild(createIcon("error", "", ["status-icon", "failed"])); 881 status.appendChild(document.createTextNode("Does not meet label requirements")); 882 // Creates help button 883 const iconButton = createIcon("help", "", ["help-icon-button"], true); 884 status.appendChild(iconButton); 885 // Shows dialog 886 const dialog = document.querySelector("#merge-help-dialog") as HTMLDialogElement; 887 iconButton.addEventListener("click", (event) => { 888 dialog.showModal(); 889 event.stopPropagation(); 890 }); 891 } else { 892 status.appendChild(createIcon("check_circle", "", ["status-icon", "succeeded"])); 893 status.appendChild(document.createTextNode("Meets label requirements")); 894 } 895 896 const arrowIcon = createIcon("expand_less"); 897 arrowIcon.classList.add("arrow-icon"); 898 899 status.classList.add("status", "expandable"); 900 status.appendChild(arrowIcon); 901 902 const queriesTable = createQueriesTable(prLabels, queries); 903 if (!labelConflict) { 904 queriesTable.classList.add("hidden"); 905 arrowIcon.textContent = "expand_more"; 906 } 907 status.addEventListener("click", () => { 908 queriesTable.classList.toggle("hidden"); 909 if (queriesTable.classList.contains("hidden")) { 910 const offLabels = queriesTable.querySelectorAll( 911 ".merge-table-label.off"); 912 offLabels.forEach(offLabel => { 913 offLabel.classList.add("hidden"); 914 }); 915 } 916 arrowIcon.textContent = arrowIcon.textContent === "expand_more" 917 ? "expand_less" : "expand_more"; 918 }); 919 statusContainer.appendChild(queriesTable); 920 } else { 921 status.appendChild(document.createTextNode("No Tide query found")); 922 status.classList.add("no-status"); 923 const p = document.createElement("P"); 924 p.textContent = "This repo may not be configured to use Tide."; 925 status.appendChild(createStatusHelp("Tide query not found", [p])); 926 } 927 return statusContainer; 928 } 929 930 /** 931 * Creates the merge conflict status. 932 */ 933 function createMergeConflictStatus(mergeConflict: boolean): HTMLElement { 934 const statusContainer = document.createElement("div"); 935 statusContainer.classList.add("status-container"); 936 const status = document.createElement("div"); 937 if (mergeConflict) { 938 status.appendChild(createIcon("error", "", ["status-icon", "failed"])); 939 status.appendChild( 940 document.createTextNode("Has merge conflicts")); 941 } else { 942 status.appendChild( 943 createIcon("check_circle", "", ["status-icon", "succeeded"])); 944 status.appendChild( 945 document.createTextNode("Does not appear to have merge conflicts")); 946 } 947 status.classList.add("status"); 948 statusContainer.appendChild(status); 949 return statusContainer; 950 } 951 952 /** 953 * Creates a help button on the status. 954 */ 955 function createStatusHelp(title: string, content: HTMLElement[]): HTMLElement { 956 const dialog = document.querySelector("#status-help-dialog")! as HTMLDialogElement; 957 const dialogTitle = dialog.querySelector(".mdl-dialog__title")!; 958 const dialogContent = dialog.querySelector(".mdl-dialog__content")!; 959 const helpIcon = createIcon("help", "", ["help-icon-button"], true); 960 helpIcon.addEventListener("click", (event) => { 961 dialogTitle.textContent = title; 962 while (dialogContent.firstChild) { 963 dialogContent.removeChild(dialogContent.firstChild); 964 } 965 content.forEach(el => { 966 dialogContent.appendChild(el); 967 }); 968 dialog.showModal(); 969 event.stopPropagation(); 970 }); 971 972 return helpIcon; 973 } 974 975 /** 976 * Creates the branch conflict status. 977 */ 978 function createBranchConflictStatus(pr: PullRequest, branchConflict: boolean): HTMLElement { 979 const statusContainer = document.createElement("div"); 980 statusContainer.classList.add("status-container"); 981 const status = document.createElement("div"); 982 if (branchConflict) { 983 status.appendChild(createIcon("error", "", ["status-icon", "failed"])); 984 status.appendChild( 985 document.createTextNode(`Merging into branch ${pr.BaseRef.Name} is currently forbidden`)); 986 status.classList.add("status"); 987 statusContainer.appendChild(status); 988 } 989 return statusContainer; 990 } 991 992 /** 993 * Creates the milestone conflict status. 994 */ 995 function createMilestoneConflictStatus(pr: PullRequest, queries: ProcessedQuery[], 996 milestoneConflict: boolean): HTMLElement { 997 const statusContainer = document.createElement("div"); 998 statusContainer.classList.add("status-container"); 999 const status = document.createElement("div"); 1000 if (milestoneConflict) { 1001 status.appendChild(createIcon("error", "", ["status-icon", "failed"])); 1002 status.appendChild( 1003 document.createTextNode(`Only merges into milestone ${queries[0].milestone} are currently allowed`)); 1004 status.classList.add("status"); 1005 statusContainer.appendChild(status); 1006 } 1007 return statusContainer; 1008 } 1009 1010 1011 function createPRCardBody(pr: PullRequest, builds: UnifiedContext[], queries: ProcessedQuery[], 1012 mergeable: boolean, branchConflict: boolean, 1013 milestoneConflict: boolean): HTMLElement { 1014 const cardBody = document.createElement("div"); 1015 const title = document.createElement("h3"); 1016 title.textContent = pr.Title; 1017 1018 cardBody.classList.add("mdl-card__supporting-text"); 1019 cardBody.appendChild(title); 1020 cardBody.appendChild(createJobStatus(builds)); 1021 const nodes = pr.Labels && pr.Labels.Nodes ? pr.Labels.Nodes : []; 1022 cardBody.appendChild(createMergeLabelStatus(nodes, queries)); 1023 cardBody.appendChild(createMergeConflictStatus(mergeable)); 1024 cardBody.appendChild(createBranchConflictStatus(pr, branchConflict)); 1025 cardBody.appendChild(createMilestoneConflictStatus(pr, queries, milestoneConflict)); 1026 1027 return cardBody; 1028 } 1029 1030 /** 1031 * Compare function that prioritizes jobs which are in failure state. 1032 */ 1033 function compareJobFn(a: UnifiedContext, b: UnifiedContext): number { 1034 const stateToPrio: {[key: string]: number} = {}; 1035 stateToPrio["success"] = stateToPrio["expected"] = 3; 1036 stateToPrio["aborted"] = 2; 1037 stateToPrio["pending"] = stateToPrio["triggered"] = 1; 1038 stateToPrio["error"] = stateToPrio["failure"] = 0; 1039 1040 return stateToPrio[a.state] > stateToPrio[b.state] ? 1 1041 : stateToPrio[a.state] < stateToPrio[b.state] ? -1 : 0; 1042 } 1043 1044 /** 1045 * closestMatchingQueries returns a list of processed TideQueries that match the PR in descending order of likeliness. 1046 */ 1047 function closestMatchingQueries(pr: PullRequest, queries: TideQuery[]): ProcessedQuery[] { 1048 const prLabelsSet = new Set(); 1049 if (pr.Labels && pr.Labels.Nodes) { 1050 pr.Labels.Nodes.forEach(label => { 1051 prLabelsSet.add(label.Label.Name); 1052 }); 1053 } 1054 const processedQueries: ProcessedQuery[] = []; 1055 queries.forEach(query => { 1056 let score = 0.0; 1057 const labels: ProcessedLabel[] = []; 1058 const missingLabels: ProcessedLabel[] = []; 1059 (query.labels || []).sort((a, b) => { 1060 if (a.length === b.length) { 1061 return 0; 1062 } 1063 return a.length < b.length ? -1 : 1; 1064 }); 1065 (query.missingLabels || []).sort((a, b) => { 1066 if (a.length === b.length) { 1067 return 0; 1068 } 1069 return a.length < b.length ? -1 : 1; 1070 }); 1071 (query.labels || []).forEach(label => { 1072 labels.push({name: label, own: prLabelsSet.has(label)}); 1073 score += labels[labels.length - 1].own ? 1 : 0; 1074 }); 1075 (query.missingLabels || []).forEach(label => { 1076 missingLabels.push({name: label, own: prLabelsSet.has(label)}); 1077 score += missingLabels[missingLabels.length - 1].own ? 0 : 1; 1078 }); 1079 score = (labels.length + missingLabels.length > 0) ? score 1080 / (labels.length + missingLabels.length) : 1.0; 1081 processedQueries.push( 1082 { 1083 score: score, labels: labels, missingLabels: missingLabels, excludedBranches: query.excludedBranches, 1084 includedBranches: query.includedBranches, milestone: query.milestone 1085 }); 1086 }); 1087 // Sort queries by descending score order. 1088 processedQueries.sort((q1, q2) => { 1089 if (pr.BaseRef && pr.BaseRef.Name) { 1090 let q1Excluded = 0, q2Excluded = 0; 1091 if (q1.excludedBranches && q1.excludedBranches.indexOf(pr.BaseRef.Name) !== -1) { 1092 q1Excluded = 1; 1093 } 1094 if (q2.excludedBranches && q2.excludedBranches.indexOf(pr.BaseRef.Name) !== -1) { 1095 q2Excluded = -1; 1096 } 1097 if (q1Excluded + q2Excluded !== 0) { 1098 return q1Excluded + q2Excluded; 1099 } 1100 1101 let q1Included = 0, q2Included = 0; 1102 if (q1.includedBranches && q1.includedBranches.indexOf(pr.BaseRef.Name) !== -1) { 1103 q1Included = -1; 1104 } 1105 if (q2.includedBranches && q2.includedBranches.indexOf(pr.BaseRef.Name) !== -1) { 1106 q2Included = 1; 1107 } 1108 if (q1Included + q2Included !== 0) { 1109 return q1Included + q2Included; 1110 } 1111 } 1112 if (pr.Milestone && pr.Milestone.Title && q1.milestone !== q2.milestone) { 1113 if (q1.milestone && pr.Milestone.Title === q1.milestone) { 1114 return -1; 1115 } 1116 if (q2.milestone && pr.Milestone.Title === q2.milestone) { 1117 return 1; 1118 } 1119 } 1120 if (Math.abs(q1.score - q2.score) < Number.EPSILON) { 1121 return 0; 1122 } 1123 return q1.score > q2.score ? -1 : 1; 1124 }); 1125 return processedQueries 1126 } 1127 1128 /** 1129 * Creates a PR card. 1130 */ 1131 function createPRCard(pr: PullRequest, builds: UnifiedContext[] = [], queries: ProcessedQuery[] = [], tidePools: TidePool[] = []): HTMLElement { 1132 const prCard = document.createElement("div"); 1133 // jobs need to be sorted from high priority (failure, error) to low 1134 // priority (success) 1135 builds.sort(compareJobFn); 1136 prCard.classList.add("pr-card", "mdl-card"); 1137 const hasMatchingQuery = queries.length > 0; 1138 const mergeConflict = pr.Mergeable ? pr.Mergeable === "CONFLICTING" : false; 1139 const branchConflict = !!((pr.BaseRef && pr.BaseRef.Name && hasMatchingQuery) && 1140 ((queries[0].excludedBranches && queries[0].excludedBranches!.indexOf(pr.BaseRef.Name) !== -1) || 1141 (queries[0].includedBranches && queries[0].includedBranches!.indexOf(pr.BaseRef.Name) === -1))); 1142 const milestoneConflict = hasMatchingQuery && queries[0].milestone ? (!pr.Milestone || !pr.Milestone.Title || pr.Milestone.Title !== queries[0].milestone) : false; 1143 const labelConflict = hasMatchingQuery ? !hasResolvedLabels(queries[0]) : false; 1144 prCard.appendChild(createPRCardTitle(pr, tidePools, jobStatus(builds), !hasMatchingQuery, labelConflict, mergeConflict, branchConflict, milestoneConflict)); 1145 prCard.appendChild(createPRCardBody(pr, builds, queries, mergeConflict, branchConflict, milestoneConflict)); 1146 return prCard; 1147 } 1148 1149 /** 1150 * Redirect to initiate github login flow. 1151 */ 1152 function forceGithubLogin(): void { 1153 window.location.href = window.location.origin + "/github-login"; 1154 } 1155 1156 type VagueState = "succeeded" | "failed" | "pending" | "unknown"; 1157 1158 /** 1159 * Returns the job status based on its state. 1160 */ 1161 function jobStatus(builds: UnifiedContext[]): VagueState { 1162 if (builds.length === 0) { 1163 return "unknown"; 1164 } 1165 switch (builds[0].state) { 1166 case "success": 1167 case "expected": 1168 return "succeeded"; 1169 case "failure": 1170 case "error": 1171 return "failed"; 1172 default: 1173 return "pending"; 1174 } 1175 } 1176 1177 /** 1178 * Returns -1 if there is no query. 1 if the PR is able to be merged by checking 1179 * the score of the first query in the query list (score === 1), the list has 1180 * been sorted by scores, otherwise 0. 1181 * @param query 1182 * @return {boolean} 1183 */ 1184 function hasResolvedLabels(query: ProcessedQuery): boolean { 1185 return Math.abs(query.score - 1.0) < Number.EPSILON; 1186 } 1187 1188 /** 1189 * Returns an icon element. 1190 */ 1191 function createIcon(iconString: string, tooltip?: string, styles?: string[], isButton?: true): HTMLButtonElement; 1192 function createIcon(iconString: string, tooltip = "", styles: string[] = [], isButton = false): HTMLElement { 1193 const icon = document.createElement("i"); 1194 icon.classList.add("icon-button", "material-icons"); 1195 icon.textContent = iconString; 1196 if (tooltip !== "") { 1197 icon.title = tooltip; 1198 } 1199 if (!isButton) { 1200 icon.classList.add(...styles); 1201 return icon; 1202 } 1203 const container = document.createElement("button"); 1204 container.appendChild(icon); 1205 container.classList.add("mdl-button", "mdl-js-button", "mdl-button--icon", 1206 ...styles); 1207 1208 return container; 1209 } 1210 1211 /** 1212 * Create a simple message with an icon. 1213 */ 1214 function createMessage(msg: string, icStr?: string): HTMLElement { 1215 const el = document.createElement("h3"); 1216 el.textContent = msg; 1217 if (icStr) { 1218 const ic = createIcon(icStr, "", ["message-icon"]); 1219 el.appendChild((ic)); 1220 } 1221 const msgContainer = document.createElement("div"); 1222 msgContainer.appendChild(el); 1223 msgContainer.classList.add("message"); 1224 1225 return msgContainer; 1226 }