Issues on the phone: read, comment, close and reopen, labels, and new issues (refs #4) 31e4f3755627
An issue opens on its own screen rather than the web page: its title, state, labels, assignees and milestone, the body and comments rendered as Markdown; a comment box, Close issue or Reopen, and a label picker that saves the whole set, when the session may write. New issue above the list opens one. What the hub refuses is said under the thread.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 80598755ff42
- When
- · 2026-10-25 22:41 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
31e4f37556277c9523f9d989414ea80df21e470444f4166953b3f15599c449fa- Depends on
- 144cbf7bf2b5 4aee5293461d 5ae4af48ddd2 98dbd1220929 e2df27a81e5d
- Depended on by
- 821035db5a1c 840c75813f86 dce8ebd2482a 2a15621eca66 62429abe5869 dfbc3e972872 1c42a70fcba3 e833eb0a9e7f
- 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
- 28 atoms, 3 tree operations, 9 files
Browse the tree at this change
added app/src/main/java/io/oxygenhub/android/ui/issues/IssueScreen.kt +189 −0
| 1 | + | package io.oxygenhub.android.ui.issues |
|
| 2 | + | ||
| 3 | + | import androidx.compose.foundation.layout.Arrangement |
|
| 4 | + | import androidx.compose.foundation.layout.Column |
|
| 5 | + | import androidx.compose.foundation.layout.ExperimentalLayoutApi |
|
| 6 | + | import androidx.compose.foundation.layout.FlowRow |
|
| 7 | + | import androidx.compose.foundation.layout.Row |
|
| 8 | + | import androidx.compose.foundation.layout.fillMaxWidth |
|
| 9 | + | import androidx.compose.foundation.layout.imePadding |
|
| 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.material.icons.Icons |
|
| 14 | + | import androidx.compose.material.icons.automirrored.outlined.Label |
|
| 15 | + | import androidx.compose.material3.AlertDialog |
|
| 16 | + | import androidx.compose.material3.Button |
|
| 17 | + | import androidx.compose.material3.Checkbox |
|
| 18 | + | import androidx.compose.material3.ElevatedCard |
|
| 19 | + | import androidx.compose.material3.HorizontalDivider |
|
| 20 | + | import androidx.compose.material3.Icon |
|
| 21 | + | import androidx.compose.material3.IconButton |
|
| 22 | + | import androidx.compose.material3.MaterialTheme |
|
| 23 | + | import androidx.compose.material3.OutlinedButton |
|
| 24 | + | import androidx.compose.material3.OutlinedTextField |
|
| 25 | + | import androidx.compose.material3.Text |
|
| 26 | + | import androidx.compose.material3.TextButton |
|
| 27 | + | import androidx.compose.runtime.Composable |
|
| 28 | + | import androidx.compose.runtime.LaunchedEffect |
|
| 29 | + | import androidx.compose.runtime.getValue |
|
| 30 | + | import androidx.compose.runtime.mutableStateListOf |
|
| 31 | + | import androidx.compose.runtime.mutableStateOf |
|
| 32 | + | import androidx.compose.runtime.remember |
|
| 33 | + | import androidx.compose.runtime.saveable.rememberSaveable |
|
| 34 | + | import androidx.compose.runtime.setValue |
|
| 35 | + | import androidx.compose.ui.Alignment |
|
| 36 | + | import androidx.compose.ui.Modifier |
|
| 37 | + | import androidx.compose.ui.unit.dp |
|
| 38 | + | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|
| 39 | + | import com.mikepenz.markdown.m3.Markdown |
|
| 40 | + | import io.oxygenhub.android.api.Comment |
|
| 41 | + | import io.oxygenhub.android.ui.common.AppScreen |
|
| 42 | + | import io.oxygenhub.android.ui.common.Load |
|
| 43 | + | import io.oxygenhub.android.ui.common.LoadContent |
|
| 44 | + | import io.oxygenhub.android.ui.common.relativeTime |
|
| 45 | + | import io.oxygenhub.android.ui.theme.LocalStatusColors |
|
| 46 | + | ||
| 47 | + | /** |
|
| 48 | + | * An issue (#4): its body and comments rendered, its labels and |
|
| 49 | + | * assignees; comment, close or reopen, and change its labels, when the |
|
| 50 | + | * session may write. |
|
| 51 | + | */ |
|
| 52 | + | @OptIn(ExperimentalLayoutApi::class) |
|
| 53 | + | @Composable |
|
| 54 | + | fun IssueScreen(vm: IssueViewModel, canWrite: Boolean, onBack: () -> Unit) { |
|
| 55 | + | val state by vm.state.collectAsStateWithLifecycle() |
|
| 56 | + | val working by vm.working.collectAsStateWithLifecycle() |
|
| 57 | + | val error by vm.error.collectAsStateWithLifecycle() |
|
| 58 | + | var picking by remember { mutableStateOf(false) } |
|
| 59 | + | val status = LocalStatusColors.current |
|
| 60 | + | AppScreen( |
|
| 61 | + | title = "#${vm.number}", |
|
| 62 | + | subtitle = "${vm.owner}/${vm.name}", |
|
| 63 | + | onBack = onBack, |
|
| 64 | + | actions = { |
|
| 65 | + | if (canWrite) { |
|
| 66 | + | IconButton(onClick = { |
|
| 67 | + | vm.loadLabels() |
|
| 68 | + | picking = true |
|
| 69 | + | }) { Icon(Icons.AutoMirrored.Outlined.Label, contentDescription = "Labels") } |
|
| 70 | + | } |
|
| 71 | + | }, |
|
| 72 | + | ) { |
|
| 73 | + | LoadContent(state, onRefresh = vm::refresh) { t -> |
|
| 74 | + | val i = t.issue |
|
| 75 | + | LazyColumn(Modifier.imePadding()) { |
|
| 76 | + | item { |
|
| 77 | + | Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { |
|
| 78 | + | Text(i.title, style = MaterialTheme.typography.titleLarge) |
|
| 79 | + | Text( |
|
| 80 | + | (if (i.isOpen) "Open" else "Closed" + if (i.reason == "not_planned") " (not planned)" else "") + |
|
| 81 | + | " · opened ${relativeTime(i.createdMs)} by ${i.author}", |
|
| 82 | + | color = if (i.isOpen) status.ok else status.warn, |
|
| 83 | + | style = MaterialTheme.typography.bodyMedium, |
|
| 84 | + | ) |
|
| 85 | + | if (i.labels.isNotEmpty()) { |
|
| 86 | + | FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { |
|
| 87 | + | i.labels.forEach { LabelChip(it) } |
|
| 88 | + | } |
|
| 89 | + | } |
|
| 90 | + | if (i.assignees.isNotEmpty()) Text("Assigned to " + i.assignees.joinToString(", "), style = MaterialTheme.typography.bodySmall) |
|
| 91 | + | i.milestone?.let { Text("Milestone: $it", style = MaterialTheme.typography.bodySmall) } |
|
| 92 | + | if (i.body.isNotBlank()) Markdown(i.body) else Text("No description.", color = MaterialTheme.colorScheme.onSurfaceVariant) |
|
| 93 | + | } |
|
| 94 | + | HorizontalDivider() |
|
| 95 | + | } |
|
| 96 | + | items(t.comments, key = { it.id }) { c -> CommentCard(c) } |
|
| 97 | + | item { |
|
| 98 | + | error?.let { |
|
| 99 | + | Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) |
|
| 100 | + | } |
|
| 101 | + | if (canWrite && !i.locked) { |
|
| 102 | + | CommentBox(working, isOpen = i.isOpen, onComment = { body, done -> vm.comment(body, done) }, onClose = { vm.close() }, onReopen = vm::reopen) |
|
| 103 | + | } else if (i.locked) { |
|
| 104 | + | Text("Locked: only collaborators may comment.", modifier = Modifier.padding(16.dp)) |
|
| 105 | + | } |
|
| 106 | + | } |
|
| 107 | + | } |
|
| 108 | + | } |
|
| 109 | + | } |
|
| 110 | + | if (picking) { |
|
| 111 | + | LabelPicker(vm, current = (state as? Load.Ready)?.value?.issue?.labels?.map { it.name }.orEmpty(), onDone = { picking = false }) |
|
| 112 | + | } |
|
| 113 | + | } |
|
| 114 | + | ||
| 115 | + | @Composable |
|
| 116 | + | private fun CommentCard(c: Comment) { |
|
| 117 | + | ElevatedCard(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp)) { |
|
| 118 | + | Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { |
|
| 119 | + | Text("${c.author} · ${relativeTime(c.createdMs)}", style = MaterialTheme.typography.labelMedium) |
|
| 120 | + | Markdown(c.body) |
|
| 121 | + | } |
|
| 122 | + | } |
|
| 123 | + | } |
|
| 124 | + | ||
| 125 | + | @Composable |
|
| 126 | + | private fun CommentBox(working: Boolean, isOpen: Boolean, onComment: (String, (Boolean) -> Unit) -> Unit, onClose: () -> Unit, onReopen: () -> Unit) { |
|
| 127 | + | var text by rememberSaveable { mutableStateOf("") } |
|
| 128 | + | Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { |
|
| 129 | + | OutlinedTextField(text, { text = it }, label = { Text("Comment (Markdown)") }, modifier = Modifier.fillMaxWidth(), minLines = 3) |
|
| 130 | + | Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { |
|
| 131 | + | Button(enabled = !working && text.isNotBlank(), onClick = { onComment(text) { ok -> if (ok) text = "" } }) { Text("Comment") } |
|
| 132 | + | if (isOpen) { |
|
| 133 | + | OutlinedButton(enabled = !working, onClick = onClose) { Text("Close issue") } |
|
| 134 | + | } else { |
|
| 135 | + | OutlinedButton(enabled = !working, onClick = onReopen) { Text("Reopen") } |
|
| 136 | + | } |
|
| 137 | + | } |
|
| 138 | + | } |
|
| 139 | + | } |
|
| 140 | + | ||
| 141 | + | /** The repository's labels, ticked where the issue has them; saved as the whole set. */ |
|
| 142 | + | @Composable |
|
| 143 | + | private fun LabelPicker(vm: IssueViewModel, current: List<String>, onDone: () -> Unit) { |
|
| 144 | + | val labels by vm.labels.collectAsStateWithLifecycle() |
|
| 145 | + | val chosen = remember { mutableStateListOf<String>().apply { addAll(current) } } |
|
| 146 | + | LaunchedEffect(Unit) { vm.loadLabels() } |
|
| 147 | + | AlertDialog( |
|
| 148 | + | onDismissRequest = onDone, |
|
| 149 | + | title = { Text("Labels") }, |
|
| 150 | + | text = { |
|
| 151 | + | when (val l = labels) { |
|
| 152 | + | is Load.Ready -> LazyColumn { |
|
| 153 | + | items(l.value, key = { it.name }) { label -> |
|
| 154 | + | Row(verticalAlignment = Alignment.CenterVertically) { |
|
| 155 | + | Checkbox(label.name in chosen, onCheckedChange = { on -> if (on) chosen += label.name else chosen -= label.name }) |
|
| 156 | + | LabelChip(label) |
|
| 157 | + | } |
|
| 158 | + | } |
|
| 159 | + | } |
|
| 160 | + | is Load.Failed -> Text(l.message) |
|
| 161 | + | Load.Loading -> Text("Loading…") |
|
| 162 | + | } |
|
| 163 | + | }, |
|
| 164 | + | confirmButton = { |
|
| 165 | + | TextButton(onClick = { |
|
| 166 | + | vm.setLabels(chosen.toList()) |
|
| 167 | + | onDone() |
|
| 168 | + | }) { Text("Save") } |
|
| 169 | + | }, |
|
| 170 | + | dismissButton = { TextButton(onClick = onDone) { Text("Cancel") } }, |
|
| 171 | + | ) |
|
| 172 | + | } |
|
| 173 | + | ||
| 174 | + | /** A new issue's title and description; opened, it shows on its own screen. */ |
|
| 175 | + | @Composable |
|
| 176 | + | fun NewIssueScreen(vm: NewIssueViewModel, onBack: () -> Unit, onOpened: (Long) -> Unit) { |
|
| 177 | + | val working by vm.working.collectAsStateWithLifecycle() |
|
| 178 | + | val error by vm.error.collectAsStateWithLifecycle() |
|
| 179 | + | var title by rememberSaveable { mutableStateOf("") } |
|
| 180 | + | var body by rememberSaveable { mutableStateOf("") } |
|
| 181 | + | AppScreen(title = "New issue", subtitle = "${vm.owner}/${vm.name}", onBack = onBack) { |
|
| 182 | + | Column(Modifier.padding(16.dp).imePadding(), verticalArrangement = Arrangement.spacedBy(12.dp)) { |
|
| 183 | + | OutlinedTextField(title, { title = it }, label = { Text("Title") }, singleLine = true, modifier = Modifier.fillMaxWidth()) |
|
| 184 | + | OutlinedTextField(body, { body = it }, label = { Text("Description (Markdown)") }, minLines = 6, modifier = Modifier.fillMaxWidth()) |
|
| 185 | + | error?.let { Text(it, color = MaterialTheme.colorScheme.error) } |
|
| 186 | + | Button(enabled = !working && title.isNotBlank(), onClick = { vm.open(title, body) { onOpened(it.number) } }) { Text("Open issue") } |
|
| 187 | + | } |
|
| 188 | + | } |
|
| 189 | + | } |
added app/src/main/java/io/oxygenhub/android/ui/issues/IssueViewModel.kt +139 −0
| 1 | + | package io.oxygenhub.android.ui.issues |
|
| 2 | + | ||
| 3 | + | import androidx.lifecycle.ViewModel |
|
| 4 | + | import androidx.lifecycle.viewModelScope |
|
| 5 | + | import io.oxygenhub.android.api.Comment |
|
| 6 | + | import io.oxygenhub.android.api.HubApi |
|
| 7 | + | import io.oxygenhub.android.api.Issue |
|
| 8 | + | import io.oxygenhub.android.api.Label |
|
| 9 | + | import io.oxygenhub.android.api.userMessage |
|
| 10 | + | import io.oxygenhub.android.ui.common.Load |
|
| 11 | + | import io.oxygenhub.android.ui.common.load |
|
| 12 | + | import kotlinx.coroutines.CancellationException |
|
| 13 | + | import kotlinx.coroutines.Job |
|
| 14 | + | import kotlinx.coroutines.async |
|
| 15 | + | import kotlinx.coroutines.coroutineScope |
|
| 16 | + | import kotlinx.coroutines.flow.MutableStateFlow |
|
| 17 | + | import kotlinx.coroutines.flow.StateFlow |
|
| 18 | + | import kotlinx.coroutines.flow.asStateFlow |
|
| 19 | + | import kotlinx.coroutines.launch |
|
| 20 | + | ||
| 21 | + | /** |
|
| 22 | + | * One issue (#4): it and its comments; comment, close or reopen, and set |
|
| 23 | + | * its labels. What the hub refuses is said in [error], and the page stays. |
|
| 24 | + | */ |
|
| 25 | + | class IssueViewModel( |
|
| 26 | + | private val api: HubApi, |
|
| 27 | + | val owner: String, |
|
| 28 | + | val name: String, |
|
| 29 | + | val number: Long, |
|
| 30 | + | ) : ViewModel() { |
|
| 31 | + | data class Thread(val issue: Issue, val comments: List<Comment>) |
|
| 32 | + | ||
| 33 | + | private val _state = MutableStateFlow<Load<Thread>>(Load.Loading) |
|
| 34 | + | val state: StateFlow<Load<Thread>> = _state.asStateFlow() |
|
| 35 | + | ||
| 36 | + | private val _working = MutableStateFlow(false) |
|
| 37 | + | val working: StateFlow<Boolean> = _working.asStateFlow() |
|
| 38 | + | ||
| 39 | + | private val _error = MutableStateFlow<String?>(null) |
|
| 40 | + | val error: StateFlow<String?> = _error.asStateFlow() |
|
| 41 | + | ||
| 42 | + | /** The repository's labels, once the label picker asks. */ |
|
| 43 | + | private val _labels = MutableStateFlow<Load<List<Label>>>(Load.Loading) |
|
| 44 | + | val labels: StateFlow<Load<List<Label>>> = _labels.asStateFlow() |
|
| 45 | + | ||
| 46 | + | private var job: Job? = null |
|
| 47 | + | private var labelsJob: Job? = null |
|
| 48 | + | ||
| 49 | + | init { |
|
| 50 | + | refresh() |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | fun refresh() { |
|
| 54 | + | job = load(_state, job) { |
|
| 55 | + | coroutineScope { |
|
| 56 | + | val issue = async { api.issue(owner, name, number) } |
|
| 57 | + | val comments = async { api.issueComments(owner, name, number) } |
|
| 58 | + | Thread(issue.await(), comments.await()) |
|
| 59 | + | } |
|
| 60 | + | } |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | fun loadLabels() { |
|
| 64 | + | if (_labels.value is Load.Ready) return |
|
| 65 | + | labelsJob = load(_labels, labelsJob) { api.labels(owner, name) } |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | /** Post [body]; true once the hub has it (the box empties then). */ |
|
| 69 | + | fun comment(body: String, then: (Boolean) -> Unit = {}) = act({ |
|
| 70 | + | val c = api.comment(owner, name, number, body.trim()) |
|
| 71 | + | current()?.let { t -> _state.value = Load.Ready(t.copy(comments = t.comments + c, issue = t.issue.copy(comments = t.issue.comments + 1))) } |
|
| 72 | + | }, then) |
|
| 73 | + | ||
| 74 | + | fun close(reason: String = "completed") = act({ swap(api.setIssueState(owner, name, number, open = false, reason = reason)) }) |
|
| 75 | + | ||
| 76 | + | fun reopen() = act({ swap(api.setIssueState(owner, name, number, open = true)) }) |
|
| 77 | + | ||
| 78 | + | fun setLabels(names: List<String>) = act({ swap(api.setIssueLabels(owner, name, number, names)) }) |
|
| 79 | + | ||
| 80 | + | fun dismissError() { |
|
| 81 | + | _error.value = null |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | private fun current(): Thread? = (_state.value as? Load.Ready)?.value |
|
| 85 | + | ||
| 86 | + | private fun swap(issue: Issue) { |
|
| 87 | + | current()?.let { _state.value = Load.Ready(it.copy(issue = issue)) } |
|
| 88 | + | } |
|
| 89 | + | ||
| 90 | + | private fun act(step: suspend () -> Unit, then: (Boolean) -> Unit = {}) { |
|
| 91 | + | if (_working.value) return |
|
| 92 | + | _working.value = true |
|
| 93 | + | _error.value = null |
|
| 94 | + | viewModelScope.launch { |
|
| 95 | + | val ok = try { |
|
| 96 | + | step() |
|
| 97 | + | true |
|
| 98 | + | } catch (e: CancellationException) { |
|
| 99 | + | throw e |
|
| 100 | + | } catch (e: Exception) { |
|
| 101 | + | _error.value = e.userMessage() |
|
| 102 | + | false |
|
| 103 | + | } finally { |
|
| 104 | + | _working.value = false |
|
| 105 | + | } |
|
| 106 | + | then(ok) |
|
| 107 | + | } |
|
| 108 | + | } |
|
| 109 | + | } |
|
| 110 | + | ||
| 111 | + | /** A new issue: its title and body, opened on the hub. */ |
|
| 112 | + | class NewIssueViewModel( |
|
| 113 | + | private val api: HubApi, |
|
| 114 | + | val owner: String, |
|
| 115 | + | val name: String, |
|
| 116 | + | ) : ViewModel() { |
|
| 117 | + | private val _working = MutableStateFlow(false) |
|
| 118 | + | val working: StateFlow<Boolean> = _working.asStateFlow() |
|
| 119 | + | ||
| 120 | + | private val _error = MutableStateFlow<String?>(null) |
|
| 121 | + | val error: StateFlow<String?> = _error.asStateFlow() |
|
| 122 | + | ||
| 123 | + | fun open(title: String, body: String, opened: (Issue) -> Unit) { |
|
| 124 | + | if (_working.value || title.isBlank()) return |
|
| 125 | + | _working.value = true |
|
| 126 | + | _error.value = null |
|
| 127 | + | viewModelScope.launch { |
|
| 128 | + | try { |
|
| 129 | + | opened(api.createIssue(owner, name, title.trim(), body.trim())) |
|
| 130 | + | } catch (e: CancellationException) { |
|
| 131 | + | throw e |
|
| 132 | + | } catch (e: Exception) { |
|
| 133 | + | _error.value = e.userMessage() |
|
| 134 | + | } finally { |
|
| 135 | + | _working.value = false |
|
| 136 | + | } |
|
| 137 | + | } |
|
| 138 | + | } |
|
| 139 | + | } |
added app/src/test/java/io/oxygenhub/android/ui/IssueViewModelTest.kt +95 −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.Comment |
|
| 6 | + | import io.oxygenhub.android.api.HubException |
|
| 7 | + | import io.oxygenhub.android.api.Issue |
|
| 8 | + | import io.oxygenhub.android.ui.common.Load |
|
| 9 | + | import io.oxygenhub.android.ui.issues.IssueViewModel |
|
| 10 | + | import io.oxygenhub.android.ui.issues.NewIssueViewModel |
|
| 11 | + | import org.junit.Assert.assertEquals |
|
| 12 | + | import org.junit.Assert.assertFalse |
|
| 13 | + | import org.junit.Assert.assertNull |
|
| 14 | + | import org.junit.Assert.assertTrue |
|
| 15 | + | import org.junit.Rule |
|
| 16 | + | import org.junit.Test |
|
| 17 | + | ||
| 18 | + | class IssueViewModelTest { |
|
| 19 | + | @get:Rule val main = MainDispatcherRule() |
|
| 20 | + | ||
| 21 | + | private val api = FakeHubApi().apply { |
|
| 22 | + | issueStore[7] = Issue(7, "Crash on start", "It **crashes**.", author = "bob", comments = 1) |
|
| 23 | + | commentStore[7] = mutableListOf(Comment(1, "bob", "Still happens")) |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | private fun thread(vm: IssueViewModel) = (vm.state.value as Load.Ready).value |
|
| 27 | + | ||
| 28 | + | @Test |
|
| 29 | + | fun showsTheIssueAndItsComments() { |
|
| 30 | + | val vm = IssueViewModel(api, "andymccall", "demo", 7) |
|
| 31 | + | assertEquals("Crash on start", thread(vm).issue.title) |
|
| 32 | + | assertEquals(listOf("Still happens"), thread(vm).comments.map { it.body }) |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | @Test |
|
| 36 | + | fun aCommentJoinsTheThreadAndTheBoxEmpties() { |
|
| 37 | + | val vm = IssueViewModel(api, "andymccall", "demo", 7) |
|
| 38 | + | var done: Boolean? = null |
|
| 39 | + | vm.comment(" Fixed in 4f07c5a0 ") { done = it } |
|
| 40 | + | assertEquals(true, done) |
|
| 41 | + | assertEquals(listOf("Still happens", "Fixed in 4f07c5a0"), thread(vm).comments.map { it.body }) |
|
| 42 | + | assertEquals(2, thread(vm).issue.comments) |
|
| 43 | + | assertFalse(vm.working.value) |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | @Test |
|
| 47 | + | fun closingAndReopeningChangeItsState() { |
|
| 48 | + | val vm = IssueViewModel(api, "andymccall", "demo", 7) |
|
| 49 | + | vm.close("not_planned") |
|
| 50 | + | assertFalse(thread(vm).issue.isOpen) |
|
| 51 | + | assertEquals("not_planned", thread(vm).issue.reason) |
|
| 52 | + | assertTrue("state 7 closed:not_planned" in api.calls) |
|
| 53 | + | vm.reopen() |
|
| 54 | + | assertTrue(thread(vm).issue.isOpen) |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | @Test |
|
| 58 | + | fun labelsAreSetAsAWholeSet() { |
|
| 59 | + | val vm = IssueViewModel(api, "andymccall", "demo", 7) |
|
| 60 | + | vm.loadLabels() |
|
| 61 | + | assertEquals(listOf("bug", "ui"), (vm.labels.value as Load.Ready).value.map { it.name }) |
|
| 62 | + | vm.setLabels(listOf("bug")) |
|
| 63 | + | assertEquals(listOf("bug"), thread(vm).issue.labels.map { it.name }) |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | @Test |
|
| 67 | + | fun whatTheHubRefusesIsSaidAndThePageStays() { |
|
| 68 | + | val vm = IssueViewModel(api, "andymccall", "demo", 7) |
|
| 69 | + | api.commentStore.clear() |
|
| 70 | + | val failing = object : io.oxygenhub.android.api.HubApi by api { |
|
| 71 | + | override suspend fun comment(owner: String, name: String, number: Long, body: String): Comment = |
|
| 72 | + | throw HubException.Http(403, "the issue is locked") |
|
| 73 | + | } |
|
| 74 | + | val locked = IssueViewModel(failing, "andymccall", "demo", 7) |
|
| 75 | + | var done: Boolean? = null |
|
| 76 | + | locked.comment("hello") { done = it } |
|
| 77 | + | assertEquals(false, done) |
|
| 78 | + | assertTrue(locked.error.value!!.contains("locked")) |
|
| 79 | + | assertEquals("Crash on start", thread(locked).issue.title) |
|
| 80 | + | locked.dismissError() |
|
| 81 | + | assertNull(locked.error.value) |
|
| 82 | + | assertEquals("Crash on start", thread(vm).issue.title) |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | @Test |
|
| 86 | + | fun aNewIssueOpensWithItsTitle() { |
|
| 87 | + | val vm = NewIssueViewModel(api, "andymccall", "demo") |
|
| 88 | + | var opened: Issue? = null |
|
| 89 | + | vm.open(" ", "body") { opened = it } |
|
| 90 | + | assertNull("no title, nothing opened", opened) |
|
| 91 | + | vm.open(" Board grows ", "Done keeps growing") { opened = it } |
|
| 92 | + | assertEquals("Board grows", opened?.title) |
|
| 93 | + | assertEquals(8L, opened?.number) |
|
| 94 | + | } |
|
| 95 | + | } |
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +67 −1
| 1 | 1 | package io.oxygenhub.android.api |
|
| 2 | 2 | ||
| 3 | + | import java.io.IOException |
|
| 3 | 4 | import kotlinx.serialization.KSerializer |
|
| 4 | 5 | import kotlinx.serialization.builtins.ListSerializer |
|
| 6 | + | import kotlinx.serialization.json.JsonArray |
|
| 7 | + | import kotlinx.serialization.json.JsonObject |
|
| 8 | + | import kotlinx.serialization.json.JsonPrimitive |
|
| 9 | + | import kotlinx.serialization.json.buildJsonObject |
|
| 10 | + | import kotlinx.serialization.json.put |
|
| 5 | 11 | import okhttp3.HttpUrl |
|
| 6 | 12 | import okhttp3.HttpUrl.Companion.toHttpUrl |
|
| 7 | 13 | import okhttp3.OkHttpClient |
|
| 8 | 14 | import okhttp3.Request |
|
| 9 | 15 | import okhttp3.RequestBody.Companion.toRequestBody |
|
| 10 | - | import java.io.IOException |
|
| 11 | 16 | ||
| 12 | 17 | /** |
|
| 13 | 18 | * What the app asks of a hub, signed in. The view models see only this, so |
|
| … | 47 unchanged lines |
||
| 61 | 66 | ||
| 62 | 67 | /** A one-time token registering this phone as a runner for the account's repositories (`repo:write`). */ |
|
| 63 | 68 | suspend fun runnerToken(): RunnerToken |
|
| 69 | + | ||
| 70 | + | suspend fun issue(owner: String, name: String, number: Long): Issue |
|
| 71 | + | ||
| 72 | + | suspend fun issueComments(owner: String, name: String, number: Long): List<Comment> |
|
| 73 | + | ||
| 74 | + | suspend fun comment(owner: String, name: String, number: Long, body: String): Comment |
|
| 75 | + | ||
| 76 | + | /** Close (with why: `completed` or `not_planned`) or reopen. */ |
|
| 77 | + | suspend fun setIssueState(owner: String, name: String, number: Long, open: Boolean, reason: String = "completed"): Issue |
|
| 78 | + | ||
| 79 | + | suspend fun createIssue(owner: String, name: String, title: String, body: String): Issue |
|
| 64 | 80 | ||
| 81 | + | /** The repository's labels, to choose from. */ |
|
| 82 | + | suspend fun labels(owner: String, name: String): List<Label> |
|
| 83 | + | ||
| 84 | + | /** The issue's whole set of labels (triage). */ |
|
| 85 | + | suspend fun setIssueLabels(owner: String, name: String, number: Long, labels: List<String>): Issue |
|
| 86 | + | ||
| 65 | 87 | /** One repository (`GET /repos/{owner}/{name}`): its default bookmark, for the code browser. */ |
|
| 66 | 88 | suspend fun repository(owner: String, name: String): Repository |
|
| 67 | 89 | ||
| … | 40 unchanged lines |
||
| 108 | 130 | get(url("notifications") { addQueryParameter("limit", limit.coerceIn(1, 200).toString()) }, Inbox.serializer()) |
|
| 109 | 131 | ||
| 110 | 132 | override suspend fun runnerToken(): RunnerToken = send("POST", url("user", "runner-token"), RunnerToken.serializer()) |
|
| 133 | + | ||
| 134 | + | override suspend fun issue(owner: String, name: String, number: Long): Issue = |
|
| 135 | + | get(url("repos", owner, name, "issues", number.toString()), Issue.serializer()) |
|
| 111 | 136 | ||
| 137 | + | override suspend fun issueComments(owner: String, name: String, number: Long): List<Comment> = |
|
| 138 | + | get(url("repos", owner, name, "issues", number.toString(), "comments"), ListSerializer(Comment.serializer())) |
|
| 139 | + | ||
| 140 | + | override suspend fun comment(owner: String, name: String, number: Long, body: String): Comment = |
|
| 141 | + | sendJson("POST", url("repos", owner, name, "issues", number.toString(), "comments"), buildJsonObject { put("body", body) }, Comment.serializer()) |
|
| 142 | + | ||
| 143 | + | override suspend fun setIssueState(owner: String, name: String, number: Long, open: Boolean, reason: String): Issue = |
|
| 144 | + | sendJson( |
|
| 145 | + | "POST", |
|
| 146 | + | url("repos", owner, name, "issues", number.toString()), |
|
| 147 | + | buildJsonObject { |
|
| 148 | + | put("state", if (open) "open" else "closed") |
|
| 149 | + | if (!open) put("reason", reason) |
|
| 150 | + | }, |
|
| 151 | + | Issue.serializer(), |
|
| 152 | + | ) |
|
| 153 | + | ||
| 154 | + | override suspend fun createIssue(owner: String, name: String, title: String, body: String): Issue = |
|
| 155 | + | sendJson("POST", url("repos", owner, name, "issues"), buildJsonObject { |
|
| 156 | + | put("title", title) |
|
| 157 | + | put("body", body) |
|
| 158 | + | }, Issue.serializer()) |
|
| 159 | + | ||
| 160 | + | override suspend fun labels(owner: String, name: String): List<Label> = |
|
| 161 | + | get(url("repos", owner, name, "labels"), ListSerializer(Label.serializer())) |
|
| 162 | + | ||
| 163 | + | override suspend fun setIssueLabels(owner: String, name: String, number: Long, labels: List<String>): Issue = |
|
| 164 | + | sendJson("PATCH", url("repos", owner, name, "issues", number.toString()), buildJsonObject { |
|
| 165 | + | put("labels", JsonArray(labels.map { JsonPrimitive(it) })) |
|
| 166 | + | }, Issue.serializer()) |
|
| 167 | + | ||
| 112 | 168 | override suspend fun repository(owner: String, name: String): Repository = get(url("repos", owner, name), Repository.serializer()) |
|
| 113 | 169 | ||
| 114 | 170 | private fun parts(path: String): Array<String> = path.split('/').filter { it.isNotEmpty() }.toTypedArray() |
|
| … | 103 unchanged lines |
||
| 218 | 274 | ||
| 219 | 275 | private suspend fun <T> send(method: String, url: HttpUrl, serializer: KSerializer<T>): T { |
|
| 220 | 276 | val body = send(method, url) |
|
| 277 | + | return try { |
|
| 278 | + | HubJson.decodeFromString(serializer, body) |
|
| 279 | + | } catch (e: IllegalArgumentException) { |
|
| 280 | + | throw HubException.Unreadable("$method ${url.encodedPath}", e) |
|
| 281 | + | } |
|
| 282 | + | } |
|
| 283 | + | ||
| 284 | + | /** [json] as the body; the answer read with [serializer]. */ |
|
| 285 | + | private suspend fun <T> sendJson(method: String, url: HttpUrl, json: JsonObject, serializer: KSerializer<T>): T { |
|
| 286 | + | val body = call(request(url).method(method, json.toString().toRequestBody(JsonMedia)).build(), notifyUnauthorized = true) |
|
| 221 | 287 | return try { |
|
| 222 | 288 | HubJson.decodeFromString(serializer, body) |
|
| 223 | 289 | } catch (e: IllegalArgumentException) { |
|
| … | 29 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/api/Models.kt +9 −0
| … | 76 unchanged lines |
||
| 77 | 77 | val isOpen: Boolean get() = state == "open" |
|
| 78 | 78 | } |
|
| 79 | 79 | ||
| 80 | + | /** A comment on an issue or proposal. */ |
|
| 81 | + | @Serializable |
|
| 82 | + | data class Comment( |
|
| 83 | + | val id: Long = 0, |
|
| 84 | + | val author: String = "", |
|
| 85 | + | val body: String = "", |
|
| 86 | + | @SerialName("created_ms") val createdMs: Long = 0, |
|
| 87 | + | ) |
|
| 88 | + | ||
| 80 | 89 | /** Which issues to list: the hub's `?state=`. */ |
|
| 81 | 90 | enum class IssueState(val query: String) { Open("open"), Closed("closed"), All("all") } |
|
| 82 | 91 | ||
| … | 235 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/issues/IssuesScreen.kt +9 −6
| … | 24 unchanged lines |
||
| 25 | 25 | import androidx.compose.material3.ListItem |
|
| 26 | 26 | import androidx.compose.material3.MaterialTheme |
|
| 27 | 27 | import androidx.compose.material3.Text |
|
| 28 | + | import androidx.compose.material3.TextButton |
|
| 28 | 29 | import androidx.compose.runtime.Composable |
|
| 29 | 30 | import androidx.compose.runtime.getValue |
|
| 30 | 31 | import androidx.compose.ui.Alignment |
|
| … | 1 unchanged lines |
||
| 32 | 33 | import androidx.compose.ui.draw.clip |
|
| 33 | 34 | import androidx.compose.ui.graphics.Color |
|
| 34 | 35 | import androidx.compose.ui.graphics.luminance |
|
| 35 | - | import androidx.compose.ui.platform.LocalUriHandler |
|
| 36 | 36 | import androidx.compose.ui.text.style.TextOverflow |
|
| 37 | 37 | import androidx.compose.ui.unit.dp |
|
| 38 | 38 | import androidx.compose.ui.unit.sp |
|
| … | 7 unchanged lines |
||
| 46 | 46 | import io.oxygenhub.android.ui.theme.LocalStatusColors |
|
| 47 | 47 | ||
| 48 | 48 | /** |
|
| 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. |
|
| 49 | + | * A repository's issues with their filter, for the repository's tab; an |
|
| 50 | + | * issue opens on its own screen (#4), and [onNewIssue] starts one. |
|
| 51 | 51 | */ |
|
| 52 | 52 | @Composable |
|
| 53 | - | fun IssueList(vm: IssuesViewModel) { |
|
| 53 | + | fun IssueList(vm: IssuesViewModel, onOpenIssue: (Long) -> Unit, onNewIssue: (() -> Unit)?) { |
|
| 54 | 54 | val state by vm.state.collectAsStateWithLifecycle() |
|
| 55 | 55 | val filter by vm.filter.collectAsStateWithLifecycle() |
|
| 56 | - | val uri = LocalUriHandler.current |
|
| 57 | 56 | Column(Modifier.fillMaxSize()) { |
|
| 58 | 57 | Row( |
|
| 59 | 58 | Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp), |
|
| 60 | 59 | horizontalArrangement = Arrangement.spacedBy(8.dp), |
|
| 60 | + | verticalAlignment = Alignment.CenterVertically, |
|
| 61 | 61 | ) { |
|
| 62 | 62 | IssueState.entries.forEach { s -> |
|
| 63 | 63 | FilterChip(selected = filter == s, onClick = { vm.setFilter(s) }, label = { Text(s.name) }) |
|
| 64 | 64 | } |
|
| 65 | + | if (onNewIssue != null) { |
|
| 66 | + | TextButton(onClick = onNewIssue) { Text("New issue") } |
|
| 67 | + | } |
|
| 65 | 68 | } |
|
| 66 | 69 | LoadContent(state, onRefresh = vm::refresh) { issues -> |
|
| 67 | 70 | if (issues.isEmpty()) { |
|
| … | 9 unchanged lines |
||
| 77 | 80 | } else { |
|
| 78 | 81 | LazyColumn(Modifier.fillMaxSize()) { |
|
| 79 | 82 | items(issues, key = { it.number }) { issue -> |
|
| 80 | - | IssueRow(issue, onClick = { if (issue.url.isNotBlank()) uri.openUri(issue.url) }) |
|
| 83 | + | IssueRow(issue, onClick = { onOpenIssue(issue.number) }) |
|
| 81 | 84 | HorizontalDivider() |
|
| 82 | 85 | } |
|
| 83 | 86 | } |
|
| … | 75 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +25 −0
| … | 47 unchanged lines |
||
| 48 | 48 | import io.oxygenhub.android.ui.dashboard.DashboardViewModel |
|
| 49 | 49 | import io.oxygenhub.android.ui.inbox.InboxScreen |
|
| 50 | 50 | import io.oxygenhub.android.ui.inbox.InboxViewModel |
|
| 51 | + | import io.oxygenhub.android.ui.issues.IssueScreen |
|
| 52 | + | import io.oxygenhub.android.ui.issues.IssueViewModel |
|
| 51 | 53 | import io.oxygenhub.android.ui.issues.IssuesViewModel |
|
| 54 | + | import io.oxygenhub.android.ui.issues.NewIssueScreen |
|
| 55 | + | import io.oxygenhub.android.ui.issues.NewIssueViewModel |
|
| 52 | 56 | import io.oxygenhub.android.ui.proposals.ProposalScreen |
|
| 53 | 57 | import io.oxygenhub.android.ui.proposals.ProposalViewModel |
|
| 54 | 58 | import io.oxygenhub.android.ui.proposals.ProposalsViewModel |
|
| … | 30 unchanged lines |
||
| 85 | 89 | ||
| 86 | 90 | @Serializable data class ChangeRoute(val owner: String, val name: String, val hash: String) |
|
| 87 | 91 | ||
| 92 | + | /** An issue, and a new one (#4). */ |
|
| 93 | + | @Serializable data class IssueRoute(val owner: String, val name: String, val number: Long) |
|
| 94 | + | ||
| 95 | + | @Serializable data class NewIssueRoute(val owner: String, val name: String) |
|
| 96 | + | ||
| 88 | 97 | enum class TopLevel(val label: String, val icon: ImageVector, val selectedIcon: ImageVector, val route: Any, val routeClass: KClass<*>) { |
|
| 89 | 98 | Dashboard("Dashboard", Icons.Outlined.Dashboard, Icons.Filled.Dashboard, DashboardRoute, DashboardRoute::class), |
|
| 90 | 99 | Repositories("Repositories", Icons.Outlined.Book, Icons.Filled.Book, RepositoriesRoute, RepositoriesRoute::class), |
|
| … | 74 unchanged lines |
||
| 165 | 174 | onOpenProposal = { nav.navigate(ProposalRoute(owner, name, it.number)) }, |
|
| 166 | 175 | onOpenFile = { ref, path -> nav.navigate(FileRoute(owner, name, ref, path)) }, |
|
| 167 | 176 | onOpenHistory = { ref -> nav.navigate(HistoryRoute(owner, name, ref)) }, |
|
| 177 | + | onOpenIssue = { nav.navigate(IssueRoute(owner, name, it)) }, |
|
| 178 | + | onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(owner, name)) } } else null, |
|
| 168 | 179 | ) |
|
| 169 | 180 | } |
|
| 170 | 181 | } |
|
| … | 7 unchanged lines |
||
| 178 | 189 | onOpenProposal = { nav.navigate(ProposalRoute(r.owner, r.name, it.number)) }, |
|
| 179 | 190 | onOpenFile = { ref, path -> nav.navigate(FileRoute(r.owner, r.name, ref, path)) }, |
|
| 180 | 191 | onOpenHistory = { ref -> nav.navigate(HistoryRoute(r.owner, r.name, ref)) }, |
|
| 192 | + | onOpenIssue = { nav.navigate(IssueRoute(r.owner, r.name, it)) }, |
|
| 193 | + | onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(r.owner, r.name)) } } else null, |
|
| 194 | + | ) |
|
| 195 | + | } |
|
| 196 | + | composable<IssueRoute> { e -> |
|
| 197 | + | val r = e.toRoute<IssueRoute>() |
|
| 198 | + | IssueScreen(rememberViewModel { IssueViewModel(api, r.owner, r.name, r.number) }, canWrite = session.canWrite, onBack = { nav.popBackStack() }) |
|
| 199 | + | } |
|
| 200 | + | composable<NewIssueRoute> { e -> |
|
| 201 | + | val r = e.toRoute<NewIssueRoute>() |
|
| 202 | + | NewIssueScreen( |
|
| 203 | + | rememberViewModel { NewIssueViewModel(api, r.owner, r.name) }, |
|
| 204 | + | onBack = { nav.popBackStack() }, |
|
| 205 | + | onOpened = { n -> nav.navigate(IssueRoute(r.owner, r.name, n)) { popUpTo<NewIssueRoute> { inclusive = true } } }, |
|
| 181 | 206 | ) |
|
| 182 | 207 | } |
|
| 183 | 208 | composable<FileRoute> { e -> |
|
| … | 54 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/repos/RepositoryScreen.kt +3 −1
| … | 31 unchanged lines |
||
| 32 | 32 | onOpenProposal: (Proposal) -> Unit, |
|
| 33 | 33 | onOpenFile: (ref: String, path: String) -> Unit, |
|
| 34 | 34 | onOpenHistory: (ref: String) -> Unit, |
|
| 35 | + | onOpenIssue: (Long) -> Unit, |
|
| 36 | + | onNewIssue: (() -> Unit)?, |
|
| 35 | 37 | initialTab: RepositoryTab = RepositoryTab.Code, |
|
| 36 | 38 | ) { |
|
| 37 | 39 | var tab by rememberSaveable { mutableIntStateOf(initialTab.ordinal) } |
|
| … | 6 unchanged lines |
||
| 44 | 46 | } |
|
| 45 | 47 | when (RepositoryTab.entries[tab]) { |
|
| 46 | 48 | RepositoryTab.Code -> CodeBrowser(code, onOpenFile, onOpenHistory) |
|
| 47 | - | RepositoryTab.Issues -> IssueList(issues) |
|
| 49 | + | RepositoryTab.Issues -> IssueList(issues, onOpenIssue, onNewIssue) |
|
| 48 | 50 | RepositoryTab.Proposals -> ProposalList(proposals, onOpenProposal) |
|
| 49 | 51 | } |
|
| 50 | 52 | } |
|
| … | 2 unchanged lines |
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +32 −0
| … | 58 unchanged lines |
||
| 59 | 59 | } |
|
| 60 | 60 | var runnerToken: () -> io.oxygenhub.android.api.RunnerToken = { io.oxygenhub.android.api.RunnerToken("flowreg_test", "andymccall") } |
|
| 61 | 61 | override suspend fun runnerToken() = runnerToken.invoke().also { calls += "runner token" } |
|
| 62 | + | val issueStore = mutableMapOf<Long, io.oxygenhub.android.api.Issue>() |
|
| 63 | + | val commentStore = mutableMapOf<Long, MutableList<io.oxygenhub.android.api.Comment>>() |
|
| 64 | + | var repoLabels = listOf(io.oxygenhub.android.api.Label("bug", "#c62d47"), io.oxygenhub.android.api.Label("ui", "#0969da")) |
|
| 65 | + | override suspend fun issue(owner: String, name: String, number: Long) = |
|
| 66 | + | (issueStore[number] ?: throw io.oxygenhub.android.api.HubException.Http(404, "no such issue")).also { calls += "issue $number" } |
|
| 67 | + | override suspend fun issueComments(owner: String, name: String, number: Long) = commentStore[number].orEmpty().toList() |
|
| 68 | + | override suspend fun comment(owner: String, name: String, number: Long, body: String): io.oxygenhub.android.api.Comment { |
|
| 69 | + | calls += "comment $number" |
|
| 70 | + | val c = io.oxygenhub.android.api.Comment(id = (commentStore.values.sumOf { it.size } + 1).toLong(), author = "andymccall", body = body) |
|
| 71 | + | commentStore.getOrPut(number) { mutableListOf() } += c |
|
| 72 | + | issueStore[number]?.let { issueStore[number] = it.copy(comments = it.comments + 1) } |
|
| 73 | + | return c |
|
| 74 | + | } |
|
| 75 | + | override suspend fun setIssueState(owner: String, name: String, number: Long, open: Boolean, reason: String): io.oxygenhub.android.api.Issue { |
|
| 76 | + | calls += "state $number ${if (open) "open" else "closed:$reason"}" |
|
| 77 | + | val i = issueStore.getValue(number).copy(state = if (open) "open" else "closed", reason = if (open) "" else reason) |
|
| 78 | + | issueStore[number] = i |
|
| 79 | + | return i |
|
| 80 | + | } |
|
| 81 | + | override suspend fun createIssue(owner: String, name: String, title: String, body: String): io.oxygenhub.android.api.Issue { |
|
| 82 | + | val n = (issueStore.keys.maxOrNull() ?: 0) + 1 |
|
| 83 | + | val i = io.oxygenhub.android.api.Issue(n, title, body, author = "andymccall") |
|
| 84 | + | issueStore[n] = i |
|
| 85 | + | calls += "create $title" |
|
| 86 | + | return i |
|
| 87 | + | } |
|
| 88 | + | override suspend fun labels(owner: String, name: String) = repoLabels |
|
| 89 | + | override suspend fun setIssueLabels(owner: String, name: String, number: Long, labels: List<String>): io.oxygenhub.android.api.Issue { |
|
| 90 | + | val i = issueStore.getValue(number).copy(labels = repoLabels.filter { it.name in labels }) |
|
| 91 | + | issueStore[number] = i |
|
| 92 | + | return i |
|
| 93 | + | } |
|
| 62 | 94 | var repository: io.oxygenhub.android.api.Repository = io.oxygenhub.android.api.Repository("andymccall", "demo", defaultBookmark = "main") |
|
| 63 | 95 | override suspend fun repository(owner: String, name: String) = repository.also { calls += "repository" } |
|
| 64 | 96 | var tree: (String, String) -> List<io.oxygenhub.android.api.TreeEntry> = { _, _ -> emptyList() } |
|
| … | 36 unchanged lines |