OXYGENHUB

Land proposals from the phone (closes #17) e2df27a81e5d

A repository has Issues and Proposals tabs. Proposals list open (with
drafts), landed, closed or all, each saying whether it is ready to land,
how much stands in the way, or that it lands when its checks pass. A
proposal shows the landing card: Land into the target after a
confirmation, land when its checks pass (auto-land on and off), the
hub's blockers in its own words and its reason when it refuses (409);
then its checks (failures first, each opening its run), sealed
approvals and asked reviewers, its changes and description.

Sign-in now asks for repo:write. A phone signed in before reads only
(Session.canWrite), and the landing card says so with Sign in again; a
403 naming the missing scope does the same. HubApi gains proposals,
proposal, checks, land and setAutoLand; fixtures are real answers from
oxygenhub.io (trimmed, two open ones derived). 11 new tests, 74 in all;
lint clean.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key e479032369fc
When
· 2026-10-25 11:22 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
e2df27a81e5ddf1270d497a9d43f8a54d86020fc5f320e34a2e7a64d8b402236
Depends on
0381bd8af55e 1416009b06af 144cbf7bf2b5 3f3508bd5f16 57c0824300db 5ae4af48ddd2 db00632a61d6
Depended on by
263c6cad5e51 aa627197ec18 c53e328b2db4 821035db5a1c d98e5a147428 840c75813f86 6e2af63ae616 1dfa631436b6 af9f2e514c72 40ab4df121e0 db7727e5de19 62429abe5869 dfbc3e972872 1c42a70fcba3 31e4f3755627 98dbd1220929 ff65ea456ff1 4aee5293461d d1f635be9442
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
40 atoms, 10 tree operations, 22 files

Browse the tree at this change

added app/src/main/java/io/oxygenhub/android/ui/proposals/ProposalList.kt +122 −0
1 +
package io.oxygenhub.android.ui.proposals
2 +
3 +
import androidx.compose.foundation.clickable
4 +
import androidx.compose.foundation.horizontalScroll
5 +
import androidx.compose.foundation.layout.Arrangement
6 +
import androidx.compose.foundation.layout.Column
7 +
import androidx.compose.foundation.layout.FlowRow
8 +
import androidx.compose.foundation.layout.Row
9 +
import androidx.compose.foundation.layout.fillMaxSize
10 +
import androidx.compose.foundation.layout.padding
11 +
import androidx.compose.foundation.lazy.LazyColumn
12 +
import androidx.compose.foundation.lazy.items
13 +
import androidx.compose.foundation.rememberScrollState
14 +
import androidx.compose.material.icons.Icons
15 +
import androidx.compose.material.icons.automirrored.outlined.CallMerge
16 +
import androidx.compose.material.icons.outlined.Block
17 +
import androidx.compose.material.icons.outlined.EditNote
18 +
import androidx.compose.material3.FilterChip
19 +
import androidx.compose.material3.HorizontalDivider
20 +
import androidx.compose.material3.Icon
21 +
import androidx.compose.material3.ListItem
22 +
import androidx.compose.material3.MaterialTheme
23 +
import androidx.compose.material3.Text
24 +
import androidx.compose.runtime.Composable
25 +
import androidx.compose.runtime.getValue
26 +
import androidx.compose.ui.Modifier
27 +
import androidx.compose.ui.graphics.Color
28 +
import androidx.compose.ui.graphics.vector.ImageVector
29 +
import androidx.compose.ui.text.style.TextOverflow
30 +
import androidx.compose.ui.unit.dp
31 +
import androidx.lifecycle.compose.collectAsStateWithLifecycle
32 +
import io.oxygenhub.android.api.Proposal
33 +
import io.oxygenhub.android.api.ProposalState
34 +
import io.oxygenhub.android.ui.common.EmptyState
35 +
import io.oxygenhub.android.ui.common.LoadContent
36 +
import io.oxygenhub.android.ui.common.relativeTime
37 +
import io.oxygenhub.android.ui.issues.LabelChip
38 +
import io.oxygenhub.android.ui.theme.LocalStatusColors
39 +
40 +
/** A repository's proposals with their filter, for a tab to hold. */
41 +
@Composable
42 +
fun ProposalList(vm: ProposalsViewModel, onOpen: (Proposal) -> Unit) {
43 +
    val state by vm.state.collectAsStateWithLifecycle()
44 +
    val filter by vm.filter.collectAsStateWithLifecycle()
45 +
    Column(Modifier.fillMaxSize()) {
46 +
        Row(
47 +
            Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp),
48 +
            horizontalArrangement = Arrangement.spacedBy(8.dp),
49 +
        ) {
50 +
            ProposalState.entries.forEach { s ->
51 +
                FilterChip(selected = filter == s, onClick = { vm.setFilter(s) }, label = { Text(s.name) })
52 +
            }
53 +
        }
54 +
        LoadContent(state, onRefresh = vm::refresh) { proposals ->
55 +
            if (proposals.isEmpty()) {
56 +
                EmptyState(
57 +
                    Icons.AutoMirrored.Outlined.CallMerge,
58 +
                    when (filter) {
59 +
                        ProposalState.Open -> "No open proposals"
60 +
                        ProposalState.Landed -> "Nothing landed yet"
61 +
                        ProposalState.Closed -> "No closed proposals"
62 +
                        ProposalState.All -> "No proposals yet"
63 +
                    },
64 +
                    "Proposals for ${vm.owner}/${vm.name} show here.",
65 +
                )
66 +
            } else {
67 +
                LazyColumn(Modifier.fillMaxSize()) {
68 +
                    items(proposals, key = { it.number }) { p ->
69 +
                        ProposalRow(p, onClick = { onOpen(p) })
70 +
                        HorizontalDivider()
71 +
                    }
72 +
                }
73 +
            }
74 +
        }
75 +
    }
76 +
}
77 +
78 +
/** The icon and colour of a proposal's state, as the hub shows them. */
79 +
@Composable
80 +
internal fun stateLook(p: Proposal): Pair<ImageVector, Color> {
81 +
    val status = LocalStatusColors.current
82 +
    return when {
83 +
        p.state == "landed" -> Icons.AutoMirrored.Outlined.CallMerge to status.warn
84 +
        p.state == "closed" -> Icons.Outlined.Block to status.bad
85 +
        p.isDraft -> Icons.Outlined.EditNote to MaterialTheme.colorScheme.onSurfaceVariant
86 +
        else -> Icons.AutoMirrored.Outlined.CallMerge to status.ok
87 +
    }
88 +
}
89 +
90 +
@Composable
91 +
fun ProposalRow(p: Proposal, onClick: () -> Unit) {
92 +
    val status = LocalStatusColors.current
93 +
    val (icon, tint) = stateLook(p)
94 +
    ListItem(
95 +
        modifier = Modifier.clickable(onClick = onClick),
96 +
        leadingContent = { Icon(icon, contentDescription = p.state, tint = tint) },
97 +
        headlineContent = { Text(p.title, maxLines = 2, overflow = TextOverflow.Ellipsis) },
98 +
        supportingContent = {
99 +
            Column {
100 +
                val what = when {
101 +
                    p.landed != null -> "landed ${relativeTime(p.landed.whenMs)} by ${p.landed.by}"
102 +
                    p.state == "closed" -> "closed ${relativeTime(p.updatedMs)}"
103 +
                    else -> "by ${p.author} into ${p.target} · ${relativeTime(p.updatedMs)}"
104 +
                }
105 +
                Text("#${p.number} $what", maxLines = 1, overflow = TextOverflow.Ellipsis)
106 +
                if (p.isOpen && !p.isDraft) {
107 +
                    val (text, colour) = when {
108 +
                        p.landable -> "Ready to land" to status.ok
109 +
                        p.autoLand != null -> "Lands when its checks pass" to MaterialTheme.colorScheme.primary
110 +
                        else -> "${p.blockers.size} to settle before landing" to status.bad
111 +
                    }
112 +
                    Text(text, color = colour, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(top = 2.dp))
113 +
                }
114 +
                if (p.labels.isNotEmpty()) {
115 +
                    FlowRow(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
116 +
                        p.labels.forEach { LabelChip(it) }
117 +
                    }
118 +
                }
119 +
            }
120 +
        },
121 +
    )
122 +
}
added app/src/main/java/io/oxygenhub/android/ui/proposals/ProposalScreen.kt +313 −0
1 +
package io.oxygenhub.android.ui.proposals
2 +
3 +
import androidx.compose.foundation.clickable
4 +
import androidx.compose.foundation.layout.Arrangement
5 +
import androidx.compose.foundation.layout.Box
6 +
import androidx.compose.foundation.layout.Column
7 +
import androidx.compose.foundation.layout.FlowRow
8 +
import androidx.compose.foundation.layout.Row
9 +
import androidx.compose.foundation.layout.Spacer
10 +
import androidx.compose.foundation.layout.fillMaxWidth
11 +
import androidx.compose.foundation.layout.padding
12 +
import androidx.compose.foundation.layout.size
13 +
import androidx.compose.foundation.layout.width
14 +
import androidx.compose.foundation.layout.widthIn
15 +
import androidx.compose.foundation.rememberScrollState
16 +
import androidx.compose.foundation.verticalScroll
17 +
import androidx.compose.material.icons.Icons
18 +
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
19 +
import androidx.compose.material.icons.outlined.Cancel
20 +
import androidx.compose.material.icons.outlined.CheckCircle
21 +
import androidx.compose.material.icons.outlined.Lock
22 +
import androidx.compose.material.icons.outlined.RemoveCircleOutline
23 +
import androidx.compose.material.icons.outlined.Schedule
24 +
import androidx.compose.material.icons.outlined.WarningAmber
25 +
import androidx.compose.material3.AlertDialog
26 +
import androidx.compose.material3.Button
27 +
import androidx.compose.material3.CircularProgressIndicator
28 +
import androidx.compose.material3.ElevatedCard
29 +
import androidx.compose.material3.HorizontalDivider
30 +
import androidx.compose.material3.Icon
31 +
import androidx.compose.material3.IconButton
32 +
import androidx.compose.material3.LinearProgressIndicator
33 +
import androidx.compose.material3.ListItem
34 +
import androidx.compose.material3.MaterialTheme
35 +
import androidx.compose.material3.OutlinedButton
36 +
import androidx.compose.material3.SnackbarHostState
37 +
import androidx.compose.material3.Switch
38 +
import androidx.compose.material3.Text
39 +
import androidx.compose.material3.TextButton
40 +
import androidx.compose.runtime.Composable
41 +
import androidx.compose.runtime.LaunchedEffect
42 +
import androidx.compose.runtime.getValue
43 +
import androidx.compose.runtime.mutableStateOf
44 +
import androidx.compose.runtime.remember
45 +
import androidx.compose.runtime.saveable.rememberSaveable
46 +
import androidx.compose.runtime.setValue
47 +
import androidx.compose.ui.Alignment
48 +
import androidx.compose.ui.Modifier
49 +
import androidx.compose.ui.platform.LocalUriHandler
50 +
import androidx.compose.ui.text.font.FontFamily
51 +
import androidx.compose.ui.text.font.FontWeight
52 +
import androidx.compose.ui.unit.dp
53 +
import androidx.lifecycle.compose.collectAsStateWithLifecycle
54 +
import io.oxygenhub.android.api.Check
55 +
import io.oxygenhub.android.api.Proposal
56 +
import io.oxygenhub.android.ui.common.AppScreen
57 +
import io.oxygenhub.android.ui.common.LoadContent
58 +
import io.oxygenhub.android.ui.common.relativeTime
59 +
import io.oxygenhub.android.ui.issues.LabelChip
60 +
import io.oxygenhub.android.ui.theme.LocalStatusColors
61 +
62 +
/**
63 +
 * One proposal: whether it may land and what stands in the way, in the
64 +
 * hub's words; landing it, or having it land when its checks pass; then
65 +
 * its checks, approvals, changes and description.
66 +
 */
67 +
@Composable
68 +
fun ProposalScreen(vm: ProposalViewModel, canWrite: Boolean, onBack: () -> Unit, onSignInAgain: () -> Unit) {
69 +
    val state by vm.state.collectAsStateWithLifecycle()
70 +
    val action by vm.action.collectAsStateWithLifecycle()
71 +
    val snackbar = remember { SnackbarHostState() }
72 +
    val uri = LocalUriHandler.current
73 +
    val done = action as? LandAction.Done
74 +
    LaunchedEffect(done) {
75 +
        if (done != null) {
76 +
            snackbar.showSnackbar(done.message)
77 +
            vm.dismiss()
78 +
        }
79 +
    }
80 +
    AppScreen(
81 +
        title = "#${vm.number}",
82 +
        subtitle = "${vm.owner}/${vm.name}",
83 +
        onBack = onBack,
84 +
        snackbar = snackbar,
85 +
        actions = {
86 +
            val url = state.valueOrNull?.proposal?.url.orEmpty()
87 +
            if (url.isNotBlank()) {
88 +
                IconButton(onClick = { uri.openUri(url) }) {
89 +
                    Icon(Icons.AutoMirrored.Outlined.OpenInNew, contentDescription = "Open on the hub")
90 +
                }
91 +
            }
92 +
        },
93 +
    ) {
94 +
        LoadContent(state, onRefresh = vm::refresh) { page ->
95 +
            Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) {
96 +
                Column(
97 +
                    Modifier.widthIn(max = 720.dp).verticalScroll(rememberScrollState()).padding(16.dp),
98 +
                    verticalArrangement = Arrangement.spacedBy(16.dp),
99 +
                ) {
100 +
                    Header(page.proposal)
101 +
                    Landing(page.proposal, action, canWrite, vm::land, vm::setAutoLand, onSignInAgain, vm::dismiss)
102 +
                    Checks(page.checks) { if (it.url.isNotBlank()) uri.openUri(it.url) }
103 +
                    Reviews(page.proposal)
104 +
                    Changes(page.proposal)
105 +
                    if (page.proposal.body.isNotBlank()) {
106 +
                        Section("Description") {
107 +
                            Text(page.proposal.body, modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.bodyMedium)
108 +
                        }
109 +
                    }
110 +
                }
111 +
            }
112 +
        }
113 +
    }
114 +
}
115 +
116 +
@Composable
117 +
private fun Header(p: Proposal) {
118 +
    val (icon, tint) = stateLook(p)
119 +
    Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
120 +
        Row(verticalAlignment = Alignment.CenterVertically) {
121 +
            Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(18.dp))
122 +
            Spacer(Modifier.width(6.dp))
123 +
            Text(p.state.replaceFirstChar { it.uppercase() }, color = tint, style = MaterialTheme.typography.labelLarge)
124 +
        }
125 +
        Text(p.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
126 +
        val n = p.changes.size
127 +
        Text(
128 +
            "${p.author} wants to land $n change${if (n == 1) "" else "s"} into ${p.target} · updated ${relativeTime(p.updatedMs)}",
129 +
            style = MaterialTheme.typography.bodyMedium,
130 +
            color = MaterialTheme.colorScheme.onSurfaceVariant,
131 +
        )
132 +
        if (p.labels.isNotEmpty()) {
133 +
            FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
134 +
                p.labels.forEach { LabelChip(it) }
135 +
            }
136 +
        }
137 +
    }
138 +
}
139 +
140 +
@Composable
141 +
private fun Landing(
142 +
    p: Proposal,
143 +
    action: LandAction,
144 +
    canWrite: Boolean,
145 +
    onLand: () -> Unit,
146 +
    onAutoLand: (Boolean) -> Unit,
147 +
    onSignInAgain: () -> Unit,
148 +
    onDismiss: () -> Unit,
149 +
) {
150 +
    val status = LocalStatusColors.current
151 +
    var confirming by rememberSaveable { mutableStateOf(false) }
152 +
    val working = action == LandAction.Working
153 +
    ElevatedCard(Modifier.fillMaxWidth()) {
154 +
        Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
155 +
            when {
156 +
                p.landed != null -> Text(
157 +
                    "Landed ${relativeTime(p.landed.whenMs)} by ${p.landed.by}${if (p.landed.via.isNotBlank()) " (${p.landed.via})" else ""}.",
158 +
                    style = MaterialTheme.typography.titleMedium,
159 +
                )
160 +
                !p.isOpen -> Text("Closed without landing.", style = MaterialTheme.typography.titleMedium)
161 +
                else -> {
162 +
                    if (p.landable) {
163 +
                        Text("Ready to land", style = MaterialTheme.typography.titleMedium, color = status.ok)
164 +
                    } else {
165 +
                        Text("Not ready to land", style = MaterialTheme.typography.titleMedium)
166 +
                        p.blockers.forEach { b ->
167 +
                            Row(verticalAlignment = Alignment.Top) {
168 +
                                Icon(Icons.Outlined.WarningAmber, contentDescription = null, tint = status.bad, modifier = Modifier.size(18.dp).padding(top = 2.dp))
169 +
                                Spacer(Modifier.width(8.dp))
170 +
                                Text(b, style = MaterialTheme.typography.bodyMedium)
171 +
                            }
172 +
                        }
173 +
                    }
174 +
                    if (!canWrite) {
175 +
                        Text(
176 +
                            "This phone was signed in to read only. Sign in again to allow landing.",
177 +
                            style = MaterialTheme.typography.bodyMedium,
178 +
                            color = MaterialTheme.colorScheme.onSurfaceVariant,
179 +
                        )
180 +
                        OutlinedButton(onClick = onSignInAgain) { Text("Sign in again") }
181 +
                    } else {
182 +
                        Button(onClick = { confirming = true }, enabled = p.landable && !working, modifier = Modifier.fillMaxWidth()) {
183 +
                            Text("Land into ${p.target}")
184 +
                        }
185 +
                        Row(verticalAlignment = Alignment.CenterVertically) {
186 +
                            Column(Modifier.weight(1f)) {
187 +
                                Text("Land when its checks pass", style = MaterialTheme.typography.bodyLarge)
188 +
                                Text(
189 +
                                    p.autoLand?.let { "Set by $it" } ?: "Lands by itself once nothing stands in the way.",
190 +
                                    style = MaterialTheme.typography.bodySmall,
191 +
                                    color = MaterialTheme.colorScheme.onSurfaceVariant,
192 +
                                )
193 +
                            }
194 +
                            Switch(checked = p.autoLand != null, onCheckedChange = onAutoLand, enabled = !working && !p.isDraft)
195 +
                        }
196 +
                    }
197 +
                }
198 +
            }
199 +
            if (working) LinearProgressIndicator(Modifier.fillMaxWidth())
200 +
            if (action is LandAction.Failed) {
201 +
                Text(action.message, color = status.bad, style = MaterialTheme.typography.bodyMedium)
202 +
                Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
203 +
                    if (action.needsWrite) OutlinedButton(onClick = onSignInAgain) { Text("Sign in again") }
204 +
                    TextButton(onClick = onDismiss) { Text("OK") }
205 +
                }
206 +
            }
207 +
        }
208 +
    }
209 +
    if (confirming) {
210 +
        AlertDialog(
211 +
            onDismissRequest = { confirming = false },
212 +
            title = { Text("Land #${p.number}?") },
213 +
            text = { Text("“${p.title}” lands into ${p.target} on the hub, as LAND on its page does.") },
214 +
            confirmButton = {
215 +
                TextButton(onClick = {
216 +
                    confirming = false
217 +
                    onLand()
218 +
                }) { Text("Land") }
219 +
            },
220 +
            dismissButton = { TextButton(onClick = { confirming = false }) { Text("Cancel") } },
221 +
        )
222 +
    }
223 +
}
224 +
225 +
@Composable
226 +
private fun Checks(checks: List<Check>?, onOpen: (Check) -> Unit) {
227 +
    val status = LocalStatusColors.current
228 +
    Section(
229 +
        when {
230 +
            checks == null -> "Checks"
231 +
            checks.isEmpty() -> "Checks"
232 +
            else -> "Checks · ${checks.count { it.status == "passed" }} of ${checks.size} passed"
233 +
        },
234 +
    ) {
235 +
        when {
236 +
            checks == null -> Text("The checks could not be read.", Modifier.padding(16.dp))
237 +
            checks.isEmpty() -> Text("No checks have run on what it would land.", Modifier.padding(16.dp))
238 +
            else -> checks.sortedBy { order(it.status) }.forEachIndexed { i, c ->
239 +
                if (i > 0) HorizontalDivider()
240 +
                ListItem(
241 +
                    modifier = Modifier.clickable { onOpen(c) },
242 +
                    leadingContent = {
243 +
                        when (c.status) {
244 +
                            "passed" -> Icon(Icons.Outlined.CheckCircle, "Passed", tint = status.ok)
245 +
                            "failed" -> Icon(Icons.Outlined.Cancel, "Failed", tint = status.bad)
246 +
                            "running" -> CircularProgressIndicator(Modifier.size(22.dp), strokeWidth = 2.dp)
247 +
                            "queued" -> Icon(Icons.Outlined.Schedule, "Queued", tint = MaterialTheme.colorScheme.onSurfaceVariant)
248 +
                            else -> Icon(Icons.Outlined.RemoveCircleOutline, c.status, tint = MaterialTheme.colorScheme.onSurfaceVariant)
249 +
                        }
250 +
                    },
251 +
                    headlineContent = { Text(c.name) },
252 +
                    supportingContent = if (c.summary.isNotBlank()) ({ Text(c.summary) }) else null,
253 +
                )
254 +
            }
255 +
        }
256 +
    }
257 +
}
258 +
259 +
/** Failures first, then what is still going, then the rest. */
260 +
private fun order(status: String): Int = when (status) {
261 +
    "failed" -> 0
262 +
    "running" -> 1
263 +
    "queued" -> 2
264 +
    else -> 3
265 +
}
266 +
267 +
@Composable
268 +
private fun Reviews(p: Proposal) {
269 +
    if (p.approvals.isEmpty() && p.requestedReviewers.isEmpty()) return
270 +
    Section("Reviews") {
271 +
        Column(Modifier.padding(vertical = 4.dp)) {
272 +
            p.approvals.forEach { a ->
273 +
                ListItem(
274 +
                    leadingContent = { Icon(Icons.Outlined.CheckCircle, null, tint = LocalStatusColors.current.ok) },
275 +
                    headlineContent = { Text(a.account) },
276 +
                    supportingContent = { Text(if (a.sealed) "approved, sealed" else "approved, not sealed: it does not count yet") },
277 +
                    trailingContent = if (a.sealed) ({ Icon(Icons.Outlined.Lock, "Sealed") }) else null,
278 +
                )
279 +
            }
280 +
            p.requestedReviewers.forEach { r ->
281 +
                ListItem(
282 +
                    leadingContent = { Icon(Icons.Outlined.Schedule, null) },
283 +
                    headlineContent = { Text(r) },
284 +
                    supportingContent = { Text("asked to review") },
285 +
                )
286 +
            }
287 +
        }
288 +
    }
289 +
}
290 +
291 +
@Composable
292 +
private fun Changes(p: Proposal) {
293 +
    if (p.changes.isEmpty()) return
294 +
    Section("Changes · ${p.changes.size}") {
295 +
        Column {
296 +
            p.changes.asReversed().forEachIndexed { i, c ->
297 +
                if (i > 0) HorizontalDivider()
298 +
                ListItem(
299 +
                    headlineContent = { Text(c.subject.ifBlank { "(no message)" }) },
300 +
                    supportingContent = { Text(c.hash.take(12), fontFamily = FontFamily.Monospace) },
301 +
                )
302 +
            }
303 +
        }
304 +
    }
305 +
}
306 +
307 +
@Composable
308 +
private fun Section(title: String, content: @Composable () -> Unit) {
309 +
    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
310 +
        Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
311 +
        ElevatedCard(Modifier.fillMaxWidth()) { content() }
312 +
    }
313 +
}
added app/src/main/java/io/oxygenhub/android/ui/proposals/ProposalViewModel.kt +121 −0
1 +
package io.oxygenhub.android.ui.proposals
2 +
3 +
import androidx.lifecycle.ViewModel
4 +
import androidx.lifecycle.viewModelScope
5 +
import io.oxygenhub.android.api.Check
6 +
import io.oxygenhub.android.api.HubApi
7 +
import io.oxygenhub.android.api.HubException
8 +
import io.oxygenhub.android.api.Proposal
9 +
import io.oxygenhub.android.api.WriteScope
10 +
import io.oxygenhub.android.api.lackedScope
11 +
import io.oxygenhub.android.api.userMessage
12 +
import io.oxygenhub.android.ui.common.Load
13 +
import io.oxygenhub.android.ui.common.load
14 +
import kotlinx.coroutines.CancellationException
15 +
import kotlinx.coroutines.Job
16 +
import kotlinx.coroutines.async
17 +
import kotlinx.coroutines.coroutineScope
18 +
import kotlinx.coroutines.flow.MutableStateFlow
19 +
import kotlinx.coroutines.flow.StateFlow
20 +
import kotlinx.coroutines.flow.asStateFlow
21 +
import kotlinx.coroutines.launch
22 +
23 +
/** A proposal and the checks on what it would land; [checks] is null when they could not be read. */
24 +
data class ProposalPage(val proposal: Proposal, val checks: List<Check>?)
25 +
26 +
/** What landing (or setting it to land by itself) is doing, or did. */
27 +
sealed interface LandAction {
28 +
    data object Idle : LandAction
29 +
    data object Working : LandAction
30 +
    data class Done(val message: String) : LandAction
31 +
32 +
    /** [needsWrite]: the token may not change things; signing in again asks for it. */
33 +
    data class Failed(val message: String, val needsWrite: Boolean = false) : LandAction
34 +
}
35 +
36 +
/** One proposal, with its checks; lands it, or sets it to land when its checks pass. */
37 +
class ProposalViewModel(
38 +
    private val api: HubApi,
39 +
    val owner: String,
40 +
    val name: String,
41 +
    val number: Long,
42 +
) : ViewModel() {
43 +
    private val _state = MutableStateFlow<Load<ProposalPage>>(Load.Loading)
44 +
    val state: StateFlow<Load<ProposalPage>> = _state.asStateFlow()
45 +
46 +
    private val _action = MutableStateFlow<LandAction>(LandAction.Idle)
47 +
    val action: StateFlow<LandAction> = _action.asStateFlow()
48 +
49 +
    private var job: Job? = null
50 +
51 +
    init {
52 +
        refresh()
53 +
    }
54 +
55 +
    fun refresh() {
56 +
        job = load(_state, job) {
57 +
            coroutineScope {
58 +
                // The checks are part of the picture, not the page: without them it still shows.
59 +
                val checks = async {
60 +
                    try {
61 +
                        api.checks(owner, name, number)
62 +
                    } catch (e: CancellationException) {
63 +
                        throw e
64 +
                    } catch (_: Exception) {
65 +
                        null
66 +
                    }
67 +
                }
68 +
                ProposalPage(api.proposal(owner, name, number), checks.await())
69 +
            }
70 +
        }
71 +
    }
72 +
73 +
    fun land() = act { p ->
74 +
        val landed = api.land(owner, name, number)
75 +
        landed to "Landed #$number into ${p.target}."
76 +
    }
77 +
78 +
    fun setAutoLand(on: Boolean) = act { _ ->
79 +
        val after = api.setAutoLand(owner, name, number, on)
80 +
        after to if (on) {
81 +
            if (after.landed != null) "Its checks had passed: landed #$number." else "#$number will land when its checks pass."
82 +
        } else {
83 +
            "#$number will not land by itself."
84 +
        }
85 +
    }
86 +
87 +
    fun dismiss() {
88 +
        _action.value = LandAction.Idle
89 +
    }
90 +
91 +
    private fun act(work: suspend (Proposal) -> Pair<Proposal, String>) {
92 +
        val page = _state.value.valueOrNull ?: return
93 +
        if (_action.value == LandAction.Working) return
94 +
        _action.value = LandAction.Working
95 +
        viewModelScope.launch {
96 +
            _action.value = try {
97 +
                val (after, message) = work(page.proposal)
98 +
                _state.value = Load.Ready(page.copy(proposal = after))
99 +
                LandAction.Done(message)
100 +
            } catch (e: CancellationException) {
101 +
                throw e
102 +
            } catch (e: Exception) {
103 +
                failure(e)
104 +
            }
105 +
            // What the hub thinks now: blockers, checks, who set auto-land.
106 +
            refresh()
107 +
        }
108 +
    }
109 +
110 +
    companion object {
111 +
        fun failure(e: Exception): LandAction.Failed = when {
112 +
            e.lackedScope == WriteScope -> LandAction.Failed(
113 +
                "This phone was signed in to read only. Sign in again to allow landing.",
114 +
                needsWrite = true,
115 +
            )
116 +
            // The hub's own reason: blockers, a conflict, a race with another landing.
117 +
            e is HubException.Http && e.status == 409 -> LandAction.Failed(e.detail)
118 +
            else -> LandAction.Failed(e.userMessage())
119 +
        }
120 +
    }
121 +
}
added app/src/main/java/io/oxygenhub/android/ui/proposals/ProposalsViewModel.kt +43 −0
1 +
package io.oxygenhub.android.ui.proposals
2 +
3 +
import androidx.lifecycle.ViewModel
4 +
import io.oxygenhub.android.api.HubApi
5 +
import io.oxygenhub.android.api.Proposal
6 +
import io.oxygenhub.android.api.ProposalState
7 +
import io.oxygenhub.android.ui.common.Load
8 +
import io.oxygenhub.android.ui.common.load
9 +
import kotlinx.coroutines.Job
10 +
import kotlinx.coroutines.flow.MutableStateFlow
11 +
import kotlinx.coroutines.flow.StateFlow
12 +
import kotlinx.coroutines.flow.asStateFlow
13 +
14 +
/** A repository's proposals: open (with drafts), landed, closed or all; newest first. */
15 +
class ProposalsViewModel(
16 +
    private val api: HubApi,
17 +
    val owner: String,
18 +
    val name: String,
19 +
) : ViewModel() {
20 +
    private val _state = MutableStateFlow<Load<List<Proposal>>>(Load.Loading)
21 +
    val state: StateFlow<Load<List<Proposal>>> = _state.asStateFlow()
22 +
23 +
    private val _filter = MutableStateFlow(ProposalState.Open)
24 +
    val filter: StateFlow<ProposalState> = _filter.asStateFlow()
25 +
26 +
    private var job: Job? = null
27 +
28 +
    init {
29 +
        refresh()
30 +
    }
31 +
32 +
    fun setFilter(state: ProposalState) {
33 +
        if (state == _filter.value) return
34 +
        _filter.value = state
35 +
        _state.value = Load.Loading
36 +
        refresh()
37 +
    }
38 +
39 +
    fun refresh() {
40 +
        val state = _filter.value
41 +
        job = load(_state, job) { api.proposals(owner, name, state).sortedByDescending { it.number } }
42 +
    }
43 +
}
added app/src/main/java/io/oxygenhub/android/ui/repos/RepositoryScreen.kt +46 −0
1 +
package io.oxygenhub.android.ui.repos
2 +
3 +
import androidx.compose.foundation.layout.Column
4 +
import androidx.compose.foundation.layout.fillMaxSize
5 +
import androidx.compose.material3.PrimaryTabRow
6 +
import androidx.compose.material3.Tab
7 +
import androidx.compose.material3.Text
8 +
import androidx.compose.runtime.Composable
9 +
import androidx.compose.runtime.getValue
10 +
import androidx.compose.runtime.mutableIntStateOf
11 +
import androidx.compose.runtime.saveable.rememberSaveable
12 +
import androidx.compose.runtime.setValue
13 +
import androidx.compose.ui.Modifier
14 +
import io.oxygenhub.android.api.Proposal
15 +
import io.oxygenhub.android.ui.common.AppScreen
16 +
import io.oxygenhub.android.ui.issues.IssueList
17 +
import io.oxygenhub.android.ui.issues.IssuesViewModel
18 +
import io.oxygenhub.android.ui.proposals.ProposalList
19 +
import io.oxygenhub.android.ui.proposals.ProposalsViewModel
20 +
21 +
/** A repository's tabs: its issues and its proposals (the overview, code and history follow, #3). */
22 +
enum class RepositoryTab(val label: String) { Issues("Issues"), Proposals("Proposals") }
23 +
24 +
@Composable
25 +
fun RepositoryScreen(
26 +
    issues: IssuesViewModel,
27 +
    proposals: ProposalsViewModel,
28 +
    onBack: (() -> Unit)?,
29 +
    onOpenProposal: (Proposal) -> Unit,
30 +
    initialTab: RepositoryTab = RepositoryTab.Issues,
31 +
) {
32 +
    var tab by rememberSaveable { mutableIntStateOf(initialTab.ordinal) }
33 +
    AppScreen(title = "${issues.owner}/${issues.name}", onBack = onBack) {
34 +
        Column(Modifier.fillMaxSize()) {
35 +
            PrimaryTabRow(selectedTabIndex = tab) {
36 +
                RepositoryTab.entries.forEach { t ->
37 +
                    Tab(selected = tab == t.ordinal, onClick = { tab = t.ordinal }, text = { Text(t.label) })
38 +
                }
39 +
            }
40 +
            when (RepositoryTab.entries[tab]) {
41 +
                RepositoryTab.Issues -> IssueList(issues)
42 +
                RepositoryTab.Proposals -> ProposalList(proposals, onOpenProposal)
43 +
            }
44 +
        }
45 +
    }
46 +
}
added app/src/test/java/io/oxygenhub/android/ui/ProposalViewModelTest.kt +104 −0
1 +
package io.oxygenhub.android.ui
2 +
3 +
import io.oxygenhub.android.FakeHubApi
4 +
import io.oxygenhub.android.MainDispatcherRule
5 +
import io.oxygenhub.android.api.HubException
6 +
import io.oxygenhub.android.api.ProposalState
7 +
import io.oxygenhub.android.auth.Session
8 +
import io.oxygenhub.android.ui.common.Load
9 +
import io.oxygenhub.android.ui.proposals.LandAction
10 +
import io.oxygenhub.android.ui.proposals.ProposalPage
11 +
import io.oxygenhub.android.ui.proposals.ProposalViewModel
12 +
import io.oxygenhub.android.ui.proposals.ProposalsViewModel
13 +
import org.junit.Assert.assertEquals
14 +
import org.junit.Assert.assertFalse
15 +
import org.junit.Assert.assertNull
16 +
import org.junit.Assert.assertTrue
17 +
import org.junit.Rule
18 +
import org.junit.Test
19 +
20 +
class ProposalViewModelTest {
21 +
    @get:Rule val main = MainDispatcherRule()
22 +
23 +
    private val api = FakeHubApi()
24 +
25 +
    private fun page(vm: ProposalViewModel): ProposalPage = (vm.state.value as Load.Ready).value
26 +
27 +
    @Test
28 +
    fun listsOpenProposalsNewestFirstAndTheLandedOnAsked() {
29 +
        val vm = ProposalsViewModel(api, "andymccall", "oxygenhub")
30 +
        assertEquals(listOf(301L, 300L), (vm.state.value as Load.Ready).value.map { it.number })
31 +
        vm.setFilter(ProposalState.Landed)
32 +
        assertEquals(listOf(264L), (vm.state.value as Load.Ready).value.map { it.number })
33 +
        assertTrue("proposals andymccall/oxygenhub landed" in api.calls)
34 +
    }
35 +
36 +
    @Test
37 +
    fun showsAProposalWithItsChecks() {
38 +
        val p = page(ProposalViewModel(api, "andymccall", "oxygenhub", 300))
39 +
        assertTrue(p.proposal.landable)
40 +
        assertEquals(5, p.checks?.size)
41 +
    }
42 +
43 +
    @Test
44 +
    fun withoutItsChecksTheProposalStillShows() {
45 +
        api.checks = { throw HubException.Http(500, "down") }
46 +
        val p = page(ProposalViewModel(api, "andymccall", "oxygenhub", 300))
47 +
        assertNull(p.checks)
48 +
        assertEquals(300L, p.proposal.number)
49 +
    }
50 +
51 +
    @Test
52 +
    fun landingSaysSoAndShowsItLanded() {
53 +
        val vm = ProposalViewModel(api, "andymccall", "oxygenhub", 300)
54 +
        // The hub's answer after landing, as the refresh will read it.
55 +
        val landed = api.land(300)
56 +
        api.proposal = { landed }
57 +
        vm.land()
58 +
        assertEquals(LandAction.Done("Landed #300 into main."), vm.action.value)
59 +
        assertEquals("landed", page(vm).proposal.state)
60 +
        assertTrue("land 300" in api.calls)
61 +
    }
62 +
63 +
    @Test
64 +
    fun aRefusalShowsTheHubsReason() {
65 +
        api.land = { throw HubException.Http(409, "required checks not passed on the projected state: test") }
66 +
        val vm = ProposalViewModel(api, "andymccall", "oxygenhub", 300)
67 +
        vm.land()
68 +
        assertEquals(LandAction.Failed("required checks not passed on the projected state: test"), vm.action.value)
69 +
        assertEquals("open", page(vm).proposal.state)
70 +
        vm.dismiss()
71 +
        assertEquals(LandAction.Idle, vm.action.value)
72 +
    }
73 +
74 +
    @Test
75 +
    fun aReadOnlyTokenIsAskedToSignInAgain() {
76 +
        api.land = { throw HubException.Http(403, "this token lacks the repo:write scope") }
77 +
        val vm = ProposalViewModel(api, "andymccall", "oxygenhub", 300)
78 +
        vm.land()
79 +
        val f = vm.action.value as LandAction.Failed
80 +
        assertTrue(f.needsWrite)
81 +
        // Another 403 is not about the scope.
82 +
        assertFalse(ProposalViewModel.failure(HubException.Http(403, "only maintainers may land over checks")).needsWrite)
83 +
    }
84 +
85 +
    @Test
86 +
    fun autoLandIsSetAndCleared() {
87 +
        val vm = ProposalViewModel(api, "andymccall", "oxygenhub", 301)
88 +
        assertEquals("andymccall", page(vm).proposal.autoLand)
89 +
        val cleared = api.autoLand(301, false)
90 +
        api.proposal = { cleared }
91 +
        vm.setAutoLand(false)
92 +
        assertEquals(LandAction.Done("#301 will not land by itself."), vm.action.value)
93 +
        assertNull(page(vm).proposal.autoLand)
94 +
        assertTrue("auto-land 301 false" in api.calls)
95 +
    }
96 +
97 +
    @Test
98 +
    fun aSessionMayWriteOnlyWithTheScope() {
99 +
        assertTrue(Session("h", "u", "t", listOf("repo:read", "repo:write")).canWrite)
100 +
        assertFalse(Session("h", "u", "t", listOf("repo:read", "user:read")).canWrite)
101 +
        // The hub did not say: let it decide.
102 +
        assertTrue(Session("h", "u", "t").canWrite)
103 +
    }
104 +
}
added app/src/test/resources/fixtures/checks.json +57 −0
1 +
[
2 +
 {
3 +
  "state": "260f02ef1913677e77b72d5b40cbaf6b02def0d2b2bf8aca064e2e19b075c3e9",
4 +
  "name": "flow/build",
5 +
  "status": "passed",
6 +
  "summary": "passed in 7 min 32 s",
7 +
  "url": "https://oxygenhub.io/andymccall/oxygenhub/flow/439",
8 +
  "reporter": "",
9 +
  "seal": null,
10 +
  "started_ms": 1792917458200,
11 +
  "finished_ms": 1792917926675
12 +
 },
13 +
 {
14 +
  "state": "260f02ef1913677e77b72d5b40cbaf6b02def0d2b2bf8aca064e2e19b075c3e9",
15 +
  "name": "flow/clippy",
16 +
  "status": "failed",
17 +
  "summary": "failed in 1 min 20 s",
18 +
  "url": "https://oxygenhub.io/andymccall/oxygenhub/flow/439",
19 +
  "reporter": "",
20 +
  "seal": null,
21 +
  "started_ms": 1792917458202,
22 +
  "finished_ms": 1792917553195
23 +
 },
24 +
 {
25 +
  "state": "260f02ef1913677e77b72d5b40cbaf6b02def0d2b2bf8aca064e2e19b075c3e9",
26 +
  "name": "flow/fmt",
27 +
  "status": "running",
28 +
  "summary": "running for 3 min",
29 +
  "url": "https://oxygenhub.io/andymccall/oxygenhub/flow/439",
30 +
  "reporter": "",
31 +
  "seal": null,
32 +
  "started_ms": 1792917458198,
33 +
  "finished_ms": null
34 +
 },
35 +
 {
36 +
  "state": "260f02ef1913677e77b72d5b40cbaf6b02def0d2b2bf8aca064e2e19b075c3e9",
37 +
  "name": "flow/test",
38 +
  "status": "passed",
39 +
  "summary": "passed in 4 min 33 s",
40 +
  "url": "https://oxygenhub.io/andymccall/oxygenhub/flow/439",
41 +
  "reporter": "",
42 +
  "seal": null,
43 +
  "started_ms": 1792917458204,
44 +
  "finished_ms": 1792917827616
45 +
 },
46 +
 {
47 +
  "state": "260f02ef1913677e77b72d5b40cbaf6b02def0d2b2bf8aca064e2e19b075c3e9",
48 +
  "name": "flow/test-postgres",
49 +
  "status": "passed",
50 +
  "summary": "passed in 4 min 56 s",
51 +
  "url": "https://oxygenhub.io/andymccall/oxygenhub/flow/439",
52 +
  "reporter": "",
53 +
  "seal": null,
54 +
  "started_ms": 1792917458205,
55 +
  "finished_ms": 1792918141806
56 +
 }
57 +
]
added app/src/test/resources/fixtures/proposal.json +116 −0
1 +
{
2 +
 "number": 300,
3 +
 "title": "Show a column's archived cards",
4 +
 "body": "Closes #263 (reported by RSTAR: a landed proposal's queued run kept its place ahead of main's, seven times on 2026-10-18).\n\nOn landing, `flow::moot_after_landing` cancels the proposal's runs that **no runner has started**:\n- its source bookmark's `push` runs, when the source is a bookmark in this repository and not the target;\n- its `proposal/N` runs (the re-check after the target moved).\n\nEach job says `moot: proposal #N landed`. Runs already going finish; other bookmarks' runs are untouched. Called from every landing path: `land_as` (web, API, auto-land), `settle_after_push` (a push that holds the work), and the page finding it landed by another route.\n\nTests: `landing_cancels_the_proposals_unstarted_runs` (queued source run and proposal run cancelled with the reason, a running one left, another bookmark's left). Gate green (fmt, clippy, 284 tests); proposals tests green on Postgres. No migration.",
5 +
 "author": "andymccall",
6 +
 "state": "open",
7 +
 "target": "main",
8 +
 "source": {
9 +
  "kind": "bookmark",
10 +
  "bookmark": "cancel-landed-runs"
11 +
 },
12 +
 "changes": [
13 +
  {
14 +
   "hash": "2abea30809ddf37d992b1ab5743b0f2b0c7daa1ddef9ecc1bd93864948816602",
15 +
   "message": "Cancel a landed proposal's runs that have not started\n\nThey can no longer change anything, and they held the queue ahead of the\ntarget's own run (refs #263). On landing (web, API, auto-land, a push\nthat holds the work, or the page finding it landed by another route),\nflow::moot_after_landing cancels the proposal's queued runs: its source\nbookmark's push runs, when that bookmark is in this repository, and its\nproposal/N runs, each job saying \"moot: proposal #N landed\". Runs that\nhave started finish. Test landing_cancels_the_proposals_unstarted_runs.",
16 +
   "author": {
17 +
    "name": "Andy McCall",
18 +
    "email": "[email protected]",
19 +
    "key": "4cd9916c150f426895a390868c6999314e0ef4955d657f30d2805647c747fc65",
20 +
    "account": null,
21 +
    "note": "signed by a key not linked to any account; its owner links it under Settings \u2192 Keys (/settings/keys)"
22 +
   },
23 +
   "when_ms": 1792360184287,
24 +
   "deps": [
25 +
    "0b20f1ec4007b9c864a8cebcb8945898b88c391cc0c6d85f8f58aeb60f685b47",
26 +
    "2a8b621676b42cbc143f985eb3226e83d515504dda995db118836ab6beb672f6"
27 +
   ],
28 +
   "head": false,
29 +
   "signature": {
30 +
    "status": "unsigned"
31 +
   }
32 +
  }
33 +
 ],
34 +
 "projected_heads": [
35 +
  "021f36a627f32602d06536c414c44f9c7448e2424a05286b46da4804baa53591",
36 +
  "0cd118748131b9e7e98a2c9b7b50df8ebcbda337097e6b2d61110408cc4d9b33",
37 +
  "19355e2283aba765a8a52394216f332db76663c2c0f5c31719803c2a72ad2b55",
38 +
  "1966661b1880eb65b7662730b9e609f06d0cbe16dddba1e9a8be8599f5296079",
39 +
  "19c71c828f86cc7dbcdd8d0c8d34d9f470b90a45d4bc384686498305b0f6b33e",
40 +
  "1b770a1fecf00ba25f4dec05fffd399e8805234618e48ded335d64c955bb5374",
41 +
  "1d9702887eee79babadd1a7aa8314ef4500def25217c5137a4ee5587f8be3db0",
42 +
  "1e23d0105921f536b1cf0483972849e0255efeb89b7f53656e36d2a8839ca14d",
43 +
  "20c36ed92b6b052a02ef585998dc7e42b857a014d92920c913f3507ba345a620",
44 +
  "260c33b4b6c4d8d23000b3d146fed71ec748a95dc6fc058119ed2a7c1627d31f",
45 +
  "2bbdc0268c23bb07c0fd0ea8922eb70e8bfa18da6bb303ec4a59e7ab5dae7fe2",
46 +
  "2d58f17e728b4125bbd7b5d2be9c692536e336f53bd3b34dc56f0361b38b50df",
47 +
  "317d776c725b16fe9ed5fd849f2fcb21afdd53bdce4d1c1eca038fc90f44e426",
48 +
  "32cfb089f5e6a8eede757776fa39cf1fe98594bda3c89cfc867f07d768d7b510",
49 +
  "3a01077da29aabb3ce6154e495f4d2064756cdef5094f011eb0da88e1fe60687",
50 +
  "3e5cd2eba62654482a1214b1c387ffce03fa802047622660d17878689e42007f",
51 +
  "439ecd8708a13ffd255537fab186ab2b3f49ef9f6a8684d55b0b294d434d6ab7",
52 +
  "4745b9844987f696f39f21d7036d00f92adec2b9a507e740fdb7bd28f5fd1d72",
53 +
  "5436f0dd942938b88d95fa7451994bfec9477a11cea9c9d15df6854bd9f5a828",
54 +
  "56a786aa42f1e2a8c6da70e1acbc76edc81a8c746294d06d9c1be6fa6725e2bf",
55 +
  "6978fd76a0666e56bc28171db3f8add9e346ebe90497646c92b8ab10c99a28dc",
56 +
  "6ff755541a622b944b170f3a83dfb95ecf972a4387dd77ff481a34df28e05e82",
57 +
  "72cde3da4176aff69eecc9cc4a625aa277dc22cd4cd5728ab1e18d0646135100",
58 +
  "73beb84b202a8785d9caf0bb65e04c2feb6ae60cea0d5779133cba332a033cb8",
59 +
  "76f93eaefe451cb3fe2daefccf8fd5b8c3bbb071a030a6876459bcde7ac54664",
60 +
  "92cea629fdf6614fe819c850b117a530402d079bc5dfc690ddacb0fe044fd336",
61 +
  "967ef74f6fd9847b2d48a7a20b27bade903fe122f34d4eab7e48efb5242a0020",
62 +
  "9991530b4beee381b95233964971965ad66ac833968f4c8f1fdbf4f1c2fb1713",
63 +
  "9aee52d392a67323824e269c3f0ffdf4c0e066e2bba44bd4d9204368f3a034a1",
64 +
  "9bc6b4720fc12185874a9710b310ddb36d2625cd86d653d23783b960915ef19d",
65 +
  "a0dd06b0094eb52d4347f6bde0572de667a0d2cbac5878d9e6928ec3bba09947",
66 +
  "a2b29af1ce88b4649df2c55022862b05a3c1079a0a325a5bd4ab6764b473cbd5",
67 +
  "a2ef53c5db269fa8bc86bb8abb13bb10ccba916129a301ca8738cb4829ff6680",
68 +
  "a7c05940ebc4ba5c73eb14afcc7579680e1cdb53995e464db10d617e055c5d57",
69 +
  "ad01845c4bf86859c3072a48277436580de78bbfe6ea6bc85de9b350522bf92b",
70 +
  "ae138eea7a71055eed1bdb63b016828e6bbe2de7ff2b8b06a741ebeaa759e950",
71 +
  "bd630386001ed3d0fc0d03c01548c193a2297f90b6fedb00b5883259f9ca1ad0",
72 +
  "bd91edeb44f8f318af68521ca74c35ee48f09e1002ce0c5972bc8ac10cea6862",
73 +
  "c21ef37dd4a0641cafb7fe4cf430093934e416d10b582d4183d9c30f722f42e5",
74 +
  "c2732e07323b52f2491200ad8989352bc6fc6834c4d97a21e550b3abbe0acafb",
75 +
  "c33dbd9a0d38f03313995c1259c16adbd34571f06efe1a71438b51783f1d7b36",
76 +
  "cefb0ea24511445166d0db129929255276092d452c7832e877235b49fcdc5881",
77 +
  "d2077a57b1f9a114ebdee7cf9e95e81f8932ebc82b59b4b126664a6b1941260b",
78 +
  "d2c1d6a7d12edeaca968546dc2fde25068f3de0afaee844ff11d5afd514e43eb",
79 +
  "d336629ffa906b6783adf44337015e3c3d9d4437a6a4f97734367f903674db51",
80 +
  "d4a0043fded4b0533c2f0377373bce57e38279a2b3935cdc62be08123505630b",
81 +
  "d94449e42718409b41ab98335c6641b2b4429332e8a1cab535e76467509986b4",
82 +
  "d9b3b84605a4f30f778d73011e379e8178f33d8d2c6d3d6704c7f5f7e108d5f7",
83 +
  "db72849cd22e82e6160e21528548f9766cf4b10867415ce1b2fe177bfa05e71f",
84 +
  "dc71378db1dc95169f80a85c58c80bc86dbd44546a90607b3a49297ec2e1843e",
85 +
  "df68c58de9471dd2f1eada82669cb2adee804c70fd2507876790c00f10edf73f",
86 +
  "e1078e99c5604bb12c431c02dacb6fba8ea2a0515abe39b3accedd23c78af9ac",
87 +
  "e15409084ed6de68f460f830d74c15458dcee9ab20a6c8dd39145d04a895d54e",
88 +
  "edcef27575a73e6acece529d1c0d3bb9f3509123de1a2c049ef4c0dd28b38bba",
89 +
  "efb51b39db1c38e7b881f05a1ce1c46d09dc3317349df6ebfd24a2c6d12279db",
90 +
  "f0f6c2d96b1cbdde9c994fe79244bfc4237f3392fc53c71abc5537085c745353",
91 +
  "f157bcea0e329a44ac78d59b88fb11028b05d17aa820106d94ba128d777df124",
92 +
  "f59e9680d880b1abc8c0b3ac7efb316c71d0001936e45fae4decc1f4a9829f47"
93 +
 ],
94 +
 "projected_state": "260f02ef1913677e77b72d5b40cbaf6b02def0d2b2bf8aca064e2e19b075c3e9",
95 +
 "conflicted": false,
96 +
 "approvals": [],
97 +
 "landable": true,
98 +
 "blockers": [],
99 +
 "created_ms": 1792360202344,
100 +
 "updated_ms": 1792400658285,
101 +
 "url": "https://oxygenhub.io/andymccall/oxygenhub/proposal/264",
102 +
 "repository_url": "https://oxygenhub.io/andymccall/oxygenhub",
103 +
 "source_url": null,
104 +
 "landed": null,
105 +
 "auto_land": null,
106 +
 "requested_reviewers": [
107 +
  "ada"
108 +
 ],
109 +
 "labels": [
110 +
  {
111 +
   "name": "performance",
112 +
   "colour": "#b48cff",
113 +
   "description": "Faster or lighter"
114 +
  }
115 +
 ]
116 +
}
added app/src/test/resources/fixtures/proposals.json +353 −0
1 +
[
2 +
 {
3 +
  "number": 300,
4 +
  "title": "Show a column's archived cards",
5 +
  "body": "Closes #263 (reported by RSTAR: a landed proposal's queued run kept its place ahead of main's, seven times on 2026-10-18).\n\nOn landing, `flow::moot_after_landing` cancels the proposal's runs that **no runner has started**:\n- its source bookmark's `push` runs, when the source is a bookmark in this repository and not the target;\n- its `proposal/N` runs (the re-check after the target moved).\n\nEach job says `moot: proposal #N landed`. Runs already going finish; other bookmarks' runs are untouched. Called from every landing path: `land_as` (web, API, auto-land), `settle_after_push` (a push that holds the work), and the page finding it landed by another route.\n\nTests: `landing_cancels_the_proposals_unstarted_runs` (queued source run and proposal run cancelled with the reason, a running one left, another bookmark's left). Gate green (fmt, clippy, 284 tests); proposals tests green on Postgres. No migration.",
6 +
  "author": "andymccall",
7 +
  "state": "open",
8 +
  "target": "main",
9 +
  "source": {
10 +
   "kind": "bookmark",
11 +
   "bookmark": "cancel-landed-runs"
12 +
  },
13 +
  "changes": [
14 +
   {
15 +
    "hash": "2abea30809ddf37d992b1ab5743b0f2b0c7daa1ddef9ecc1bd93864948816602",
16 +
    "message": "Cancel a landed proposal's runs that have not started\n\nThey can no longer change anything, and they held the queue ahead of the\ntarget's own run (refs #263). On landing (web, API, auto-land, a push\nthat holds the work, or the page finding it landed by another route),\nflow::moot_after_landing cancels the proposal's queued runs: its source\nbookmark's push runs, when that bookmark is in this repository, and its\nproposal/N runs, each job saying \"moot: proposal #N landed\". Runs that\nhave started finish. Test landing_cancels_the_proposals_unstarted_runs.",
17 +
    "author": {
18 +
     "name": "Andy McCall",
19 +
     "email": "[email protected]",
20 +
     "key": "4cd9916c150f426895a390868c6999314e0ef4955d657f30d2805647c747fc65",
21 +
     "account": null,
22 +
     "note": "signed by a key not linked to any account; its owner links it under Settings \u2192 Keys (/settings/keys)"
23 +
    },
24 +
    "when_ms": 1792360184287,
25 +
    "deps": [
26 +
     "0b20f1ec4007b9c864a8cebcb8945898b88c391cc0c6d85f8f58aeb60f685b47",
27 +
     "2a8b621676b42cbc143f985eb3226e83d515504dda995db118836ab6beb672f6"
28 +
    ],
29 +
    "head": false,
30 +
    "signature": {
31 +
     "status": "unsigned"
32 +
    }
33 +
   }
34 +
  ],
35 +
  "projected_heads": [
36 +
   "021f36a627f32602d06536c414c44f9c7448e2424a05286b46da4804baa53591",
37 +
   "0cd118748131b9e7e98a2c9b7b50df8ebcbda337097e6b2d61110408cc4d9b33",
38 +
   "19355e2283aba765a8a52394216f332db76663c2c0f5c31719803c2a72ad2b55",
39 +
   "1966661b1880eb65b7662730b9e609f06d0cbe16dddba1e9a8be8599f5296079",
40 +
   "19c71c828f86cc7dbcdd8d0c8d34d9f470b90a45d4bc384686498305b0f6b33e",
41 +
   "1b770a1fecf00ba25f4dec05fffd399e8805234618e48ded335d64c955bb5374",
42 +
   "1d9702887eee79babadd1a7aa8314ef4500def25217c5137a4ee5587f8be3db0",
43 +
   "1e23d0105921f536b1cf0483972849e0255efeb89b7f53656e36d2a8839ca14d",
44 +
   "20c36ed92b6b052a02ef585998dc7e42b857a014d92920c913f3507ba345a620",
45 +
   "260c33b4b6c4d8d23000b3d146fed71ec748a95dc6fc058119ed2a7c1627d31f",
46 +
   "2bbdc0268c23bb07c0fd0ea8922eb70e8bfa18da6bb303ec4a59e7ab5dae7fe2",
47 +
   "2d58f17e728b4125bbd7b5d2be9c692536e336f53bd3b34dc56f0361b38b50df",
48 +
   "317d776c725b16fe9ed5fd849f2fcb21afdd53bdce4d1c1eca038fc90f44e426",
49 +
   "32cfb089f5e6a8eede757776fa39cf1fe98594bda3c89cfc867f07d768d7b510",
50 +
   "3a01077da29aabb3ce6154e495f4d2064756cdef5094f011eb0da88e1fe60687",
51 +
   "3e5cd2eba62654482a1214b1c387ffce03fa802047622660d17878689e42007f",
52 +
   "439ecd8708a13ffd255537fab186ab2b3f49ef9f6a8684d55b0b294d434d6ab7",
53 +
   "4745b9844987f696f39f21d7036d00f92adec2b9a507e740fdb7bd28f5fd1d72",
54 +
   "5436f0dd942938b88d95fa7451994bfec9477a11cea9c9d15df6854bd9f5a828",
55 +
   "56a786aa42f1e2a8c6da70e1acbc76edc81a8c746294d06d9c1be6fa6725e2bf",
56 +
   "6978fd76a0666e56bc28171db3f8add9e346ebe90497646c92b8ab10c99a28dc",
57 +
   "6ff755541a622b944b170f3a83dfb95ecf972a4387dd77ff481a34df28e05e82",
58 +
   "72cde3da4176aff69eecc9cc4a625aa277dc22cd4cd5728ab1e18d0646135100",
59 +
   "73beb84b202a8785d9caf0bb65e04c2feb6ae60cea0d5779133cba332a033cb8",
60 +
   "76f93eaefe451cb3fe2daefccf8fd5b8c3bbb071a030a6876459bcde7ac54664",
61 +
   "92cea629fdf6614fe819c850b117a530402d079bc5dfc690ddacb0fe044fd336",
62 +
   "967ef74f6fd9847b2d48a7a20b27bade903fe122f34d4eab7e48efb5242a0020",
63 +
   "9991530b4beee381b95233964971965ad66ac833968f4c8f1fdbf4f1c2fb1713",
64 +
   "9aee52d392a67323824e269c3f0ffdf4c0e066e2bba44bd4d9204368f3a034a1",
65 +
   "9bc6b4720fc12185874a9710b310ddb36d2625cd86d653d23783b960915ef19d",
66 +
   "a0dd06b0094eb52d4347f6bde0572de667a0d2cbac5878d9e6928ec3bba09947",
67 +
   "a2b29af1ce88b4649df2c55022862b05a3c1079a0a325a5bd4ab6764b473cbd5",
68 +
   "a2ef53c5db269fa8bc86bb8abb13bb10ccba916129a301ca8738cb4829ff6680",
69 +
   "a7c05940ebc4ba5c73eb14afcc7579680e1cdb53995e464db10d617e055c5d57",
70 +
   "ad01845c4bf86859c3072a48277436580de78bbfe6ea6bc85de9b350522bf92b",
71 +
   "ae138eea7a71055eed1bdb63b016828e6bbe2de7ff2b8b06a741ebeaa759e950",
72 +
   "bd630386001ed3d0fc0d03c01548c193a2297f90b6fedb00b5883259f9ca1ad0",
73 +
   "bd91edeb44f8f318af68521ca74c35ee48f09e1002ce0c5972bc8ac10cea6862",
74 +
   "c21ef37dd4a0641cafb7fe4cf430093934e416d10b582d4183d9c30f722f42e5",
75 +
   "c2732e07323b52f2491200ad8989352bc6fc6834c4d97a21e550b3abbe0acafb",
76 +
   "c33dbd9a0d38f03313995c1259c16adbd34571f06efe1a71438b51783f1d7b36",
77 +
   "cefb0ea24511445166d0db129929255276092d452c7832e877235b49fcdc5881",
78 +
   "d2077a57b1f9a114ebdee7cf9e95e81f8932ebc82b59b4b126664a6b1941260b",
79 +
   "d2c1d6a7d12edeaca968546dc2fde25068f3de0afaee844ff11d5afd514e43eb",
80 +
   "d336629ffa906b6783adf44337015e3c3d9d4437a6a4f97734367f903674db51",
81 +
   "d4a0043fded4b0533c2f0377373bce57e38279a2b3935cdc62be08123505630b",
82 +
   "d94449e42718409b41ab98335c6641b2b4429332e8a1cab535e76467509986b4",
83 +
   "d9b3b84605a4f30f778d73011e379e8178f33d8d2c6d3d6704c7f5f7e108d5f7",
84 +
   "db72849cd22e82e6160e21528548f9766cf4b10867415ce1b2fe177bfa05e71f",
85 +
   "dc71378db1dc95169f80a85c58c80bc86dbd44546a90607b3a49297ec2e1843e",
86 +
   "df68c58de9471dd2f1eada82669cb2adee804c70fd2507876790c00f10edf73f",
87 +
   "e1078e99c5604bb12c431c02dacb6fba8ea2a0515abe39b3accedd23c78af9ac",
88 +
   "e15409084ed6de68f460f830d74c15458dcee9ab20a6c8dd39145d04a895d54e",
89 +
   "edcef27575a73e6acece529d1c0d3bb9f3509123de1a2c049ef4c0dd28b38bba",
90 +
   "efb51b39db1c38e7b881f05a1ce1c46d09dc3317349df6ebfd24a2c6d12279db",
91 +
   "f0f6c2d96b1cbdde9c994fe79244bfc4237f3392fc53c71abc5537085c745353",
92 +
   "f157bcea0e329a44ac78d59b88fb11028b05d17aa820106d94ba128d777df124",
93 +
   "f59e9680d880b1abc8c0b3ac7efb316c71d0001936e45fae4decc1f4a9829f47"
94 +
  ],
95 +
  "projected_state": "260f02ef1913677e77b72d5b40cbaf6b02def0d2b2bf8aca064e2e19b075c3e9",
96 +
  "conflicted": false,
97 +
  "approvals": [],
98 +
  "landable": true,
99 +
  "blockers": [],
100 +
  "created_ms": 1792360202344,
101 +
  "updated_ms": 1792400658285,
102 +
  "url": "https://oxygenhub.io/andymccall/oxygenhub/proposal/264",
103 +
  "repository_url": "https://oxygenhub.io/andymccall/oxygenhub",
104 +
  "source_url": null,
105 +
  "landed": null,
106 +
  "auto_land": null,
107 +
  "requested_reviewers": [
108 +
   "ada"
109 +
  ],
110 +
  "labels": [
111 +
   {
112 +
    "name": "performance",
113 +
    "colour": "#b48cff",
114 +
    "description": "Faster or lighter"
115 +
   }
116 +
  ]
117 +
 },
118 +
 {
119 +
  "number": 301,
120 +
  "title": "Move the hub to Postgres",
121 +
  "body": "Closes #263 (reported by RSTAR: a landed proposal's queued run kept its place ahead of main's, seven times on 2026-10-18).\n\nOn landing, `flow::moot_after_landing` cancels the proposal's runs that **no runner has started**:\n- its source bookmark's `push` runs, when the source is a bookmark in this repository and not the target;\n- its `proposal/N` runs (the re-check after the target moved).\n\nEach job says `moot: proposal #N landed`. Runs already going finish; other bookmarks' runs are untouched. Called from every landing path: `land_as` (web, API, auto-land), `settle_after_push` (a push that holds the work), and the page finding it landed by another route.\n\nTests: `landing_cancels_the_proposals_unstarted_runs` (queued source run and proposal run cancelled with the reason, a running one left, another bookmark's left). Gate green (fmt, clippy, 284 tests); proposals tests green on Postgres. No migration.",
122 +
  "author": "andymccall",
123 +
  "state": "open",
124 +
  "target": "main",
125 +
  "source": {
126 +
   "kind": "bookmark",
127 +
   "bookmark": "cancel-landed-runs"
128 +
  },
129 +
  "changes": [
130 +
   {
131 +
    "hash": "2abea30809ddf37d992b1ab5743b0f2b0c7daa1ddef9ecc1bd93864948816602",
132 +
    "message": "Cancel a landed proposal's runs that have not started\n\nThey can no longer change anything, and they held the queue ahead of the\ntarget's own run (refs #263). On landing (web, API, auto-land, a push\nthat holds the work, or the page finding it landed by another route),\nflow::moot_after_landing cancels the proposal's queued runs: its source\nbookmark's push runs, when that bookmark is in this repository, and its\nproposal/N runs, each job saying \"moot: proposal #N landed\". Runs that\nhave started finish. Test landing_cancels_the_proposals_unstarted_runs.",
133 +
    "author": {
134 +
     "name": "Andy McCall",
135 +
     "email": "[email protected]",
136 +
     "key": "4cd9916c150f426895a390868c6999314e0ef4955d657f30d2805647c747fc65",
137 +
     "account": null,
138 +
     "note": "signed by a key not linked to any account; its owner links it under Settings \u2192 Keys (/settings/keys)"
139 +
    },
140 +
    "when_ms": 1792360184287,
141 +
    "deps": [
142 +
     "0b20f1ec4007b9c864a8cebcb8945898b88c391cc0c6d85f8f58aeb60f685b47",
143 +
     "2a8b621676b42cbc143f985eb3226e83d515504dda995db118836ab6beb672f6"
144 +
    ],
145 +
    "head": false,
146 +
    "signature": {
147 +
     "status": "unsigned"
148 +
    }
149 +
   }
150 +
  ],
151 +
  "projected_heads": [
152 +
   "021f36a627f32602d06536c414c44f9c7448e2424a05286b46da4804baa53591",
153 +
   "0cd118748131b9e7e98a2c9b7b50df8ebcbda337097e6b2d61110408cc4d9b33",
154 +
   "19355e2283aba765a8a52394216f332db76663c2c0f5c31719803c2a72ad2b55",
155 +
   "1966661b1880eb65b7662730b9e609f06d0cbe16dddba1e9a8be8599f5296079",
156 +
   "19c71c828f86cc7dbcdd8d0c8d34d9f470b90a45d4bc384686498305b0f6b33e",
157 +
   "1b770a1fecf00ba25f4dec05fffd399e8805234618e48ded335d64c955bb5374",
158 +
   "1d9702887eee79babadd1a7aa8314ef4500def25217c5137a4ee5587f8be3db0",
159 +
   "1e23d0105921f536b1cf0483972849e0255efeb89b7f53656e36d2a8839ca14d",
160 +
   "20c36ed92b6b052a02ef585998dc7e42b857a014d92920c913f3507ba345a620",
161 +
   "260c33b4b6c4d8d23000b3d146fed71ec748a95dc6fc058119ed2a7c1627d31f",
162 +
   "2bbdc0268c23bb07c0fd0ea8922eb70e8bfa18da6bb303ec4a59e7ab5dae7fe2",
163 +
   "2d58f17e728b4125bbd7b5d2be9c692536e336f53bd3b34dc56f0361b38b50df",
164 +
   "317d776c725b16fe9ed5fd849f2fcb21afdd53bdce4d1c1eca038fc90f44e426",
165 +
   "32cfb089f5e6a8eede757776fa39cf1fe98594bda3c89cfc867f07d768d7b510",
166 +
   "3a01077da29aabb3ce6154e495f4d2064756cdef5094f011eb0da88e1fe60687",
167 +
   "3e5cd2eba62654482a1214b1c387ffce03fa802047622660d17878689e42007f",
168 +
   "439ecd8708a13ffd255537fab186ab2b3f49ef9f6a8684d55b0b294d434d6ab7",
169 +
   "4745b9844987f696f39f21d7036d00f92adec2b9a507e740fdb7bd28f5fd1d72",
170 +
   "5436f0dd942938b88d95fa7451994bfec9477a11cea9c9d15df6854bd9f5a828",
171 +
   "56a786aa42f1e2a8c6da70e1acbc76edc81a8c746294d06d9c1be6fa6725e2bf",
172 +
   "6978fd76a0666e56bc28171db3f8add9e346ebe90497646c92b8ab10c99a28dc",
173 +
   "6ff755541a622b944b170f3a83dfb95ecf972a4387dd77ff481a34df28e05e82",
174 +
   "72cde3da4176aff69eecc9cc4a625aa277dc22cd4cd5728ab1e18d0646135100",
175 +
   "73beb84b202a8785d9caf0bb65e04c2feb6ae60cea0d5779133cba332a033cb8",
176 +
   "76f93eaefe451cb3fe2daefccf8fd5b8c3bbb071a030a6876459bcde7ac54664",
177 +
   "92cea629fdf6614fe819c850b117a530402d079bc5dfc690ddacb0fe044fd336",
178 +
   "967ef74f6fd9847b2d48a7a20b27bade903fe122f34d4eab7e48efb5242a0020",
179 +
   "9991530b4beee381b95233964971965ad66ac833968f4c8f1fdbf4f1c2fb1713",
180 +
   "9aee52d392a67323824e269c3f0ffdf4c0e066e2bba44bd4d9204368f3a034a1",
181 +
   "9bc6b4720fc12185874a9710b310ddb36d2625cd86d653d23783b960915ef19d",
182 +
   "a0dd06b0094eb52d4347f6bde0572de667a0d2cbac5878d9e6928ec3bba09947",
183 +
   "a2b29af1ce88b4649df2c55022862b05a3c1079a0a325a5bd4ab6764b473cbd5",
184 +
   "a2ef53c5db269fa8bc86bb8abb13bb10ccba916129a301ca8738cb4829ff6680",
185 +
   "a7c05940ebc4ba5c73eb14afcc7579680e1cdb53995e464db10d617e055c5d57",
186 +
   "ad01845c4bf86859c3072a48277436580de78bbfe6ea6bc85de9b350522bf92b",
187 +
   "ae138eea7a71055eed1bdb63b016828e6bbe2de7ff2b8b06a741ebeaa759e950",
188 +
   "bd630386001ed3d0fc0d03c01548c193a2297f90b6fedb00b5883259f9ca1ad0",
189 +
   "bd91edeb44f8f318af68521ca74c35ee48f09e1002ce0c5972bc8ac10cea6862",
190 +
   "c21ef37dd4a0641cafb7fe4cf430093934e416d10b582d4183d9c30f722f42e5",
191 +
   "c2732e07323b52f2491200ad8989352bc6fc6834c4d97a21e550b3abbe0acafb",
192 +
   "c33dbd9a0d38f03313995c1259c16adbd34571f06efe1a71438b51783f1d7b36",
193 +
   "cefb0ea24511445166d0db129929255276092d452c7832e877235b49fcdc5881",
194 +
   "d2077a57b1f9a114ebdee7cf9e95e81f8932ebc82b59b4b126664a6b1941260b",
195 +
   "d2c1d6a7d12edeaca968546dc2fde25068f3de0afaee844ff11d5afd514e43eb",
196 +
   "d336629ffa906b6783adf44337015e3c3d9d4437a6a4f97734367f903674db51",
197 +
   "d4a0043fded4b0533c2f0377373bce57e38279a2b3935cdc62be08123505630b",
198 +
   "d94449e42718409b41ab98335c6641b2b4429332e8a1cab535e76467509986b4",
199 +
   "d9b3b84605a4f30f778d73011e379e8178f33d8d2c6d3d6704c7f5f7e108d5f7",
200 +
   "db72849cd22e82e6160e21528548f9766cf4b10867415ce1b2fe177bfa05e71f",
201 +
   "dc71378db1dc95169f80a85c58c80bc86dbd44546a90607b3a49297ec2e1843e",
202 +
   "df68c58de9471dd2f1eada82669cb2adee804c70fd2507876790c00f10edf73f",
203 +
   "e1078e99c5604bb12c431c02dacb6fba8ea2a0515abe39b3accedd23c78af9ac",
204 +
   "e15409084ed6de68f460f830d74c15458dcee9ab20a6c8dd39145d04a895d54e",
205 +
   "edcef27575a73e6acece529d1c0d3bb9f3509123de1a2c049ef4c0dd28b38bba",
206 +
   "efb51b39db1c38e7b881f05a1ce1c46d09dc3317349df6ebfd24a2c6d12279db",
207 +
   "f0f6c2d96b1cbdde9c994fe79244bfc4237f3392fc53c71abc5537085c745353",
208 +
   "f157bcea0e329a44ac78d59b88fb11028b05d17aa820106d94ba128d777df124",
209 +
   "f59e9680d880b1abc8c0b3ac7efb316c71d0001936e45fae4decc1f4a9829f47"
210 +
  ],
211 +
  "projected_state": "260f02ef1913677e77b72d5b40cbaf6b02def0d2b2bf8aca064e2e19b075c3e9",
212 +
  "conflicted": false,
213 +
  "approvals": [],
214 +
  "landable": false,
215 +
  "blockers": [
216 +
   "0 of 1 required sealed approvals",
217 +
   "required checks not passed on the projected state: test"
218 +
  ],
219 +
  "created_ms": 1792360202344,
220 +
  "updated_ms": 1792400658285,
221 +
  "url": "https://oxygenhub.io/andymccall/oxygenhub/proposal/264",
222 +
  "repository_url": "https://oxygenhub.io/andymccall/oxygenhub",
223 +
  "source_url": null,
224 +
  "landed": null,
225 +
  "auto_land": "andymccall",
226 +
  "requested_reviewers": [],
227 +
  "labels": [
228 +
   {
229 +
    "name": "performance",
230 +
    "colour": "#b48cff",
231 +
    "description": "Faster or lighter"
232 +
   }
233 +
  ]
234 +
 },
235 +
 {
236 +
  "number": 264,
237 +
  "title": "Cancel a landed proposal's runs that have not started (#263)",
238 +
  "body": "Closes #263 (reported by RSTAR: a landed proposal's queued run kept its place ahead of main's, seven times on 2026-10-18).\n\nOn landing, `flow::moot_after_landing` cancels the proposal's runs that **no runner has started**:\n- its source bookmark's `push` runs, when the source is a bookmark in this repository and not the target;\n- its `proposal/N` runs (the re-check after the target moved).\n\nEach job says `moot: proposal #N landed`. Runs already going finish; other bookmarks' runs are untouched. Called from every landing path: `land_as` (web, API, auto-land), `settle_after_push` (a push that holds the work), and the page finding it landed by another route.\n\nTests: `landing_cancels_the_proposals_unstarted_runs` (queued source run and proposal run cancelled with the reason, a running one left, another bookmark's left). Gate green (fmt, clippy, 284 tests); proposals tests green on Postgres. No migration.",
239 +
  "author": "andymccall",
240 +
  "state": "landed",
241 +
  "target": "main",
242 +
  "source": {
243 +
   "kind": "bookmark",
244 +
   "bookmark": "cancel-landed-runs"
245 +
  },
246 +
  "changes": [
247 +
   {
248 +
    "hash": "2abea30809ddf37d992b1ab5743b0f2b0c7daa1ddef9ecc1bd93864948816602",
249 +
    "message": "Cancel a landed proposal's runs that have not started\n\nThey can no longer change anything, and they held the queue ahead of the\ntarget's own run (refs #263). On landing (web, API, auto-land, a push\nthat holds the work, or the page finding it landed by another route),\nflow::moot_after_landing cancels the proposal's queued runs: its source\nbookmark's push runs, when that bookmark is in this repository, and its\nproposal/N runs, each job saying \"moot: proposal #N landed\". Runs that\nhave started finish. Test landing_cancels_the_proposals_unstarted_runs.",
250 +
    "author": {
251 +
     "name": "Andy McCall",
252 +
     "email": "[email protected]",
253 +
     "key": "4cd9916c150f426895a390868c6999314e0ef4955d657f30d2805647c747fc65",
254 +
     "account": null,
255 +
     "note": "signed by a key not linked to any account; its owner links it under Settings \u2192 Keys (/settings/keys)"
256 +
    },
257 +
    "when_ms": 1792360184287,
258 +
    "deps": [
259 +
     "0b20f1ec4007b9c864a8cebcb8945898b88c391cc0c6d85f8f58aeb60f685b47",
260 +
     "2a8b621676b42cbc143f985eb3226e83d515504dda995db118836ab6beb672f6"
261 +
    ],
262 +
    "head": false,
263 +
    "signature": {
264 +
     "status": "unsigned"
265 +
    }
266 +
   }
267 +
  ],
268 +
  "projected_heads": [
269 +
   "021f36a627f32602d06536c414c44f9c7448e2424a05286b46da4804baa53591",
270 +
   "0cd118748131b9e7e98a2c9b7b50df8ebcbda337097e6b2d61110408cc4d9b33",
271 +
   "19355e2283aba765a8a52394216f332db76663c2c0f5c31719803c2a72ad2b55",
272 +
   "1966661b1880eb65b7662730b9e609f06d0cbe16dddba1e9a8be8599f5296079",
273 +
   "19c71c828f86cc7dbcdd8d0c8d34d9f470b90a45d4bc384686498305b0f6b33e",
274 +
   "1b770a1fecf00ba25f4dec05fffd399e8805234618e48ded335d64c955bb5374",
275 +
   "1d9702887eee79babadd1a7aa8314ef4500def25217c5137a4ee5587f8be3db0",
276 +
   "1e23d0105921f536b1cf0483972849e0255efeb89b7f53656e36d2a8839ca14d",
277 +
   "20c36ed92b6b052a02ef585998dc7e42b857a014d92920c913f3507ba345a620",
278 +
   "260c33b4b6c4d8d23000b3d146fed71ec748a95dc6fc058119ed2a7c1627d31f",
279 +
   "2bbdc0268c23bb07c0fd0ea8922eb70e8bfa18da6bb303ec4a59e7ab5dae7fe2",
280 +
   "2d58f17e728b4125bbd7b5d2be9c692536e336f53bd3b34dc56f0361b38b50df",
281 +
   "317d776c725b16fe9ed5fd849f2fcb21afdd53bdce4d1c1eca038fc90f44e426",
282 +
   "32cfb089f5e6a8eede757776fa39cf1fe98594bda3c89cfc867f07d768d7b510",
283 +
   "3a01077da29aabb3ce6154e495f4d2064756cdef5094f011eb0da88e1fe60687",
284 +
   "3e5cd2eba62654482a1214b1c387ffce03fa802047622660d17878689e42007f",
285 +
   "439ecd8708a13ffd255537fab186ab2b3f49ef9f6a8684d55b0b294d434d6ab7",
286 +
   "4745b9844987f696f39f21d7036d00f92adec2b9a507e740fdb7bd28f5fd1d72",
287 +
   "5436f0dd942938b88d95fa7451994bfec9477a11cea9c9d15df6854bd9f5a828",
288 +
   "56a786aa42f1e2a8c6da70e1acbc76edc81a8c746294d06d9c1be6fa6725e2bf",
289 +
   "6978fd76a0666e56bc28171db3f8add9e346ebe90497646c92b8ab10c99a28dc",
290 +
   "6ff755541a622b944b170f3a83dfb95ecf972a4387dd77ff481a34df28e05e82",
291 +
   "72cde3da4176aff69eecc9cc4a625aa277dc22cd4cd5728ab1e18d0646135100",
292 +
   "73beb84b202a8785d9caf0bb65e04c2feb6ae60cea0d5779133cba332a033cb8",
293 +
   "76f93eaefe451cb3fe2daefccf8fd5b8c3bbb071a030a6876459bcde7ac54664",
294 +
   "92cea629fdf6614fe819c850b117a530402d079bc5dfc690ddacb0fe044fd336",
295 +
   "967ef74f6fd9847b2d48a7a20b27bade903fe122f34d4eab7e48efb5242a0020",
296 +
   "9991530b4beee381b95233964971965ad66ac833968f4c8f1fdbf4f1c2fb1713",
297 +
   "9aee52d392a67323824e269c3f0ffdf4c0e066e2bba44bd4d9204368f3a034a1",
298 +
   "9bc6b4720fc12185874a9710b310ddb36d2625cd86d653d23783b960915ef19d",
299 +
   "a0dd06b0094eb52d4347f6bde0572de667a0d2cbac5878d9e6928ec3bba09947",
300 +
   "a2b29af1ce88b4649df2c55022862b05a3c1079a0a325a5bd4ab6764b473cbd5",
301 +
   "a2ef53c5db269fa8bc86bb8abb13bb10ccba916129a301ca8738cb4829ff6680",
302 +
   "a7c05940ebc4ba5c73eb14afcc7579680e1cdb53995e464db10d617e055c5d57",
303 +
   "ad01845c4bf86859c3072a48277436580de78bbfe6ea6bc85de9b350522bf92b",
304 +
   "ae138eea7a71055eed1bdb63b016828e6bbe2de7ff2b8b06a741ebeaa759e950",
305 +
   "bd630386001ed3d0fc0d03c01548c193a2297f90b6fedb00b5883259f9ca1ad0",
306 +
   "bd91edeb44f8f318af68521ca74c35ee48f09e1002ce0c5972bc8ac10cea6862",
307 +
   "c21ef37dd4a0641cafb7fe4cf430093934e416d10b582d4183d9c30f722f42e5",
308 +
   "c2732e07323b52f2491200ad8989352bc6fc6834c4d97a21e550b3abbe0acafb",
309 +
   "c33dbd9a0d38f03313995c1259c16adbd34571f06efe1a71438b51783f1d7b36",
310 +
   "cefb0ea24511445166d0db129929255276092d452c7832e877235b49fcdc5881",
311 +
   "d2077a57b1f9a114ebdee7cf9e95e81f8932ebc82b59b4b126664a6b1941260b",
312 +
   "d2c1d6a7d12edeaca968546dc2fde25068f3de0afaee844ff11d5afd514e43eb",
313 +
   "d336629ffa906b6783adf44337015e3c3d9d4437a6a4f97734367f903674db51",
314 +
   "d4a0043fded4b0533c2f0377373bce57e38279a2b3935cdc62be08123505630b",
315 +
   "d94449e42718409b41ab98335c6641b2b4429332e8a1cab535e76467509986b4",
316 +
   "d9b3b84605a4f30f778d73011e379e8178f33d8d2c6d3d6704c7f5f7e108d5f7",
317 +
   "db72849cd22e82e6160e21528548f9766cf4b10867415ce1b2fe177bfa05e71f",
318 +
   "dc71378db1dc95169f80a85c58c80bc86dbd44546a90607b3a49297ec2e1843e",
319 +
   "df68c58de9471dd2f1eada82669cb2adee804c70fd2507876790c00f10edf73f",
320 +
   "e1078e99c5604bb12c431c02dacb6fba8ea2a0515abe39b3accedd23c78af9ac",
321 +
   "e15409084ed6de68f460f830d74c15458dcee9ab20a6c8dd39145d04a895d54e",
322 +
   "edcef27575a73e6acece529d1c0d3bb9f3509123de1a2c049ef4c0dd28b38bba",
323 +
   "efb51b39db1c38e7b881f05a1ce1c46d09dc3317349df6ebfd24a2c6d12279db",
324 +
   "f0f6c2d96b1cbdde9c994fe79244bfc4237f3392fc53c71abc5537085c745353",
325 +
   "f157bcea0e329a44ac78d59b88fb11028b05d17aa820106d94ba128d777df124",
326 +
   "f59e9680d880b1abc8c0b3ac7efb316c71d0001936e45fae4decc1f4a9829f47"
327 +
  ],
328 +
  "projected_state": "260f02ef1913677e77b72d5b40cbaf6b02def0d2b2bf8aca064e2e19b075c3e9",
329 +
  "conflicted": false,
330 +
  "approvals": [],
331 +
  "landable": false,
332 +
  "blockers": [],
333 +
  "created_ms": 1792360202344,
334 +
  "updated_ms": 1792400658285,
335 +
  "url": "https://oxygenhub.io/andymccall/oxygenhub/proposal/264",
336 +
  "repository_url": "https://oxygenhub.io/andymccall/oxygenhub",
337 +
  "source_url": null,
338 +
  "landed": {
339 +
   "by": "andymccall",
340 +
   "via": "web",
341 +
   "when_ms": 1792400658285
342 +
  },
343 +
  "auto_land": null,
344 +
  "requested_reviewers": [],
345 +
  "labels": [
346 +
   {
347 +
    "name": "performance",
348 +
    "colour": "#b48cff",
349 +
    "description": "Faster or lighter"
350 +
   }
351 +
  ]
352 +
 }
353 +
]
modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Land proposals from the phone (#17):** a repository has Issues and Proposals tabs; proposals open (with drafts), landed, closed or all, each saying whether it is ready to land, what stands in the way, or that it will land when its checks pass. A proposal shows the landing card (Land into the target, after a confirmation; land when its checks pass, on or off; the hub's blockers in its own words, and its reason when it refuses), then its checks (failures first, each opening its run), sealed approvals and asked reviewers, its changes and description. Landing needs `repo:write`, which sign-in now asks for: a phone signed in before this reads only, and says so with a button to sign in again rather than failing.
5 6
- **A proper launcher icon (#15):** OxygenHub's mark, the hub's two joined rings in its cyan-to-blue gradient, on the app's night navy with a soft glow, as an adaptive icon (any launcher's mask, inside the safe zone) with a monochrome layer for themed icons; the default robot's bitmaps are gone. A rendered preview is in `docs/screenshots/launcher-icon.png`.
6 7
- **Your avatar (#13):** the dashboard's greeting and the Account screen show the signed-in account's avatar from the hub's `/avatar/{name}`: an uploaded picture as it is, or the hub's identicon, read from its SVG (hue and cells) and drawn natively, so no SVG library; the initial while there is neither, or the hub cannot be reached. Fetched without the token (it is public), kept for the run, and refreshed with the dashboard.
7 8
- **The keyboard no longer comes up on its own on Repositories (#12):** opening Repositories gave the filter focus and brought the keyboard up on every visit (tapping the navigation bar gives the app's view focus, which Compose hands to the screen's first focusable). The filter now takes focus only when it is touched, or reached from a keyboard. Checked on a Pixel 10 Pro Fold.
…
2 unchanged lines
modified README.md +3 −0
…
37 unchanged lines
38 38
* 📚 **Repositories:** yours or all you can see, filtered as you type; a
39 39
  repository's **issues** (open, closed, all; pinned first). An issue opens on
40 40
  the hub's web page for now (#4 brings it into the app).
41 +
* 🔀 **Proposals:** a repository's proposals beside its issues; a proposal's
42 +
  checks, approvals, changes and what stands in the way, and **Land** (or
43 +
  land when its checks pass) from the phone (#17).
41 44
* 📥 **Inbox:** notifications, unread first, with a badge on the navigation;
42 45
  opening one marks it read, and the whole inbox can be marked read.
43 46
* 📱 **Phone, foldable and tablet:** a navigation bar on a phone, a rail when
…
67 unchanged lines
modified app/src/main/java/io/oxygenhub/android/api/DeviceFlow.kt +4 −1
…
9 unchanged lines
10 10
import java.io.IOException
11 11
12 12
/** The scopes the app asks for. The user may untick any of them on the web. */
13 -
val AppScopes: List<String> = listOf("repo:read", "user:read")
13 +
val AppScopes: List<String> = listOf("repo:read", "repo:write", "user:read")
14 14
15 +
/** Lands proposals (and more as the app grows); asked for since #17. */
16 +
const val WriteScope = "repo:write"
17 +
15 18
/** Why a device sign-in ended without a token. */
16 19
sealed class DeviceFlowException(message: String) : Exception(message) {
17 20
    class Denied : DeviceFlowException("The sign-in was denied on the web.")
…
89 unchanged lines
modified app/src/main/java/io/oxygenhub/android/api/Http.kt +5 −0
…
73 unchanged lines
74 74
    return body.trim().take(200)
75 75
}
76 76
77 +
/** The scope a token lacked when the hub refused it for that (`this token lacks the repo:write scope`), else null. */
78 +
val Throwable.lackedScope: String?
79 +
    get() = (this as? HubException.Http)?.takeIf { it.status == 403 }
80 +
        ?.let { Regex("""lacks the (\S+) scope""").find(it.detail)?.groupValues?.get(1) }
81 +
77 82
/** A sentence for the user, for any failure the API layer throws. */
78 83
fun Throwable.userMessage(): String = when (this) {
79 84
    is HubException.Http -> when (status) {
…
10 unchanged lines
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +43 −2
…
35 unchanged lines
36 36
    /** Revokes the token this client signs in with (`DELETE /user/token`). */
37 37
    suspend fun revokeToken()
38 38
39 +
    /** A repository's proposals: open (with drafts), landed, closed or all. */
40 +
    suspend fun proposals(owner: String, name: String, state: ProposalState = ProposalState.Open): List<Proposal>
41 +
42 +
    suspend fun proposal(owner: String, name: String, number: Long): Proposal
43 +
44 +
    /** The checks on a proposal's projected state. */
45 +
    suspend fun checks(owner: String, name: String, number: Long): List<Check>
46 +
47 +
    /**
48 +
     * Land it (`repo:write`); the proposal as landed. The hub refuses with 409
49 +
     * and its reason when it may not land, and 403 when the token lacks the scope.
50 +
     */
51 +
    suspend fun land(owner: String, name: String, number: Long): Proposal
52 +
53 +
    /** Land it by itself when its checks pass, or not (`repo:write`); the proposal after. */
54 +
    suspend fun setAutoLand(owner: String, name: String, number: Long, on: Boolean): Proposal
55 +
39 56
    /** An account's picture (`/avatar/{name}`, public); null when the hub has none this app can draw. */
40 57
    suspend fun avatar(name: String): Avatar?
41 58
}
…
21 unchanged lines
63 80
64 81
    override suspend fun inbox(limit: Int): Inbox =
65 82
        get(url("notifications") { addQueryParameter("limit", limit.coerceIn(1, 200).toString()) }, Inbox.serializer())
83 +
84 +
    override suspend fun proposals(owner: String, name: String, state: ProposalState): List<Proposal> =
85 +
        get(url("repos", owner, name, "proposals") { addQueryParameter("state", state.query) }, ListSerializer(Proposal.serializer()))
86 +
87 +
    override suspend fun proposal(owner: String, name: String, number: Long): Proposal =
88 +
        get(url("repos", owner, name, "proposals", number.toString()), Proposal.serializer())
89 +
90 +
    override suspend fun checks(owner: String, name: String, number: Long): List<Check> =
91 +
        get(url("repos", owner, name, "proposals", number.toString(), "checks"), ListSerializer(Check.serializer()))
66 92
93 +
    override suspend fun land(owner: String, name: String, number: Long): Proposal =
94 +
        send("POST", url("repos", owner, name, "proposals", number.toString(), "land"), Proposal.serializer())
95 +
96 +
    override suspend fun setAutoLand(owner: String, name: String, number: Long, on: Boolean): Proposal =
97 +
        send(if (on) "POST" else "DELETE", url("repos", owner, name, "proposals", number.toString(), "auto-land"), Proposal.serializer())
98 +
67 99
    override suspend fun markRead(id: Long) {
68 100
        send("POST", url("notifications", id.toString(), "read"))
69 101
    }
…
49 unchanged lines
119 151
        }
120 152
    }
121 153
122 -
    private suspend fun send(method: String, url: HttpUrl, notifyUnauthorized: Boolean = true) {
154 +
    private suspend fun send(method: String, url: HttpUrl, notifyUnauthorized: Boolean = true): String {
123 155
        val body = if (method == "DELETE") null else "{}".toRequestBody(JsonMedia)
124 -
        call(request(url).method(method, body).build(), notifyUnauthorized)
156 +
        return call(request(url).method(method, body).build(), notifyUnauthorized)
157 +
    }
158 +
159 +
    private suspend fun <T> send(method: String, url: HttpUrl, serializer: KSerializer<T>): T {
160 +
        val body = send(method, url)
161 +
        return try {
162 +
            HubJson.decodeFromString(serializer, body)
163 +
        } catch (e: IllegalArgumentException) {
164 +
            throw HubException.Unreadable("$method ${url.encodedPath}", e)
165 +
        }
125 166
    }
126 167
127 168
    private fun request(url: HttpUrl): Request.Builder = Request.Builder()
…
24 unchanged lines
modified app/src/main/java/io/oxygenhub/android/api/Models.kt +69 −0
…
79 unchanged lines
80 80
/** Which issues to list: the hub's `?state=`. */
81 81
enum class IssueState(val query: String) { Open("open"), Closed("closed"), All("all") }
82 82
83 +
/**
84 +
 * A proposal (`GET /api/v1/repos/{owner}/{name}/proposals`, `…/proposals/{n}`):
85 +
 * changes asking to join a target bookmark. The hub decides whether it may
86 +
 * land, and says why not in [blockers], in its own words.
87 +
 */
88 +
@Serializable
89 +
data class Proposal(
90 +
    val number: Long,
91 +
    val title: String,
92 +
    val body: String = "",
93 +
    val author: String = "",
94 +
    /** `draft`, `open`, `landed` or `closed`. */
95 +
    val state: String = "open",
96 +
    /** The bookmark it lands on. */
97 +
    val target: String = "main",
98 +
    /** The changes it adds, in dependency order, newest last. */
99 +
    val changes: List<ProposalChange> = emptyList(),
100 +
    val conflicted: Boolean = false,
101 +
    val approvals: List<Approval> = emptyList(),
102 +
    val landable: Boolean = false,
103 +
    val blockers: List<String> = emptyList(),
104 +
    @SerialName("created_ms") val createdMs: Long = 0,
105 +
    @SerialName("updated_ms") val updatedMs: Long = 0,
106 +
    val url: String = "",
107 +
    val landed: Landed? = null,
108 +
    /** Who set it to land by itself when its checks pass, while set. */
109 +
    @SerialName("auto_land") val autoLand: String? = null,
110 +
    @SerialName("requested_reviewers") val requestedReviewers: List<String> = emptyList(),
111 +
    val labels: List<Label> = emptyList(),
112 +
) {
113 +
    val isOpen: Boolean get() = state == "open" || state == "draft"
114 +
    val isDraft: Boolean get() = state == "draft"
115 +
}
116 +
117 +
/** A change a proposal adds. */
118 +
@Serializable
119 +
data class ProposalChange(
120 +
    val hash: String,
121 +
    val message: String = "",
122 +
    /** One that nothing else in the proposal depends on. */
123 +
    val head: Boolean = false,
124 +
) {
125 +
    /** The message's first line. */
126 +
    val subject: String get() = message.lineSequence().firstOrNull().orEmpty()
127 +
}
128 +
129 +
/** A reviewer's approval; it counts only when [sealed] (a seal over every change, by a key linked to them). */
130 +
@Serializable
131 +
data class Approval(val account: String, val sealed: Boolean = false)
132 +
133 +
/** Who landed a proposal, how (`web`, or `api (token NAME)`), and when. */
134 +
@Serializable
135 +
data class Landed(val by: String, val via: String = "", @SerialName("when_ms") val whenMs: Long = 0)
136 +
137 +
/** Which proposals to list. `Open` includes drafts, as the hub's own list does. */
138 +
enum class ProposalState(val query: String) { Open("open"), Landed("landed"), Closed("closed"), All("all") }
139 +
140 +
/** A check on a state (`GET …/proposals/{n}/checks`): a Flow job, or another reporter's. */
141 +
@Serializable
142 +
data class Check(
143 +
    val name: String,
144 +
    /** `queued`, `running`, `passed`, `failed`, `cancelled` or `skipped`. */
145 +
    val status: String = "queued",
146 +
    val summary: String = "",
147 +
    val url: String = "",
148 +
    @SerialName("started_ms") val startedMs: Long = 0,
149 +
    @SerialName("finished_ms") val finishedMs: Long? = null,
150 +
)
151 +
83 152
/** One entry in the caller's inbox. */
84 153
@Serializable
85 154
data class Notification(
…
64 unchanged lines
modified app/src/main/java/io/oxygenhub/android/auth/Session.kt +6 −0
…
8 unchanged lines
9 9
    /** The scopes the user allowed when approving the device. */
10 10
    val scopes: List<String> = emptyList(),
11 11
) {
12 +
    /**
13 +
     * Whether the token may change things (land a proposal): the scope was
14 +
     * allowed, or the hub did not say which were (then the hub decides).
15 +
     */
16 +
    val canWrite: Boolean get() = scopes.isEmpty() || io.oxygenhub.android.api.WriteScope in scopes
17 +
12 18
    // The token never reaches a log through toString.
13 19
    override fun toString(): String = "Session(hub=$hub, username=$username, scopes=$scopes, token=<redacted>)"
14 20
}
…
22 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/issues/IssuesScreen.kt +28 −33
…
39 unchanged lines
40 40
import io.oxygenhub.android.api.Issue
41 41
import io.oxygenhub.android.api.IssueState
42 42
import io.oxygenhub.android.api.Label
43 -
import io.oxygenhub.android.ui.common.AppScreen
44 43
import io.oxygenhub.android.ui.common.EmptyState
45 44
import io.oxygenhub.android.ui.common.LoadContent
46 45
import io.oxygenhub.android.ui.common.relativeTime
47 46
import io.oxygenhub.android.ui.theme.LocalStatusColors
48 47
49 48
/**
50 -
 * A repository's issues. For now this is the repository's whole detail
51 -
 * pane; the repository overview (oxygenhub-android#3) will hold it as one
52 -
 * of its tabs. An issue opens on the hub's web page until #4 brings the
53 -
 * issue screen.
49 +
 * A repository's issues with their filter, for the repository's tab. An
50 +
 * issue opens on the hub's web page until #4 brings the issue screen.
54 51
 */
55 52
@Composable
56 -
fun IssuesScreen(vm: IssuesViewModel, onBack: (() -> Unit)?) {
53 +
fun IssueList(vm: IssuesViewModel) {
57 54
    val state by vm.state.collectAsStateWithLifecycle()
58 55
    val filter by vm.filter.collectAsStateWithLifecycle()
59 56
    val uri = LocalUriHandler.current
60 -
    AppScreen(title = "${vm.owner}/${vm.name}", subtitle = "Issues", onBack = onBack) {
61 -
        Column(Modifier.fillMaxSize()) {
62 -
            Row(
63 -
                Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp),
64 -
                horizontalArrangement = Arrangement.spacedBy(8.dp),
65 -
            ) {
66 -
                IssueState.entries.forEach { s ->
67 -
                    FilterChip(selected = filter == s, onClick = { vm.setFilter(s) }, label = { Text(s.name) })
68 -
                }
57 +
    Column(Modifier.fillMaxSize()) {
58 +
        Row(
59 +
            Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp),
60 +
            horizontalArrangement = Arrangement.spacedBy(8.dp),
61 +
        ) {
62 +
            IssueState.entries.forEach { s ->
63 +
                FilterChip(selected = filter == s, onClick = { vm.setFilter(s) }, label = { Text(s.name) })
69 64
            }
70 -
            LoadContent(state, onRefresh = vm::refresh) { issues ->
71 -
                if (issues.isEmpty()) {
72 -
                    EmptyState(
73 -
                        Icons.Outlined.TaskAlt,
74 -
                        when (filter) {
75 -
                            IssueState.Open -> "No open issues"
76 -
                            IssueState.Closed -> "No closed issues"
77 -
                            IssueState.All -> "No issues yet"
78 -
                        },
79 -
                        "Issues for ${vm.owner}/${vm.name} show here.",
80 -
                    )
81 -
                } else {
82 -
                    LazyColumn(Modifier.fillMaxSize()) {
83 -
                        items(issues, key = { it.number }) { issue ->
84 -
                            IssueRow(issue, onClick = { if (issue.url.isNotBlank()) uri.openUri(issue.url) })
85 -
                            HorizontalDivider()
86 -
                        }
65 +
        }
66 +
        LoadContent(state, onRefresh = vm::refresh) { issues ->
67 +
            if (issues.isEmpty()) {
68 +
                EmptyState(
69 +
                    Icons.Outlined.TaskAlt,
70 +
                    when (filter) {
71 +
                        IssueState.Open -> "No open issues"
72 +
                        IssueState.Closed -> "No closed issues"
73 +
                        IssueState.All -> "No issues yet"
74 +
                    },
75 +
                    "Issues for ${vm.owner}/${vm.name} show here.",
76 +
                )
77 +
            } else {
78 +
                LazyColumn(Modifier.fillMaxSize()) {
79 +
                    items(issues, key = { it.number }) { issue ->
80 +
                        IssueRow(issue, onClick = { if (issue.url.isNotBlank()) uri.openUri(issue.url) })
81 +
                        HorizontalDivider()
87 82
                    }
88 83
                }
89 84
            }
…
50 unchanged lines
140 135
}
141 136
142 137
@Composable
143 -
private fun LabelChip(label: Label) {
138 +
internal fun LabelChip(label: Label) {
144 139
    val colour = parseColour(label.colour) ?: MaterialTheme.colorScheme.secondaryContainer
145 140
    val ink = if (colour.luminance() > 0.5f) Color.Black else Color.White
146 141
    Text(
…
17 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +32 −3
…
34 unchanged lines
35 35
import io.oxygenhub.android.auth.Session
36 36
import io.oxygenhub.android.ui.account.AccountScreen
37 37
import io.oxygenhub.android.ui.common.rememberAvatar
38 +
import androidx.compose.runtime.rememberCoroutineScope
39 +
import io.oxygenhub.android.ui.proposals.ProposalScreen
40 +
import io.oxygenhub.android.ui.proposals.ProposalViewModel
41 +
import io.oxygenhub.android.ui.proposals.ProposalsViewModel
42 +
import io.oxygenhub.android.ui.repos.RepositoryScreen
43 +
import kotlinx.coroutines.launch
38 44
import io.oxygenhub.android.ui.dashboard.DashboardScreen
39 45
import io.oxygenhub.android.ui.dashboard.DashboardViewModel
40 46
import io.oxygenhub.android.ui.inbox.InboxScreen
41 47
import io.oxygenhub.android.ui.inbox.InboxViewModel
42 -
import io.oxygenhub.android.ui.issues.IssuesScreen
43 48
import io.oxygenhub.android.ui.issues.IssuesViewModel
44 49
import io.oxygenhub.android.ui.repos.RepositoriesScreen
45 50
import io.oxygenhub.android.ui.repos.RepositoriesViewModel
…
16 unchanged lines
62 67
/** A repository's issues, opened from outside the repositories list (the dashboard). */
63 68
@Serializable data class IssuesRoute(val owner: String, val name: String)
64 69
70 +
/** One proposal, pushed over the repository it belongs to. */
71 +
@Serializable data class ProposalRoute(val owner: String, val name: String, val number: Long)
72 +
65 73
enum class TopLevel(val label: String, val icon: ImageVector, val selectedIcon: ImageVector, val route: Any, val routeClass: KClass<*>) {
66 74
    Dashboard("Dashboard", Icons.Outlined.Dashboard, Icons.Filled.Dashboard, DashboardRoute, DashboardRoute::class),
67 75
    Repositories("Repositories", Icons.Outlined.Book, Icons.Filled.Book, RepositoriesRoute, RepositoriesRoute::class),
…
62 unchanged lines
130 138
            composable<RepositoriesRoute> {
131 139
                RepositoriesScreen(rememberViewModel { RepositoriesViewModel(api, session.username) }, selectedRepository) { slug, onBack ->
132 140
                    val (owner, name) = slug.split('/', limit = 2)
133 -
                    IssuesScreen(rememberViewModel(key = "issues:$slug") { IssuesViewModel(api, owner, name) }, onBack)
141 +
                    RepositoryScreen(
142 +
                        rememberViewModel(key = "issues:$slug") { IssuesViewModel(api, owner, name) },
143 +
                        rememberViewModel(key = "proposals:$slug") { ProposalsViewModel(api, owner, name) },
144 +
                        onBack,
145 +
                        onOpenProposal = { nav.navigate(ProposalRoute(owner, name, it.number)) },
146 +
                    )
134 147
                }
135 148
            }
136 149
            composable<IssuesRoute> { e ->
137 150
                val r = e.toRoute<IssuesRoute>()
138 -
                IssuesScreen(rememberViewModel { IssuesViewModel(api, r.owner, r.name) }, onBack = { nav.popBackStack() })
151 +
                RepositoryScreen(
152 +
                    rememberViewModel { IssuesViewModel(api, r.owner, r.name) },
153 +
                    rememberViewModel { ProposalsViewModel(api, r.owner, r.name) },
154 +
                    onBack = { nav.popBackStack() },
155 +
                    onOpenProposal = { nav.navigate(ProposalRoute(r.owner, r.name, it.number)) },
156 +
                )
157 +
            }
158 +
            composable<ProposalRoute> { e ->
159 +
                val r = e.toRoute<ProposalRoute>()
160 +
                val scope = rememberCoroutineScope()
161 +
                ProposalScreen(
162 +
                    rememberViewModel { ProposalViewModel(api, r.owner, r.name, r.number) },
163 +
                    canWrite = session.canWrite,
164 +
                    onBack = { nav.popBackStack() },
165 +
                    // Signing out revokes this token; the new sign-in asks for repo:write.
166 +
                    onSignInAgain = { scope.launch { onSignOut() } },
167 +
                )
139 168
            }
140 169
            composable<InboxRoute> { InboxScreen(inbox) }
141 170
            composable<AccountRoute> { AccountScreen(session, rememberAvatar(api, session.username), nativeCore, onSignOut) }
…
12 unchanged lines
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +30 −0
1 1
package io.oxygenhub.android
2 2
3 3
import io.oxygenhub.android.api.Avatar
4 +
import io.oxygenhub.android.api.Check
5 +
import io.oxygenhub.android.api.Proposal
6 +
import io.oxygenhub.android.api.ProposalState
4 7
import io.oxygenhub.android.api.HubApi
5 8
import io.oxygenhub.android.api.Inbox
6 9
import io.oxygenhub.android.api.Issue
…
13 unchanged lines
20 23
    var markAllRead: () -> Unit = {}
21 24
    var revoke: () -> Unit = {}
22 25
    var avatar: (String) -> Avatar? = { null }
26 +
    var proposals: (ProposalState) -> List<Proposal> = { state ->
27 +
        Fixtures.proposals.filter {
28 +
            when (state) {
29 +
                ProposalState.Open -> it.isOpen
30 +
                ProposalState.All -> true
31 +
                else -> it.state == state.query
32 +
            }
33 +
        }
34 +
    }
35 +
    var proposal: (Long) -> Proposal = { n -> Fixtures.proposals.first { it.number == n } }
36 +
    var checks: (Long) -> List<Check> = { Fixtures.checks }
37 +
    var land: (Long) -> Proposal = { n -> proposal(n).copy(state = "landed", landed = io.oxygenhub.android.api.Landed("andymccall", "api (token phone)", 1)) }
38 +
    var autoLand: (Long, Boolean) -> Proposal = { n, on -> proposal(n).copy(autoLand = if (on) "andymccall" else null) }
23 39
24 40
    val calls = mutableListOf<String>()
25 41
…
9 unchanged lines
35 51
    override suspend fun markAllRead() {
36 52
        calls += "read all"
37 53
        markAllRead.invoke()
54 +
    }
55 +
    override suspend fun proposals(owner: String, name: String, state: ProposalState): List<Proposal> =
56 +
        proposals.invoke(state).also { calls += "proposals $owner/$name ${state.query}" }
57 +
    override suspend fun proposal(owner: String, name: String, number: Long): Proposal =
58 +
        proposal.invoke(number).also { calls += "proposal $number" }
59 +
    override suspend fun checks(owner: String, name: String, number: Long): List<Check> =
60 +
        checks.invoke(number).also { calls += "checks $number" }
61 +
    override suspend fun land(owner: String, name: String, number: Long): Proposal {
62 +
        calls += "land $number"
63 +
        return land.invoke(number)
64 +
    }
65 +
    override suspend fun setAutoLand(owner: String, name: String, number: Long, on: Boolean): Proposal {
66 +
        calls += "auto-land $number $on"
67 +
        return autoLand.invoke(number, on)
38 68
    }
39 69
    override suspend fun avatar(name: String): Avatar? = avatar.invoke(name).also { calls += "avatar $name" }
40 70
    override suspend fun revokeToken() {
…
4 unchanged lines
modified app/src/test/java/io/oxygenhub/android/Fixtures.kt +4 −0
…
12 unchanged lines
13 13
        requireNotNull(Fixtures::class.java.classLoader!!.getResource("fixtures/$name")) { "no fixture $name" }.readText()
14 14
15 15
    val user: User get() = HubJson.decodeFromString(User.serializer(), text("user.json"))
16 +
    val proposals: List<io.oxygenhub.android.api.Proposal>
17 +
        get() = HubJson.decodeFromString(ListSerializer(io.oxygenhub.android.api.Proposal.serializer()), text("proposals.json"))
18 +
    val checks: List<io.oxygenhub.android.api.Check>
19 +
        get() = HubJson.decodeFromString(ListSerializer(io.oxygenhub.android.api.Check.serializer()), text("checks.json"))
16 20
    val repos: List<Repository> get() = HubJson.decodeFromString(ListSerializer(Repository.serializer()), text("repos.json"))
17 21
    val issues: List<Issue> get() = HubJson.decodeFromString(ListSerializer(Issue.serializer()), text("issues.json"))
18 22
    val inbox: Inbox get() = HubJson.decodeFromString(Inbox.serializer(), text("inbox.json"))
…
1 unchanged lines
modified app/src/test/java/io/oxygenhub/android/api/HubClientTest.kt +33 −0
…
63 unchanged lines
64 64
    }
65 65
66 66
    @Test
67 +
    fun proposalsAreListedReadAndLanded() = runTest {
68 +
        server.enqueue(json("proposals.json"))
69 +
        assertEquals(3, client().proposals("andymccall", "oxygenhub", ProposalState.All).size)
70 +
        assertEquals("/api/v1/repos/andymccall/oxygenhub/proposals?state=all", server.takeRequest().path)
71 +
        server.enqueue(json("checks.json"))
72 +
        assertEquals("failed", client().checks("andymccall", "oxygenhub", 300)[1].status)
73 +
        assertEquals("/api/v1/repos/andymccall/oxygenhub/proposals/300/checks", server.takeRequest().path)
74 +
        server.enqueue(json("proposal.json"))
75 +
        assertEquals(300L, client().land("andymccall", "oxygenhub", 300).number)
76 +
        val land = server.takeRequest()
77 +
        assertEquals("POST", land.method)
78 +
        assertEquals("/api/v1/repos/andymccall/oxygenhub/proposals/300/land", land.path)
79 +
        assertEquals("{}", land.body.readUtf8())
80 +
        server.enqueue(json("proposal.json"))
81 +
        client().setAutoLand("andymccall", "oxygenhub", 300, on = false)
82 +
        val off = server.takeRequest()
83 +
        assertEquals("DELETE" to "/api/v1/repos/andymccall/oxygenhub/proposals/300/auto-land", off.method to off.path)
84 +
    }
85 +
86 +
    @Test
87 +
    fun aRefusedLandingCarriesTheHubsReason() = runTest {
88 +
        server.enqueue(
89 +
            MockResponse().setResponseCode(409).setHeader("Content-Type", "application/problem+json")
90 +
                .setBody("""{"status":409,"title":"Conflict","detail":"0 of 1 required sealed approvals"}"""),
91 +
        )
92 +
        val e = runCatching { client().land("andymccall", "oxygenhub", 301) }.exceptionOrNull() as HubException.Http
93 +
        assertEquals(409 to "0 of 1 required sealed approvals", e.status to e.detail)
94 +
        server.enqueue(json("problem_403.json", 403))
95 +
        val scope = runCatching { client().land("andymccall", "oxygenhub", 301) }.exceptionOrNull()
96 +
        assertEquals("user:read", scope?.lackedScope)
97 +
    }
98 +
99 +
    @Test
67 100
    fun repositories() = runTest {
68 101
        server.enqueue(json("repos.json"))
69 102
        assertEquals(3, client().repositories().size)
…
114 unchanged lines
modified app/src/test/java/io/oxygenhub/android/api/ModelsTest.kt +14 −0
…
8 unchanged lines
9 9
10 10
class ModelsTest {
11 11
    @Test
12 +
    fun proposalsAsTheHubServesThem() {
13 +
        val ps = Fixtures.proposals
14 +
        val landed = ps.first { it.number == 264L }
15 +
        assertEquals("landed", landed.state)
16 +
        assertEquals("andymccall", landed.landed?.by)
17 +
        assertEquals("Cancel a landed proposal's runs that have not started", landed.changes.single().subject)
18 +
        val blocked = ps.first { it.number == 301L }
19 +
        assertEquals(listOf("0 of 1 required sealed approvals", "required checks not passed on the projected state: test"), blocked.blockers)
20 +
        assertEquals("andymccall", blocked.autoLand)
21 +
        assertEquals(listOf("flow/build", "flow/clippy", "flow/fmt", "flow/test", "flow/test-postgres"), Fixtures.checks.map { it.name })
22 +
        assertEquals(null, Fixtures.checks[2].finishedMs)
23 +
    }
24 +
25 +
    @Test
12 26
    fun user() {
13 27
        val u = Fixtures.user
14 28
        assertEquals("andymccall", u.username)
…
98 unchanged lines