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 |