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  }