Assign issues from the phone: the Assignees action, with Assign me and any username, saved as the whole set (refs #4) 2a15621eca66
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 45d526822a8f
- When
- · 2026-10-27 22:28 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
2a15621eca664d8d990b0297c3247b8d91db3d700150e28bb2a10232ed2dd077- Depends on
- 144cbf7bf2b5 31e4f3755627 57c0824300db 5ae4af48ddd2 98dbd1220929 ca672a3ada1f
- Depended on by
- 821035db5a1c 840c75813f86 dce8ebd2482a 1dfa631436b6 0ca942a9a1e4
- 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
- 15 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Assign issues from the phone (#4):** an issue's Assignees action shows who it is assigned to, each removable, with Assign me and a field for anyone's username; saved as the whole set (triage access on the hub). |
|
| 5 | 6 | - **Approve, reject and expedite runs (#5):** a run's job that waits for a person shows Approve and Reject (its approval rules on the hub decide who may), and a live run has Expedite, which asks why it is urgent (a maintainer's, within the owner's cap). Needs OxygenHub 0.9.11 (the job's `approval` and the expedite route). |
|
| 6 | 7 | - **Review a proposal from the phone (#4):** an open proposal's Reviews section takes a review: Approve, Request changes or Comment, with what you think (needed to ask for changes or to comment), for a phone signed in to change things. An approval from the phone is not sealed, as it holds no key of yours: where a repository needs sealed approvals it does not count yet. |
|
| 7 | 8 | - **A proposal's files and their diffs (#4):** a proposal's Changes section opens Files changed: each file it changes, folding open to its diff (old and new line numbers, added and removed lines tinted), as its page on the hub shows them, from the hub's `GET …/proposals/N/diff` (OxygenHub 0.9.10). The change screen and this one share the same diff list. |
|
| … | 21 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +8 −0
| … | 120 unchanged lines |
||
| 121 | 121 | /** The issue's whole set of labels (triage). */ |
|
| 122 | 122 | suspend fun setIssueLabels(owner: String, name: String, number: Long, labels: List<String>): Issue |
|
| 123 | 123 | ||
| 124 | + | /** The issue's whole set of assignees (triage, #4). */ |
|
| 125 | + | suspend fun setIssueAssignees(owner: String, name: String, number: Long, assignees: List<String>): Issue |
|
| 126 | + | ||
| 124 | 127 | /** One repository (`GET /repos/{owner}/{name}`): its default bookmark, for the code browser. */ |
|
| 125 | 128 | suspend fun repository(owner: String, name: String): Repository |
|
| 126 | 129 | ||
| … | 123 unchanged lines |
||
| 250 | 253 | ||
| 251 | 254 | override suspend fun labels(owner: String, name: String): List<Label> = |
|
| 252 | 255 | get(url("repos", owner, name, "labels"), ListSerializer(Label.serializer())) |
|
| 256 | + | ||
| 257 | + | override suspend fun setIssueAssignees(owner: String, name: String, number: Long, assignees: List<String>): Issue = |
|
| 258 | + | sendJson("PATCH", url("repos", owner, name, "issues", number.toString()), buildJsonObject { |
|
| 259 | + | put("assignees", JsonArray(assignees.map { JsonPrimitive(it) })) |
|
| 260 | + | }, Issue.serializer()) |
|
| 253 | 261 | ||
| 254 | 262 | override suspend fun setIssueLabels(owner: String, name: String, number: Long, labels: List<String>): Issue = |
|
| 255 | 263 | sendJson("PATCH", url("repos", owner, name, "issues", number.toString()), buildJsonObject { |
|
| … | 174 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/issues/IssueScreen.kt +55 −1
| 1 | 1 | package io.oxygenhub.android.ui.issues |
|
| 2 | 2 | ||
| 3 | + | import androidx.compose.material.icons.outlined.PersonAdd |
|
| 4 | + | import androidx.compose.material.icons.outlined.Close |
|
| 5 | + | import androidx.compose.material.icons.outlined.Add |
|
| 3 | 6 | import androidx.compose.foundation.layout.Arrangement |
|
| 4 | 7 | import androidx.compose.foundation.layout.Column |
|
| 5 | 8 | import androidx.compose.foundation.layout.ExperimentalLayoutApi |
|
| … | 45 unchanged lines |
||
| 51 | 54 | */ |
|
| 52 | 55 | @OptIn(ExperimentalLayoutApi::class) |
|
| 53 | 56 | @Composable |
|
| 54 | - | fun IssueScreen(vm: IssueViewModel, canWrite: Boolean, onBack: () -> Unit) { |
|
| 57 | + | fun IssueScreen(vm: IssueViewModel, canWrite: Boolean, onBack: () -> Unit, me: String = "") { |
|
| 55 | 58 | val state by vm.state.collectAsStateWithLifecycle() |
|
| 56 | 59 | val working by vm.working.collectAsStateWithLifecycle() |
|
| 57 | 60 | val error by vm.error.collectAsStateWithLifecycle() |
|
| 58 | 61 | var picking by remember { mutableStateOf(false) } |
|
| 62 | + | var assigning by remember { mutableStateOf(false) } |
|
| 59 | 63 | val status = LocalStatusColors.current |
|
| 60 | 64 | AppScreen( |
|
| 61 | 65 | title = "#${vm.number}", |
|
| … | 5 unchanged lines |
||
| 67 | 71 | vm.loadLabels() |
|
| 68 | 72 | picking = true |
|
| 69 | 73 | }) { Icon(Icons.AutoMirrored.Outlined.Label, contentDescription = "Labels") } |
|
| 74 | + | IconButton(onClick = { assigning = true }) { Icon(Icons.Outlined.PersonAdd, contentDescription = "Assignees") } |
|
| 70 | 75 | } |
|
| 71 | 76 | }, |
|
| 72 | 77 | ) { |
|
| … | 34 unchanged lines |
||
| 107 | 112 | } |
|
| 108 | 113 | } |
|
| 109 | 114 | } |
|
| 115 | + | if (assigning) { |
|
| 116 | + | AssigneePicker(current = (state as? Load.Ready)?.value?.issue?.assignees.orEmpty(), me = me, onSave = vm::setAssignees, onDone = { assigning = false }) |
|
| 117 | + | } |
|
| 110 | 118 | if (picking) { |
|
| 111 | 119 | LabelPicker(vm, current = (state as? Load.Ready)?.value?.issue?.labels?.map { it.name }.orEmpty(), onDone = { picking = false }) |
|
| 112 | 120 | } |
|
| … | 51 unchanged lines |
||
| 164 | 172 | confirmButton = { |
|
| 165 | 173 | TextButton(onClick = { |
|
| 166 | 174 | vm.setLabels(chosen.toList()) |
|
| 175 | + | onDone() |
|
| 176 | + | }) { Text("Save") } |
|
| 177 | + | }, |
|
| 178 | + | dismissButton = { TextButton(onClick = onDone) { Text("Cancel") } }, |
|
| 179 | + | ) |
|
| 180 | + | } |
|
| 181 | + | ||
| 182 | + | /** |
|
| 183 | + | * Who the issue is assigned to (#4): each removable, you with one tap, and |
|
| 184 | + | * anyone by their username; saved as the whole set (triage on the hub). |
|
| 185 | + | */ |
|
| 186 | + | @Composable |
|
| 187 | + | private fun AssigneePicker(current: List<String>, me: String, onSave: (List<String>) -> Unit, onDone: () -> Unit) { |
|
| 188 | + | val chosen = remember { mutableStateListOf<String>().apply { addAll(current) } } |
|
| 189 | + | var name by remember { mutableStateOf("") } |
|
| 190 | + | AlertDialog( |
|
| 191 | + | onDismissRequest = onDone, |
|
| 192 | + | title = { Text("Assignees") }, |
|
| 193 | + | text = { |
|
| 194 | + | Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { |
|
| 195 | + | if (chosen.isEmpty()) Text("No one yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) |
|
| 196 | + | chosen.forEach { who -> |
|
| 197 | + | Row(verticalAlignment = Alignment.CenterVertically) { |
|
| 198 | + | Text("@$who", modifier = Modifier.weight(1f)) |
|
| 199 | + | IconButton(onClick = { chosen -= who }) { Icon(Icons.Outlined.Close, contentDescription = "Unassign $who") } |
|
| 200 | + | } |
|
| 201 | + | } |
|
| 202 | + | if (me.isNotEmpty() && me !in chosen) TextButton(onClick = { chosen += me }) { Text("Assign me") } |
|
| 203 | + | OutlinedTextField( |
|
| 204 | + | value = name, |
|
| 205 | + | onValueChange = { name = it.trim() }, |
|
| 206 | + | label = { Text("Username") }, |
|
| 207 | + | singleLine = true, |
|
| 208 | + | trailingIcon = { |
|
| 209 | + | IconButton(enabled = name.isNotBlank(), onClick = { |
|
| 210 | + | val who = name.removePrefix("@") |
|
| 211 | + | if (who.isNotEmpty() && who !in chosen) chosen += who |
|
| 212 | + | name = "" |
|
| 213 | + | }) { Icon(Icons.Outlined.Add, contentDescription = "Add") } |
|
| 214 | + | }, |
|
| 215 | + | ) |
|
| 216 | + | } |
|
| 217 | + | }, |
|
| 218 | + | confirmButton = { |
|
| 219 | + | TextButton(onClick = { |
|
| 220 | + | onSave(chosen.toList()) |
|
| 167 | 221 | onDone() |
|
| 168 | 222 | }) { Text("Save") } |
|
| 169 | 223 | }, |
|
| … | 20 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/issues/IssueViewModel.kt +4 −0
| … | 76 unchanged lines |
||
| 77 | 77 | ||
| 78 | 78 | fun setLabels(names: List<String>) = act({ swap(api.setIssueLabels(owner, name, number, names)) }) |
|
| 79 | 79 | ||
| 80 | + | /** Who the issue is assigned to, as the whole set (#4): names trimmed, `@` dropped, each once. */ |
|
| 81 | + | fun setAssignees(names: List<String>) = |
|
| 82 | + | act({ swap(api.setIssueAssignees(owner, name, number, names.map { it.trim().removePrefix("@") }.filter { it.isNotEmpty() }.distinct())) }) |
|
| 83 | + | ||
| 80 | 84 | fun dismissError() { |
|
| 81 | 85 | _error.value = null |
|
| 82 | 86 | } |
|
| … | 57 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +1 −1
| … | 276 unchanged lines |
||
| 277 | 277 | } |
|
| 278 | 278 | composable<IssueRoute> { e -> |
|
| 279 | 279 | val r = e.toRoute<IssueRoute>() |
|
| 280 | - | IssueScreen(rememberViewModel { IssueViewModel(api, r.owner, r.name, r.number) }, canWrite = session.canWrite, onBack = { nav.popBackStack() }) |
|
| 280 | + | IssueScreen(rememberViewModel { IssueViewModel(api, r.owner, r.name, r.number) }, canWrite = session.canWrite, onBack = { nav.popBackStack() }, me = session.username) |
|
| 281 | 281 | } |
|
| 282 | 282 | composable<NewIssueRoute> { e -> |
|
| 283 | 283 | val r = e.toRoute<NewIssueRoute>() |
|
| … | 66 unchanged lines |
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +6 −0
| … | 137 unchanged lines |
||
| 138 | 138 | issueStore[number] = i |
|
| 139 | 139 | return i |
|
| 140 | 140 | } |
|
| 141 | + | override suspend fun setIssueAssignees(owner: String, name: String, number: Long, assignees: List<String>): io.oxygenhub.android.api.Issue { |
|
| 142 | + | val i = issueStore.getValue(number).copy(assignees = assignees) |
|
| 143 | + | issueStore[number] = i |
|
| 144 | + | calls += "assign $number ${assignees.joinToString(",")}" |
|
| 145 | + | return i |
|
| 146 | + | } |
|
| 141 | 147 | var repository: io.oxygenhub.android.api.Repository = io.oxygenhub.android.api.Repository("andymccall", "demo", defaultBookmark = "main") |
|
| 142 | 148 | override suspend fun repository(owner: String, name: String) = repository.also { calls += "repository" } |
|
| 143 | 149 | var tree: (String, String) -> List<io.oxygenhub.android.api.TreeEntry> = { _, _ -> emptyList() } |
|
| … | 42 unchanged lines |
modified app/src/test/java/io/oxygenhub/android/ui/IssueViewModelTest.kt +10 −0
| … | 54 unchanged lines |
||
| 55 | 55 | } |
|
| 56 | 56 | ||
| 57 | 57 | @Test |
|
| 58 | + | fun assigneesAreSetAsAWholeSetTidied() { |
|
| 59 | + | val vm = IssueViewModel(api, "andymccall", "demo", 7) |
|
| 60 | + | vm.setAssignees(listOf(" andymccall ", "@bob", "bob", "")) |
|
| 61 | + | assertEquals(listOf("andymccall", "bob"), thread(vm).issue.assignees) |
|
| 62 | + | assertTrue("assign 7 andymccall,bob" in api.calls) |
|
| 63 | + | vm.setAssignees(emptyList()) |
|
| 64 | + | assertEquals(emptyList<String>(), thread(vm).issue.assignees) |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | @Test |
|
| 58 | 68 | fun labelsAreSetAsAWholeSet() { |
|
| 59 | 69 | val vm = IssueViewModel(api, "andymccall", "demo", 7) |
|
| 60 | 70 | vm.loadLabels() |
|
| … | 35 unchanged lines |