Skip to content

Commit d384ff3

Browse files
committed
feat(lists): GitHub-backed lists — repo picker, repo link, refresh, locked schema
A list can now be created from a GitHub repository's issues, and the detail screen operates one honestly. Create: the "New list" sheet gains a Local / GitHub-backed choice. The GitHub half picks a repository (optionally scoped to an organisation via `GET /api/github/orgs` → `GET /api/github/repos?org=`) and sends `source: "github"` with `githubRepo` and `githubSource: "issues"`. The `owner/repo` form is validated client-side because the server refuses anything else with `400 bad_request` before it ever reaches GitHub. Repo link: a GitHub-backed list links `owner/repo issues` under its title. When the repository is private on GitHub the link carries a **Private repo** tag whose copy says, explicitly, that it describes the *repository* and not the list, and warns that an invited collaborator may still meet a GitHub sign-in or "not found" page. An unrecorded visibility (`githubRepoPrivate: null`) shows no tag rather than being presented as public. Visibility is re-read from each list payload, so a sync picks up a repository that changed. Refresh: `POST /api/lists/{id}/refresh` is surfaced as "Refresh from GitHub", and only on a GitHub-backed list — the endpoint 400s on a local one. Rows are issues: the schema mapper now reads `isReadOnly` (and the ListProperty `propertyKey`/`propertyName`/`validationRules` spellings the synthetic GitHub schema uses), so `Issue #`, `Link`, `Created` and `Updated` are shown but never sent. The row form names the operation a save performs, using `GET /api/github/repos/{owner}/{repo}/next-issue-number` to say which issue is about to be opened, and delete is labelled as closing the issue. Schema editor: locked to parent-only for these lists. The fixed columns render read-only with an explanation, add/remove/retype and save are refused in the ViewModel as well as hidden, and a parent-list picker (`PUT /api/lists/{id}` with `parentId`) is the one edit that remains — silently allowing a schema edit would only produce a confusing server error. Unlinked GitHub is handled as a first-class state: an unlinked account (400 "GitHub account not linked") and a refused token (401 `github_error`) each get their own explanation and a route to the existing connected-accounts screen, rather than an empty picker. OAuth linking itself (#39) is not attempted. `:feature:lists` calls `/api/github/…` through its own Retrofit interface and DTOs rather than depending on `:feature:integrations`, since no feature module in this repo depends on another. Extends #54's `CreateListRequest` rather than duplicating it: `githubRepo` and `githubSource` join the existing `parentId`/`folderId`/`messageId`/`initialRows`/ `metadata`/`source` options, all still omitted when absent. Tests: create sends `githubRepo`/`githubSource` (and a local create still sends neither); the schema editor is locked for a GitHub-backed list and unlocked for a local one; refresh updates rows and is never spent on a local list; the Private repo tag renders from `githubRepoPrivate` and its copy is pinned to name the repository, not the list. Closes #50
1 parent 513d1de commit d384ff3

41 files changed

Lines changed: 3004 additions & 92 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.interlinedlist.android.navigation
22

3+
import android.content.Intent
4+
import android.net.Uri
35
import androidx.compose.foundation.layout.padding
46
import androidx.compose.material.icons.Icons
57
import androidx.compose.material.icons.automirrored.filled.List
@@ -344,13 +346,18 @@ private fun MainShell(
344346
onOpenConnections = { tabNav.navigate(Routes.LIST_CONNECTIONS) },
345347
onOpenSharedWithMe = { tabNav.navigate(Routes.LISTS_SHARED_WITH_ME) },
346348
onOpenFolders = { tabNav.navigate(Routes.LIST_FOLDERS) },
349+
// A GitHub-backed list needs GitHub linked with the Issues
350+
// scope; linking is an OAuth flow this app does not drive, so
351+
// the picker routes to the existing connected-accounts screen.
352+
onOpenConnectedAccounts = { tabNav.navigate(Routes.INTEGRATIONS_ACCOUNTS) },
347353
)
348354
}
349355
composable(
350356
Routes.LIST_DETAIL,
351357
arguments = listOf(navArgument("listId") { type = NavType.StringType }),
352358
) { entry ->
353359
val listId = entry.arguments?.getString("listId").orEmpty()
360+
val context = LocalContext.current
354361
ListDetailRoute(
355362
onBack = { tabNav.popBackStack() },
356363
onListDeleted = { tabNav.popBackStack() },
@@ -359,6 +366,13 @@ private fun MainShell(
359366
onOpenShare = { tabNav.navigate(Routes.listShare(listId)) },
360367
// Breadcrumb hops and newly created child lists open a detail route.
361368
onOpenList = { id -> tabNav.navigate(Routes.listDetail(id)) },
369+
// The repository link under a GitHub-backed list's title opens
370+
// that repo's issues page in the browser, as the web app does.
371+
onOpenRepo = { url ->
372+
runCatching {
373+
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
374+
}
375+
},
362376
)
363377
}
364378
composable(
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package com.interlinedlist.android.feature.lists.ui.detail
2+
3+
import androidx.compose.ui.test.assertIsDisplayed
4+
import androidx.compose.ui.test.junit4.createComposeRule
5+
import androidx.compose.ui.test.onNodeWithTag
6+
import androidx.compose.ui.test.onNodeWithText
7+
import androidx.compose.ui.test.performClick
8+
import androidx.test.ext.junit.runners.AndroidJUnit4
9+
import com.google.common.truth.Truth.assertThat
10+
import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
11+
import com.interlinedlist.android.feature.lists.domain.GithubRepoLink
12+
import com.interlinedlist.android.feature.lists.domain.ListSchema
13+
import com.interlinedlist.android.feature.lists.domain.ListSource
14+
import com.interlinedlist.android.feature.lists.domain.ListSummary
15+
import org.junit.Rule
16+
import org.junit.Test
17+
import org.junit.runner.RunWith
18+
19+
/**
20+
* The repository link under a GitHub-backed list's title, and its **Private repo**
21+
* tag — which describes the repository's visibility on GitHub, never the list's.
22+
*/
23+
@RunWith(AndroidJUnit4::class)
24+
class GithubRepoLinkRowTest {
25+
26+
@get:Rule
27+
val composeRule = createComposeRule()
28+
29+
private fun setRow(isPrivate: Boolean?, onOpenRepo: (String) -> Unit = {}) {
30+
composeRule.setContent {
31+
InterlinedListTheme {
32+
GithubRepoLinkRow(
33+
repo = "octocat/Hello-World",
34+
githubRepoPrivate = isPrivate,
35+
onOpenRepo = onOpenRepo,
36+
)
37+
}
38+
}
39+
}
40+
41+
@Test
42+
fun linksTheRepositoryIssuesPage() {
43+
var opened: String? = null
44+
setRow(isPrivate = false) { opened = it }
45+
46+
composeRule.onNodeWithTag(GithubRepoLinkTestTags.LINK).assertIsDisplayed()
47+
composeRule.onNodeWithText("octocat/Hello-World issues").assertIsDisplayed()
48+
49+
composeRule.onNodeWithTag(GithubRepoLinkTestTags.LINK).performClick()
50+
assertThat(opened).isEqualTo("https://github.com/octocat/Hello-World/issues")
51+
}
52+
53+
@Test
54+
fun showsThePrivateRepoTag_whenTheRepositoryIsPrivate() {
55+
setRow(isPrivate = true)
56+
57+
composeRule.onNodeWithTag(GithubRepoLinkTestTags.PRIVATE_TAG).assertIsDisplayed()
58+
composeRule.onNodeWithText(GithubRepoLink.PRIVATE_TAG).assertIsDisplayed()
59+
}
60+
61+
@Test
62+
fun theTagExplainsTheRepositoryIsPrivate_notTheList() {
63+
setRow(isPrivate = true)
64+
65+
composeRule.onNodeWithTag(GithubRepoLinkTestTags.PRIVATE_TAG).performClick()
66+
67+
composeRule.onNodeWithTag(GithubRepoLinkTestTags.PRIVATE_EXPLANATION).assertIsDisplayed()
68+
// The copy names the repository, and says the list's visibility is separate.
69+
val explanation = GithubRepoLink.PRIVATE_TAG_EXPLANATION
70+
assertThat(explanation).contains("This repository is private on GitHub")
71+
assertThat(explanation).contains("separate from who can see this list")
72+
composeRule.onNodeWithText(explanation).assertIsDisplayed()
73+
}
74+
75+
@Test
76+
fun showsNoTag_whenTheRepositoryIsPublic() {
77+
setRow(isPrivate = false)
78+
79+
composeRule.onNodeWithTag(GithubRepoLinkTestTags.PRIVATE_TAG).assertDoesNotExist()
80+
}
81+
82+
@Test
83+
fun showsNoTag_whenTheRepositoryVisibilityIsNotYetKnown() {
84+
// A list that has not synced since the tag existed: unknown is never
85+
// presented as public, and it is never presented as private either.
86+
setRow(isPrivate = null)
87+
88+
composeRule.onNodeWithTag(GithubRepoLinkTestTags.LINK).assertIsDisplayed()
89+
composeRule.onNodeWithTag(GithubRepoLinkTestTags.PRIVATE_TAG).assertDoesNotExist()
90+
}
91+
92+
@Test
93+
fun rendersUnderTheTitleOfAGithubBackedListOnly() {
94+
val githubList = ListSummary(
95+
id = "L1",
96+
title = "Repo issues",
97+
description = null,
98+
itemCount = 0,
99+
folderId = null,
100+
isPublic = false,
101+
updatedAt = null,
102+
parentId = null,
103+
source = ListSource.GITHUB,
104+
githubRepo = "octocat/Hello-World",
105+
githubRepoPrivate = true,
106+
)
107+
composeRule.setContent {
108+
InterlinedListTheme {
109+
ListDetailScreen(
110+
state = ListDetailUiState(
111+
summary = githubList,
112+
schema = ListSchema.EMPTY,
113+
isLoading = false,
114+
),
115+
onBack = {},
116+
onAddRow = {},
117+
onEditRow = {},
118+
onDeleteRow = {},
119+
onDeleteList = {},
120+
)
121+
}
122+
}
123+
124+
composeRule.onNodeWithTag(GithubRepoLinkTestTags.ROW).assertIsDisplayed()
125+
composeRule.onNodeWithTag(GithubRepoLinkTestTags.PRIVATE_TAG).assertIsDisplayed()
126+
// Refresh from GitHub is offered here and nowhere else.
127+
composeRule.onNodeWithTag(ListDetailTestTags.REFRESH).assertIsDisplayed()
128+
}
129+
130+
@Test
131+
fun aLocalListShowsNoRepoLinkAndNoRefresh() {
132+
composeRule.setContent {
133+
InterlinedListTheme {
134+
ListDetailScreen(
135+
state = ListDetailUiState(
136+
summary = ListSummary("L1", "Reading", null, 0, null, false, null),
137+
schema = ListSchema.EMPTY,
138+
isLoading = false,
139+
),
140+
onBack = {},
141+
onAddRow = {},
142+
onEditRow = {},
143+
onDeleteRow = {},
144+
onDeleteList = {},
145+
)
146+
}
147+
}
148+
149+
composeRule.onNodeWithTag(GithubRepoLinkTestTags.ROW).assertDoesNotExist()
150+
composeRule.onNodeWithTag(ListDetailTestTags.REFRESH).assertDoesNotExist()
151+
}
152+
}

‎feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreenTest.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class ListsScreenTest {
3434
onOpenConnections = {},
3535
onSearchQueryChange = {},
3636
onLoadMore = {},
37-
onCreateList = {},
37+
onNewList = {},
3838
)
3939
}
4040
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package com.interlinedlist.android.feature.lists.ui.list
2+
3+
import androidx.compose.ui.test.assertIsDisplayed
4+
import androidx.compose.ui.test.assertIsNotEnabled
5+
import androidx.compose.ui.test.junit4.createComposeRule
6+
import androidx.compose.ui.test.onNodeWithTag
7+
import androidx.compose.ui.test.performClick
8+
import androidx.test.ext.junit.runners.AndroidJUnit4
9+
import com.google.common.truth.Truth.assertThat
10+
import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
11+
import com.interlinedlist.android.feature.lists.domain.GithubRepo
12+
import com.interlinedlist.android.feature.lists.ui.github.GithubLinkProblem
13+
import org.junit.Rule
14+
import org.junit.Test
15+
import org.junit.runner.RunWith
16+
17+
/**
18+
* The "New list" sheet, in particular the GitHub half: picking a repository, and
19+
* what a user without GitHub linked is shown instead of a broken picker.
20+
*/
21+
@RunWith(AndroidJUnit4::class)
22+
class NewListSheetTest {
23+
24+
@get:Rule
25+
val composeRule = createComposeRule()
26+
27+
private val helloWorld = GithubRepo("octocat", "Hello-World")
28+
private val secretPlans = GithubRepo("acme", "secret-plans", isPrivate = true)
29+
30+
private fun setSheet(
31+
state: NewListUiState,
32+
onSelectRepo: (GithubRepo) -> Unit = {},
33+
onOpenConnectedAccounts: () -> Unit = {},
34+
) {
35+
composeRule.setContent {
36+
InterlinedListTheme {
37+
NewListSheet(
38+
state = state,
39+
onSelectKind = {},
40+
onTitleChange = {},
41+
onPublicChange = {},
42+
onRepoQueryChange = {},
43+
onSelectRepo = onSelectRepo,
44+
onSelectOrg = {},
45+
onCreate = {},
46+
onCancel = {},
47+
onOpenConnectedAccounts = onOpenConnectedAccounts,
48+
)
49+
}
50+
}
51+
}
52+
53+
@Test
54+
fun listsRepositories_andReportsTheOneThatIsPicked() {
55+
var picked: GithubRepo? = null
56+
setSheet(
57+
NewListUiState(
58+
kind = NewListKind.GITHUB,
59+
repos = listOf(helloWorld, secretPlans),
60+
),
61+
onSelectRepo = { picked = it },
62+
)
63+
64+
composeRule.onNodeWithTag(NewListTestTags.repo("octocat/Hello-World")).assertIsDisplayed()
65+
composeRule.onNodeWithTag(NewListTestTags.repo("acme/secret-plans")).performClick()
66+
67+
assertThat(picked).isEqualTo(secretPlans)
68+
}
69+
70+
@Test
71+
fun explainsAnUnlinkedAccount_andRoutesToConnectedAccounts() {
72+
var routed = false
73+
setSheet(
74+
NewListUiState(kind = NewListKind.GITHUB, linkProblem = GithubLinkProblem.NOT_LINKED),
75+
onOpenConnectedAccounts = { routed = true },
76+
)
77+
78+
composeRule.onNodeWithTag(NewListTestTags.LINK_PROBLEM).assertIsDisplayed()
79+
// No picker at all, rather than an empty one.
80+
composeRule.onNodeWithTag(NewListTestTags.REPO_SEARCH).assertDoesNotExist()
81+
composeRule.onNodeWithTag(NewListTestTags.CREATE).assertIsNotEnabled()
82+
83+
composeRule.onNodeWithTag(NewListTestTags.LINK_ACTION).performClick()
84+
assertThat(routed).isTrue()
85+
}
86+
87+
@Test
88+
fun explainsAnEmptyRepoList_asAnOrgApprovalProblem() {
89+
setSheet(NewListUiState(kind = NewListKind.GITHUB, repos = emptyList()))
90+
91+
composeRule.onNodeWithTag(NewListTestTags.REPO_EMPTY).assertIsDisplayed()
92+
}
93+
94+
@Test
95+
fun theTitleFieldWaitsForARepositoryToBeChosen() {
96+
setSheet(NewListUiState(kind = NewListKind.GITHUB, repos = listOf(helloWorld)))
97+
98+
// Nothing to title until there is a repository behind the list.
99+
composeRule.onNodeWithTag(NewListTestTags.TITLE).assertDoesNotExist()
100+
composeRule.onNodeWithTag(NewListTestTags.CREATE).assertIsNotEnabled()
101+
}
102+
103+
@Test
104+
fun theTitleFieldAppearsOnceARepositoryIsChosen() {
105+
setSheet(
106+
NewListUiState(
107+
kind = NewListKind.GITHUB,
108+
repos = listOf(helloWorld),
109+
selectedRepo = helloWorld,
110+
title = "Hello-World",
111+
),
112+
)
113+
114+
composeRule.onNodeWithTag(NewListTestTags.TITLE).assertIsDisplayed()
115+
composeRule.onNodeWithTag(NewListTestTags.VISIBILITY).assertIsDisplayed()
116+
}
117+
118+
@Test
119+
fun aLocalListNeedsNoRepositoryPicker() {
120+
setSheet(NewListUiState(kind = NewListKind.LOCAL, title = "Books"))
121+
122+
composeRule.onNodeWithTag(NewListTestTags.TITLE).assertIsDisplayed()
123+
composeRule.onNodeWithTag(NewListTestTags.REPO_SEARCH).assertDoesNotExist()
124+
composeRule.onNodeWithTag(NewListTestTags.LINK_PROBLEM).assertDoesNotExist()
125+
}
126+
}

0 commit comments

Comments
 (0)