Notify as jobs finish: per-job bells and each-job runs

0fc3e53 · Gabriel Morell · 2026-10-19 17:16

12 files +447 -40

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