OXYGENHUB

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