15.4 KiB · text History 9cdb602
.pragma library

// CI status vocabulary, shared by every view. Mirrors the Android
// client's StatusChip.kt (and the `gg` CLI's output.rs) so the widget,
// the phone and the terminal agree on what a colour means.
//
// Two names here aren't server statuses: `awaiting_approval` on a run
// (runStatus — the server says `queued`) and `superseded`, a job
// cancelled because a newer one in its concurrency group replaced it
// (jobStatus). Superseded is grey, not red: the newer run decides.

// The only endings. Anything else — waiting, awaiting_approval, or a
// status the server learns later — is still in progress, so polling
// keeps going rather than calling it done.
var FINISHED = ["success", "failure", "cancelled", "skipped", "timed_out", "superseded"]

// `conclusion` when the thing has finished, else its raw `status`.
function effective(status, conclusion) {
    if (status === "completed" || status === "finished") return conclusion || status
    return status || "unknown"
}

// What a run's circle says. A fork's run waiting for approval is
// `queued` on the server, but "queued" would promise a runner is
// coming — nothing happens until someone approves it.
function runStatus(run) {
    if (!run) return "unknown"
    if (run.awaiting_approval && (run.status === "queued" || run.status === "running")) return "awaiting_approval"
    return effective(run.status, run.conclusion)
}

// `superseded` for a job a newer one replaced, else its effective status.
function jobStatus(job) {
    if (!job) return "unknown"
    if (job.superseded) return "superseded"
    return effective(job.status, job.conclusion)
}

function isFinished(status) {
    return FINISHED.indexOf(status) !== -1 || status === "completed" || status === "finished"
}

// Not finished. "unknown" is this widget's own word for "nothing known
// yet" (a repo that never ran CI), not a server status, so it's idle.
function isActive(status) {
    return !!status && status !== "unknown" && !isFinished(status)
}

// Worth the fast poll: in flight, but not parked on a person. A run
// waiting for approval is still watched on every poll, just at the idle
// pace; once approved it's `queued` again and the fast poll picks it up.
function pollsFast(status) {
    return isActive(status) && status !== "awaiting_approval"
}

// success | failure | running | queued | dim
function category(status) {
    switch (status) {
    case "success":
        return "success"
    case "failure":
    case "cancelled":
    case "timed_out":
        return "failure"
    case "running":
        return "running"
    case "queued":
    case "pending":
    case "blocked":
    case "waiting":
    case "awaiting_approval":
        return "queued"
    case "skipped":
    case "superseded":
        return "dim"
    default:
        // A status this widget doesn't know yet but that hasn't ended
        // reads as pending, not as idle.
        return isActive(status) ? "queued" : "dim"
    }
}

function color(status, theme) {
    switch (category(status)) {
    case "success": return theme.positiveTextColor
    case "failure": return theme.negativeTextColor
    case "running": return theme.neutralTextColor
    case "queued": return theme.neutralTextColor
    default: return theme.disabledTextColor
    }
}

function icon(status) {
    switch (status) {
    case "success": return "emblem-success"
    case "failure":
    case "timed_out": return "emblem-error"
    case "cancelled":
    case "superseded": return "dialog-cancel"
    case "running": return "media-playback-start"
    case "queued":
    case "pending":
    case "blocked":
    case "waiting": return "chronometer"
    case "awaiting_approval": return "flag"
    case "skipped": return "go-next-skip"
    default: return "question"
    }
}

// A status as words: `awaiting_approval` reads "needs approval",
// `timed_out` "timed out".
function label(status) {
    if (!status) return "unknown"
    if (status === "awaiting_approval") return "needs approval"
    return status.replace(/_/g, " ")
}

// What started a run, as a person would say it. Unknown events fall
// through as their raw name with underscores spaced.
function triggerLabel(trigger) {
    switch (trigger) {
    case undefined:
    case null:
    case "": return ""
    case "pull_request": return "pull request"
    case "pull_request_target": return "pull request (target)"
    case "workflow_dispatch": return "manual"
    case "schedule": return "scheduled"
    case "create": return "branch/tag created"
    case "delete": return "branch/tag deleted"
    case "workflow_run": return "after a workflow"
    default: return String(trigger).replace(/_/g, " ")
    }
}

// Worst-first ordering for folding several repos into one circle: a
// run in flight is the most interesting thing, then a broken build.
var PRIORITY = { running: 5, queued: 4, failure: 3, success: 2, dim: 1 }

function aggregate(statuses) {
    var best = null
    var bestScore = 0
    for (var i = 0; i < statuses.length; i++) {
        var score = PRIORITY[category(statuses[i])] || 0
        if (score > bestScore) {
            best = statuses[i]
            bestScore = score
        }
    }
    return best || "unknown"
}

// ── formatting ────────────────────────────────────────────────────────

function shortSha(sha) {
    return sha ? sha.substring(0, 7) : ""
}

function shortRef(ref) {
    if (!ref) return ""
    return ref.replace(/^refs\/(heads|tags)\//, "")
}

function relativeTime(iso) {
    if (!iso) return ""
    var then = new Date(iso).getTime()
    if (isNaN(then)) return ""
    var secs = Math.max(0, Math.round((Date.now() - then) / 1000))
    if (secs < 60) return "just now"
    var mins = Math.round(secs / 60)
    if (mins < 60) return mins + "m ago"
    var hours = Math.round(mins / 60)
    if (hours < 24) return hours + "h ago"
    var days = Math.round(hours / 24)
    return days + "d ago"
}

// Elapsed between two timestamps; a missing end means "still going".
function duration(startIso, endIso) {
    if (!startIso) return ""
    var start = new Date(startIso).getTime()
    var end = endIso ? new Date(endIso).getTime() : Date.now()
    if (isNaN(start) || isNaN(end)) return ""
    var secs = Math.max(0, Math.round((end - start) / 1000))
    if (secs < 60) return secs + "s"
    var mins = Math.floor(secs / 60)
    if (mins < 60) return mins + "m " + (secs % 60) + "s"
    return Math.floor(mins / 60) + "h " + (mins % 60) + "m"
}

function runTitle(run) {
    return run.workflow_name || run.workflow_path || "workflow"
}

function runMeta(run) {
    var parts = []
    if (run.head_ref) parts.push(shortRef(run.head_ref))
    if (run.head_sha) parts.push(shortSha(run.head_sha))
    if (run.trigger) parts.push(triggerLabel(run.trigger))
    if (run.fork) parts.push("from a fork")
    if (run.triggered_by && run.triggered_by.handle) parts.push("by " + run.triggered_by.handle)
    return parts.join(" · ")
}

// What to say under a job: the server's reason (why it waits, was
// skipped or cancelled, or why its matrix couldn't expand), or for a
// matrix placeholder with nothing to explain yet, what it is.
function jobReason(job) {
    if (job.status_reason && String(job.status_reason).trim() !== "") return job.status_reason
    if (job.placeholder && job.needs && job.needs.length > 0 && jobStatus(job) === "blocked") {
        return "Matrix expands when " + job.needs.join(", ") + " finish"
    }
    return ""
}

// "Waiting for approval" notice for a fork's run nobody has approved.
function approvalNotice(run) {
    return "This run comes from a fork. Its jobs start once someone with write access approves it"
           + (run.pull_request_number ? " on pull request #" + run.pull_request_number + "." : ".")
}

function jobLabel(job) {
    var label = job.name || job.job_id || ("job " + job.id)
    if (job.matrix && typeof job.matrix === "object") {
        var values = []
        for (var k in job.matrix) values.push(job.matrix[k])
        if (values.length > 0 && label.indexOf("(") === -1) label += " (" + values.join(", ") + ")"
    }
    return label
}

// ── following runs ────────────────────────────────────────────────────

// "owner/name#12" — the key runs go by everywhere: accordion state,
// details, run windows, and the watched list kept in the config.
function runKey(fullName, number) {
    return fullName + "#" + number
}

// {fullName, number} for a well-formed key, else null.
function parseRunKey(key) {
    var m = /^([^\/#\s]+\/[^\/#\s]+)#(\d+)$/.exec(String(key || ""))
    if (!m) return null
    var number = parseInt(m[2], 10)
    return number > 0 ? { fullName: m[1], number: number } : null
}

// How a finished run ended, as the word a notification uses; "" while
// it is still going.
function outcome(status) {
    switch (status) {
    case "success": return "succeeded"
    case "failure": return "failed"
    case "cancelled": return "cancelled"
    case "timed_out": return "timed_out"
    case "skipped": return "skipped"
    case "superseded": return "superseded"
    default: return isFinished(status) ? "finished" : ""
    }
}

// Runs in `next` that `handle` triggered and that failed (or timed out)
// since `prev`, where `prev` saw them still going. A run first seen
// already failed isn't news: it may have failed hours ago.
function newlyFailed(prev, next, handle) {
    if (!handle) return []
    var before = {}
    for (var i = 0; i < (prev || []).length; i++) before[prev[i].number] = prev[i]
    var out = []
    for (var j = 0; j < (next || []).length; j++) {
        var run = next[j]
        var was = before[run.number]
        if (!was || !isActive(runStatus(was))) continue
        if (!run.triggered_by || run.triggered_by.handle !== handle) continue
        var o = outcome(runStatus(run))
        if (o === "failed" || o === "timed_out") out.push(run)
    }
    return out
}

// ── following jobs ────────────────────────────────────────────────────

// "owner/name#12/<job row id>/<job_id>" — a belled job. The job_id (the
// workflow's key for it, URI-encoded) lets the watch follow the job
// when its row is replaced: a matrix placeholder by its legs, or a job
// by its re-run.
function jobKey(fullName, number, job) {
    return runKey(fullName, number) + "/" + job.id + "/" + encodeURIComponent(job.job_id || "")
}

// {fullName, number, runKey, id, jobId} for a well-formed key, else null.
function parseJobKey(key) {
    var m = /^([^#]+#\d+)\/([^\/]+)\/([^\/]*)$/.exec(String(key || ""))
    if (!m) return null
    var run = parseRunKey(m[1])
    if (!run) return null
    var jobId
    try { jobId = decodeURIComponent(m[3]) } catch (e) { return null }
    return { fullName: run.fullName, number: run.number, runKey: m[1], id: m[2], jobId: jobId }
}

// Where each watched job ({id, jobId}) went in the run's current `jobs`:
// the same row if it's still there, else every real job sharing its
// job_id (a placeholder's legs, or the job's re-run). A watch with
// neither has vanished and is dropped. Returns the job rows, each once.
function resolveJobWatches(watched, jobs) {
    var out = []
    var taken = {}
    function take(job) {
        if (taken[job.id]) return
        taken[job.id] = true
        out.push(job)
    }
    for (var i = 0; i < watched.length; i++) {
        var w = watched[i]
        var same = null
        for (var j = 0; j < jobs.length; j++) {
            if (String(jobs[j].id) === String(w.id)) { same = jobs[j]; break }
        }
        if (same) { take(same); continue }
        if (!w.jobId) continue
        for (var k = 0; k < jobs.length; k++) {
            if (jobs[k].job_id === w.jobId && !jobs[k].placeholder) take(jobs[k])
        }
    }
    return out
}

// Jobs that ended since the last look. `seen` holds the ids that were
// in flight then; a job first seen already finished isn't news. Skipped
// and superseded jobs arrive in batches after a failure or a newer run,
// so they're left for the run's own notification to count.
// Returns {finished: [job], active: {id: true}} — `active` is the next `seen`.
function jobEvents(seen, jobs) {
    var active = {}
    var finished = []
    for (var i = 0; i < (jobs || []).length; i++) {
        var job = jobs[i]
        var s = jobStatus(job)
        if (isActive(s)) active[job.id] = true
        else if (seen && seen[job.id] && isFinished(s) && s !== "skipped" && s !== "superseded") finished.push(job)
    }
    return { finished: finished, active: active }
}

function countJobs(jobs, status) {
    var n = 0
    for (var i = 0; i < (jobs || []).length; i++) {
        if (jobStatus(jobs[i]) === status) n++
    }
    return n
}

// One character per outcome, for a list of jobs in a single line.
function jobMark(status) {
    switch (status) {
    case "success": return "✓"
    case "failure":
    case "timed_out": return "✗"
    case "cancelled": return "⊘"
    case "skipped":
    case "superseded": return "–"
    default: return "?"
    }
}

// The status that best sums up several finished jobs: a failure over a
// cancellation over a success.
function worstOf(statuses) {
    var rank = { failure: 5, timed_out: 5, cancelled: 4, superseded: 2, skipped: 2, success: 3 }
    var best = ""
    var score = 0
    for (var i = 0; i < statuses.length; i++) {
        var r = rank[statuses[i]] || 1
        if (r > score) { best = statuses[i]; score = r }
    }
    return best
}

// ── the live CI wall ──────────────────────────────────────────────────

// How many cards each column of the wall holds, left to right: the
// web's and the Android app's rule. Up to `maxCols` cards, a column
// each; past that the columns fill up from the right — the 7th (of 6)
// pairs the rightmost, the 8th the next one in — so the first cards in
// order keep the most room.
function wallColumns(n, maxCols) {
    if (n <= 0 || maxCols <= 0) return []
    var cols = Math.min(n, maxCols)
    var base = Math.floor(n / cols)
    var extra = n % cols
    var counts = []
    for (var i = 0; i < cols; i++) counts.push(i < cols - extra ? base : base + 1)
    return counts
}

// `items` split into those columns, in order.
function wallSplit(items, maxCols) {
    var counts = wallColumns(items.length, maxCols)
    var out = []
    var from = 0
    for (var i = 0; i < counts.length; i++) {
        out.push(items.slice(from, from + counts[i]))
        from += counts[i]
    }
    return out
}

// A running job's clock: "4:07", or "1:02:09" past the hour.
function clock(startIso, now) {
    if (!startIso) return "–"
    var start = new Date(startIso).getTime()
    if (isNaN(start)) return "–"
    var s = Math.max(0, Math.floor(((now || Date.now()) - start) / 1000))
    function two(n) { return (n < 10 ? "0" : "") + n }
    var h = Math.floor(s / 3600)
    var m = Math.floor((s % 3600) / 60)
    return h > 0 ? h + ":" + two(m) + ":" + two(s % 60) : m + ":" + two(s % 60)
}

// The step a running job is on: the one running, else the last done.
function currentStep(steps) {
    steps = steps || []
    for (var i = 0; i < steps.length; i++) {
        if (steps[i].status === "running" || steps[i].status === "in_progress") return steps[i]
    }
    for (var j = steps.length - 1; j >= 0; j--) {
        if (isFinished(effective(steps[j].status, steps[j].conclusion))) return steps[j]
    }
    return null
}