Approve, reject and expedite from the phone: a job waiting for a person shows Approve and Reject, a live run has Expedite with its reason (refs #5) ca672a3ada1f
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key bd3b3d3cbaa4
- When
- · 2026-10-27 22:24 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
ca672a3ada1f85800cc72ba39f26f9ffe0fcca9c43c274887dac673cb4bfb811- Depends on
- 1c42a70fcba3 57c0824300db 5ae4af48ddd2 af9f2e514c72 e833eb0a9e7f
- Depended on by
- 1dfa631436b6 0ca942a9a1e4 2a15621eca66
- In bookmarks
- main
- In tags
- v0.1.0 v0.1.1 v0.1.2 v0.1.3 v0.1.4 v0.1.5 v0.1.6 v0.1.7 v0.1.8 v0.1.9
- Edits
- 14 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Approve, reject and expedite runs (#5):** a run's job that waits for a person shows Approve and Reject (its approval rules on the hub decide who may), and a live run has Expedite, which asks why it is urgent (a maintainer's, within the owner's cap). Needs OxygenHub 0.9.11 (the job's `approval` and the expedite route). |
|
| 5 | 6 | - **Review a proposal from the phone (#4):** an open proposal's Reviews section takes a review: Approve, Request changes or Comment, with what you think (needed to ask for changes or to comment), for a phone signed in to change things. An approval from the phone is not sealed, as it holds no key of yours: where a repository needs sealed approvals it does not count yet. |
|
| 6 | 7 | - **A proposal's files and their diffs (#4):** a proposal's Changes section opens Files changed: each file it changes, folding open to its diff (old and new line numbers, added and removed lines tinted), as its page on the hub shows them, from the hub's `GET …/proposals/N/diff` (OxygenHub 0.9.10). The change screen and this one share the same diff list. |
|
| 7 | 8 | - **Clone a repository from the phone (#3):** the repository screen's Clone action shows its `oxy clone` command, selectable, with a button that copies it. |
|
| … | 20 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +16 −0
| … | 91 unchanged lines |
||
| 92 | 92 | ||
| 93 | 93 | suspend fun cancelRun(owner: String, name: String, number: Long) |
|
| 94 | 94 | ||
| 95 | + | /** Approve, or reject, a job that waits for a person (#5, oxygenflow#57); its approval rules decide who may. */ |
|
| 96 | + | suspend fun decideJob(owner: String, name: String, number: Long, job: String, approve: Boolean) |
|
| 97 | + | ||
| 98 | + | /** Make a run urgent, saying why (#5, oxygenhub#244): a maintainer's, within the owner's cap. */ |
|
| 99 | + | suspend fun expediteRun(owner: String, name: String, number: Long, reason: String) |
|
| 100 | + | ||
| 95 | 101 | /** [owner]'s project boards the caller can see. */ |
|
| 96 | 102 | suspend fun projects(owner: String): List<Project> |
|
| 97 | 103 | ||
| … | 97 unchanged lines |
||
| 195 | 201 | ||
| 196 | 202 | override suspend fun cancelRun(owner: String, name: String, number: Long) { |
|
| 197 | 203 | send("POST", url("repos", owner, name, "flow", "runs", number.toString(), "cancel")) |
|
| 204 | + | } |
|
| 205 | + | ||
| 206 | + | override suspend fun decideJob(owner: String, name: String, number: Long, job: String, approve: Boolean) { |
|
| 207 | + | send("POST", url("repos", owner, name, "flow", "runs", number.toString(), "jobs", job, if (approve) "approve" else "reject")) |
|
| 208 | + | } |
|
| 209 | + | ||
| 210 | + | override suspend fun expediteRun(owner: String, name: String, number: Long, reason: String) { |
|
| 211 | + | sendJson("POST", url("repos", owner, name, "flow", "runs", number.toString(), "expedite"), buildJsonObject { |
|
| 212 | + | put("reason", reason) |
|
| 213 | + | }, Expedited.serializer()) |
|
| 198 | 214 | } |
|
| 199 | 215 | ||
| 200 | 216 | override suspend fun projects(owner: String): List<Project> = get(url("projects", owner), ListSerializer(Project.serializer())) |
|
| … | 213 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/api/Models.kt +9 −0
| … | 109 unchanged lines |
||
| 110 | 110 | @SerialName("finished_ms") val finishedMs: Long? = null, |
|
| 111 | 111 | @SerialName("log_lines") val logLines: Long = 0, |
|
| 112 | 112 | val waiting: String = "", |
|
| 113 | + | /** Its approval gate: `waiting` while it waits for a person, `approved` or `rejected` after (OxygenHub 0.9.11). */ |
|
| 114 | + | val approval: String = "", |
|
| 113 | 115 | ) { |
|
| 114 | 116 | val live: Boolean get() = status == "queued" || status == "running" |
|
| 115 | 117 | ||
| 118 | + | /** Queued and waiting for someone to approve or reject it (#5). */ |
|
| 119 | + | val waitsForPerson: Boolean get() = status == "queued" && approval == "waiting" |
|
| 120 | + | ||
| 116 | 121 | /** How long it ran, or has been running, in seconds. */ |
|
| 117 | 122 | fun seconds(now: Long = System.currentTimeMillis()): Long? = startedMs?.let { ((finishedMs ?: now) - it) / 1000 } |
|
| 118 | 123 | } |
|
| … | 336 unchanged lines |
||
| 455 | 460 | /** What `POST …/reviews` answers; the app only needs that it was taken. */ |
|
| 456 | 461 | @Serializable |
|
| 457 | 462 | data class ReviewPosted(val verdict: String = "") |
|
| 463 | + | ||
| 464 | + | /** What `POST …/flow/runs/N/expedite` answers. */ |
|
| 465 | + | @Serializable |
|
| 466 | + | data class Expedited(val expedited: Boolean = false) |
modified app/src/main/java/io/oxygenhub/android/ui/flow/FlowScreens.kt +39 −0
| 1 | 1 | package io.oxygenhub.android.ui.flow |
|
| 2 | 2 | ||
| 3 | + | import androidx.compose.material.icons.outlined.Bolt |
|
| 4 | + | import androidx.compose.material3.Button |
|
| 5 | + | import androidx.compose.material3.OutlinedButton |
|
| 6 | + | import androidx.compose.material3.TextButton |
|
| 7 | + | import androidx.compose.material3.AlertDialog |
|
| 8 | + | import androidx.compose.material3.OutlinedTextField |
|
| 9 | + | import androidx.compose.runtime.saveable.rememberSaveable |
|
| 10 | + | import androidx.compose.runtime.mutableStateOf |
|
| 11 | + | import androidx.compose.runtime.setValue |
|
| 3 | 12 | import androidx.compose.foundation.clickable |
|
| 4 | 13 | import androidx.compose.foundation.horizontalScroll |
|
| 5 | 14 | import androidx.compose.foundation.layout.Arrangement |
|
| … | 96 unchanged lines |
||
| 102 | 111 | val state by vm.state.collectAsStateWithLifecycle() |
|
| 103 | 112 | val error by vm.error.collectAsStateWithLifecycle() |
|
| 104 | 113 | val run = (state as? Load.Ready)?.value |
|
| 114 | + | var expediting by rememberSaveable { mutableStateOf(false) } |
|
| 105 | 115 | AppScreen( |
|
| 106 | 116 | title = "Run #${vm.number}", |
|
| 107 | 117 | subtitle = listOfNotNull("${vm.owner}/${vm.name}", run?.bookmark, run?.status).joinToString(" · "), |
|
| … | 1 unchanged lines |
||
| 109 | 119 | actions = { |
|
| 110 | 120 | if (canWrite && run != null) { |
|
| 111 | 121 | if (run.live) { |
|
| 122 | + | IconButton(onClick = { expediting = true }) { Icon(Icons.Outlined.Bolt, contentDescription = "Expedite the run") } |
|
| 112 | 123 | IconButton(onClick = vm::cancel) { Icon(Icons.Outlined.Block, contentDescription = "Cancel the run") } |
|
| 113 | 124 | } else { |
|
| 114 | 125 | IconButton(onClick = vm::rerun) { Icon(Icons.Outlined.Replay, contentDescription = "Run it again") } |
|
| … | 22 unchanged lines |
||
| 137 | 148 | ).joinToString(" · "), |
|
| 138 | 149 | ) |
|
| 139 | 150 | if (j.waiting.isNotEmpty()) Text(j.waiting, style = MaterialTheme.typography.bodySmall) |
|
| 151 | + | // A job that waits for a person (#5): the hub's rules decide who may. |
|
| 152 | + | if (canWrite && j.waitsForPerson) { |
|
| 153 | + | Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { |
|
| 154 | + | Button(onClick = { vm.decide(j.name, approve = true) }) { Text("Approve") } |
|
| 155 | + | OutlinedButton(onClick = { vm.decide(j.name, approve = false) }) { Text("Reject") } |
|
| 156 | + | } |
|
| 157 | + | } |
|
| 140 | 158 | if (j.error.isNotEmpty()) Text(j.error, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) |
|
| 141 | 159 | } |
|
| 142 | 160 | }, |
|
| … | 8 unchanged lines |
||
| 151 | 169 | } |
|
| 152 | 170 | } |
|
| 153 | 171 | } |
|
| 172 | + | if (expediting) { |
|
| 173 | + | ExpediteDialog(onDismiss = { expediting = false }, onExpedite = { reason -> expediting = false; vm.expedite(reason) }) |
|
| 174 | + | } |
|
| 175 | + | } |
|
| 176 | + | ||
| 177 | + | /** Make a run urgent, saying why (#5): a maintainer's, within the owner's cap of urgent runs. */ |
|
| 178 | + | @Composable |
|
| 179 | + | private fun ExpediteDialog(onDismiss: () -> Unit, onExpedite: (String) -> Unit) { |
|
| 180 | + | var reason by rememberSaveable { mutableStateOf("") } |
|
| 181 | + | AlertDialog( |
|
| 182 | + | onDismissRequest = onDismiss, |
|
| 183 | + | title = { Text("Expedite") }, |
|
| 184 | + | text = { |
|
| 185 | + | Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { |
|
| 186 | + | Text("It goes ahead of everyday work until it ends. Say why: it is shown on the run and audited.", style = MaterialTheme.typography.bodySmall) |
|
| 187 | + | OutlinedTextField(value = reason, onValueChange = { reason = it.take(200) }, label = { Text("Why it is urgent") }, singleLine = true) |
|
| 188 | + | } |
|
| 189 | + | }, |
|
| 190 | + | confirmButton = { TextButton(onClick = { onExpedite(reason) }, enabled = reason.isNotBlank()) { Text("Expedite") } }, |
|
| 191 | + | dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, |
|
| 192 | + | ) |
|
| 154 | 193 | } |
|
| 155 | 194 | ||
| 156 | 195 | /** A job's log, following new lines while it runs. */ |
|
| … | 44 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/flow/FlowViewModels.kt +6 −0
| … | 84 unchanged lines |
||
| 85 | 85 | ||
| 86 | 86 | fun cancel() = act { api.cancelRun(owner, name, number) } |
|
| 87 | 87 | ||
| 88 | + | /** Approve, or reject, a job that waits for a person (#5). */ |
|
| 89 | + | fun decide(job: String, approve: Boolean) = act { api.decideJob(owner, name, number, job, approve) } |
|
| 90 | + | ||
| 91 | + | /** Make the run urgent, saying why (#5). */ |
|
| 92 | + | fun expedite(reason: String) = act { api.expediteRun(owner, name, number, reason.trim()) } |
|
| 93 | + | ||
| 88 | 94 | private fun act(step: suspend () -> Unit) { |
|
| 89 | 95 | _error.value = null |
|
| 90 | 96 | viewModelScope.launch { |
|
| … | 59 unchanged lines |
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +8 −0
| … | 80 unchanged lines |
||
| 81 | 81 | override suspend fun rerunJob(owner: String, name: String, number: Long, job: String) { |
|
| 82 | 82 | calls += "rerun $number $job" |
|
| 83 | 83 | } |
|
| 84 | + | override suspend fun decideJob(owner: String, name: String, number: Long, job: String, approve: Boolean) { |
|
| 85 | + | calls += "${if (approve) "approve" else "reject"} $number $job" |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | override suspend fun expediteRun(owner: String, name: String, number: Long, reason: String) { |
|
| 89 | + | calls += "expedite $number $reason" |
|
| 90 | + | } |
|
| 91 | + | ||
| 84 | 92 | override suspend fun cancelRun(owner: String, name: String, number: Long) { |
|
| 85 | 93 | calls += "cancel $number" |
|
| 86 | 94 | runStore[number]?.let { runStore[number] = it.copy(status = "cancelled") } |
|
| … | 91 unchanged lines |
modified app/src/test/java/io/oxygenhub/android/ui/FlowViewModelsTest.kt +12 −0
| … | 44 unchanged lines |
||
| 45 | 45 | } |
|
| 46 | 46 | ||
| 47 | 47 | @Test |
|
| 48 | + | fun aGatedJobIsApprovedOrRejectedAndARunExpedited() = runTest { |
|
| 49 | + | val vm = RunViewModel(api, "andymccall", "phone-runner-demo", 56, pollMs = 0) |
|
| 50 | + | vm.decide("deploy", approve = true) |
|
| 51 | + | vm.decide("deploy", approve = false) |
|
| 52 | + | vm.expedite(" the fix for the outage ") |
|
| 53 | + | assertEquals(listOf("approve 56 deploy", "reject 56 deploy", "expedite 56 the fix for the outage"), api.calls.filter { it.startsWith("approve") || it.startsWith("reject") || it.startsWith("expedite") }) |
|
| 54 | + | val gated = io.oxygenhub.android.api.FlowJob("deploy", status = "queued", approval = "waiting") |
|
| 55 | + | assertTrue(gated.waitsForPerson) |
|
| 56 | + | assertTrue(!gated.copy(approval = "approved").waitsForPerson && !gated.copy(status = "running").waitsForPerson) |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | @Test |
|
| 48 | 60 | fun aLogGrowsFromTheLastLineHeldUntilTheJobEnds() = runTest { |
|
| 49 | 61 | val vm = JobLogViewModel(api, "andymccall", "phone-runner-demo", 56, "count", pollMs = 0) |
|
| 50 | 62 | var log = (vm.state.value as Load.Ready).value |
|
| … | 14 unchanged lines |