diff --git a/prow/cmd/deck/main.go b/prow/cmd/deck/main.go index 2ad4da012260b..8f1f89a1b0ffb 100644 --- a/prow/cmd/deck/main.go +++ b/prow/cmd/deck/main.go @@ -451,13 +451,14 @@ func prodOnlyMain(cfg config.Getter, o options, mux *http.ServeMux) *http.ServeM githubOAuthConfig.InitGitHubOAuthConfig(cookie) goa := githuboauth.NewAgent(&githubOAuthConfig, logrus.WithField("client", "githuboauth")) - oauthClient := &oauth2.Config{ + oauthClient := githuboauth.NewClient(&oauth2.Config{ ClientID: githubOAuthConfig.ClientID, ClientSecret: githubOAuthConfig.ClientSecret, RedirectURL: githubOAuthConfig.RedirectURL, Scopes: githubOAuthConfig.Scopes, Endpoint: github.Endpoint, - } + }, + ) repoSet := make(map[string]bool) for r := range cfg().Presubmits { @@ -1211,6 +1212,5 @@ func handleFavicon(staticFilesLocation string, cfg config.Getter) http.HandlerFu func isValidatedGitOAuthConfig(githubOAuthConfig *config.GitHubOAuthConfig) bool { return githubOAuthConfig.ClientID != "" && githubOAuthConfig.ClientSecret != "" && - githubOAuthConfig.RedirectURL != "" && - githubOAuthConfig.FinalRedirectURL != "" + githubOAuthConfig.RedirectURL != "" } diff --git a/prow/cmd/deck/static/common/common.ts b/prow/cmd/deck/static/common/common.ts index c579f0e1ae010..a68e90c8c136a 100644 --- a/prow/cmd/deck/static/common/common.ts +++ b/prow/cmd/deck/static/common/common.ts @@ -201,3 +201,18 @@ export namespace tidehistory { return link; } } + +export function getCookieByName(name: string): string { + if (!document.cookie) { + return ""; + } + const docCookies = decodeURIComponent(document.cookie).split(";"); + for (const cookie of docCookies) { + const c = cookie.trim(); + const pref = name + "="; + if (c.indexOf(pref) === 0) { + return c.slice(pref.length); + } + } + return ""; +} diff --git a/prow/cmd/deck/static/pr/pr.ts b/prow/cmd/deck/static/pr/pr.ts index 403642c2607c9..b7e229cebbe50 100644 --- a/prow/cmd/deck/static/pr/pr.ts +++ b/prow/cmd/deck/static/pr/pr.ts @@ -4,7 +4,7 @@ import {Context} from '../api/github'; import {Label, PullRequest, UserData} from '../api/pr'; import {Job, JobState} from '../api/prow'; import {Blocker, TideData, TidePool, TideQuery as ITideQuery} from '../api/tide'; -import {tidehistory} from '../common/common'; +import {getCookieByName, tidehistory} from '../common/common'; declare const tideData: TideData; declare const allBuilds: Job[]; @@ -179,24 +179,6 @@ function onLoadQuery(): string { return ""; } -/** - * Gets cookie by its name. - */ -function getCookieByName(name: string): string { - if (!document.cookie) { - return ""; - } - const cookies = decodeURIComponent(document.cookie).split(";"); - for (const cookie of cookies) { - const c = cookie.trim(); - const pref = name + "="; - if (c.indexOf(pref) === 0) { - return c.slice(pref.length); - } - } - return ""; -} - /** * Creates an alert for merge blocking issues on tide. */ @@ -1154,7 +1136,7 @@ function createPRCard(pr: PullRequest, builds: UnifiedContext[] = [], queries: P * Redirect to initiate github login flow. */ function forceGitHubLogin(): void { - window.location.href = window.location.origin + "/github-login"; + window.location.href = window.location.origin + "/github-login?dest=%2Fpr"; } type VagueState = "succeeded" | "failed" | "pending" | "unknown"; diff --git a/prow/cmd/deck/static/prow/prow.ts b/prow/cmd/deck/static/prow/prow.ts index a66f01910e50f..9d86f2351cc40 100644 --- a/prow/cmd/deck/static/prow/prow.ts +++ b/prow/cmd/deck/static/prow/prow.ts @@ -1,6 +1,6 @@ import moment from "moment"; import {Job, JobState, JobType} from "../api/prow"; -import {cell, icon} from "../common/common"; +import {cell, getCookieByName, icon} from "../common/common"; import {FuzzySearch} from './fuzzy-search'; import {JobHistogram, JobSample} from './histogram'; @@ -397,6 +397,7 @@ function escapeRegexLiteral(s: string): string { } function redraw(fz: FuzzySearch): void { + const rerunStatus = getParameterByName("rerun"); const modal = document.getElementById('rerun')!; const rerunCommand = document.getElementById('rerun-content')!; window.onclick = (event) => { @@ -648,12 +649,18 @@ function redraw(fz: FuzzySearch): void { max = 2 * 3600; } drawJobHistogram(totalJob, jobHistogram, now - (12 * 3600), now, max); + if (rerunStatus != null) { + modal.style.display = "block"; + rerunCommand.innerHTML = `Nice try! The direct rerun feature hasn't been implemented yet, so that button does nothing.`; + } + } function createRerunCell(modal: HTMLElement, rerunElement: HTMLElement, prowjob: string): HTMLTableDataCellElement { - const url = `https://${window.location.hostname}/rerun?prowjob=${prowjob}`; + const url = `/rerun?prowjob=${prowjob}`; const c = document.createElement("td"); const i = icon.create("refresh", "Show instructions for rerunning this job"); + const login = getCookieByName("github_login"); i.onclick = () => { modal.style.display = "block"; rerunElement.innerHTML = `kubectl create -f "${url}"`; @@ -662,6 +669,23 @@ function createRerunCell(modal: HTMLElement, rerunElement: HTMLElement, prowjob: copyButton.onclick = () => copyToClipboardWithToast(`kubectl create -f "${url}"`); copyButton.innerHTML = "file_copy"; rerunElement.appendChild(copyButton); + const runButton = document.createElement('a'); + runButton.innerHTML = ""; + if (login === "") { + runButton.href = `/github-login?dest=%2F?rerun=work_in_progress`; + } else { + if (rerunCreatesJob) { + runButton.onclick = () => { + const form = document.createElement('form'); + form.method = 'POST'; + form.action = `${url}`; + c.appendChild(form); + form.submit(); + }; + } + runButton.href = `/?rerun=work_in_progress`; + } + rerunElement.appendChild(runButton); }; c.appendChild(i); c.classList.add("icon-cell"); diff --git a/prow/cmd/deck/static/prow/prow.ts.orig b/prow/cmd/deck/static/prow/prow.ts.orig new file mode 100644 index 0000000000000..41bc0b7f94883 --- /dev/null +++ b/prow/cmd/deck/static/prow/prow.ts.orig @@ -0,0 +1,955 @@ +import moment from "moment"; +import {Job, JobState, JobType} from "../api/prow"; +import {cell, getCookieByName, icon} from "../common/common"; +import {FuzzySearch} from './fuzzy-search'; +import {JobHistogram, JobSample} from './histogram'; + +declare const allBuilds: Job[]; +declare const spyglass: boolean; +declare const rerunCreatesJob: boolean; + +// http://stackoverflow.com/a/5158301/3694 +function getParameterByName(name: string): string | null { + const match = RegExp(`[?&]${name}=([^&/]*)`).exec( + window.location.search); + return match && decodeURIComponent(match[1].replace(/\+/g, ' ')); +} + +function shortenBuildRefs(buildRef: string): string { + return buildRef && buildRef.replace(/:[0-9a-f]*/g, ''); +} + +interface RepoOptions { + types: {[key: string]: boolean}; + repos: {[key: string]: boolean}; + jobs: {[key: string]: boolean}; + authors: {[key: string]: boolean}; + pulls: {[key: string]: boolean}; + batches: {[key: string]: boolean}; + states: {[key: string]: boolean}; +} + +function optionsForRepo(repo: string): RepoOptions { + const opts: RepoOptions = { + authors: {}, + batches: {}, + jobs: {}, + pulls: {}, + repos: {}, + states: {}, + types: {}, + }; + + for (const build of allBuilds) { + opts.types[build.type] = true; + const repoKey = `${build.refs.org}/${build.refs.repo}`; + if (repoKey) { + opts.repos[repoKey] = true; + } + if (!repo || repo === repoKey) { + opts.jobs[build.job] = true; + opts.states[build.state] = true; + if (build.type === "presubmit" && + build.refs.pulls && + build.refs.pulls.length > 0) { + opts.authors[build.refs.pulls[0].author] = true; + opts.pulls[build.refs.pulls[0].number] = true; + } else if (build.type === "batch") { + opts.batches[shortenBuildRefs(build.refs_key)] = true; + } + } + } + + return opts; +} + +function redrawOptions(fz: FuzzySearch, opts: RepoOptions) { + const ts = Object.keys(opts.types).sort(); + const selectedType = addOptions(ts, "type") as JobType; + const rs = Object.keys(opts.repos).filter((r) => r !== "/").sort(); + addOptions(rs, "repo"); + const js = Object.keys(opts.jobs).sort(); + const jobInput = document.getElementById("job-input") as HTMLInputElement; + const jobList = document.getElementById("job-list") as HTMLUListElement; + addOptionFuzzySearch(fz, js, "job", jobList, jobInput); + const as = Object.keys(opts.authors).sort( + (a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + addOptions(as, "author"); + if (selectedType === "batch") { + opts.pulls = opts.batches; + } + if (selectedType !== "periodic" && selectedType !== "postsubmit") { + const ps = Object.keys(opts.pulls).sort((a, b) => Number(a) - Number(b)); + addOptions(ps, "pull"); + } else { + addOptions([], "pull"); + } + const ss = Object.keys(opts.states).sort(); + addOptions(ss, "state"); +} + +function adjustScroll(el: Element): void { + const parent = el.parentElement!; + const parentRect = parent.getBoundingClientRect(); + const elRect = el.getBoundingClientRect(); + + if (elRect.top < parentRect.top) { + parent.scrollTop -= elRect.height; + } else if (elRect.top + elRect.height >= parentRect.top + + parentRect.height) { + parent.scrollTop += elRect.height; + } +} + +function handleDownKey(): void { + const activeSearches = + document.getElementsByClassName("active-fuzzy-search"); + if (activeSearches !== null && activeSearches.length !== 1) { + return; + } + const activeSearch = activeSearches[0]; + if (activeSearch.tagName !== "UL" || + activeSearch.childElementCount === 0) { + return; + } + + const selectedJobs = activeSearch.getElementsByClassName("job-selected"); + if (selectedJobs.length > 1) { + return; + } + if (selectedJobs.length === 0) { + // If no job selected, select the first one that visible in the list. + const jobs = Array.from(activeSearch.children) + .filter((elChild) => { + const childRect = elChild.getBoundingClientRect(); + const listRect = activeSearch.getBoundingClientRect(); + return childRect.top >= listRect.top && + (childRect.top < listRect.top + listRect.height); + }); + if (jobs.length === 0) { + return; + } + jobs[0].classList.add("job-selected"); + return; + } + const selectedJob = selectedJobs[0] as Element; + const nextSibling = selectedJob.nextElementSibling; + if (!nextSibling) { + return; + } + + selectedJob.classList.remove("job-selected"); + nextSibling.classList.add("job-selected"); + adjustScroll(nextSibling); +} + +function handleUpKey(): void { + const activeSearches = + document.getElementsByClassName("active-fuzzy-search"); + if (activeSearches && activeSearches.length !== 1) { + return; + } + const activeSearch = activeSearches[0]; + if (activeSearch.tagName !== "UL" || + activeSearch.childElementCount === 0) { + return; + } + + const selectedJobs = activeSearch.getElementsByClassName("job-selected"); + if (selectedJobs.length !== 1) { + return; + } + + const selectedJob = selectedJobs[0] as Element; + const previousSibling = selectedJob.previousElementSibling; + if (!previousSibling) { + return; + } + + selectedJob.classList.remove("job-selected"); + previousSibling.classList.add("job-selected"); + adjustScroll(previousSibling); +} + +window.onload = (): void => { + const topNavigator = document.getElementById("top-navigator")!; + let navigatorTimeOut: number | undefined; + const main = document.querySelector("main")! as HTMLElement; + main.onscroll = () => { + topNavigator.classList.add("hidden"); + if (navigatorTimeOut) { + clearTimeout(navigatorTimeOut); + } + navigatorTimeOut = setTimeout(() => { + if (main.scrollTop === 0) { + topNavigator.classList.add("hidden"); + } else if (main.scrollTop > 100) { + topNavigator.classList.remove("hidden"); + } + }, 100); + }; + topNavigator.onclick = () => { + main.scrollTop = 0; + }; + + document.addEventListener("keydown", (event) => { + if (event.keyCode === 40) { + handleDownKey(); + } else if (event.keyCode === 38) { + handleUpKey(); + } + }); + // Register selection on change functions + const filterBox = document.getElementById("filter-box")!; + const options = filterBox.querySelectorAll("select")!; + options.forEach((opt) => { + opt.onchange = () => { + redraw(fz); + }; + }); + // Attach job status bar on click + const stateFilter = document.getElementById("state")! as HTMLSelectElement; + document.querySelectorAll(".job-bar-state").forEach((jb) => { + const state = jb.id.slice("job-bar-".length); + if (state === "unknown") { + return; + } + jb.addEventListener("click", () => { + stateFilter.value = state; + stateFilter.onchange!.call(stateFilter, new Event("change")); + }); + }); + // Attach job histogram on click to scroll the selected build into view + const jobHistogram = document.getElementById("job-histogram-content") as HTMLTableSectionElement; + jobHistogram.addEventListener("click", (event) => { + const target = event.target as HTMLElement; + if (target == null) { + return; + } + if (!target.classList.contains('active')) { + return; + } + const row = target.dataset.sampleRow; + if (row == null || row.length === 0) { + return; + } + const rowNumber = Number(row); + const builds = document.getElementById("builds")!.getElementsByTagName("tbody")[0]; + if (builds == null || rowNumber >= builds.childNodes.length) { + return; + } + const targetRow = builds.childNodes[rowNumber] as HTMLTableRowElement; + targetRow.scrollIntoView(); + }); + // set dropdown based on options from query string + const opts = optionsForRepo(""); + const fz = initFuzzySearch( + "job", + "job-input", + "job-list", + Object.keys(opts.jobs).sort()); + redrawOptions(fz, opts); + redraw(fz); +}; + +function displayFuzzySearchResult(el: HTMLElement, inputContainer: ClientRect | DOMRect): void { + el.classList.add("active-fuzzy-search"); + el.style.top = inputContainer.height - 1 + "px"; + el.style.width = inputContainer.width + "px"; + el.style.height = 200 + "px"; + el.style.zIndex = "9999"; +} + +function fuzzySearch(fz: FuzzySearch, id: string, list: HTMLElement, input: HTMLInputElement): void { + const inputValue = input.value.trim(); + addOptionFuzzySearch(fz, fz.search(inputValue), id, list, input, true); +} + +function validToken(token: number): boolean { + // 0-9 + if (token >= 48 && token <= 57) { + return true; + } + // a-z + if (token >= 65 && token <= 90) { + return true; + } + // - and backspace + return token === 189 || token === 8; +} + +function handleEnterKeyDown(fz: FuzzySearch, list: HTMLElement, input: HTMLInputElement): void { + const selectedJobs = list.getElementsByClassName("job-selected"); + if (selectedJobs && selectedJobs.length === 1) { + input.value = (selectedJobs[0] as HTMLElement).innerHTML; + } + // TODO(@qhuynh96): according to discussion in https://github.com/kubernetes/test-infra/pull/7165, the + // fuzzy search should respect user input no matter it is in the list or not. User may + // experience being redirected back to default view if the search input is invalid. + input.blur(); + list.classList.remove("active-fuzzy-search"); + redraw(fz); +} + +function registerFuzzySearchHandler(fz: FuzzySearch, id: string, list: HTMLElement, input: HTMLInputElement): void { + input.addEventListener("keydown", (event) => { + if (event.keyCode === 13) { + handleEnterKeyDown(fz, list, input); + } else if (validToken(event.keyCode)) { + // Delay 1 frame that the input character is recorded before getting + // input value + setTimeout(() => fuzzySearch(fz, id, list, input), 32); + } + }); +} + +function initFuzzySearch(id: string, inputId: string, listId: string, + data: string[]): FuzzySearch { + const fz = new FuzzySearch(data); + const el = document.getElementById(id)!; + const input = document.getElementById(inputId)! as HTMLInputElement; + const list = document.getElementById(listId)!; + + list.classList.remove("active-fuzzy-search"); + input.addEventListener("focus", () => { + fuzzySearch(fz, id, list, input); + displayFuzzySearchResult(list, el.getBoundingClientRect()); + }); + input.addEventListener("blur", () => list.classList.remove("active-fuzzy-search")); + + registerFuzzySearchHandler(fz, id, list, input); + return fz; +} + +function registerJobResultEventHandler(fz: FuzzySearch, li: HTMLElement, input: HTMLInputElement) { + li.addEventListener("mousedown", (event) => { + input.value = (event.currentTarget as HTMLElement).innerHTML; + redraw(fz); + }); + li.addEventListener("mouseover", (event) => { + const selectedJobs = document.getElementsByClassName("job-selected"); + if (!selectedJobs) { + return; + } + + for (const job of Array.from(selectedJobs)) { + job.classList.remove("job-selected"); + } + (event.currentTarget as HTMLElement).classList.add("job-selected"); + }); + li.addEventListener("mouseout", (event) => { + (event.currentTarget as HTMLElement).classList.remove("job-selected"); + }); +} + +function addOptionFuzzySearch(fz: FuzzySearch, data: string[], id: string, + list: HTMLElement, input: HTMLInputElement, + stopAutoFill?: boolean): void { + if (!stopAutoFill) { + input.value = getParameterByName(id) || ''; + } + while (list.firstChild) { + list.removeChild(list.firstChild); + } + list.scrollTop = 0; + for (const datum of data) { + const li = document.createElement("li"); + li.innerHTML = datum; + registerJobResultEventHandler(fz, li, input); + list.appendChild(li); + } +} + +function addOptions(options: string[], selectID: string): string | null { + const sel = document.getElementById(selectID)! as HTMLSelectElement; + while (sel.length > 1) { + sel.removeChild(sel.lastChild!); + } + const param = getParameterByName(selectID); + for (const option of options) { + const o = document.createElement("option"); + o.text = option; + if (param && option === param) { + o.selected = true; + } + sel.appendChild(o); + } + return param; +} + +function selectionText(sel: HTMLSelectElement): string { + return sel.selectedIndex === 0 ? "" : sel.options[sel.selectedIndex].text; +} + +function equalSelected(sel: string, t: string): boolean { + return sel === "" || sel === t; +} + +function groupKey(build: Job): string { + const pr = (build.refs.pulls && build.refs.pulls.length === 1) ? build.refs.pulls[0].number : 0; + return `${build.refs.repo} ${pr} ${build.refs_key}`; +} + +// escapeRegexLiteral ensures the given string is escaped so that it is treated as +// an exact value when used within a RegExp. This is the standard substitution recommended +// by https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions. +function escapeRegexLiteral(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function redraw(fz: FuzzySearch): void { + const rerunStatus = getParameterByName("rerun"); + const modal = document.getElementById('rerun')!; + const rerunCommand = document.getElementById('rerun-content')!; + window.onclick = (event) => { + if (event.target === modal) { + modal.style.display = "none"; + } + }; + const builds = document.getElementById("builds")!.getElementsByTagName( + "tbody")[0]; + while (builds.firstChild) { + builds.removeChild(builds.firstChild); + } + + const args: string[] = []; + + function getSelection(name: string): string { + const sel = selectionText(document.getElementById(name) as HTMLSelectElement); + if (sel && opts && !opts[name + 's' as keyof RepoOptions][sel]) { + return ""; + } + if (sel !== "") { + args.push(`${name}=${encodeURIComponent(sel)}`); + } + return sel; + } + + function getSelectionFuzzySearch(id: string, inputId: string): RegExp { + const input = document.getElementById(inputId) as HTMLInputElement; + const inputText = input.value; + if (inputText === "") { + return new RegExp(''); + } + if (inputText !== "") { + args.push(`${id}=${encodeURIComponent(inputText)}`); + } + if (inputText !== "" && opts && opts[id + 's' as keyof RepoOptions][inputText]) { + return new RegExp(`^${escapeRegexLiteral(inputText)}$`); + } + const expr = inputText.split('*').map(escapeRegexLiteral); + return new RegExp(`^${expr.join('.*')}$`); + } + + const repoSel = getSelection("repo"); + const opts = optionsForRepo(repoSel); + + const typeSel = getSelection("type") as JobType; + if (typeSel === "batch") { + opts.pulls = opts.batches; + } + const pullSel = getSelection("pull"); + const authorSel = getSelection("author"); + const jobSel = getSelectionFuzzySearch("job", "job-input"); + const stateSel = getSelection("state"); + + if (window.history && window.history.replaceState !== undefined) { + if (args.length > 0) { + history.replaceState(null, "", "/?" + args.join('&')); + } else { + history.replaceState(null, "", "/"); + } + } + fz.setDict(Object.keys(opts.jobs)); + redrawOptions(fz, opts); + + let lastKey = ''; + const jobCountMap = new Map() as Map; + const jobInterval: Array<[number, number]> = [[3600 * 3, 0], [3600 * 12, 0], [3600 * 48, 0]]; + let currentInterval = 0; + const jobHistogram = new JobHistogram(); + const now = moment().unix(); + let totalJob = 0; + let displayedJob = 0; + for (let i = 0; i < allBuilds.length; i++) { + const build = allBuilds[i]; + if (!equalSelected(typeSel, build.type)) { + continue; + } + if (!equalSelected(repoSel, `${build.refs.org}/${build.refs.repo}`)) { + continue; + } + if (!equalSelected(stateSel, build.state)) { + continue; + } + if (!jobSel.test(build.job)) { + continue; + } + if (build.type === "presubmit") { + if (build.refs.pulls && build.refs.pulls.length > 0) { + const pull = build.refs.pulls[0]; + if (!equalSelected(pullSel, pull.number.toString())) { + continue; + } + if (!equalSelected(authorSel, pull.author)) { + continue; + } + } + } else if (build.type === "batch" && !authorSel) { + if (!equalSelected(pullSel, shortenBuildRefs(build.refs_key))) { + continue; + } + } else if (pullSel || authorSel) { + continue; + } + + if (!jobCountMap.has(build.state)) { + jobCountMap.set(build.state, 0); + } + totalJob++; + jobCountMap.set(build.state, jobCountMap.get(build.state)! + 1); + + // accumulate a count of the percentage of successful jobs over each interval + const started = Number(build.started); + if (currentInterval >= 0 && (now - started) > jobInterval[currentInterval][0]) { + let successCount = jobCountMap.get("success"); + if (!successCount) { + successCount = 0; + } + let failureCount = jobCountMap.get("failure"); + if (!failureCount) { + failureCount = 0; + } + const total = successCount + failureCount; + if (total > 0) { + jobInterval[currentInterval][1] = successCount / total; + } else { + jobInterval[currentInterval][1] = 0; + } + currentInterval++; + if (currentInterval >= jobInterval.length) { + currentInterval = -1; + } + } + + if (displayedJob >= 500) { + jobHistogram.add(new JobSample(started, parseDuration(build.duration), build.state, -1)); + continue; + } else { + jobHistogram.add(new JobSample(started, parseDuration(build.duration), build.state, builds.childElementCount)); + } + displayedJob++; + const r = document.createElement("tr"); + r.appendChild(cell.state(build.state)); + if (build.pod_name) { + const logIcon = icon.create("description", "Build log"); + logIcon.href = `log?job=${build.job}&id=${build.build_id}`; + const c = document.createElement("td"); + c.classList.add("icon-cell"); + c.appendChild(logIcon); + r.appendChild(c); + } else { + r.appendChild(cell.text("")); + } + r.appendChild(createRerunCell(modal, rerunCommand, build.prow_job)); + r.appendChild(createViewJobCell(build.prow_job)); + const key = groupKey(build); + if (key !== lastKey) { + // This is a different PR or commit than the previous row. + lastKey = key; + r.className = "changed"; + + if (build.type === "periodic") { + r.appendChild(cell.text("")); + } else { + let repoLink = build.refs.repo_link; + if (!repoLink) { + repoLink = `https://github.com/${build.refs.org}/${build.refs.repo}`; + } + r.appendChild(cell.link(`${build.refs.org}/${build.refs.repo}`, repoLink)); + } + if (build.type === "presubmit") { + if (build.refs.pulls && build.refs.pulls.length > 0) { + r.appendChild(cell.prRevision(`${build.refs.org}/${build.refs.repo}`, build.refs.pulls[0])); + } else { + r.appendChild(cell.text("")); + } + } else if (build.type === "batch") { + r.appendChild(batchRevisionCell(build)); + } else if (build.type === "postsubmit") { + r.appendChild(cell.commitRevision(`${build.refs.org}/${build.refs.repo}`, build.refs.base_ref || "", + build.refs.base_sha || "", build.refs.base_link || "")); + } else if (build.type === "periodic") { + r.appendChild(cell.text("")); + } + } else { + // Don't render identical cells for the same PR/commit. + r.appendChild(cell.text("")); + r.appendChild(cell.text("")); + } + if (spyglass) { + const buildIndex = build.url.indexOf('/build/'); + if (buildIndex !== -1) { + const url = `${window.location.origin}/view/gcs/${build.url.substring(buildIndex + '/build/'.length)}`; + r.appendChild(createSpyglassCell(url)); + } else if (build.url.includes('/view/')) { + r.appendChild(createSpyglassCell(build.url)); + } else { + r.appendChild(cell.text('')); + } + } else { + r.appendChild(cell.text('')); + } + if (build.url === "") { + r.appendChild(cell.text(build.job)); + } else { + r.appendChild(cell.link(build.job, build.url)); + } + + r.appendChild(cell.time(i.toString(), moment.unix(Number(build.started)))); + r.appendChild(cell.text(build.duration)); + builds.appendChild(r); + } + + // fill out the remaining intervals if necessary + if (currentInterval !== -1) { + let successCount = jobCountMap.get("success"); + if (!successCount) { + successCount = 0; + } + let failureCount = jobCountMap.get("failure"); + if (!failureCount) { + failureCount = 0; + } + const total = successCount + failureCount; + for (let i = currentInterval; i < jobInterval.length; i++) { + if (total > 0) { + jobInterval[i][1] = successCount / total; + } else { + jobInterval[i][1] = 0; + } + } + } + + const jobSummary = document.getElementById("job-histogram-summary")!; + const success = jobInterval.map((interval) => { + if (interval[1] < 0.5) { + return `${formatDuration(interval[0])}: ${Math.ceil(interval[1] * 100)}%`; + } + return `${formatDuration(interval[0])}: ${Math.ceil(interval[1] * 100)}%`; + }).join(", "); + jobSummary.innerHTML = `Success rate over time: ${success}`; + const jobCount = document.getElementById("job-count")!; + jobCount.textContent = `Showing ${displayedJob}/${totalJob} jobs`; + drawJobBar(totalJob, jobCountMap); + + // if we aren't filtering the output, cap the histogram y axis to 2 hours because it + // contains the bulk of our jobs + let max = Number.MAX_SAFE_INTEGER; + if (totalJob === allBuilds.length) { + max = 2 * 3600; + } + drawJobHistogram(totalJob, jobHistogram, now - (12 * 3600), now, max); + if (rerunStatus != null) { + modal.style.display = "block"; + rerunCommand.innerHTML = `Nice try! The direct rerun feature hasn't been implemented yet, so that button does nothing.`; + } + +} + +function createRerunCell(modal: HTMLElement, rerunElement: HTMLElement, prowjob: string): HTMLTableDataCellElement { + const url = `/rerun?prowjob=${prowjob}`; + const c = document.createElement("td"); +<<<<<<< HEAD + let i; + if (rerunCreatesJob) { + i = icon.create("refresh", "Rerun this job"); + i.onclick = () => { + const form = document.createElement('form'); + form.method = 'POST'; + form.action = `${url}`; + c.appendChild(form); + form.submit(); + }; + } else { + i = icon.create("refresh", "Show instructions for rerunning this job"); + i.onclick = () => { +======= + const i = icon.create("refresh", "Show instructions for rerunning this job"); + const login = getCookieByName("github_login"); + i.onclick = () => { +>>>>>>> Try to fix various git issues + modal.style.display = "block"; + rerunElement.innerHTML = `kubectl create -f "${url}"`; + const copyButton = document.createElement('a'); + copyButton.className = "mdl-button mdl-js-button mdl-button--icon"; + copyButton.onclick = () => copyToClipboardWithToast(`kubectl create -f "${url}"`); + copyButton.innerHTML = "file_copy"; + rerunElement.appendChild(copyButton); +<<<<<<< HEAD + }; + } +======= + const runButton = document.createElement('a'); + runButton.innerHTML = ""; + if (login === "") { + runButton.href = `/github-login?dest=%2F?rerun=work_in_progress`; + } else { + runButton.href = `/?rerun=work_in_progress`; + } + rerunElement.appendChild(runButton); + }; +>>>>>>> Try to fix various git issues + c.appendChild(i); + c.classList.add("icon-cell"); + return c; +} + +function createViewJobCell(prowjob: string): HTMLTableDataCellElement { + const c = document.createElement("td"); + const i = icon.create("pageview", "Show job YAML"); + i.href = `https://${window.location.hostname}/prowjob?prowjob=${prowjob}`; + c.classList.add("icon-cell"); + c.appendChild(i); + return c; +} + +// copyToClipboard is from https://stackoverflow.com/a/33928558 +// Copies a string to the clipboard. Must be called from within an +// event handler such as click. May return false if it failed, but +// this is not always possible. Browser support for Chrome 43+, +// Firefox 42+, Safari 10+, Edge and IE 10+. +// IE: The clipboard feature may be disabled by an administrator. By +// default a prompt is shown the first time the clipboard is +// used (per session). +function copyToClipboard(text: string) { + if (window.clipboardData && window.clipboardData.setData) { + // IE specific code path to prevent textarea being shown while dialog is visible. + return window.clipboardData.setData("Text", text); + } else if (document.queryCommandSupported && document.queryCommandSupported("copy")) { + const textarea = document.createElement("textarea"); + textarea.textContent = text; + textarea.style.position = "fixed"; // Prevent scrolling to bottom of page in MS Edge. + document.body.appendChild(textarea); + textarea.select(); + try { + return document.execCommand("copy"); // Security exception may be thrown by some browsers. + } catch (ex) { + console.warn("Copy to clipboard failed.", ex); + return false; + } finally { + document.body.removeChild(textarea); + } + } +} + +function copyToClipboardWithToast(text: string): void { + copyToClipboard(text); + + const toast = document.getElementById("toast") as SnackbarElement; + toast.MaterialSnackbar.showSnackbar({message: "Copied to clipboard"}); +} + +function batchRevisionCell(build: Job): HTMLTableDataCellElement { + const c = document.createElement("td"); + if (!build.refs.pulls) { + return c; + } + for (let i = 0; i < build.refs.pulls.length; i++) { + if (i !== 0) { + c.appendChild(document.createTextNode(", ")); + } + const l = document.createElement("a"); + const link = build.refs.pulls[i].link; + if (link) { + l.href = link; + } else { + l.href = `https://github.com/${build.refs.org}/${build.refs.repo}/pull/${build.refs.pulls[i].number}`; + } + l.text = build.refs.pulls[i].number.toString(); + c.appendChild(document.createTextNode("#")); + c.appendChild(l); + } + return c; +} + +function drawJobBar(total: number, jobCountMap: Map): void { + const states: JobState[] = ["success", "pending", "triggered", "error", "failure", "aborted", ""]; + states.sort((s1, s2) => { + return jobCountMap.get(s1)! - jobCountMap.get(s2)!; + }); + states.forEach((state, index) => { + const count = jobCountMap.get(state); + // If state is undefined or empty, treats it as unknown state. + if (!state) { + state = "unknown"; + } + const id = "job-bar-" + state; + const el = document.getElementById(id)!; + const tt = document.getElementById(state + "-tooltip")!; + if (!count || count === 0 || total === 0) { + el.textContent = ""; + tt.textContent = ""; + el.style.width = "0"; + } else { + el.textContent = count.toString(); + tt.textContent = `${count} ${stateToAdj(state)} jobs`; + if (index === states.length - 1) { + el.style.width = "auto"; + } else { + el.style.width = Math.max((count / total * 100), 1) + "%"; + } + } + }); +} + +function stateToAdj(state: JobState): string { + switch (state) { + case "success": + return "succeeded"; + case "failure": + return "failed"; + default: + return state; + } +} + +function parseDuration(duration: string): number { + if (duration.length === 0) { + return 0; + } + let seconds = 0; + let multiple = 0; + for (let i = duration.length; i >= 0; i--) { + const ch = duration[i]; + if (ch === 's') { + multiple = 1; + } else if (ch === 'm') { + multiple = 60; + } else if (ch === 'h') { + multiple = 60 * 60; + } else if (ch >= '0' && ch <= '9') { + seconds += Number(ch) * multiple; + multiple *= 10; + } + } + return seconds; +} + +function formatDuration(seconds: number): string { + const parts: string[] = []; + if (seconds >= 3600) { + const hours = Math.floor(seconds / 3600); + parts.push(String(hours)); + parts.push('h'); + seconds = seconds % 3600; + } + if (seconds >= 60) { + const minutes = Math.floor(seconds / 60); + if (minutes > 0) { + parts.push(String(minutes)); + parts.push('m'); + seconds = seconds % 60; + } + } + if (seconds > 0) { + parts.push(String(seconds)); + parts.push('s'); + } + return parts.join(''); +} + +function drawJobHistogram(total: number, jobHistogram: JobHistogram, start: number, end: number, maximum: number): void { + const startEl = document.getElementById("job-histogram-start") as HTMLSpanElement; + if (startEl != null) { + startEl.textContent = `${formatDuration(end - start)} ago`; + } + + // make sure the empty table is hidden + const tableEl = document.getElementById("job-histogram") as HTMLTableElement; + const labelsEl = document.getElementById("job-histogram-labels") as HTMLDivElement; + if (jobHistogram.length === 0) { + tableEl.style.display = "none"; + labelsEl.style.display = "none"; + return; + } + tableEl.style.display = ""; + labelsEl.style.display = ""; + + const el = document.getElementById("job-histogram-content") as HTMLTableSectionElement; + el.title = `Showing ${jobHistogram.length} builds from last ${formatDuration(end - start)} by start time and duration, newest to oldest.`; + const rows = 10; + const width = 12; + const cols = Math.round(el.clientWidth / width); + + // initialize the table if the row count changes + if (el.childNodes.length !== rows) { + el.innerHTML = ""; + for (let i = 0; i < rows; i++) { + const tr = document.createElement('tr'); + for (let j = 0; j < cols; j++) { + const td = document.createElement('td'); + tr.appendChild(td); + } + el.appendChild(tr); + } + } + + const buckets = jobHistogram.buckets(start, end, cols); + buckets.limitMaximum(maximum); + + // show the max and mid y-axis labels rounded up to the nearest 10 minute mark + let maxY = buckets.max; + maxY = Math.ceil(maxY / 600); + const yMax = document.getElementById("job-histogram-labels-y-max") as HTMLSpanElement; + yMax.innerText = `${formatDuration(maxY * 600)}+`; + const yMid = document.getElementById("job-histogram-labels-y-mid") as HTMLSpanElement; + yMid.innerText = `${formatDuration(maxY / 2 * 600)}`; + + // populate the buckets + buckets.data.forEach((bucket, colIndex) => { + let lastRowIndex = 0; + buckets.linearChunks(bucket, rows).forEach((samples, rowIndex) => { + lastRowIndex = rowIndex + 1; + const td = el.childNodes[rows - 1 - rowIndex].childNodes[cols - colIndex - 1] as HTMLTableCellElement; + if (samples.length === 0) { + td.removeAttribute('title'); + td.className = ''; + return; + } + td.dataset.sampleRow = String(samples[0].row); + const failures = samples.reduce((sum, sample) => { + return sample.state !== 'success' ? sum + 1 : sum; + }, 0); + if (failures === 0) { + td.title = `${samples.length} succeeded`; + } else { + if (failures === samples.length) { + td.title = `${failures} failed`; + } else { + td.title = `${failures}/${samples.length} failed`; + } + } + td.style.opacity = String(0.2 + samples.length / bucket.length * 0.8); + if (samples[0].row !== -1) { + td.className = `active success-${Math.floor(10 - (failures / samples.length) * 10)}`; + } else { + td.className = `success-${Math.floor(10 - (failures / samples.length) * 10)}`; + } + }); + for (let rowIndex = lastRowIndex; rowIndex < rows; rowIndex++) { + const td = el.childNodes[rows - 1 - rowIndex].childNodes[cols - colIndex - 1] as HTMLTableCellElement; + td.removeAttribute('title'); + td.className = ''; + } + }); +} + +function createSpyglassCell(url: string): HTMLTableDataCellElement { + const i = icon.create('visibility', 'View in Spyglass'); + i.href = url; + const c = document.createElement('td'); + c.classList.add('icon-cell'); + c.appendChild(i); + return c; +} diff --git a/prow/config/githuboauth.go b/prow/config/githuboauth.go index d13e00888ef1c..9fc9162148357 100644 --- a/prow/config/githuboauth.go +++ b/prow/config/githuboauth.go @@ -31,11 +31,10 @@ type Cookie struct { // GitHubOAuthConfig is a config for requesting users access tokens from GitHub API. It also has // a Cookie Store that retains user credentials deriving from GitHub API. type GitHubOAuthConfig struct { - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` - RedirectURL string `json:"redirect_url"` - Scopes []string `json:"scopes,omitempty"` - FinalRedirectURL string `json:"final_redirect_url"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + RedirectURL string `json:"redirect_url"` + Scopes []string `json:"scopes,omitempty"` CookieStore *sessions.CookieStore `json:"-"` } diff --git a/prow/githuboauth/githuboauth.go b/prow/githuboauth/githuboauth.go index cadee9f557a32..c67241da6c5c4 100644 --- a/prow/githuboauth/githuboauth.go +++ b/prow/githuboauth/githuboauth.go @@ -21,6 +21,7 @@ import ( "encoding/hex" "fmt" "net/http" + "net/url" "time" "github.com/google/go-github/github" @@ -54,6 +55,7 @@ type GitHubClientGetter interface { // OAuthClient is an interface for a GitHub OAuth client. type OAuthClient interface { + WithFinalRedirectURL(url string) (OAuthClient, error) // Exchanges code from GitHub OAuth redirect for user access token. Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) // Returns a URL to GitHub's OAuth 2.0 consent page. The state is a token to protect the user @@ -61,6 +63,35 @@ type OAuthClient interface { AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string } +type client struct { + *oauth2.Config +} + +func NewClient(config *oauth2.Config) client { + return client{ + config, + } +} + +func (cli client) WithFinalRedirectURL(path string) (OAuthClient, error) { + parsedURL, err := url.Parse(cli.RedirectURL) + if err != nil { + return nil, err + } + q := parsedURL.Query() + q.Set("dest", path) + parsedURL.RawQuery = q.Encode() + return NewClient( + &oauth2.Config{ + ClientID: cli.ClientID, + ClientSecret: cli.ClientSecret, + RedirectURL: parsedURL.String(), + Scopes: cli.Scopes, + Endpoint: cli.Endpoint, + }, + ), nil +} + type githubClientGetter struct{} func (gci *githubClientGetter) GetGitHubClient(accessToken string, dryRun bool) GitHubClientWrapper { @@ -92,6 +123,7 @@ func NewAgent(config *config.GitHubOAuthConfig, logger *logrus.Entry) *Agent { // redirect user to GitHub OAuth end-point for authentication. func (ga *Agent) HandleLogin(client OAuthClient) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + destPage := r.URL.Query().Get("dest") stateToken := xsrftoken.Generate(ga.gc.ClientSecret, "", "") state := hex.EncodeToString([]byte(stateToken)) oauthSession, err := ga.gc.CookieStore.New(r, oauthSessionCookie) @@ -108,8 +140,11 @@ func (ga *Agent) HandleLogin(client OAuthClient) http.HandlerFunc { ga.serverError(w, "Save oauth session", err) return } - - redirectURL := client.AuthCodeURL(state, oauth2.ApprovalForce, oauth2.AccessTypeOnline) + newClient, err := client.WithFinalRedirectURL(destPage) + if err != nil { + ga.serverError(w, "Failed to parse redirect URL", err) + } + redirectURL := newClient.AuthCodeURL(state, oauth2.ApprovalForce, oauth2.AccessTypeOnline) http.Redirect(w, r, redirectURL, http.StatusFound) } } @@ -135,7 +170,7 @@ func (ga *Agent) HandleLogout(client OAuthClient) http.HandlerFunc { loginCookie.Expires = time.Now().Add(-time.Hour * 24) http.SetCookie(w, loginCookie) } - http.Redirect(w, r, ga.gc.FinalRedirectURL, http.StatusFound) + http.Redirect(w, r, r.URL.Host, http.StatusFound) } } @@ -144,6 +179,14 @@ func (ga *Agent) HandleLogout(client OAuthClient) http.HandlerFunc { // the final destination in the config, which should be the front-end. func (ga *Agent) HandleRedirect(client OAuthClient, getter GitHubClientGetter) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + finalRedirectURL, err := r.URL.Parse(r.URL.Query().Get("dest")) + //This check prevents someone from specifying a different host to redirect to. + if finalRedirectURL.Host != "" { + ga.serverError(w, "Invalid hostname", fmt.Errorf("%s, expected prow.k8s.io", finalRedirectURL.Host)) + } + if err != nil { + ga.serverError(w, "Failed to parse final destination from OAuth redirect payload", err) + } state := r.FormValue("state") stateTokenRaw, err := hex.DecodeString(state) if err != nil { @@ -163,7 +206,7 @@ func (ga *Agent) HandleRedirect(client OAuthClient, getter GitHubClientGetter) h } secretState, ok := oauthSession.Values[stateKey].(string) if !ok { - ga.serverError(w, "Get secret state", fmt.Errorf("empty string or cannot convert to string")) + ga.serverError(w, "Get secret state", fmt.Errorf("empty string or cannot convert to string. this probably means the options passed to GitHub don't match what was expected")) return } // Validate the state parameter to prevent cross-site attack. @@ -219,7 +262,7 @@ func (ga *Agent) HandleRedirect(client OAuthClient, getter GitHubClientGetter) h Expires: time.Now().Add(time.Hour * 24 * 30), Secure: true, }) - http.Redirect(w, r, ga.gc.FinalRedirectURL, http.StatusFound) + http.Redirect(w, r, finalRedirectURL.String(), http.StatusFound) } } diff --git a/prow/githuboauth/githuboauth_test.go b/prow/githuboauth/githuboauth_test.go index 6a37e17209054..c8612bac1f59b 100644 --- a/prow/githuboauth/githuboauth_test.go +++ b/prow/githuboauth/githuboauth_test.go @@ -21,6 +21,8 @@ import ( "encoding/hex" "net/http" "net/http/httptest" + "net/url" + "strings" "testing" "github.com/google/go-github/github" @@ -36,30 +38,42 @@ import ( const mockAccessToken = "justSomeRandomSecretToken" -type MockOAuthClient struct{} +type mockOAuthClient struct { + config *oauth2.Config +} + +func (c mockOAuthClient) WithFinalRedirectURL(path string) (OAuthClient, error) { + parsedURL, err := url.Parse("www.something.com") + if err != nil { + return nil, err + } + q := parsedURL.Query() + q.Set("dest", path) + parsedURL.RawQuery = q.Encode() + return mockOAuthClient{&oauth2.Config{RedirectURL: parsedURL.String()}}, nil +} -func (c *MockOAuthClient) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { +func (c mockOAuthClient) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { return &oauth2.Token{ AccessToken: mockAccessToken, }, nil } -func (c *MockOAuthClient) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { - return "mock-auth-url" +func (c mockOAuthClient) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { + return c.config.AuthCodeURL(state, opts...) } func getMockConfig(cookie *sessions.CookieStore) *config.GitHubOAuthConfig { clientID := "mock-client-id" clientSecret := "mock-client-secret" - redirectURL := "/uni-test/redirect-url" + redirectURL := "uni-test/redirect-url" scopes := []string{} return &config.GitHubOAuthConfig{ - ClientID: clientID, - ClientSecret: clientSecret, - RedirectURL: redirectURL, - Scopes: scopes, - FinalRedirectURL: "/unit-test/final-redirect-url", + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Scopes: scopes, CookieStore: cookie, } @@ -80,13 +94,15 @@ func isEqual(token1 *oauth2.Token, token2 *oauth2.Token) bool { } func TestHandleLogin(t *testing.T) { + dest := "wherever" + rerunStatus := "working" cookie := sessions.NewCookieStore([]byte("secret-key")) mockConfig := getMockConfig(cookie) mockLogger := logrus.WithField("uni-test", "githuboauth") mockAgent := NewAgent(mockConfig, mockLogger) - mockOAuthClient := &MockOAuthClient{} + mockOAuthClient := mockOAuthClient{} - mockRequest := httptest.NewRequest(http.MethodGet, "/mock-login", nil) + mockRequest := httptest.NewRequest(http.MethodGet, "/mock-login?dest="+dest+"?rerun="+rerunStatus, nil) mockResponse := httptest.NewRecorder() handleLoginFn := mockAgent.HandleLogin(mockOAuthClient) @@ -125,6 +141,16 @@ func TestHandleLogin(t *testing.T) { if state == "" { t.Error("Expect state parameter is not empty, found empty") } + destCount := 0 + path := mockResponse.Header().Get("Location") + for _, q := range strings.Split(path, "&") { + if q == "redirect_uri=www.something.com%3Fdest%3Dwherever%253Frerun%253Dworking" { + destCount++ + } + } + if destCount != 1 { + t.Errorf("Redirect URI in path does not include correct destination. path: %s, destination: %s", path, "?dest="+dest+"?rerun=working") + } } func TestHandleLogout(t *testing.T) { @@ -132,7 +158,7 @@ func TestHandleLogout(t *testing.T) { mockConfig := getMockConfig(cookie) mockLogger := logrus.WithField("uni-test", "githuboauth") mockAgent := NewAgent(mockConfig, mockLogger) - mockOAuthClient := &MockOAuthClient{} + mockOAuthClient := mockOAuthClient{} mockRequest := httptest.NewRequest(http.MethodGet, "/mock-logout", nil) _, err := cookie.New(mockRequest, tokenSession) @@ -172,7 +198,7 @@ func TestHandleLogoutWithLoginSession(t *testing.T) { mockConfig := getMockConfig(cookie) mockLogger := logrus.WithField("uni-test", "githuboauth") mockAgent := NewAgent(mockConfig, mockLogger) - mockOAuthClient := &MockOAuthClient{} + mockOAuthClient := mockOAuthClient{} mockRequest := httptest.NewRequest(http.MethodGet, "/mock-logout", nil) _, err := cookie.New(mockRequest, tokenSession) @@ -232,7 +258,7 @@ func TestHandleRedirectWithInvalidState(t *testing.T) { mockConfig := getMockConfig(cookie) mockLogger := logrus.WithField("uni-test", "githuboauth") mockAgent := NewAgent(mockConfig, mockLogger) - mockOAuthClient := &MockOAuthClient{} + mockOAuthClient := mockOAuthClient{} mockStateToken := createMockStateToken(mockConfig) mockRequest := httptest.NewRequest(http.MethodGet, "/mock-login", nil) @@ -262,13 +288,16 @@ func TestHandleRedirectWithValidState(t *testing.T) { mockLogger := logrus.WithField("uni-test", "githuboauth") mockAgent := NewAgent(mockConfig, mockLogger) mockLogin := "foo_name" - mockOAuthClient := &MockOAuthClient{} + mockOAuthClient := mockOAuthClient{} mockStateToken := createMockStateToken(mockConfig) - mockRequest := httptest.NewRequest(http.MethodGet, "/mock-login", nil) + dest := "somewhere" + rerunStatus := "working" + mockRequest := httptest.NewRequest(http.MethodGet, "/mock-login?dest="+dest+"?rerun="+rerunStatus, nil) mockResponse := httptest.NewRecorder() query := mockRequest.URL.Query() query.Add("state", mockStateToken) + query.Add("rerun", "working") mockRequest.URL.RawQuery = query.Encode() mockSession, err := sessions.GetRegistry(mockRequest).Get(cookie, oauthSessionCookie) @@ -321,4 +350,8 @@ func TestHandleRedirectWithValidState(t *testing.T) { if loginCookie.Value != mockLogin { t.Errorf("Mismatch github login. Got %v, expected %v", loginCookie.Value, mockLogin) } + path := mockResponse.Header().Get("Location") + if path != "/"+dest+"?rerun="+rerunStatus { + t.Errorf("Incorrect final redirect URL. Actual path: %s, Expected path: /%s", path, dest+"?rerun="+rerunStatus) + } }