OXYGENHUB

Account and organisation pages 62429abe5869

Who an owner is, their open boards and the repositories of theirs you
can read; from a repository's top bar, members, and /{owner} or
/orgs/{name} links.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-26 01:49 UTC
Signature
✔ signed by Andy McCall
Hash
62429abe58699a35cb07b3a1ec4cba3c9d2ac5dca11f541b55cd8f177ddf120b
Depends on
144cbf7bf2b5 1c42a70fcba3 31e4f3755627 57c0824300db 5ae4af48ddd2 98dbd1220929 d1f635be9442 db00632a61d6 e2df27a81e5d e833eb0a9e7f ed6c64d23fca fbc95ee8777c
Depended on by
263c6cad5e51 aa627197ec18 821035db5a1c d98e5a147428 6e2af63ae616 dce8ebd2482a 1dfa631436b6 db7727e5de19 8a657957d5a0
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
22 atoms, 3 tree operations, 10 files

Browse the tree at this change

added app/src/main/java/io/oxygenhub/android/ui/owners/Owner.kt +174 −0
1 +
package io.oxygenhub.android.ui.owners
2 +
3 +
import androidx.compose.foundation.clickable
4 +
import androidx.compose.foundation.layout.Arrangement
5 +
import androidx.compose.foundation.layout.Column
6 +
import androidx.compose.foundation.layout.ExperimentalLayoutApi
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.material.icons.Icons
14 +
import androidx.compose.material.icons.outlined.Lock
15 +
import androidx.compose.material.icons.outlined.ViewKanban
16 +
import androidx.compose.material3.AssistChip
17 +
import androidx.compose.material3.HorizontalDivider
18 +
import androidx.compose.material3.Icon
19 +
import androidx.compose.material3.ListItem
20 +
import androidx.compose.material3.MaterialTheme
21 +
import androidx.compose.material3.Text
22 +
import androidx.compose.runtime.Composable
23 +
import androidx.compose.runtime.getValue
24 +
import androidx.compose.ui.Alignment
25 +
import androidx.compose.ui.Modifier
26 +
import androidx.compose.ui.unit.dp
27 +
import androidx.lifecycle.ViewModel
28 +
import androidx.lifecycle.compose.collectAsStateWithLifecycle
29 +
import io.oxygenhub.android.api.HubApi
30 +
import io.oxygenhub.android.api.HubException
31 +
import io.oxygenhub.android.api.Owner
32 +
import io.oxygenhub.android.api.Project
33 +
import io.oxygenhub.android.api.Repository
34 +
import io.oxygenhub.android.api.Visibility
35 +
import io.oxygenhub.android.ui.common.AppScreen
36 +
import io.oxygenhub.android.ui.common.AvatarImage
37 +
import io.oxygenhub.android.ui.common.LoadContent
38 +
import io.oxygenhub.android.ui.common.Load
39 +
import io.oxygenhub.android.ui.common.load
40 +
import io.oxygenhub.android.ui.common.rememberAvatar
41 +
import io.oxygenhub.android.ui.common.relativeTime
42 +
import kotlinx.coroutines.Job
43 +
import kotlinx.coroutines.async
44 +
import kotlinx.coroutines.coroutineScope
45 +
import kotlinx.coroutines.flow.MutableStateFlow
46 +
import kotlinx.coroutines.flow.StateFlow
47 +
import kotlinx.coroutines.flow.asStateFlow
48 +
49 +
/** An owner's page: who they are, their repositories (those you can read) and boards. */
50 +
data class OwnerPage(val owner: Owner, val repositories: List<Repository>, val projects: List<Project>)
51 +
52 +
/** An account's or organisation's page; a name is tried as an account, then as an organisation. */
53 +
class OwnerViewModel(private val api: HubApi, val name: String) : ViewModel() {
54 +
    private val _state = MutableStateFlow<Load<OwnerPage>>(Load.Loading)
55 +
    val state: StateFlow<Load<OwnerPage>> = _state.asStateFlow()
56 +
    private var job: Job? = null
57 +
58 +
    init {
59 +
        refresh()
60 +
    }
61 +
62 +
    fun refresh() {
63 +
        job = load(_state, job) {
64 +
            coroutineScope {
65 +
                val repos = async { api.repositories().filter { it.owner.equals(name, ignoreCase = true) }.sortedByDescending { it.updatedMs } }
66 +
                val projects = async { runCatching { api.projects(name) }.getOrDefault(emptyList()).filter { !it.closed } }
67 +
                val owner = try {
68 +
                    Owner.Person(api.account(name))
69 +
                } catch (e: HubException.Http) {
70 +
                    if (e.status != 404) throw e
71 +
                    Owner.Org(api.organisation(name))
72 +
                }
73 +
                OwnerPage(owner, repos.await(), projects.await())
74 +
            }
75 +
        }
76 +
    }
77 +
}
78 +
79 +
@OptIn(ExperimentalLayoutApi::class)
80 +
@Composable
81 +
fun OwnerScreen(
82 +
    vm: OwnerViewModel,
83 +
    api: HubApi,
84 +
    onBack: () -> Unit,
85 +
    onOpenOwner: (String) -> Unit,
86 +
    onOpenRepository: (Repository) -> Unit,
87 +
    onOpenProject: (Project) -> Unit,
88 +
) {
89 +
    val state by vm.state.collectAsStateWithLifecycle()
90 +
    AppScreen(title = vm.name, onBack = onBack) {
91 +
        LoadContent(state, onRefresh = vm::refresh) { page ->
92 +
            LazyColumn(Modifier.fillMaxSize()) {
93 +
                item {
94 +
                    Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
95 +
                        AvatarImage(rememberAvatar(api, page.owner.name), page.owner.name, size = 64.dp)
96 +
                        Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
97 +
                            Text(page.owner.shownName, style = MaterialTheme.typography.titleLarge)
98 +
                            Text(
99 +
                                when (val o = page.owner) {
100 +
                                    is Owner.Person -> listOfNotNull(
101 +
                                        o.user.username.takeIf { it != o.shownName },
102 +
                                        "joined ${relativeTime(o.user.createdMs)}".takeIf { o.user.createdMs > 0 },
103 +
                                    ).joinToString(" · ")
104 +
                                    is Owner.Org -> "organisation · ${o.org.members.size} ${if (o.org.members.size == 1) "member" else "members"}"
105 +
                                },
106 +
                                style = MaterialTheme.typography.bodyMedium,
107 +
                                color = MaterialTheme.colorScheme.onSurfaceVariant,
108 +
                            )
109 +
                        }
110 +
                    }
111 +
                    (page.owner as? Owner.Org)?.let { o ->
112 +
                        Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
113 +
                            if (o.org.description.isNotBlank()) Text(o.org.description, style = MaterialTheme.typography.bodyMedium)
114 +
                            FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
115 +
                                o.org.members.forEach { m ->
116 +
                                    AssistChip(
117 +
                                        onClick = { onOpenOwner(m) },
118 +
                                        label = { Text(m) },
119 +
                                        leadingIcon = { AvatarImage(rememberAvatar(api, m), m, size = 18.dp) },
120 +
                                    )
121 +
                                }
122 +
                            }
123 +
                        }
124 +
                    }
125 +
                }
126 +
                if (page.projects.isNotEmpty()) {
127 +
                    item { Section("Projects") }
128 +
                    items(page.projects, key = { "p${it.number}" }) { p ->
129 +
                        ListItem(
130 +
                            modifier = Modifier.clickable { onOpenProject(p) },
131 +
                            leadingContent = { Icon(Icons.Outlined.ViewKanban, contentDescription = null) },
132 +
                            headlineContent = { Text(p.title.ifBlank { "Project ${p.number}" }) },
133 +
                            supportingContent = if (p.description.isNotBlank()) {
134 +
                                { Text(p.description, maxLines = 2) }
135 +
                            } else {
136 +
                                null
137 +
                            },
138 +
                        )
139 +
                    }
140 +
                }
141 +
                item { Section(if (page.repositories.isEmpty()) "No repositories you can read" else "Repositories") }
142 +
                items(page.repositories, key = { it.slug }) { r ->
143 +
                    ListItem(
144 +
                        modifier = Modifier.clickable { onOpenRepository(r) },
145 +
                        headlineContent = { Text(r.name) },
146 +
                        supportingContent = {
147 +
                            Text(
148 +
                                listOfNotNull(r.description.takeIf { it.isNotBlank() }, "updated ${relativeTime(r.updatedMs)}".takeIf { r.updatedMs > 0 })
149 +
                                    .joinToString(" · "),
150 +
                                maxLines = 2,
151 +
                            )
152 +
                        },
153 +
                        trailingContent = if (r.visibility != Visibility.Public) {
154 +
                            { Icon(Icons.Outlined.Lock, contentDescription = "Not public") }
155 +
                        } else {
156 +
                            null
157 +
                        },
158 +
                    )
159 +
                    HorizontalDivider()
160 +
                }
161 +
            }
162 +
        }
163 +
    }
164 +
}
165 +
166 +
@Composable
167 +
private fun Section(title: String) {
168 +
    Text(
169 +
        title,
170 +
        modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp),
171 +
        style = MaterialTheme.typography.titleSmall,
172 +
        color = MaterialTheme.colorScheme.primary,
173 +
    )
174 +
}
added app/src/test/java/io/oxygenhub/android/ui/OwnerViewModelTest.kt +50 −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.Organisation
7 +
import io.oxygenhub.android.api.Owner
8 +
import io.oxygenhub.android.api.Repository
9 +
import io.oxygenhub.android.api.User
10 +
import io.oxygenhub.android.ui.common.Load
11 +
import io.oxygenhub.android.ui.owners.OwnerViewModel
12 +
import org.junit.Assert.assertEquals
13 +
import org.junit.Assert.assertTrue
14 +
import org.junit.Rule
15 +
import org.junit.Test
16 +
17 +
class OwnerViewModelTest {
18 +
    @get:Rule val main = MainDispatcherRule()
19 +
20 +
    private val api = FakeHubApi().apply {
21 +
        repositories = {
22 +
            listOf(
23 +
                Repository("doomsdayone", "tetris", updatedMs = 1),
24 +
                Repository("andymccall", "oxygen", updatedMs = 5),
25 +
                Repository("doomsdayone", "supermarioland2", updatedMs = 9),
26 +
            )
27 +
        }
28 +
    }
29 +
30 +
    @Test
31 +
    fun aNameIsAnAccountOrElseAnOrganisation() {
32 +
        api.accounts = mapOf("andymccall" to User("andymccall", displayName = "Andy McCall"))
33 +
        api.orgs = mapOf("doomsdayone" to Organisation("doomsdayone", "Doomsday One", members = listOf("andymccall")))
34 +
35 +
        val andy = (OwnerViewModel(api, "andymccall").state.value as Load.Ready).value
36 +
        assertEquals("Andy McCall", andy.owner.shownName)
37 +
        assertEquals(listOf("oxygen"), andy.repositories.map { it.name })
38 +
39 +
        val org = (OwnerViewModel(api, "doomsdayone").state.value as Load.Ready).value
40 +
        assertTrue(org.owner is Owner.Org)
41 +
        assertEquals(listOf("andymccall"), (org.owner as Owner.Org).org.members)
42 +
        assertEquals(listOf("supermarioland2", "tetris"), org.repositories.map { it.name })
43 +
        assertTrue("asked as an organisation once no account has the name", "org doomsdayone" in api.calls)
44 +
    }
45 +
46 +
    @Test
47 +
    fun neitherIsAFailure() {
48 +
        assertTrue(OwnerViewModel(api, "nobody").state.value is Load.Failed)
49 +
    }
50 +
}
modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Account and organisation pages:** an owner's page shows who they are (avatar, name, when they joined; an organisation's description and members, each opening their own page), their open project boards and the repositories of theirs you can read, newest first. Opened from a repository's top bar, from a member, and from hub links to `/{owner}` or `/orgs/{name}`. From `GET /users/{name}`, else `GET /orgs/{name}`.
5 6
- **Off charge, the runner gives a minute's notice (#8, oxygenflow#76/#77):** taken off charge, the phone claims nothing more, finishes a job the hub expects to end within a minute and hands back any other to another runner, its checkpoint saved so the next attempt resumes where it stopped. Leaving Wi-Fi still hands back at once. Pins OxygenFlow's main (checkpoints, `leave_within`).
6 7
- **Diffs on a change (#21):** each file a change touched shows its diff in the app: context, removed and added lines with their old and new numbers, tinted, long lines wrapped, gaps between hunks marked. A small change (up to five files, 400 lines) opens every diff; a larger one starts folded and a file's row opens it. The icon on the row still opens the file at the change. From the hub's change API (hub #320); an older hub sends no lines, and the row says so.
7 8
- **The phone as an OxygenFlow runner (#8, first part):** Account → OxygenFlow runner → *Offer this phone as a runner* asks the hub for a one-time token (`POST /api/v1/user/runner-token`) and registers the phone as a runner for your own repositories, labelled `android`, offering the WASM tier. The runner is OxygenFlow's own, built into the app through rust-core (OxygenFlow is a subrepo, `oxygenflow/`, and rust-core's oxy-core is the one it pins), running WASM modules in a thread with wasmtime. It runs in a foreground service with a notification, only while the phone is charging on Wi-Fi (an unmetered network); coming off charge or off Wi-Fi drains it (it takes nothing more and finishes its job). Start and Stop on the card. The app is larger: the runner and wasmtime are about 29 MB per architecture.
…
7 unchanged lines
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +9 −0
…
126 unchanged lines
127 127
128 128
    /** An account's picture (`/avatar/{name}`, public); null when the hub has none this app can draw. */
129 129
    suspend fun avatar(name: String): Avatar?
130 +
131 +
    /** An account's public profile, and an organisation's (with its members). */
132 +
    suspend fun account(name: String): User
133 +
134 +
    suspend fun organisation(name: String): Organisation
130 135
}
131 136
132 137
/**
…
88 unchanged lines
221 226
        sendJson("PATCH", url("repos", owner, name, "issues", number.toString()), buildJsonObject {
222 227
            put("labels", JsonArray(labels.map { JsonPrimitive(it) }))
223 228
        }, Issue.serializer())
229 +
230 +
    override suspend fun account(name: String): User = get(url("users", name), User.serializer())
231 +
232 +
    override suspend fun organisation(name: String): Organisation = get(url("orgs", name), Organisation.serializer())
224 233
225 234
    override suspend fun repository(owner: String, name: String): Repository = get(url("repos", owner, name), Repository.serializer())
226 235
…
149 unchanged lines
modified app/src/main/java/io/oxygenhub/android/api/Models.kt +25 −0
…
219 unchanged lines
220 220
    val landable: List<ProposalBrief> = emptyList(),
221 221
)
222 222
223 +
/** An organisation (`GET /orgs/{name}`). */
224 +
@Serializable
225 +
data class Organisation(
226 +
    val name: String,
227 +
    @SerialName("display_name") val displayName: String = "",
228 +
    val description: String = "",
229 +
    val members: List<String> = emptyList(),
230 +
)
231 +
232 +
/** An account or an organisation, as its page shows it. */
233 +
sealed interface Owner {
234 +
    val name: String
235 +
    val shownName: String
236 +
237 +
    data class Person(val user: User) : Owner {
238 +
        override val name get() = user.username
239 +
        override val shownName get() = user.shownName
240 +
    }
241 +
242 +
    data class Org(val org: Organisation) : Owner {
243 +
        override val name get() = org.name
244 +
        override val shownName get() = org.displayName.ifBlank { org.name }
245 +
    }
246 +
}
247 +
223 248
/** A proposal in a list across repositories. */
224 249
@Serializable
225 250
data class ProposalBrief(
…
199 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +18 −0
…
39 unchanged lines
40 40
import io.oxygenhub.android.auth.Session
41 41
import io.oxygenhub.android.ui.account.AccountScreen
42 42
import io.oxygenhub.android.ui.code.ChangeScreen
43 +
import io.oxygenhub.android.ui.owners.OwnerScreen
44 +
import io.oxygenhub.android.ui.owners.OwnerViewModel
43 45
import io.oxygenhub.android.ui.code.ChangeViewModel
44 46
import io.oxygenhub.android.ui.code.CodeViewModel
45 47
import io.oxygenhub.android.ui.code.FileScreen
…
65 unchanged lines
111 113
112 114
@Serializable data class NewIssueRoute(val owner: String, val name: String)
113 115
116 +
/** An account's or organisation's page. */
117 +
@Serializable data class OwnerRoute(val name: String)
118 +
114 119
/** A Flow run, and a job's log (#5). */
115 120
@Serializable data class RunRoute(val owner: String, val name: String, val number: Long)
116 121
…
87 unchanged lines
204 209
                        onOpenIssue = { nav.navigate(IssueRoute(owner, name, it)) },
205 210
                        onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(owner, name)) } } else null,
206 211
                        onOpenRun = { nav.navigate(RunRoute(owner, name, it)) },
212 +
                        onOpenOwner = { nav.navigate(OwnerRoute(owner)) },
207 213
                    )
208 214
                }
209 215
            }
…
11 unchanged lines
221 227
                    onOpenIssue = { nav.navigate(IssueRoute(r.owner, r.name, it)) },
222 228
                    onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(r.owner, r.name)) } } else null,
223 229
                    onOpenRun = { nav.navigate(RunRoute(r.owner, r.name, it)) },
230 +
                    onOpenOwner = { nav.navigate(OwnerRoute(r.owner)) },
231 +
                )
232 +
            }
233 +
            composable<OwnerRoute> { e ->
234 +
                val r = e.toRoute<OwnerRoute>()
235 +
                OwnerScreen(
236 +
                    rememberViewModel { OwnerViewModel(api, r.name) },
237 +
                    api,
238 +
                    onBack = { nav.popBackStack() },
239 +
                    onOpenOwner = { nav.navigate(OwnerRoute(it)) },
240 +
                    onOpenRepository = { nav.navigate(IssuesRoute(it.owner, it.name)) },
241 +
                    onOpenProject = { nav.navigate(BoardRoute(it.owner, it.number)) },
224 242
                )
225 243
            }
226 244
            composable<ProjectsRoute> {
…
95 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/nav/Links.kt +3 −1
…
4 unchanged lines
5 5
/**
6 6
 * The app's own screen for a page of the hub (Andy, 2026-10-25: whatever
7 7
 * is tapped in the inbox opens natively): an issue, proposal, run, change,
8 -
 * file, history, board or repository. Null for a page the app does not
8 +
 * file, history, board, repository, account or organisation. Null for a page the app does not
9 9
 * show itself, or another site's, which the browser opens.
10 10
 */
11 11
fun routeFor(url: String, hub: String): Any? {
…
4 unchanged lines
16 16
    if (parts.size >= 3 && parts[0] == "projects") {
17 17
        return parts[2].toLongOrNull()?.let { BoardRoute(parts[1], it) }
18 18
    }
19 +
    if (parts.size == 2 && parts[0] == "orgs") return OwnerRoute(parts[1])
20 +
    if (parts.size == 1 && parts[0] !in HUB_PAGES) return OwnerRoute(parts[0])
19 21
    if (parts.size < 2 || parts[0] in HUB_PAGES) return null
20 22
    val (owner, name) = parts[0] to parts[1]
21 23
    val rest = parts.drop(2)
…
18 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/repos/RepositoryScreen.kt +14 −1
…
4 unchanged lines
5 5
import androidx.compose.material3.PrimaryScrollableTabRow
6 6
import androidx.compose.material3.Tab
7 7
import androidx.compose.material3.Text
8 +
import androidx.compose.material3.IconButton
9 +
import androidx.compose.material3.Icon
10 +
import androidx.compose.material.icons.Icons
11 +
import androidx.compose.material.icons.outlined.AccountCircle
8 12
import androidx.compose.runtime.Composable
9 13
import androidx.compose.runtime.getValue
10 14
import androidx.compose.runtime.mutableIntStateOf
…
28 unchanged lines
39 43
    onOpenIssue: (Long) -> Unit,
40 44
    onNewIssue: (() -> Unit)?,
41 45
    onOpenRun: (Long) -> Unit,
46 +
    onOpenOwner: (() -> Unit)? = null,
42 47
    initialTab: RepositoryTab = RepositoryTab.Code,
43 48
) {
44 49
    var tab by rememberSaveable { mutableIntStateOf(initialTab.ordinal) }
45 -
    AppScreen(title = "${issues.owner}/${issues.name}", onBack = onBack) {
50 +
    AppScreen(
51 +
        title = "${issues.owner}/${issues.name}",
52 +
        onBack = onBack,
53 +
        actions = {
54 +
            onOpenOwner?.let { open ->
55 +
                IconButton(onClick = open) { Icon(Icons.Outlined.AccountCircle, contentDescription = "${issues.owner}'s page") }
56 +
            }
57 +
        },
58 +
    ) {
46 59
        Column(Modifier.fillMaxSize()) {
47 60
            PrimaryScrollableTabRow(selectedTabIndex = tab, edgePadding = 0.dp) {
48 61
                RepositoryTab.entries.forEach { t ->
…
12 unchanged lines
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +7 −0
1 1
package io.oxygenhub.android
2 2
3 3
import io.oxygenhub.android.api.Avatar
4 +
import io.oxygenhub.android.api.HubException
4 5
import io.oxygenhub.android.api.Check
5 6
import io.oxygenhub.android.api.Proposal
6 7
import io.oxygenhub.android.api.ProposalState
…
148 unchanged lines
155 156
        return autoLand.invoke(number, on)
156 157
    }
157 158
    override suspend fun avatar(name: String): Avatar? = avatar.invoke(name).also { calls += "avatar $name" }
159 +
    var accounts: Map<String, io.oxygenhub.android.api.User> = emptyMap()
160 +
    var orgs: Map<String, io.oxygenhub.android.api.Organisation> = emptyMap()
161 +
    override suspend fun account(name: String) =
162 +
        (accounts[name] ?: throw HubException.Http(404, "no such account")).also { calls += "account $name" }
163 +
    override suspend fun organisation(name: String) =
164 +
        (orgs[name] ?: throw HubException.Http(404, "no such organisation")).also { calls += "org $name" }
158 165
    override suspend fun revokeToken() {
159 166
        calls += "revoke"
160 167
        revoke.invoke()
…
2 unchanged lines
modified app/src/test/java/io/oxygenhub/android/ui/LinksTest.kt +4 −0
…
5 unchanged lines
6 6
import io.oxygenhub.android.ui.nav.HistoryRoute
7 7
import io.oxygenhub.android.ui.nav.IssueRoute
8 8
import io.oxygenhub.android.ui.nav.IssuesRoute
9 +
import io.oxygenhub.android.ui.nav.OwnerRoute
9 10
import io.oxygenhub.android.ui.nav.ProposalRoute
10 11
import io.oxygenhub.android.ui.nav.RunRoute
11 12
import io.oxygenhub.android.ui.nav.routeFor
…
15 unchanged lines
27 28
        assertEquals(HistoryRoute("andymccall", "oxygen", "main"), r("/andymccall/oxygen/changes/main"))
28 29
        assertEquals(BoardRoute("doomsdayone", 4), r("/projects/doomsdayone/4"))
29 30
        assertEquals(IssuesRoute("andymccall", "oxygen"), r("/andymccall/oxygen"))
31 +
        assertEquals(OwnerRoute("andymccall"), r("/andymccall"))
32 +
        assertEquals(OwnerRoute("doomsdayone"), r("/orgs/doomsdayone"))
30 33
    }
31 34
32 35
    @Test
33 36
    fun otherPagesAndSitesGoToTheBrowser() {
34 37
        assertNull(r("/andymccall/oxygenhub/releases/tag/v0.9.4"))
35 38
        assertNull(r("/admin/runners"))
39 +
        assertNull("a hub page, not an owner", r("/explore"))
36 40
        assertNull(routeFor("https://example.com/andymccall/oxygen/issue/1", hub))
37 41
        assertNull(r("/andymccall/oxygen/issue/not-a-number"))
38 42
    }
…
1 unchanged lines