OXYGENHUB

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