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