Follow the forge's newer CI vocabulary

010513e · Gabriel Morell · 2026-10-19 16:51

11 files +209 -30

Files changed

modified README.md
+22 −4
@@ -7,7 +7,24 @@ A Plasma 6 panel widget that shows [GitGud](https://gg.neiam.co) CI runs as a ro
7 7 ```
8 8
9 9 - **Logo**: opens a popup with every recent run across everything you watch.
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: green is success, red is failure or cancelled, a pulsing orange circle is running, and a hollow orange ring is queued. Hover a circle for the workflow, run number, ref, SHA and age. Click it to open just that circle's runs.
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 +
12 +## Statuses
13 +
14 +Statuses read as they do in the Android app and the `gg` CLI.
15 +
16 +| Mark | Status | Meaning |
17 +|---|---|---|
18 +| green dot | success | |
19 +| red dot | failure, timed out, cancelled | |
20 +| pulsing orange dot | running | |
21 +| hollow orange ring | queued, blocked, waiting | not started yet: waiting for a runner, for its `needs`, or held by its concurrency group |
22 +| orange ring with a dot | needs approval | a fork pull request's run, waiting for someone with write access to approve it on the pull request |
23 +| grey dot | skipped, superseded | superseded means a newer run or job in its concurrency group replaced it. It isn't a failure. |
24 +
25 +Only success, failure, cancelled, skipped and timed out are endings. Anything else, including a status the widget doesn't know yet, counts as still in progress and keeps being refreshed. A run waiting for approval is refreshed at the normal pace, not the fast one, because it's waiting on a person.
26 +
27 +Triggers read as words: *scheduled*, *manual* (`workflow_dispatch`), *pull request (target)*, *branch/tag created/deleted*, and *after a workflow* (`workflow_run`). A run from a fork says *from a fork*.
11 28
12 29 ## Run accordion
13 30
@@ -24,7 +41,8 @@ Runs expand the same way as in the Android app:
24 41 ```
25 42
26 43 - Click a **run** to see its jobs, artifacts, and the **Re-run all**, **Cancel** and **Open** buttons.
27 −- Click a **job** to see its steps. The ⟳ button re-runs only that job.
44 +- Click a **job** to see its steps. The ⟳ button re-runs only that job. Under the job's status you'll see why it is waiting, was skipped or was cancelled, and *attempt N* once it has been re-run. A dynamic matrix that hasn't expanded yet says *Matrix expands when … finish*. Its jobs replace it once they're created.
45 +- A fork's run that is **waiting for approval** says so and links to its pull request. You approve it there.
28 46 - Click a **step** to see its log slice. Failed steps open on their own.
29 47
30 48 Open runs keep refreshing while they're in flight. Re-run and cancel are only shown when the server says you can manage the repository and the token has `ci:write`.
@@ -68,8 +86,8 @@ kpackagetool6 --type Plasma/Applet --install .
68 86 kpackagetool6 --type Plasma/Applet --upgrade .
69 87
70 88 # Or build a release archive
71 −./package.sh 0.1.0
72 −kpackagetool6 --type Plasma/Applet --install gitgud-plasmoid-0.1.0.tar.gz
89 +./package.sh 0.2.0
90 +kpackagetool6 --type Plasma/Applet --install gitgud-plasmoid-0.2.0.tar.gz
73 91 ```
74 92
75 93 Test without installing:
modified contents/ui/GitGudAPI.qml
+4 −0
@@ -196,6 +196,10 @@ QtObject {
196 196 request("POST", repoPath(fullName) + "/runs/" + number + "/cancel", callback)
197 197 }
198 198
199 + function pullUrl(fullName, number) {
200 + return normalizedBase() + "/r/" + fullName + "/pulls/" + number
201 + }
202 +
199 203 function webUrl(fullName, runNumber) {
200 204 var url = normalizedBase() + "/r/" + fullName
201 205 if (runNumber !== undefined) url += "/actions/" + runNumber
modified contents/ui/JobItem.qml
+21 −4
@@ -21,7 +21,8 @@ ColumnLayout {
21 21
22 22 readonly property bool expanded: store.expandedJobs[job.id] === true
23 23 readonly property var logs: store.jobLogs[job.id] || null
24 − readonly property string status: Status.effective(job.status, job.conclusion)
24 + readonly property string status: Status.jobStatus(job)
25 + readonly property string reason: Status.jobReason(job)
25 26
26 27 spacing: 0
27 28
@@ -67,15 +68,30 @@ ColumnLayout {
67 68 elide: Text.ElideRight
68 69 font: Kirigami.Theme.smallFont
69 70 opacity: 0.7
70 − text: (jobItem.store.now, [jobItem.status,
71 + text: (jobItem.store.now, [Status.label(jobItem.status),
72 + jobItem.job.attempt > 1 ? "attempt " + jobItem.job.attempt : "",
71 73 Status.duration(jobItem.job.started_at, jobItem.job.completed_at),
72 74 (jobItem.job.needs && jobItem.job.needs.length > 0) ? "needs " + jobItem.job.needs.join(", ") : ""]
73 75 .filter(function(s) { return s !== "" }).join(" · "))
74 76 }
77 +
78 + // Why it's waiting, skipped or cancelled — or, for a
79 + // matrix placeholder, when it expands.
80 + PlasmaComponents3.Label {
81 + visible: jobItem.reason !== ""
82 + Layout.fillWidth: true
83 + wrapMode: Text.WordWrap
84 + maximumLineCount: 3
85 + elide: Text.ElideRight
86 + font: Kirigami.Theme.smallFont
87 + opacity: 0.7
88 + text: jobItem.reason
89 + }
75 90 }
76 91
77 92 PlasmaComponents3.ToolButton {
78 − visible: jobItem.canManage && !!jobItem.job.job_id
93 + // A placeholder isn't a job yet: its legs replace it.
94 + visible: jobItem.canManage && !!jobItem.job.job_id && !jobItem.job.placeholder
79 95 enabled: !jobItem.store.busy
80 96 icon.name: "view-refresh"
81 97 onClicked: jobItem.store.rerunJob(jobItem.fullName, jobItem.runNumber, jobItem.job.job_id)
@@ -93,7 +109,8 @@ ColumnLayout {
93 109
94 110 PlasmaComponents3.Label {
95 111 visible: jobItem.job.steps.length === 0
96 − text: i18n("No steps yet")
112 + text: jobItem.job.placeholder ? i18n("Matrix not expanded yet — its jobs appear here once it does")
113 + : i18n("No steps yet")
97 114 opacity: 0.7
98 115 font: Kirigami.Theme.smallFont
99 116 }
modified contents/ui/RunItem.qml
+34 −2
@@ -22,8 +22,10 @@ ColumnLayout {
22 22 readonly property string runKey: fullName + "#" + run.number
23 23 readonly property bool expanded: store.expandedRuns[runKey] === true
24 24 readonly property var detail: store.runDetails[runKey] || null
25 − readonly property string status: Status.effective(run.status, run.conclusion)
25 + readonly property string status: Status.runStatus(run)
26 26 readonly property bool active: Status.isActive(run.status)
27 + // The detail's copy is fresher than the list row's once it's loaded.
28 + readonly property var current: detail ? detail.run : run
27 29
28 30 spacing: 0
29 31
@@ -75,7 +77,7 @@ ColumnLayout {
75 77 Layout.alignment: Qt.AlignRight
76 78 font: Kirigami.Theme.smallFont
77 79 color: Status.color(runItem.status, Kirigami.Theme)
78 − text: runItem.status
80 + text: Status.label(runItem.status)
79 81 }
80 82
81 83 PlasmaComponents3.Label {
@@ -105,6 +107,36 @@ ColumnLayout {
105 107 Layout.preferredHeight: Kirigami.Units.iconSizes.medium
106 108 }
107 109
110 + // A fork's run waits for someone with write access to approve
111 + // it, and that happens on the pull request — say so, link there.
112 + ColumnLayout {
113 + visible: !!runItem.current.awaiting_approval
114 + Layout.fillWidth: true
115 + spacing: 0
116 +
117 + PlasmaComponents3.Label {
118 + Layout.fillWidth: true
119 + color: Kirigami.Theme.neutralTextColor
120 + text: i18n("Waiting for approval")
121 + }
122 +
123 + PlasmaComponents3.Label {
124 + Layout.fillWidth: true
125 + wrapMode: Text.WordWrap
126 + font: Kirigami.Theme.smallFont
127 + opacity: 0.7
128 + text: Status.approvalNotice(runItem.current)
129 + }
130 +
131 + PlasmaComponents3.ToolButton {
132 + visible: !!runItem.current.pull_request_number
133 + icon.name: "internet-web-browser"
134 + text: i18n("Open pull request #%1", runItem.current.pull_request_number)
135 + display: QQC2.AbstractButton.TextBesideIcon
136 + onClicked: Qt.openUrlExternally(runItem.store.api.pullUrl(runItem.fullName, runItem.current.pull_request_number))
137 + }
138 + }
139 +
108 140 RowLayout {
109 141 visible: !!runItem.detail
110 142 Layout.fillWidth: true
modified contents/ui/Status.js
+100 −9
@@ -1,10 +1,18 @@
1 1 .pragma library
2 2
3 3 // CI status vocabulary, shared by every view. Mirrors the Android
4 −// client's StatusChip.kt so the widget and the phone agree on what a
5 −// colour means.
6 −
7 −var ACTIVE = ["queued", "running", "pending", "blocked"]
4 +// client's StatusChip.kt (and the `gg` CLI's output.rs) so the widget,
5 +// the phone and the terminal agree on what a colour means.
6 +//
7 +// Two names here aren't server statuses: `awaiting_approval` on a run
8 +// (runStatus — the server says `queued`) and `superseded`, a job
9 +// cancelled because a newer one in its concurrency group replaced it
10 +// (jobStatus). Superseded is grey, not red: the newer run decides.
11 +
12 +// The only endings. Anything else — waiting, awaiting_approval, or a
13 +// status the server learns later — is still in progress, so polling
14 +// keeps going rather than calling it done.
15 +var FINISHED = ["success", "failure", "cancelled", "skipped", "timed_out", "superseded"]
8 16
9 17 // `conclusion` when the thing has finished, else its raw `status`.
10 18 function effective(status, conclusion) {
@@ -12,8 +20,37 @@ function effective(status, conclusion) {
12 20 return status || "unknown"
13 21 }
14 22
23 +// What a run's circle says. A fork's run waiting for approval is
24 +// `queued` on the server, but "queued" would promise a runner is
25 +// coming — nothing happens until someone approves it.
26 +function runStatus(run) {
27 + if (!run) return "unknown"
28 + if (run.awaiting_approval && (run.status === "queued" || run.status === "running")) return "awaiting_approval"
29 + return effective(run.status, run.conclusion)
30 +}
31 +
32 +// `superseded` for a job a newer one replaced, else its effective status.
33 +function jobStatus(job) {
34 + if (!job) return "unknown"
35 + if (job.superseded) return "superseded"
36 + return effective(job.status, job.conclusion)
37 +}
38 +
39 +function isFinished(status) {
40 + return FINISHED.indexOf(status) !== -1 || status === "completed" || status === "finished"
41 +}
42 +
43 +// Not finished. "unknown" is this widget's own word for "nothing known
44 +// yet" (a repo that never ran CI), not a server status, so it's idle.
15 45 function isActive(status) {
16 − return ACTIVE.indexOf(status) !== -1
46 + return !!status && status !== "unknown" && !isFinished(status)
47 +}
48 +
49 +// Worth the fast poll: in flight, but not parked on a person. A run
50 +// waiting for approval is still watched on every poll, just at the idle
51 +// pace; once approved it's `queued` again and the fast poll picks it up.
52 +function pollsFast(status) {
53 + return isActive(status) && status !== "awaiting_approval"
17 54 }
18 55
19 56 // success | failure | running | queued | dim
@@ -30,9 +67,16 @@ function category(status) {
30 67 case "queued":
31 68 case "pending":
32 69 case "blocked":
70 + case "waiting":
71 + case "awaiting_approval":
33 72 return "queued"
34 − default:
73 + case "skipped":
74 + case "superseded":
35 75 return "dim"
76 + default:
77 + // A status this widget doesn't know yet but that hasn't ended
78 + // reads as pending, not as idle.
79 + return isActive(status) ? "queued" : "dim"
36 80 }
37 81 }
38 82
@@ -51,16 +95,45 @@ function icon(status) {
51 95 case "success": return "emblem-success"
52 96 case "failure":
53 97 case "timed_out": return "emblem-error"
54 − case "cancelled": return "dialog-cancel"
98 + case "cancelled":
99 + case "superseded": return "dialog-cancel"
55 100 case "running": return "media-playback-start"
56 101 case "queued":
57 102 case "pending":
58 − case "blocked": return "chronometer"
103 + case "blocked":
104 + case "waiting": return "chronometer"
105 + case "awaiting_approval": return "flag"
59 106 case "skipped": return "go-next-skip"
60 107 default: return "question"
61 108 }
62 109 }
63 110
111 +// A status as words: `awaiting_approval` reads "needs approval",
112 +// `timed_out` "timed out".
113 +function label(status) {
114 + if (!status) return "unknown"
115 + if (status === "awaiting_approval") return "needs approval"
116 + return status.replace(/_/g, " ")
117 +}
118 +
119 +// What started a run, as a person would say it. Unknown events fall
120 +// through as their raw name with underscores spaced.
121 +function triggerLabel(trigger) {
122 + switch (trigger) {
123 + case undefined:
124 + case null:
125 + case "": return ""
126 + case "pull_request": return "pull request"
127 + case "pull_request_target": return "pull request (target)"
128 + case "workflow_dispatch": return "manual"
129 + case "schedule": return "scheduled"
130 + case "create": return "branch/tag created"
131 + case "delete": return "branch/tag deleted"
132 + case "workflow_run": return "after a workflow"
133 + default: return String(trigger).replace(/_/g, " ")
134 + }
135 +}
136 +
64 137 // Worst-first ordering for folding several repos into one circle: a
65 138 // run in flight is the most interesting thing, then a broken build.
66 139 var PRIORITY = { running: 5, queued: 4, failure: 3, success: 2, dim: 1 }
@@ -124,11 +197,29 @@ function runMeta(run) {
124 197 var parts = []
125 198 if (run.head_ref) parts.push(shortRef(run.head_ref))
126 199 if (run.head_sha) parts.push(shortSha(run.head_sha))
127 − if (run.trigger) parts.push(run.trigger)
200 + if (run.trigger) parts.push(triggerLabel(run.trigger))
201 + if (run.fork) parts.push("from a fork")
128 202 if (run.triggered_by && run.triggered_by.handle) parts.push("by " + run.triggered_by.handle)
129 203 return parts.join(" · ")
130 204 }
131 205
206 +// What to say under a job: the server's reason (why it waits, was
207 +// skipped or cancelled, or why its matrix couldn't expand), or for a
208 +// matrix placeholder with nothing to explain yet, what it is.
209 +function jobReason(job) {
210 + if (job.status_reason && String(job.status_reason).trim() !== "") return job.status_reason
211 + if (job.placeholder && job.needs && job.needs.length > 0 && jobStatus(job) === "blocked") {
212 + return "Matrix expands when " + job.needs.join(", ") + " finish"
213 + }
214 + return ""
215 +}
216 +
217 +// "Waiting for approval" notice for a fork's run nobody has approved.
218 +function approvalNotice(run) {
219 + return "This run comes from a fork. Its jobs start once someone with write access approves it"
220 + + (run.pull_request_number ? " on pull request #" + run.pull_request_number + "." : ".")
221 +}
222 +
132 223 function jobLabel(job) {
133 224 var label = job.name || job.job_id || ("job " + job.id)
134 225 if (job.matrix && typeof job.matrix === "object") {
modified contents/ui/StatusCircle.qml
+13 −2
@@ -3,8 +3,10 @@ import org.kde.kirigami as Kirigami
3 3
4 4 import "Status.js" as Status
5 5
6 −// A single status dot. Running pulses; queued is a hollow ring, so the
7 −// two in-flight states read differently at panel size.
6 +// A single status dot. Running pulses; queued (and waiting, blocked) is
7 +// a hollow ring, so the two in-flight states read differently at panel
8 +// size. Needs-approval is a ring with a dot in it: pending colour, but
9 +// its own mark, since it waits on a person rather than a runner.
8 10 Item {
9 11 id: circle
10 12
@@ -53,4 +55,13 @@ Item {
53 55 Behavior on width { NumberAnimation { duration: Kirigami.Units.shortDuration } }
54 56 Behavior on color { ColorAnimation { duration: Kirigami.Units.longDuration } }
55 57 }
58 +
59 + Rectangle {
60 + visible: circle.status === "awaiting_approval"
61 + anchors.centerIn: parent
62 + width: parent.width * 0.25
63 + height: width
64 + radius: width / 2
65 + color: circle.statusColor
66 + }
56 67 }
modified contents/ui/configBehavior.qml
+1 −1
@@ -34,7 +34,7 @@ KCM.SimpleKCM {
34 34 wrapMode: Text.WordWrap
35 35 font: Kirigami.Theme.smallFont
36 36 color: Kirigami.Theme.disabledTextColor
37 − text: i18n("While anything is queued or running the widget polls faster, and only refetches repositories whose status moved.")
37 + text: i18n("While anything is queued, waiting or running the widget polls faster, and only refetches repositories whose status moved or hasn't finished. A fork's run waiting for approval is checked at the normal pace.")
38 38 }
39 39
40 40 QQC2.SpinBox {
modified contents/ui/main.qml
+13 −7
@@ -166,7 +166,7 @@ PlasmoidItem {
166 166
167 167 function repoStatus(fullName) {
168 168 var run = latestRun(fullName)
169 − if (run) return Status.effective(run.status, run.conclusion)
169 + if (run) return Status.runStatus(run)
170 170 var row = repoIndex[fullName]
171 171 return row && row.ci_status ? row.ci_status : "unknown"
172 172 }
@@ -184,11 +184,11 @@ PlasmoidItem {
184 184 lines.push(prefix + i18n("no runs"))
185 185 continue
186 186 }
187 − var status = Status.effective(run.status, run.conclusion)
187 + var status = Status.runStatus(run)
188 188 var meta = [Status.shortRef(run.head_ref), Status.shortSha(run.head_sha), Status.relativeTime(run.inserted_at)]
189 189 .filter(function(s) { return s !== "" }).join(" · ")
190 190 lines.push(prefix + Status.runTitle(run) + " #" + run.number
191 − + " — <font color=\"" + Status.color(status, Kirigami.Theme) + "\">" + status + "</font>"
191 + + " — <font color=\"" + Status.color(status, Kirigami.Theme) + "\">" + Status.label(status) + "</font>"
192 192 + (meta ? "<br/><small>" + meta + "</small>" : ""))
193 193 }
194 194 if (repos.length === 0) lines.push(i18n("No repositories found in this namespace"))
@@ -229,7 +229,9 @@ PlasmoidItem {
229 229 for (var j = 0; j < c.repos.length; j++) {
230 230 var s = repoStatus(c.repos[j])
231 231 statuses.push(s)
232 − if (Status.isActive(s)) active = true
232 + // A run held for approval is still watched every poll,
233 + // but only at the idle pace: it waits on a person.
234 + if (Status.pollsFast(s)) active = true
233 235 }
234 236 var status = Status.aggregate(statuses)
235 237 if (hideIdle && status === "unknown") continue
@@ -362,7 +364,8 @@ PlasmoidItem {
362 364 if (runs[i].number === number) { summary = runs[i]; break }
363 365 }
364 366 if (!detail || Status.isActive(detail.run.status)
365 − || (summary && (summary.status !== detail.run.status || summary.conclusion !== detail.run.conclusion))) {
367 + || (summary && (summary.status !== detail.run.status || summary.conclusion !== detail.run.conclusion
368 + || !!summary.awaiting_approval !== !!detail.run.awaiting_approval))) {
366 369 loadRunDetail(fullName, number)
367 370 }
368 371 }
@@ -435,9 +438,12 @@ PlasmoidItem {
435 438 api.getRun(fullName, number, function(ok, data) {
436 439 if (!ok) { lastError = data; return }
437 440 runDetails = setKey(runDetails, key, data)
438 − // Keep open jobs' logs following the run.
441 + // Keep open jobs' logs following the run. A matrix
442 + // placeholder has no logs: its row is replaced by its legs
443 + // (new ids) once it expands, and this re-read shows them.
439 444 var jobs = data.jobs || []
440 445 for (var i = 0; i < jobs.length; i++) {
446 + if (jobs[i].placeholder) continue
441 447 if (expandedJobs[jobs[i].id] && (Status.isActive(jobs[i].status) || !jobLogs[jobs[i].id]
442 448 || jobLogs[jobs[i].id].job.status !== jobs[i].status)) {
443 449 loadJobLogs(fullName, jobs[i].id)
@@ -465,7 +471,7 @@ PlasmoidItem {
465 471 function toggleJob(fullName, job) {
466 472 var open = !expandedJobs[job.id]
467 473 expandedJobs = setKey(expandedJobs, job.id, open ? true : undefined)
468 − if (open) loadJobLogs(fullName, job.id)
474 + if (open && !job.placeholder) loadJobLogs(fullName, job.id)
469 475 }
470 476
471 477 function stepOpen(stepKey, status) {
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.1.0",
14 + "Version": "0.2.0",
15 15 "Website": "http://neiam.org"
16 16 },
17 17 "KPackageStructure": "Plasma/Applet",

Parents: db86591