neiam
/gitgud-plasmoid
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Notify as jobs finish: per-job bells and each-job runs
0fc3e53 · Gabriel Morell · 2026-10-19 17:16
Files changed
modified
README.md
+15
−5
@@ -10,7 +10,7 @@ A Plasma 6 panel widget that shows [GitGud](https://gg.neiam.co) CI runs as a ro
| 10 | 10 | - **Circles**: one per watched repository, or one per organization/user if you fold it into a single circle. The colour shows the latest run (see [Statuses](#statuses)). Hover a circle for the workflow, run number, ref, SHA and age. Click it to open just that circle's runs. |
| 11 | 11 | - **Pin** (the pin button in the popup's header, *Keep Open*): the popup stays open when you click elsewhere, as the system tray's does. Click it again to unpin. The setting is remembered. |
| 12 | 12 | - **Run windows**: break a run out of the popup into its own window. See [Run windows](#run-windows). |
| 13 | −- **Notify when finished**: ring the bell on a run and get a desktop notification when it ends. See [Notifications](#notifications). | |
| 13 | +- **Notify when finished**: ring the bell on a run or a single job and get a desktop notification when it ends, or ask to hear about every job of a run as it finishes. See [Notifications](#notifications). | |
| 14 | 14 | |
| 15 | 15 | ## Statuses |
| 16 | 16 |
@@ -65,8 +65,18 @@ Click the window button on a run (on hover, or the one next to **Open** once it'
| 65 | 65 | |
| 66 | 66 | Ring the **bell** on a run that is still going (on hover in the popup, in the run's window, or right-click → **Notify When Finished**). The bell stays lit while you're waiting. When the run ends you get a notification such as *neiam/ppl #12 failed*, with the workflow, ref and duration under it and an icon for the outcome. Click it to open the run's window, or use **Open in Browser**. A run is notified once and then stops being watched. Click a lit bell to stop waiting. |
| 67 | 67 | |
| 68 | −- Watched runs are refreshed at the fast interval until they finish, whatever else the widget is doing, and are fetched by number so they're still followed after newer runs push them off the list. | |
| 69 | −- The watched list is saved in the widget's config (`watchedRuns`), so it survives a Plasma restart or a reboot. A run that finished while Plasma wasn't running is announced the next time it starts. A run the server no longer has (404) is quietly dropped. | |
| 68 | +### Jobs | |
| 69 | + | |
| 70 | +- **One job**: hover a job (in the popup or a run window) and ring its bell. You're told when that job ends, e.g. *neiam/ppl #12 · build (x86_64) failed*, with the workflow, the job's duration and the server's reason if it gave one. Clicking the notification opens the run's window with that job expanded and scrolled into view. Bell a matrix that hasn't expanded yet and the bell moves to each of its legs once they appear. A re-run job keeps its bell too. | |
| 71 | +- **Every job of a run**: right-click the run → **Notify For Each Job**, or tick **Each job** next to the bell in its window. This also watches the run. Each job is announced once as it finishes, and failures are urgent. The run's own notification still comes last. A few things keep the noise down: | |
| 72 | + - Jobs that had already finished when the widget first saw them aren't announced. | |
| 73 | + - Skipped and superseded jobs come in batches after a failure, so they aren't announced one by one. The run's notification counts them instead (*3 skipped*). | |
| 74 | + - When more than two jobs finish between two refreshes you get one notification, e.g. *neiam/ppl #12 · 3 jobs finished* with *a ✓, b ✓, c ✗* under it. Clicking it opens the worst of them. Jobs that finish in the same refresh as the run are listed in the run's notification. | |
| 75 | + | |
| 76 | +### Watching | |
| 77 | + | |
| 78 | +- Watched runs, and the runs of belled jobs, are refreshed at the fast interval until they finish, whatever else the widget is doing. They are fetched by number, so they're still followed after newer runs push them off the list. | |
| 79 | +- What you're waiting on is saved in the widget's config (`watchedRuns`, `eachJobRuns`, `watchedJobs`), so it survives a Plasma restart or a reboot. A belled run or job that finished while Plasma wasn't running is announced the next time it starts. Per-job announcements for an each-job run are not, because the widget never saw those jobs running. A run the server no longer has (404) is quietly dropped, along with its jobs. | |
| 70 | 80 | - Notifications go through Plasma's own notification event, so they follow *System Settings → Notifications → Plasma Workspace*, including Do Not Disturb. |
| 71 | 81 | - **Notify when any run I triggered fails** (*Behavior*, off by default) also notifies about failed or timed-out runs started by the token's user. It covers runs in watched repositories that the widget saw while they were still running. A run that was already failed when the widget first saw it is not announced. |
| 72 | 82 |
@@ -110,8 +120,8 @@ kpackagetool6 --type Plasma/Applet --install .
| 110 | 120 | kpackagetool6 --type Plasma/Applet --upgrade . |
| 111 | 121 | |
| 112 | 122 | # Or build a release archive |
| 113 | −./package.sh 0.3.0 | |
| 114 | −kpackagetool6 --type Plasma/Applet --install gitgud-plasmoid-0.3.0.tar.gz | |
| 123 | +./package.sh 0.4.0 | |
| 124 | +kpackagetool6 --type Plasma/Applet --install gitgud-plasmoid-0.4.0.tar.gz | |
| 115 | 125 | ``` |
| 116 | 126 | |
| 117 | 127 | Test without installing: |
modified
contents/config/main.xml
+12
−0
@@ -44,6 +44,18 @@
| 44 | 44 | <default></default> |
| 45 | 45 | </entry> |
| 46 | 46 | |
| 47 | + <!-- "owner/name#number/jobRowId/jobId" of each job to announce once it finishes --> | |
| 48 | + <entry name="watchedJobs" type="StringList"> | |
| 49 | + <label>Jobs to notify about when they finish</label> | |
| 50 | + <default></default> | |
| 51 | + </entry> | |
| 52 | + | |
| 53 | + <!-- "owner/name#number" of watched runs that also announce each job --> | |
| 54 | + <entry name="eachJobRuns" type="StringList"> | |
| 55 | + <label>Watched runs that notify as each job finishes</label> | |
| 56 | + <default></default> | |
| 57 | + </entry> | |
| 58 | + | |
| 47 | 59 | <entry name="notifyMyFailures" type="Bool"> |
| 48 | 60 | <label>Notify when any run the token's user triggered fails</label> |
| 49 | 61 | <default>false</default> |
modified
contents/ui/JobItem.qml
+20
−0
@@ -23,6 +23,8 @@ ColumnLayout {
| 23 | 23 | readonly property var logs: store.jobLogs[job.id] || null |
| 24 | 24 | readonly property string status: Status.jobStatus(job) |
| 25 | 25 | readonly property string reason: Status.jobReason(job) |
| 26 | + readonly property bool active: Status.isActive(status) | |
| 27 | + readonly property bool watching: store.watchingJob(fullName, runNumber, job.id) | |
| 26 | 28 | |
| 27 | 29 | spacing: 0 |
| 28 | 30 |
@@ -35,6 +37,7 @@ ColumnLayout {
| 35 | 37 | } |
| 36 | 38 | |
| 37 | 39 | PlasmaComponents3.ItemDelegate { |
| 40 | + id: jobHeader | |
| 38 | 41 | Layout.fillWidth: true |
| 39 | 42 | onClicked: jobItem.store.toggleJob(jobItem.fullName, jobItem.job) |
| 40 | 43 |
@@ -89,6 +92,23 @@ ColumnLayout {
| 89 | 92 | } |
| 90 | 93 | } |
| 91 | 94 | |
| 95 | + // Ring when this job ends. On a matrix placeholder the bell | |
| 96 | + // moves to each of its legs once they exist. | |
| 97 | + PlasmaComponents3.ToolButton { | |
| 98 | + visible: jobItem.watching || (jobHeader.hovered && jobItem.active) | |
| 99 | + checkable: true | |
| 100 | + checked: jobItem.watching | |
| 101 | + icon.name: jobItem.watching ? "notifications" : "notification-inactive" | |
| 102 | + text: i18n("Notify When Finished") | |
| 103 | + display: QQC2.AbstractButton.IconOnly | |
| 104 | + onToggled: jobItem.store.toggleWatchJob(jobItem.fullName, jobItem.runNumber, jobItem.job) | |
| 105 | + PlasmaComponents3.ToolTip { | |
| 106 | + text: jobItem.watching ? i18n("You'll be notified when this job finishes") | |
| 107 | + : jobItem.job.placeholder ? i18n("Notify when each job of this matrix finishes") | |
| 108 | + : i18n("Notify When Finished") | |
| 109 | + } | |
| 110 | + } | |
| 111 | + | |
| 92 | 112 | PlasmaComponents3.ToolButton { |
| 93 | 113 | // A placeholder isn't a job yet: its legs replace it. |
| 94 | 114 | visible: jobItem.canManage && !!jobItem.job.job_id && !jobItem.job.placeholder |
modified
contents/ui/RunBody.qml
+10
−0
@@ -24,6 +24,15 @@ ColumnLayout {
| 24 | 24 | |
| 25 | 25 | readonly property bool active: Status.isActive(run.status) |
| 26 | 26 | |
| 27 | + // The JobItem showing job row `id`, if it's there yet. | |
| 28 | + function jobItem(id) { | |
| 29 | + for (var i = 0; i < jobRepeater.count; i++) { | |
| 30 | + var item = jobRepeater.itemAt(i) as JobItem | |
| 31 | + if (item && String(item.job.id) === String(id)) return item | |
| 32 | + } | |
| 33 | + return null | |
| 34 | + } | |
| 35 | + | |
| 27 | 36 | spacing: Kirigami.Units.smallSpacing |
| 28 | 37 | |
| 29 | 38 | PlasmaComponents3.BusyIndicator { |
@@ -106,6 +115,7 @@ ColumnLayout {
| 106 | 115 | } |
| 107 | 116 | |
| 108 | 117 | Repeater { |
| 118 | + id: jobRepeater | |
| 109 | 119 | model: runBody.detail ? runBody.detail.jobs : [] |
| 110 | 120 | |
| 111 | 121 | delegate: JobItem { |
modified
contents/ui/RunItem.qml
+12
−2
@@ -27,6 +27,7 @@ ColumnLayout {
| 27 | 27 | readonly property string status: Status.runStatus(run) |
| 28 | 28 | readonly property bool active: Status.isActive(run.status) |
| 29 | 29 | readonly property bool watching: store.watchingRun(runKey) |
| 30 | + readonly property bool eachJob: store.notifyingEachJob(runKey) | |
| 30 | 31 | // The detail's copy is fresher than the list row's once it's loaded. |
| 31 | 32 | readonly property var current: detail ? detail.run : run |
| 32 | 33 |
@@ -59,6 +60,14 @@ ColumnLayout {
| 59 | 60 | onClicked: runItem.store.toggleWatchRun(runItem.fullName, runItem.run.number) |
| 60 | 61 | } |
| 61 | 62 | |
| 63 | + PlasmaComponents3.MenuItem { | |
| 64 | + enabled: runItem.active || runItem.eachJob | |
| 65 | + checkable: true | |
| 66 | + checked: runItem.eachJob | |
| 67 | + text: i18n("Notify For Each Job") | |
| 68 | + onTriggered: runItem.store.toggleEachJob(runItem.fullName, runItem.run.number) | |
| 69 | + } | |
| 70 | + | |
| 62 | 71 | PlasmaComponents3.MenuItem { |
| 63 | 72 | icon.name: "internet-web-browser" |
| 64 | 73 | text: i18n("Open in Browser") |
@@ -134,8 +143,9 @@ ColumnLayout {
| 134 | 143 | display: QQC2.AbstractButton.IconOnly |
| 135 | 144 | onToggled: runItem.store.toggleWatchRun(runItem.fullName, runItem.run.number) |
| 136 | 145 | PlasmaComponents3.ToolTip { |
| 137 | − text: runItem.watching ? i18n("You'll be notified when this run finishes") | |
| 138 | − : i18n("Notify When Finished") | |
| 146 | + text: runItem.eachJob ? i18n("You'll be notified as each job finishes, and when the run does") | |
| 147 | + : runItem.watching ? i18n("You'll be notified when this run finishes") | |
| 148 | + : i18n("Notify When Finished") | |
| 139 | 149 | } |
| 140 | 150 | } |
| 141 | 151 |
modified
contents/ui/RunWindow.qml
+30
−0
@@ -26,6 +26,9 @@ Window {
| 26 | 26 | readonly property string status: Status.runStatus(run) |
| 27 | 27 | readonly property bool inFlight: Status.isActive(status) |
| 28 | 28 | readonly property bool watching: store.watchingRun(runKey) |
| 29 | + readonly property bool eachJob: store.notifyingEachJob(runKey) | |
| 30 | + // A job to bring into view once it's on screen (from a notification). | |
| 31 | + property string focusJobId: "" | |
| 29 | 32 | |
| 30 | 33 | // Not a child of the panel: a normal window with its own taskbar entry. |
| 31 | 34 | transientParent: null |
@@ -47,6 +50,23 @@ Window {
| 47 | 50 | runWindow.destroy() |
| 48 | 51 | } |
| 49 | 52 | |
| 53 | + function focusJob(id) { | |
| 54 | + focusJobId = id | |
| 55 | + Qt.callLater(scrollToJob) | |
| 56 | + } | |
| 57 | + | |
| 58 | + function scrollToJob() { | |
| 59 | + if (focusJobId === "") return | |
| 60 | + var item = body.jobItem(focusJobId) | |
| 61 | + if (!item) return // not loaded yet: onDetailChanged tries again | |
| 62 | + var flick = scroll.contentItem as Flickable | |
| 63 | + var y = item.mapToItem(body, 0, 0).y | |
| 64 | + flick.contentY = Math.max(0, Math.min(y, flick.contentHeight - flick.height)) | |
| 65 | + focusJobId = "" | |
| 66 | + } | |
| 67 | + | |
| 68 | + onDetailChanged: if (focusJobId !== "") Qt.callLater(scrollToJob) | |
| 69 | + | |
| 50 | 70 | function present() { |
| 51 | 71 | if (visibility === Window.Minimized) showNormal() |
| 52 | 72 | else show() |
@@ -117,6 +137,15 @@ Window {
| 117 | 137 | Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium |
| 118 | 138 | } |
| 119 | 139 | |
| 140 | + PlasmaComponents3.CheckBox { | |
| 141 | + Layout.alignment: Qt.AlignTop | |
| 142 | + visible: runWindow.inFlight || runWindow.eachJob | |
| 143 | + checked: runWindow.eachJob | |
| 144 | + text: i18n("Each job") | |
| 145 | + onToggled: runWindow.store.toggleEachJob(runWindow.fullName, runWindow.runNumber) | |
| 146 | + PlasmaComponents3.ToolTip { text: i18n("Also notify as each job finishes") } | |
| 147 | + } | |
| 148 | + | |
| 120 | 149 | PlasmaComponents3.ToolButton { |
| 121 | 150 | id: bellButton |
| 122 | 151 | Layout.alignment: Qt.AlignTop |
@@ -172,6 +201,7 @@ Window {
| 172 | 201 | contentWidth: availableWidth |
| 173 | 202 | |
| 174 | 203 | RunBody { |
| 204 | + id: body | |
| 175 | 205 | width: scroll.availableWidth |
| 176 | 206 | store: runWindow.store |
| 177 | 207 | fullName: runWindow.fullName |
modified
contents/ui/Status.js
+99
−0
@@ -278,3 +278,102 @@ function newlyFailed(prev, next, handle) {
| 278 | 278 | } |
| 279 | 279 | return out |
| 280 | 280 | } |
| 281 | + | |
| 282 | +// ── following jobs ──────────────────────────────────────────────────── | |
| 283 | + | |
| 284 | +// "owner/name#12/<job row id>/<job_id>" — a belled job. The job_id (the | |
| 285 | +// workflow's key for it, URI-encoded) lets the watch follow the job | |
| 286 | +// when its row is replaced: a matrix placeholder by its legs, or a job | |
| 287 | +// by its re-run. | |
| 288 | +function jobKey(fullName, number, job) { | |
| 289 | + return runKey(fullName, number) + "/" + job.id + "/" + encodeURIComponent(job.job_id || "") | |
| 290 | +} | |
| 291 | + | |
| 292 | +// {fullName, number, runKey, id, jobId} for a well-formed key, else null. | |
| 293 | +function parseJobKey(key) { | |
| 294 | + var m = /^([^#]+#\d+)\/([^\/]+)\/([^\/]*)$/.exec(String(key || "")) | |
| 295 | + if (!m) return null | |
| 296 | + var run = parseRunKey(m[1]) | |
| 297 | + if (!run) return null | |
| 298 | + var jobId | |
| 299 | + try { jobId = decodeURIComponent(m[3]) } catch (e) { return null } | |
| 300 | + return { fullName: run.fullName, number: run.number, runKey: m[1], id: m[2], jobId: jobId } | |
| 301 | +} | |
| 302 | + | |
| 303 | +// Where each watched job ({id, jobId}) went in the run's current `jobs`: | |
| 304 | +// the same row if it's still there, else every real job sharing its | |
| 305 | +// job_id (a placeholder's legs, or the job's re-run). A watch with | |
| 306 | +// neither has vanished and is dropped. Returns the job rows, each once. | |
| 307 | +function resolveJobWatches(watched, jobs) { | |
| 308 | + var out = [] | |
| 309 | + var taken = {} | |
| 310 | + function take(job) { | |
| 311 | + if (taken[job.id]) return | |
| 312 | + taken[job.id] = true | |
| 313 | + out.push(job) | |
| 314 | + } | |
| 315 | + for (var i = 0; i < watched.length; i++) { | |
| 316 | + var w = watched[i] | |
| 317 | + var same = null | |
| 318 | + for (var j = 0; j < jobs.length; j++) { | |
| 319 | + if (String(jobs[j].id) === String(w.id)) { same = jobs[j]; break } | |
| 320 | + } | |
| 321 | + if (same) { take(same); continue } | |
| 322 | + if (!w.jobId) continue | |
| 323 | + for (var k = 0; k < jobs.length; k++) { | |
| 324 | + if (jobs[k].job_id === w.jobId && !jobs[k].placeholder) take(jobs[k]) | |
| 325 | + } | |
| 326 | + } | |
| 327 | + return out | |
| 328 | +} | |
| 329 | + | |
| 330 | +// Jobs that ended since the last look. `seen` holds the ids that were | |
| 331 | +// in flight then; a job first seen already finished isn't news. Skipped | |
| 332 | +// and superseded jobs arrive in batches after a failure or a newer run, | |
| 333 | +// so they're left for the run's own notification to count. | |
| 334 | +// Returns {finished: [job], active: {id: true}} — `active` is the next `seen`. | |
| 335 | +function jobEvents(seen, jobs) { | |
| 336 | + var active = {} | |
| 337 | + var finished = [] | |
| 338 | + for (var i = 0; i < (jobs || []).length; i++) { | |
| 339 | + var job = jobs[i] | |
| 340 | + var s = jobStatus(job) | |
| 341 | + if (isActive(s)) active[job.id] = true | |
| 342 | + else if (seen && seen[job.id] && isFinished(s) && s !== "skipped" && s !== "superseded") finished.push(job) | |
| 343 | + } | |
| 344 | + return { finished: finished, active: active } | |
| 345 | +} | |
| 346 | + | |
| 347 | +function countJobs(jobs, status) { | |
| 348 | + var n = 0 | |
| 349 | + for (var i = 0; i < (jobs || []).length; i++) { | |
| 350 | + if (jobStatus(jobs[i]) === status) n++ | |
| 351 | + } | |
| 352 | + return n | |
| 353 | +} | |
| 354 | + | |
| 355 | +// One character per outcome, for a list of jobs in a single line. | |
| 356 | +function jobMark(status) { | |
| 357 | + switch (status) { | |
| 358 | + case "success": return "✓" | |
| 359 | + case "failure": | |
| 360 | + case "timed_out": return "✗" | |
| 361 | + case "cancelled": return "⊘" | |
| 362 | + case "skipped": | |
| 363 | + case "superseded": return "–" | |
| 364 | + default: return "?" | |
| 365 | + } | |
| 366 | +} | |
| 367 | + | |
| 368 | +// The status that best sums up several finished jobs: a failure over a | |
| 369 | +// cancellation over a success. | |
| 370 | +function worstOf(statuses) { | |
| 371 | + var rank = { failure: 5, timed_out: 5, cancelled: 4, superseded: 2, skipped: 2, success: 3 } | |
| 372 | + var best = "" | |
| 373 | + var score = 0 | |
| 374 | + for (var i = 0; i < statuses.length; i++) { | |
| 375 | + var r = rank[statuses[i]] || 1 | |
| 376 | + if (r > score) { best = statuses[i]; score = r } | |
| 377 | + } | |
| 378 | + return best | |
| 379 | +} |
modified
contents/ui/main.qml
+248
−32
@@ -45,6 +45,13 @@ PlasmoidItem {
| 45 | 45 | // "owner/name#n" of each run to announce once it finishes. A plain |
| 46 | 46 | // array copy, so the usual Array methods work on it. |
| 47 | 47 | readonly property var watchedRuns: Array.prototype.slice.call(Plasmoid.configuration.watchedRuns || []) |
| 48 | + readonly property var eachJobRuns: Array.prototype.slice.call(Plasmoid.configuration.eachJobRuns || []) | |
| 49 | + readonly property var watchedJobs: Array.prototype.slice.call(Plasmoid.configuration.watchedJobs || []) | |
| 50 | + // Run keys of every belled job's run. | |
| 51 | + readonly property var watchedJobRuns: watchedJobs.map(function(k) { | |
| 52 | + var p = Status.parseJobKey(k) | |
| 53 | + return p ? p.runKey : "" | |
| 54 | + }).filter(function(k) { return k !== "" }) | |
| 48 | 55 | |
| 49 | 56 | property var watched: [] |
| 50 | 57 | property bool hasValidConfig: api.configured |
@@ -69,7 +76,8 @@ PlasmoidItem {
| 69 | 76 | |
| 70 | 77 | // Following runs outside the list: windows and notifications. |
| 71 | 78 | property var runWindows: ({}) // "owner/name#n" -> RunWindow |
| 72 | − property var notified: ({}) // "owner/name#n@completed_at" -> true, once per ending | |
| 79 | + property var notified: ({}) // "<run or job key>@completed_at" -> true, once per ending | |
| 80 | + property var seenActiveJobs: ({}) // "owner/name#n" -> {job row id: true} seen in flight | |
| 73 | 81 | property string myHandle: "" // the token's user, for "runs I triggered" |
| 74 | 82 | |
| 75 | 83 | // A watched or windowed run still in flight keeps the fast poll |
@@ -77,7 +85,7 @@ PlasmoidItem {
| 77 | 85 | // has dropped out of the list. One not loaded yet counts too, so its |
| 78 | 86 | // first answer comes quickly. |
| 79 | 87 | readonly property bool followingActive: { |
| 80 | − var keys = watchedRuns.concat(Object.keys(runWindows)) | |
| 88 | + var keys = watchedRuns.concat(watchedJobRuns, Object.keys(runWindows)) | |
| 81 | 89 | for (var i = 0; i < keys.length; i++) { |
| 82 | 90 | var d = runDetails[keys[i]] |
| 83 | 91 | if (!d || Status.pollsFast(Status.runStatus(d.run))) return true |
@@ -390,7 +398,7 @@ PlasmoidItem {
| 390 | 398 | var runs = data.runs || [] |
| 391 | 399 | if (root.myHandle !== "") { |
| 392 | 400 | Status.newlyFailed(updated[fullName], runs, root.myHandle).forEach(function(run) { |
| 393 | − root.notifyRun(fullName, run) | |
| 401 | + root.notifyRun(fullName, run, [], []) | |
| 394 | 402 | }) |
| 395 | 403 | } |
| 396 | 404 | updated[fullName] = runs |
@@ -415,6 +423,7 @@ PlasmoidItem {
| 415 | 423 | for (var k in expandedRuns) if (expandedRuns[k]) keys[k] = true |
| 416 | 424 | for (var w in runWindows) keys[w] = true |
| 417 | 425 | for (var j = 0; j < watchedRuns.length; j++) keys[watchedRuns[j]] = true |
| 426 | + for (var b = 0; b < watchedJobRuns.length; b++) keys[watchedJobRuns[b]] = true | |
| 418 | 427 | |
| 419 | 428 | for (var key in keys) { |
| 420 | 429 | var parsed = Status.parseRunKey(key) |
@@ -424,7 +433,7 @@ PlasmoidItem {
| 424 | 433 | var detail = runDetails[key] |
| 425 | 434 | var summary = findRun(fullName, number) |
| 426 | 435 | // Already known finished: nothing to fetch, just announce it. |
| 427 | − if (detail) checkWatched(fullName, detail.run) | |
| 436 | + if (detail) followRun(fullName, detail) | |
| 428 | 437 | if (!detail || Status.isActive(detail.run.status) |
| 429 | 438 | || (summary && (summary.status !== detail.run.status || summary.conclusion !== detail.run.conclusion |
| 430 | 439 | || !!summary.awaiting_approval !== !!detail.run.awaiting_approval))) { |
@@ -501,12 +510,12 @@ PlasmoidItem {
| 501 | 510 | api.getRun(fullName, number, function(ok, data, network, httpStatus) { |
| 502 | 511 | if (!ok) { |
| 503 | 512 | // Gone (deleted, or a repo the token lost): stop waiting on it. |
| 504 | − if (httpStatus === 404) unwatchRun(key) | |
| 513 | + if (httpStatus === 404) dropRun(key) | |
| 505 | 514 | lastError = data |
| 506 | 515 | return |
| 507 | 516 | } |
| 508 | 517 | runDetails = setKey(runDetails, key, data) |
| 509 | − checkWatched(fullName, data.run) | |
| 518 | + followRun(fullName, data) | |
| 510 | 519 | // Keep open jobs' logs following the run. A matrix |
| 511 | 520 | // placeholder has no logs: its row is replaced by its legs |
| 512 | 521 | // (new ids) once it expands, and this re-read shows them. |
@@ -600,7 +609,7 @@ PlasmoidItem {
| 600 | 609 | runDetails = setKey(runDetails, key, { |
| 601 | 610 | run: data.run, jobs: data.jobs || [], artifacts: prev.artifacts, can_manage: prev.can_manage |
| 602 | 611 | }) |
| 603 | − if (data.run) checkWatched(fullName, data.run) | |
| 612 | + followRun(fullName, runDetails[key]) | |
| 604 | 613 | } |
| 605 | 614 | |
| 606 | 615 | // ── run windows ───────────────────────────────────────────────────── |
@@ -611,7 +620,8 @@ PlasmoidItem {
| 611 | 620 | } |
| 612 | 621 | |
| 613 | 622 | // One window per run: asking again brings the existing one forward. |
| 614 | − function openRunWindow(fullName, number) { | |
| 623 | + // With `jobRowId`, that job is opened and scrolled to. | |
| 624 | + function openRunWindow(fullName, number, jobRowId) { | |
| 615 | 625 | var key = Status.runKey(fullName, number) |
| 616 | 626 | var win = runWindows[key] |
| 617 | 627 | if (!win) { |
@@ -624,6 +634,13 @@ PlasmoidItem {
| 624 | 634 | loadRunDetail(fullName, number) |
| 625 | 635 | } |
| 626 | 636 | win.present() |
| 637 | + if (jobRowId !== undefined && jobRowId !== null && String(jobRowId) !== "") { | |
| 638 | + if (!expandedJobs[jobRowId]) { | |
| 639 | + expandedJobs = setKey(expandedJobs, jobRowId, true) | |
| 640 | + loadJobLogs(fullName, jobRowId) | |
| 641 | + } | |
| 642 | + win.focusJob(String(jobRowId)) | |
| 643 | + } | |
| 627 | 644 | } |
| 628 | 645 | |
| 629 | 646 | function forgetRunWindow(key, win) { |
@@ -631,17 +648,68 @@ PlasmoidItem {
| 631 | 648 | } |
| 632 | 649 | |
| 633 | 650 | // ── notify when finished ──────────────────────────────────────────── |
| 651 | + // | |
| 652 | + // Three lists in the config, so they survive a restart: | |
| 653 | + // watchedRuns "owner/name#n" ring when the run ends | |
| 654 | + // eachJobRuns "owner/name#n" …and as each of its jobs ends | |
| 655 | + // watchedJobs "owner/name#n/id/job_id" ring when that one job ends | |
| 656 | + // Every entry goes once it has rung, or once the server has no such run. | |
| 634 | 657 | |
| 635 | 658 | function watchingRun(key) { |
| 636 | 659 | return watchedRuns.indexOf(key) !== -1 |
| 637 | 660 | } |
| 638 | 661 | |
| 662 | + function notifyingEachJob(key) { | |
| 663 | + return eachJobRuns.indexOf(key) !== -1 | |
| 664 | + } | |
| 665 | + | |
| 666 | + // The belled jobs of one run, as {key, id, jobId}. | |
| 667 | + function watchedJobsOf(runKey) { | |
| 668 | + var out = [] | |
| 669 | + for (var i = 0; i < watchedJobs.length; i++) { | |
| 670 | + var p = Status.parseJobKey(watchedJobs[i]) | |
| 671 | + if (p && p.runKey === runKey) out.push({ key: watchedJobs[i], id: p.id, jobId: p.jobId }) | |
| 672 | + } | |
| 673 | + return out | |
| 674 | + } | |
| 675 | + | |
| 676 | + function watchingJob(fullName, number, jobRowId) { | |
| 677 | + var mine = watchedJobsOf(Status.runKey(fullName, number)) | |
| 678 | + for (var i = 0; i < mine.length; i++) { | |
| 679 | + if (mine[i].id === String(jobRowId)) return true | |
| 680 | + } | |
| 681 | + return false | |
| 682 | + } | |
| 683 | + | |
| 639 | 684 | function setWatchedRuns(list) { |
| 640 | 685 | Plasmoid.configuration.watchedRuns = list |
| 641 | 686 | } |
| 642 | 687 | |
| 688 | + function setEachJobRuns(list) { | |
| 689 | + Plasmoid.configuration.eachJobRuns = list | |
| 690 | + } | |
| 691 | + | |
| 692 | + function setWatchedJobs(list) { | |
| 693 | + Plasmoid.configuration.watchedJobs = list | |
| 694 | + } | |
| 695 | + | |
| 696 | + function without(list, key) { | |
| 697 | + return list.filter(function(k) { return k !== key }) | |
| 698 | + } | |
| 699 | + | |
| 643 | 700 | function unwatchRun(key) { |
| 644 | − if (watchingRun(key)) setWatchedRuns(watchedRuns.filter(function(k) { return k !== key })) | |
| 701 | + if (watchingRun(key)) setWatchedRuns(without(watchedRuns, key)) | |
| 702 | + if (notifyingEachJob(key)) setEachJobRuns(without(eachJobRuns, key)) | |
| 703 | + } | |
| 704 | + | |
| 705 | + // Replace one run's belled jobs with `keys`. | |
| 706 | + function setWatchedJobsOf(runKey, keys) { | |
| 707 | + var others = watchedJobs.filter(function(k) { | |
| 708 | + var p = Status.parseJobKey(k) | |
| 709 | + return !p || p.runKey !== runKey | |
| 710 | + }) | |
| 711 | + var next = others.concat(keys) | |
| 712 | + if (next.join("\n") !== watchedJobs.join("\n")) setWatchedJobs(next) | |
| 645 | 713 | } |
| 646 | 714 | |
| 647 | 715 | function toggleWatchRun(fullName, number) { |
@@ -656,21 +724,104 @@ PlasmoidItem {
| 656 | 724 | loadRunDetail(fullName, number) |
| 657 | 725 | } |
| 658 | 726 | |
| 659 | − // Drop entries that aren't run keys (hand-edited, or an older format). | |
| 727 | + // "Notify for each job" implies watching the run: its own ending | |
| 728 | + // still rings, last. | |
| 729 | + function toggleEachJob(fullName, number) { | |
| 730 | + var key = Status.runKey(fullName, number) | |
| 731 | + if (notifyingEachJob(key)) { | |
| 732 | + setEachJobRuns(without(eachJobRuns, key)) | |
| 733 | + return | |
| 734 | + } | |
| 735 | + setEachJobRuns(eachJobRuns.concat([key])) | |
| 736 | + if (!watchingRun(key)) setWatchedRuns(watchedRuns.concat([key])) | |
| 737 | + loadRunDetail(fullName, number) | |
| 738 | + } | |
| 739 | + | |
| 740 | + function toggleWatchJob(fullName, number, job) { | |
| 741 | + var key = Status.runKey(fullName, number) | |
| 742 | + if (watchingJob(fullName, number, job.id)) { | |
| 743 | + setWatchedJobsOf(key, watchedJobsOf(key) | |
| 744 | + .filter(function(w) { return w.id !== String(job.id) }) | |
| 745 | + .map(function(w) { return w.key })) | |
| 746 | + return | |
| 747 | + } | |
| 748 | + setWatchedJobs(watchedJobs.concat([Status.jobKey(fullName, number, job)])) | |
| 749 | + loadRunDetail(fullName, number) | |
| 750 | + } | |
| 751 | + | |
| 752 | + // Drop entries that aren't keys (hand-edited, or an older format), | |
| 753 | + // duplicates, and each-job flags whose run isn't watched. | |
| 660 | 754 | function pruneWatchedRuns() { |
| 661 | − var kept = watchedRuns.filter(function(k, i, all) { | |
| 662 | − return Status.parseRunKey(k) !== null && all.indexOf(k) === i | |
| 663 | − }) | |
| 664 | − if (kept.length !== watchedRuns.length) setWatchedRuns(kept) | |
| 755 | + function unique(k, i, all) { return all.indexOf(k) === i } | |
| 756 | + var runs = watchedRuns.filter(function(k, i, all) { return Status.parseRunKey(k) !== null && unique(k, i, all) }) | |
| 757 | + if (runs.length !== watchedRuns.length) setWatchedRuns(runs) | |
| 758 | + var each = eachJobRuns.filter(function(k, i, all) { return runs.indexOf(k) !== -1 && unique(k, i, all) }) | |
| 759 | + if (each.length !== eachJobRuns.length) setEachJobRuns(each) | |
| 760 | + var jobs = watchedJobs.filter(function(k, i, all) { return Status.parseJobKey(k) !== null && unique(k, i, all) }) | |
| 761 | + if (jobs.length !== watchedJobs.length) setWatchedJobs(jobs) | |
| 665 | 762 | } |
| 666 | 763 | |
| 667 | − // A watched run that has ended: announce it once, then stop watching. | |
| 668 | − function checkWatched(fullName, run) { | |
| 669 | − if (!run) return | |
| 670 | − var key = Status.runKey(fullName, run.number) | |
| 671 | − if (!watchingRun(key) || !Status.isFinished(Status.runStatus(run))) return | |
| 764 | + // Forget everything waiting on a run the server no longer has. | |
| 765 | + function dropRun(key) { | |
| 672 | 766 | unwatchRun(key) |
| 673 | − notifyRun(fullName, run) | |
| 767 | + setWatchedJobsOf(key, []) | |
| 768 | + seenActiveJobs = setKey(seenActiveJobs, key, undefined) | |
| 769 | + } | |
| 770 | + | |
| 771 | + // Called with every fresh detail ({run, jobs}) of a run: rings for | |
| 772 | + // belled jobs that ended, for each job of an each-job run that ended | |
| 773 | + // since the last look, and for the run itself once it ends. Whatever | |
| 774 | + // ended in this one look goes out together — folded into the run's | |
| 775 | + // notification if the run ended too, else one per job, or a single | |
| 776 | + // summary when more than two finished at once. | |
| 777 | + function followRun(fullName, data) { | |
| 778 | + if (!data || !data.run) return | |
| 779 | + var run = data.run | |
| 780 | + var key = Status.runKey(fullName, run.number) | |
| 781 | + var jobs = data.jobs || [] | |
| 782 | + var runDone = Status.isFinished(Status.runStatus(run)) | |
| 783 | + var ended = [] | |
| 784 | + function addEnded(job) { | |
| 785 | + for (var i = 0; i < ended.length; i++) if (ended[i].id === job.id) return | |
| 786 | + ended.push(job) | |
| 787 | + } | |
| 788 | + | |
| 789 | + // Belled jobs, following placeholders to their legs and re-runs | |
| 790 | + // to their new rows. An empty job list says nothing yet. | |
| 791 | + var belled = watchedJobsOf(key) | |
| 792 | + if (belled.length > 0 && jobs.length > 0) { | |
| 793 | + var still = [] | |
| 794 | + Status.resolveJobWatches(belled, jobs).forEach(function(job) { | |
| 795 | + if (Status.isFinished(Status.jobStatus(job))) addEnded(job) | |
| 796 | + else still.push(Status.jobKey(fullName, run.number, job)) | |
| 797 | + }) | |
| 798 | + setWatchedJobsOf(key, runDone ? [] : still) | |
| 799 | + } else if (belled.length > 0 && runDone) { | |
| 800 | + setWatchedJobsOf(key, []) | |
| 801 | + } | |
| 802 | + | |
| 803 | + // Every job seen in flight is remembered, so a run switched to | |
| 804 | + // each-job later still knows which endings are news. | |
| 805 | + var events = Status.jobEvents(seenActiveJobs[key], jobs) | |
| 806 | + seenActiveJobs = setKey(seenActiveJobs, key, runDone ? undefined : events.active) | |
| 807 | + if (notifyingEachJob(key)) events.finished.forEach(addEnded) | |
| 808 | + | |
| 809 | + // Once per ending: a re-run can end the same job or run again. | |
| 810 | + ended = ended.filter(function(job) { | |
| 811 | + var once = Status.jobKey(fullName, run.number, job) + "@" + (job.completed_at || Status.jobStatus(job)) | |
| 812 | + if (notified[once]) return false | |
| 813 | + notified = setKey(notified, once, true) | |
| 814 | + return true | |
| 815 | + }) | |
| 816 | + | |
| 817 | + if (runDone && watchingRun(key)) { | |
| 818 | + unwatchRun(key) | |
| 819 | + notifyRun(fullName, run, jobs, ended) | |
| 820 | + } else if (ended.length > 2) { | |
| 821 | + notifyJobs(fullName, run, ended) | |
| 822 | + } else { | |
| 823 | + ended.forEach(function(job) { notifyJob(fullName, run, job) }) | |
| 824 | + } | |
| 674 | 825 | } |
| 675 | 826 | |
| 676 | 827 | function notificationTitle(fullName, run) { |
@@ -686,39 +837,104 @@ PlasmoidItem {
| 686 | 837 | } |
| 687 | 838 | } |
| 688 | 839 | |
| 689 | − function notifyRun(fullName, run) { | |
| 840 | + function jobNotificationTitle(fullName, run, job) { | |
| 841 | + var n = run.number | |
| 842 | + var name = Status.jobLabel(job) | |
| 843 | + switch (Status.outcome(Status.jobStatus(job))) { | |
| 844 | + case "succeeded": return i18n("%1 #%2 · %3 succeeded", fullName, n, name) | |
| 845 | + case "failed": return i18n("%1 #%2 · %3 failed", fullName, n, name) | |
| 846 | + case "cancelled": return i18n("%1 #%2 · %3 cancelled", fullName, n, name) | |
| 847 | + case "timed_out": return i18n("%1 #%2 · %3 timed out", fullName, n, name) | |
| 848 | + case "skipped": return i18n("%1 #%2 · %3 skipped", fullName, n, name) | |
| 849 | + case "superseded": return i18n("%1 #%2 · %3 superseded", fullName, n, name) | |
| 850 | + default: return i18n("%1 #%2 · %3 finished", fullName, n, name) | |
| 851 | + } | |
| 852 | + } | |
| 853 | + | |
| 854 | + // "build ✓, test (x86_64) ✗" | |
| 855 | + function jobMarks(jobs) { | |
| 856 | + return jobs.map(function(j) { return Status.jobLabel(j) + " " + Status.jobMark(Status.jobStatus(j)) }).join(", ") | |
| 857 | + } | |
| 858 | + | |
| 859 | + function urgencyFor(status) { | |
| 860 | + var o = Status.outcome(status) | |
| 861 | + return o === "failed" || o === "timed_out" ? Notification.HighUrgency : Notification.NormalUrgency | |
| 862 | + } | |
| 863 | + | |
| 864 | + function joined(parts) { | |
| 865 | + return parts.filter(function(s) { return !!s }).join(" · ") | |
| 866 | + } | |
| 867 | + | |
| 868 | + function send(props) { | |
| 869 | + props.jobRowId = props.jobRowId === undefined ? "" : String(props.jobRowId) | |
| 870 | + var note = notificationComponent.createObject(root, props) | |
| 871 | + if (note) (note as Notification).sendEvent() | |
| 872 | + } | |
| 873 | + | |
| 874 | + // `jobs`: the run's jobs, to count the skipped ones. `ended`: jobs | |
| 875 | + // that ended in the same look, named here instead of separately. | |
| 876 | + function notifyRun(fullName, run, jobs, ended) { | |
| 690 | 877 | var key = Status.runKey(fullName, run.number) |
| 691 | − // Once per ending: a re-run job can end the same run again later. | |
| 692 | 878 | var once = key + "@" + (run.completed_at || Status.runStatus(run)) |
| 693 | 879 | if (notified[once]) return |
| 694 | 880 | notified = setKey(notified, once, true) |
| 695 | 881 | |
| 696 | 882 | var status = Status.runStatus(run) |
| 697 | − var outcome = Status.outcome(status) | |
| 698 | − var note = notificationComponent.createObject(root, { | |
| 883 | + var skipped = Status.countJobs(jobs || [], "skipped") | |
| 884 | + send({ | |
| 699 | 885 | fullName: fullName, |
| 700 | 886 | runNumber: run.number, |
| 701 | 887 | title: notificationTitle(fullName, run), |
| 702 | − text: [Status.runTitle(run), Status.shortRef(run.head_ref), | |
| 703 | − Status.duration(run.started_at, run.completed_at)] | |
| 704 | − .filter(function(s) { return s !== "" }).join(" · "), | |
| 888 | + text: joined([Status.runTitle(run), Status.shortRef(run.head_ref), | |
| 889 | + Status.duration(run.started_at, run.completed_at), | |
| 890 | + skipped > 0 ? i18np("1 skipped", "%1 skipped", skipped) : "", | |
| 891 | + ended && ended.length > 0 ? jobMarks(ended) : ""]), | |
| 705 | 892 | iconName: Status.icon(status), |
| 706 | − urgency: outcome === "failed" || outcome === "timed_out" ? Notification.HighUrgency | |
| 707 | − : Notification.NormalUrgency | |
| 893 | + urgency: urgencyFor(status) | |
| 894 | + }) | |
| 895 | + } | |
| 896 | + | |
| 897 | + function notifyJob(fullName, run, job) { | |
| 898 | + var status = Status.jobStatus(job) | |
| 899 | + send({ | |
| 900 | + fullName: fullName, | |
| 901 | + runNumber: run.number, | |
| 902 | + jobRowId: job.id, | |
| 903 | + title: jobNotificationTitle(fullName, run, job), | |
| 904 | + text: joined([Status.runTitle(run), Status.duration(job.started_at, job.completed_at), Status.jobReason(job)]), | |
| 905 | + iconName: Status.icon(status), | |
| 906 | + urgency: urgencyFor(status) | |
| 907 | + }) | |
| 908 | + } | |
| 909 | + | |
| 910 | + // Several jobs at once: one notification listing them. Clicking it | |
| 911 | + // opens the worst of them. | |
| 912 | + function notifyJobs(fullName, run, jobs) { | |
| 913 | + var worst = Status.worstOf(jobs.map(function(j) { return Status.jobStatus(j) })) | |
| 914 | + var first = jobs.filter(function(j) { return Status.jobStatus(j) === worst })[0] | |
| 915 | + send({ | |
| 916 | + fullName: fullName, | |
| 917 | + runNumber: run.number, | |
| 918 | + jobRowId: first ? first.id : "", | |
| 919 | + title: i18np("%2 #%3 · 1 job finished", "%2 #%3 · %1 jobs finished", jobs.length, fullName, run.number), | |
| 920 | + text: joined([jobMarks(jobs), Status.runTitle(run)]), | |
| 921 | + iconName: Status.icon(worst), | |
| 922 | + urgency: urgencyFor(worst) | |
| 708 | 923 | }) |
| 709 | − if (note) (note as Notification).sendEvent() | |
| 710 | 924 | } |
| 711 | 925 | |
| 712 | 926 | Component { |
| 713 | 927 | id: notificationComponent |
| 714 | 928 | |
| 715 | 929 | // Sent through Plasma's own notification event, so it needs no |
| 716 | − // notifyrc of its own. Clicking it opens the run's window. | |
| 930 | + // notifyrc of its own. Clicking it opens the run's window, at the | |
| 931 | + // job if it's about one. | |
| 717 | 932 | Notification { |
| 718 | 933 | id: note |
| 719 | 934 | |
| 720 | 935 | property string fullName |
| 721 | 936 | property int runNumber |
| 937 | + property string jobRowId | |
| 722 | 938 | |
| 723 | 939 | componentName: "plasma_workspace" |
| 724 | 940 | eventId: "notification" |
@@ -727,7 +943,7 @@ PlasmoidItem {
| 727 | 943 | |
| 728 | 944 | defaultAction: NotificationAction { |
| 729 | 945 | label: i18n("Show Run") |
| 730 | − onActivated: root.openRunWindow(note.fullName, note.runNumber) | |
| 946 | + onActivated: root.openRunWindow(note.fullName, note.runNumber, note.jobRowId) | |
| 731 | 947 | } |
| 732 | 948 | |
| 733 | 949 | actions: [ |
modified
metadata.json
+1
−1
@@ -11,7 +11,7 @@
| 11 | 11 | "Icon": "run-build", |
| 12 | 12 | "Id": "org.neiam.kde.gitgud", |
| 13 | 13 | "Name": "GitGud CI", |
| 14 | − "Version": "0.3.0", | |
| 14 | + "Version": "0.4.0", | |
| 15 | 15 | "Website": "http://neiam.org" |
| 16 | 16 | }, |
| 17 | 17 | "KPackageStructure": "Plasma/Applet", |
Parents: 4f26744