|
// ==UserScript== |
|
// @name Github Actions: Link to newest run |
|
// @namespace https://github.com |
|
// @version 0.2 |
|
// @description Add a link "Go to newest run" or "Go to equivalent job in latest run" if there's a newer run of the same workflow+branch. Polls every 20s. |
|
// @match https://github.com/*/actions/runs/* |
|
// @grant none |
|
// @author Martín Gaitán |
|
// @homepageURL https://gist.github.com/mgaitan/d1e6f2e1410e487a7f84a0094cd0e3c8/ |
|
// @updateURL https://gist.githubusercontent.com/mgaitan/d1e6f2e1410e487a7f84a0094cd0e3c8/raw/gha_go_to_newest_run.user.js |
|
// @downloadURL https://gist.githubusercontent.com/mgaitan/d1e6f2e1410e487a7f84a0094cd0e3c8/raw/gha_go_to_newest_run.user.js |
|
// @match https://github.com/*/actions/runs/* |
|
// ==/UserScript== |
|
|
|
(function() { |
|
'use strict'; |
|
|
|
console.log("[Debug] GH Actions: Link to newest run script loaded."); |
|
|
|
// 1) Parse the URL to see if it's a summary or job page: |
|
// e.g. https://github.com/OWNER/REPO/actions/runs/1234/job/5678 |
|
const urlRegex = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/actions\/runs\/(\d+)(\/jobs?\/(\d+))?/; |
|
const match = window.location.href.match(urlRegex); |
|
if (!match) { |
|
console.log("[Debug] Not recognized as a GitHub Actions run page; abort."); |
|
return; |
|
} |
|
|
|
const owner = match[1]; |
|
const repo = match[2]; |
|
const runId = match[3]; |
|
const jobId = match[5] || null; |
|
const isJobPage = !!jobId; |
|
|
|
console.log("[Debug] Detected owner=%s, repo=%s, runId=%s, jobId=%s", owner, repo, runId, jobId); |
|
|
|
// We'll need a branch name to query for newer runs. |
|
let branchName = null; |
|
|
|
// If on a job page, we also want to store the job's index in the nav, |
|
// so we can map that index in the newest run. |
|
let jobIndex = -1; |
|
|
|
// 2) If we are on a job page, we must fetch the run summary to get the branch name. |
|
// While we're at it, we also find the jobIndex in the current DOM if possible. |
|
if (isJobPage) { |
|
// Attempt to find <li data-item-id="job_<jobId>"> in the current nav |
|
// to figure out the index among all job <li> items. |
|
const currentJobLi = document.querySelector(`li[data-item-id="job_${jobId}"]`); |
|
if (currentJobLi) { |
|
const allJobLis = [...document.querySelectorAll('li[data-item-id^="job_"]')]; |
|
jobIndex = allJobLis.indexOf(currentJobLi); |
|
console.log("[Debug] Found current job index:", jobIndex); |
|
} else { |
|
console.log("[Debug] Could not find nav li for job_%s in this page. We'll do best-effort fallback later.", jobId); |
|
} |
|
|
|
// Summaries are at /actions/runs/<runId> |
|
const summaryUrl = `https://github.com/${owner}/${repo}/actions/runs/${runId}`; |
|
console.log("[Debug] Will fetch summary to get branch name:", summaryUrl); |
|
|
|
fetch(summaryUrl) |
|
.then(resp => resp.ok ? resp.text() : Promise.reject("[Debug] Summary fetch failed: " + resp.status)) |
|
.then(html => { |
|
const parser = new DOMParser(); |
|
const doc = parser.parseFromString(html, 'text/html'); |
|
|
|
const branchEl = doc.querySelector('a.branch-name'); |
|
if (branchEl) { |
|
branchName = branchEl.textContent.trim() || branchEl.getAttribute('title'); |
|
console.log("[Debug] Extracted branch name from summary:", branchName); |
|
} else { |
|
console.log("[Debug] Could not find a.branch-name in summary. Aborting checks."); |
|
return; |
|
} |
|
|
|
// Start polling |
|
startPollingForNewest(); |
|
}) |
|
.catch(err => { |
|
console.error("[Debug] Error fetching summary or parsing branch name:", err); |
|
}); |
|
|
|
} else { |
|
// Not a job page => presumably a run summary page |
|
// We can read branch name directly from the DOM |
|
const branchEl = document.querySelector('a.branch-name'); |
|
if (!branchEl) { |
|
console.log("[Debug] Branch name not found in summary page. Aborting."); |
|
return; |
|
} |
|
branchName = branchEl.textContent.trim() || branchEl.getAttribute('title'); |
|
console.log("[Debug] Found branchName in summary page:", branchName); |
|
|
|
startPollingForNewest(); |
|
} |
|
|
|
/** |
|
* Sets an interval every 20s to check if there's a newer run. |
|
*/ |
|
function startPollingForNewest() { |
|
checkNewestRun(); |
|
setInterval(checkNewestRun, 20000); |
|
} |
|
|
|
/** |
|
* Queries the same workflow+branch listing to see if there's a newer run ID. |
|
*/ |
|
function checkNewestRun() { |
|
if (!branchName) { |
|
console.log("[Debug] Missing branchName => cannot check for newer run."); |
|
return; |
|
} |
|
|
|
// We find the link to the workflow in .PageHeader-parentLink |
|
const workflowLinkEl = document.querySelector('.PageHeader-parentLink a[href*="/actions/workflows/"]'); |
|
if (!workflowLinkEl) { |
|
console.log("[Debug] No workflow link => fallback to 'Latest run'."); |
|
updateOrInsertButton(null, "Latest run", null); |
|
return; |
|
} |
|
|
|
const workflowUrl = workflowLinkEl.href; |
|
const listingUrl = workflowUrl + '?query=branch:' + encodeURIComponent(branchName); |
|
console.log("[Debug] Checking listing:", listingUrl); |
|
|
|
fetch(listingUrl) |
|
.then(r => r.ok ? r.text() : Promise.reject("[Debug] Listing fetch error: " + r.status)) |
|
.then(html => { |
|
const parser = new DOMParser(); |
|
const doc = parser.parseFromString(html, 'text/html'); |
|
|
|
// The first matching run in descending order is presumably the newest |
|
const firstRunLink = doc.querySelector('a[href*="/actions/runs/"]'); |
|
if (!firstRunLink) { |
|
console.log("[Debug] No runs in listing => we must be the newest."); |
|
updateOrInsertButton(null, "Latest run", null); |
|
return; |
|
} |
|
|
|
const href = firstRunLink.getAttribute('href') || ''; |
|
const m = href.match(/\/runs\/(\d+)/); |
|
if (!m) { |
|
console.log("[Debug] Could not parse run ID => fallback to 'Latest run'."); |
|
updateOrInsertButton(null, "Latest run", null); |
|
return; |
|
} |
|
|
|
const newestRunId = m[1]; |
|
if (newestRunId === runId) { |
|
// Already the newest |
|
updateOrInsertButton(null, "Latest run", null); |
|
} else { |
|
// There's a newer run |
|
const newestRunSummaryUrl = href.startsWith('/') |
|
? 'https://github.com' + href |
|
: href; |
|
|
|
if (isJobPage && jobIndex >= 0) { |
|
console.log("[Debug] On a job page, try mapping job index =>", jobIndex); |
|
mapJobIndexInNewestRun(newestRunSummaryUrl, jobIndex); |
|
} else { |
|
// summary or no index => link to the run summary |
|
updateOrInsertButton(newestRunSummaryUrl, null, "Go to latest run"); |
|
} |
|
} |
|
}) |
|
.catch(err => { |
|
console.error("[Debug] Error fetching listing or parsing:", err); |
|
updateOrInsertButton(null, "Latest run", null); |
|
}); |
|
} |
|
|
|
/** |
|
* If we are on a job page and have a known jobIndex, fetch the newest run's summary, |
|
* gather job <li> items, pick the same index, and link to that job. Otherwise fallback. |
|
*/ |
|
function mapJobIndexInNewestRun(newestRunSummaryUrl, jobIndex) { |
|
console.log("[Debug] mapJobIndexInNewestRun => summary:", newestRunSummaryUrl, "index=", jobIndex); |
|
|
|
fetch(newestRunSummaryUrl) |
|
.then(r => r.ok ? r.text() : Promise.reject("[Debug] Newest run summary fetch error: " + r.status)) |
|
.then(html => { |
|
const parser = new DOMParser(); |
|
const doc = parser.parseFromString(html, 'text/html'); |
|
|
|
const newRunJobLis = [...doc.querySelectorAll('li[data-item-id^="job_"]')]; |
|
console.log("[Debug] Found", newRunJobLis.length, "jobs in the newest run."); |
|
|
|
if (jobIndex < 0 || jobIndex >= newRunJobLis.length) { |
|
console.log("[Debug] jobIndex out of range => fallback to summary"); |
|
updateOrInsertButton(newestRunSummaryUrl, null, "Go to latest run"); |
|
return; |
|
} |
|
|
|
const targetLi = newRunJobLis[jobIndex]; |
|
const jobLink = targetLi.querySelector('a[href*="/job/"]'); |
|
if (!jobLink) { |
|
console.log("[Debug] No job link found at that index => fallback to summary"); |
|
updateOrInsertButton(newestRunSummaryUrl, null, "Go to latest run"); |
|
return; |
|
} |
|
|
|
let finalUrl = jobLink.getAttribute('href') || ''; |
|
if (finalUrl.startsWith('/')) { |
|
finalUrl = 'https://github.com' + finalUrl; |
|
} |
|
console.log("[Debug] Mapped nav index => job link:", finalUrl); |
|
|
|
// We have a direct link to the equivalent job => use a custom label |
|
updateOrInsertButton(finalUrl, null, "Go to equivalent job in latest run"); |
|
}) |
|
.catch(err => { |
|
console.error("[Debug] Could not fetch or parse newest run summary:", err); |
|
// fallback |
|
updateOrInsertButton(newestRunSummaryUrl, null, "Go to latest run"); |
|
}); |
|
} |
|
|
|
/** |
|
* Inserts or updates the user-script button near "Re-run all jobs." |
|
* If targetUrl is null => show a "Latest run" label (disabled). |
|
* Otherwise => link with a custom text label (default "Go to latest run"). |
|
* |
|
* @param {string|null} targetUrl |
|
* @param {string|null} labelIfNoLink e.g. "Latest run" |
|
* @param {string|null} linkLabel e.g. "Go to equivalent job in latest run" |
|
*/ |
|
function updateOrInsertButton(targetUrl, labelIfNoLink, linkLabel) { |
|
const actionsContainer = document.querySelector('.PageHeader-actions .d-none.d-md-flex'); |
|
|
|
// Remove any existing button to avoid duplicates |
|
const existing = document.getElementById('go-to-newest-run-container'); |
|
if (existing && existing.parentNode) { |
|
existing.parentNode.removeChild(existing); |
|
} |
|
|
|
const container = document.createElement('div'); |
|
container.id = 'go-to-newest-run-container'; |
|
container.style.marginRight = '8px'; |
|
|
|
if (targetUrl) { |
|
// Create a link |
|
const linkBtn = document.createElement('a'); |
|
linkBtn.href = targetUrl; |
|
linkBtn.textContent = linkLabel || "Go to latest run"; |
|
linkBtn.className = 'Button--secondary Button--medium Button'; |
|
linkBtn.style.padding = '6px 12px'; |
|
linkBtn.style.textDecoration = 'none'; |
|
container.appendChild(linkBtn); |
|
} else { |
|
// Show a disabled-like label |
|
const label = document.createElement('span'); |
|
label.textContent = labelIfNoLink || "Latest run"; |
|
label.className = 'Button--secondary Button--medium Button disabled'; |
|
label.style.padding = '6px 12px'; |
|
container.appendChild(label); |
|
} |
|
|
|
if (actionsContainer) { |
|
actionsContainer.insertBefore(container, actionsContainer.firstChild); |
|
} else { |
|
// fallback if we can't find that container |
|
document.body.insertAdjacentElement('afterbegin', container); |
|
} |
|
} |
|
|
|
})(); |