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  }