Implement TASK-008: pull-request and issue read-only UI

Separate issue and PR list/detail tabs, paginated, with comments
(issues) and reviews+comments (PRs). Deleted/unavailable authors
render a fixed placeholder instead of crashing or inventing identity;
every status is a text label, never color-only.

Reuses TASK-010 directly; no shared/domain file touched. Both list
types dedupe by number per page. Entry point is a new button on the
repository references screen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-08-26 15:08:26 +09:00
parent 429668bf26
commit c397fbeea9
9 changed files with 924 additions and 15 deletions

View File

@ -27,6 +27,10 @@ import com.ubisam.ubgitea.feature.history.HistoryViewModel
import com.ubisam.ubgitea.feature.repositories.ReferenceKind
import com.ubisam.ubgitea.feature.repositories.RepositoryListScreen
import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel
import com.ubisam.ubgitea.feature.workitems.WorkItemsScreen
import com.ubisam.ubgitea.feature.workitems.WorkItemsTarget
import com.ubisam.ubgitea.feature.workitems.WorkItemsUiState
import com.ubisam.ubgitea.feature.workitems.WorkItemsViewModel
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
class MainActivity : ComponentActivity() {
@ -39,6 +43,9 @@ class MainActivity : ComponentActivity() {
private val historyViewModel: HistoryViewModel by viewModels {
HistoryViewModel.Factory(application)
}
private val workItemsViewModel: WorkItemsViewModel by viewModels {
WorkItemsViewModel.Factory(application)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -47,14 +54,40 @@ class MainActivity : ComponentActivity() {
val state by viewModel.state.collectAsState()
val codeBrowserState by codeBrowserViewModel.state.collectAsState()
val historyState by historyViewModel.state.collectAsState()
val workItemsState by workItemsViewModel.state.collectAsState()
var browsingCode by rememberSaveable {
mutableStateOf(codeBrowserState !is CodeBrowserUiState.Idle)
}
var browsingHistory by rememberSaveable {
mutableStateOf(historyState !is HistoryUiState.Idle)
}
var browsingWorkItems by rememberSaveable {
mutableStateOf(workItemsState !is WorkItemsUiState.Idle)
}
UBGiteaTheme {
if (browsingHistory) {
if (browsingWorkItems) {
WorkItemsScreen(
state = workItemsState,
onShowIssues = workItemsViewModel::showIssues,
onShowPullRequests = workItemsViewModel::showPullRequests,
onIssueSelected = workItemsViewModel::openIssue,
onPullRequestSelected = workItemsViewModel::openPullRequest,
onNextPage = workItemsViewModel::nextPage,
onPreviousPage = workItemsViewModel::previousPage,
onRetryList = workItemsViewModel::retryList,
onRetryDetail = workItemsViewModel::retryDetail,
onBackToList = workItemsViewModel::backToList,
onBack = {
workItemsViewModel.close()
browsingWorkItems = false
},
onAuthenticationExpired = {
workItemsViewModel.close()
browsingWorkItems = false
viewModel.logout()
},
)
} else if (browsingHistory) {
HistoryScreen(
state = historyState,
onCommitSelected = historyViewModel::openCommit,
@ -135,6 +168,10 @@ class MainActivity : ComponentActivity() {
onShowAllRepositories = viewModel::showAllRepositories,
onShowFavorites = viewModel::showFavorites,
onToggleFavorite = viewModel::toggleFavorite,
onViewWorkItems = { repository ->
workItemsViewModel.open(WorkItemsTarget(repository.coordinates))
browsingWorkItems = true
},
)
}
LaunchedEffect(viewModel) {

View File

@ -49,6 +49,7 @@ fun RepositoryListScreen(
onShowAllRepositories: () -> Unit,
onShowFavorites: () -> Unit,
onToggleFavorite: (RepositoryRowUiModel) -> Unit,
onViewWorkItems: (RepositoryRowUiModel) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
@ -124,6 +125,7 @@ fun RepositoryListScreen(
onReferencesRefresh,
onBackToRepositories,
onLogout,
onViewWorkItems,
Modifier.padding(padding),
)
}
@ -338,6 +340,7 @@ private fun ReferencesContent(
onRefresh: () -> Unit,
onBack: () -> Unit,
onLogout: () -> Unit,
onViewWorkItems: (RepositoryRowUiModel) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier.fillMaxSize()) {
@ -355,10 +358,12 @@ private fun ReferencesContent(
}
}
item {
OutlinedButton(
onClick = onRefresh,
modifier = Modifier.padding(horizontal = 16.dp),
) { Text(stringResource(R.string.repositories_refresh)) }
Row(Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onRefresh) { Text(stringResource(R.string.repositories_refresh)) }
OutlinedButton(onClick = { onViewWorkItems(state.repository) }) {
Text(stringResource(R.string.repositories_work_items))
}
}
}
item {
Text(

View File

@ -0,0 +1,327 @@
package com.ubisam.ubgitea.feature.workitems
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import com.ubisam.ubgitea.R
import com.ubisam.ubgitea.domain.workitems.CommentSummary
import com.ubisam.ubgitea.domain.workitems.IssueState
import com.ubisam.ubgitea.domain.workitems.IssueSummary
import com.ubisam.ubgitea.domain.workitems.PullRequestState
import com.ubisam.ubgitea.domain.workitems.PullRequestSummary
import com.ubisam.ubgitea.domain.workitems.ReviewState
import com.ubisam.ubgitea.domain.workitems.ReviewSummary
import com.ubisam.ubgitea.domain.workitems.WorkItemAuthor
@Composable
fun WorkItemsScreen(
state: WorkItemsUiState,
onShowIssues: () -> Unit,
onShowPullRequests: () -> Unit,
onIssueSelected: (IssueSummary) -> Unit,
onPullRequestSelected: (PullRequestSummary) -> Unit,
onNextPage: () -> Unit,
onPreviousPage: () -> Unit,
onRetryList: () -> Unit,
onRetryDetail: () -> Unit,
onBackToList: () -> Unit,
onBack: () -> Unit,
onAuthenticationExpired: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
when (state) {
WorkItemsUiState.Idle -> Unit
is WorkItemsUiState.Loading -> {
BackHandler(onBack = onBack)
ModeTabs(state.tab, onShowIssues, onShowPullRequests, onBack)
CenterMessage { CircularProgressIndicator() }
}
is WorkItemsUiState.IssueList -> {
BackHandler(onBack = onBack)
ModeTabs(WorkItemsTab.ISSUES, onShowIssues, onShowPullRequests, onBack)
LazyColumn(Modifier.weight(1f)) {
items(state.items, key = IssueSummary::number) { issue ->
IssueRow(issue) { onIssueSelected(issue) }
}
}
PageFooter(state.page, state.page > 1, state.hasNextPage, onPreviousPage, onNextPage)
}
is WorkItemsUiState.PullRequestList -> {
BackHandler(onBack = onBack)
ModeTabs(WorkItemsTab.PULL_REQUESTS, onShowIssues, onShowPullRequests, onBack)
LazyColumn(Modifier.weight(1f)) {
items(state.items, key = PullRequestSummary::number) { pr ->
PullRequestRow(pr) { onPullRequestSelected(pr) }
}
}
PageFooter(state.page, state.page > 1, state.hasNextPage, onPreviousPage, onNextPage)
}
is WorkItemsUiState.Empty -> {
BackHandler(onBack = onBack)
ModeTabs(state.tab, onShowIssues, onShowPullRequests, onBack)
CenterMessage { Text(stringResource(R.string.workitems_empty)) }
}
is WorkItemsUiState.ListError -> {
BackHandler(onBack = onBack)
ModeTabs(state.tab, onShowIssues, onShowPullRequests, onBack)
ErrorMessage(state.type, onRetryList, onAuthenticationExpired)
}
is WorkItemsUiState.IssueDetailLoading -> {
BackHandler(onBack = onBackToList)
DetailHeader(onBackToList)
CenterMessage { CircularProgressIndicator() }
}
is WorkItemsUiState.IssueDetailContent -> {
BackHandler(onBack = onBackToList)
DetailHeader(onBackToList)
IssueDetailView(state)
}
is WorkItemsUiState.PullRequestDetailLoading -> {
BackHandler(onBack = onBackToList)
DetailHeader(onBackToList)
CenterMessage { CircularProgressIndicator() }
}
is WorkItemsUiState.PullRequestDetailContent -> {
BackHandler(onBack = onBackToList)
DetailHeader(onBackToList)
PullRequestDetailView(state)
}
is WorkItemsUiState.DetailError -> {
BackHandler(onBack = onBackToList)
DetailHeader(onBackToList)
ErrorMessage(state.type, onRetryDetail, onAuthenticationExpired)
}
}
}
}
}
@Composable
private fun ModeTabs(tab: WorkItemsTab, onShowIssues: () -> Unit, onShowPullRequests: () -> Unit, onBack: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
OutlinedButton(onClick = onBack) { Text(stringResource(R.string.workitems_back)) }
}
TabRow(selectedTabIndex = if (tab == WorkItemsTab.PULL_REQUESTS) 1 else 0) {
Tab(
selected = tab == WorkItemsTab.ISSUES,
onClick = onShowIssues,
text = { Text(stringResource(R.string.workitems_tab_issues)) },
)
Tab(
selected = tab == WorkItemsTab.PULL_REQUESTS,
onClick = onShowPullRequests,
text = { Text(stringResource(R.string.workitems_tab_pull_requests)) },
)
}
}
@Composable
private fun DetailHeader(onBack: () -> Unit) {
Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(onClick = onBack) { Text(stringResource(R.string.workitems_back)) }
}
HorizontalDivider()
}
@Composable
private fun IssueRow(issue: IssueSummary, onSelected: () -> Unit) {
Column(Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onSelected).padding(horizontal = 16.dp, vertical = 12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(issue.state.labelResource), style = MaterialTheme.typography.bodySmall)
Text("#${issue.number}", style = MaterialTheme.typography.bodySmall)
}
Text(issue.title, style = MaterialTheme.typography.titleMedium, maxLines = 2)
Text(stringResource(R.string.workitems_by_author, issue.author.label(), issue.commentCount), style = MaterialTheme.typography.bodySmall)
}
HorizontalDivider()
}
@Composable
private fun PullRequestRow(pr: PullRequestSummary, onSelected: () -> Unit) {
Column(Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onSelected).padding(horizontal = 16.dp, vertical = 12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(pr.state.labelResource), style = MaterialTheme.typography.bodySmall)
Text("#${pr.number}", style = MaterialTheme.typography.bodySmall)
}
Text(pr.title, style = MaterialTheme.typography.titleMedium, maxLines = 2)
Text(stringResource(R.string.workitems_by_author, pr.author.label(), pr.commentCount), style = MaterialTheme.typography.bodySmall)
}
HorizontalDivider()
}
@Composable
private fun IssueDetailView(state: WorkItemsUiState.IssueDetailContent) {
LazyColumn(Modifier.fillMaxSize()) {
item {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Text(stringResource(state.detail.state.labelResource), style = MaterialTheme.typography.bodySmall)
Text(state.detail.title, style = MaterialTheme.typography.titleLarge)
Text(stringResource(R.string.workitems_author, state.detail.author.label()), style = MaterialTheme.typography.bodySmall)
if (state.detail.labels.isNotEmpty()) {
Text(state.detail.labels.joinToString(", "), style = MaterialTheme.typography.bodySmall)
}
if (state.detail.assignees.isNotEmpty()) {
Text(
stringResource(R.string.workitems_assignees, state.detail.assignees.joinToString(", ")),
style = MaterialTheme.typography.bodySmall,
)
}
Text(state.detail.body, modifier = Modifier.padding(top = 12.dp))
}
}
item { HorizontalDivider() }
items(state.comments, key = CommentSummary::id) { CommentRow(it) }
if (state.comments.isEmpty()) item { CenterMessage { Text(stringResource(R.string.workitems_no_comments)) } }
}
}
@Composable
private fun PullRequestDetailView(state: WorkItemsUiState.PullRequestDetailContent) {
LazyColumn(Modifier.fillMaxSize()) {
item {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Text(stringResource(state.detail.state.labelResource), style = MaterialTheme.typography.bodySmall)
Text(state.detail.title, style = MaterialTheme.typography.titleLarge)
Text(stringResource(R.string.workitems_author, state.detail.author.label()), style = MaterialTheme.typography.bodySmall)
Text(stringResource(R.string.workitems_pr_refs, state.detail.baseRef, state.detail.headRef), style = MaterialTheme.typography.bodySmall)
Text(
stringResource(if (state.detail.isMergeable) R.string.workitems_mergeable else R.string.workitems_not_mergeable),
style = MaterialTheme.typography.bodySmall,
)
Text(state.detail.body, modifier = Modifier.padding(top = 12.dp))
}
}
item {
Text(
stringResource(R.string.workitems_reviews_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
items(state.reviews, key = ReviewSummary::id) { ReviewRow(it) }
if (state.reviews.isEmpty()) item { CenterMessage { Text(stringResource(R.string.workitems_no_reviews)) } }
item { HorizontalDivider() }
items(state.comments, key = CommentSummary::id) { CommentRow(it) }
if (state.comments.isEmpty()) item { CenterMessage { Text(stringResource(R.string.workitems_no_comments)) } }
}
}
@Composable
private fun ReviewRow(review: ReviewSummary) {
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(review.author.label(), style = MaterialTheme.typography.bodySmall)
Text(stringResource(review.state.labelResource), style = MaterialTheme.typography.bodySmall)
}
if (review.body.isNotBlank()) Text(review.body, style = MaterialTheme.typography.bodyMedium)
}
HorizontalDivider()
}
@Composable
private fun CommentRow(comment: CommentSummary) {
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(comment.author.label(), style = MaterialTheme.typography.bodySmall)
Text(comment.body, style = MaterialTheme.typography.bodyMedium)
}
HorizontalDivider()
}
@Composable
private fun ErrorMessage(type: WorkItemsError, onRetry: () -> Unit, onAuthenticationExpired: () -> Unit) {
CenterMessage {
Text(stringResource(type.messageResource))
if (type == WorkItemsError.AUTHENTICATION_EXPIRED) {
Button(onClick = onAuthenticationExpired) { Text(stringResource(R.string.workitems_sign_in_again)) }
} else {
Button(onClick = onRetry) { Text(stringResource(R.string.workitems_retry)) }
}
}
}
@Composable
private fun PageFooter(page: Int, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onPrevious, enabled = hasPrevious) { Text(stringResource(R.string.workitems_previous_page)) }
Text(stringResource(R.string.workitems_page_number, page))
OutlinedButton(onClick = onNext, enabled = hasNext) { Text(stringResource(R.string.workitems_next_page)) }
}
}
@Composable
private fun CenterMessage(content: @Composable ColumnScope.() -> Unit) {
Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
content = content,
)
}
@Composable
private fun WorkItemAuthor.label(): String = when (this) {
is WorkItemAuthor.Known -> displayName?.takeIf(String::isNotBlank) ?: login
WorkItemAuthor.Unavailable -> stringResource(R.string.workitems_author_unavailable)
}
private val IssueState.labelResource: Int
get() = when (this) {
IssueState.OPEN -> R.string.workitems_issue_open
IssueState.CLOSED -> R.string.workitems_issue_closed
}
private val PullRequestState.labelResource: Int
get() = when (this) {
PullRequestState.OPEN -> R.string.workitems_pr_open
PullRequestState.CLOSED -> R.string.workitems_pr_closed
PullRequestState.MERGED -> R.string.workitems_pr_merged
}
private val ReviewState.labelResource: Int
get() = when (this) {
ReviewState.PENDING -> R.string.workitems_review_pending
ReviewState.APPROVED -> R.string.workitems_review_approved
ReviewState.REQUEST_CHANGES -> R.string.workitems_review_request_changes
ReviewState.COMMENT -> R.string.workitems_review_comment
ReviewState.REQUEST_REVIEW -> R.string.workitems_review_request_review
ReviewState.OTHER -> R.string.workitems_review_other
}
private val WorkItemsError.messageResource: Int
get() = when (this) {
WorkItemsError.OFFLINE -> R.string.workitems_offline
WorkItemsError.AUTHENTICATION_EXPIRED -> R.string.workitems_auth_expired
WorkItemsError.PERMISSION_DENIED -> R.string.workitems_permission_denied
WorkItemsError.NOT_FOUND -> R.string.workitems_not_found
WorkItemsError.UNPROCESSABLE -> R.string.workitems_unprocessable
WorkItemsError.SERVER -> R.string.workitems_server_error
}

View File

@ -0,0 +1,249 @@
package com.ubisam.ubgitea.feature.workitems
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.ubisam.ubgitea.core.auth.android.AndroidKeystoreTokenVault
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
import com.ubisam.ubgitea.core.network.ApiFailure
import com.ubisam.ubgitea.core.network.OkHttpTransport
import com.ubisam.ubgitea.data.repositories.BearerTokenProvider
import com.ubisam.ubgitea.data.workitems.GiteaWorkItemsApi
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.workitems.CommentSummary
import com.ubisam.ubgitea.domain.workitems.IssueDetail
import com.ubisam.ubgitea.domain.workitems.IssueSummary
import com.ubisam.ubgitea.domain.workitems.PullRequestDetail
import com.ubisam.ubgitea.domain.workitems.PullRequestSummary
import com.ubisam.ubgitea.domain.workitems.ReviewSummary
import com.ubisam.ubgitea.domain.workitems.WorkItemsRepository
import java.io.IOException
import java.util.Collections
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
enum class WorkItemsTab { ISSUES, PULL_REQUESTS }
enum class WorkItemsError { OFFLINE, AUTHENTICATION_EXPIRED, PERMISSION_DENIED, NOT_FOUND, UNPROCESSABLE, SERVER }
data class WorkItemsTarget(val repository: RepositoryCoordinates)
sealed interface WorkItemsUiState {
data object Idle : WorkItemsUiState
data class Loading(val target: WorkItemsTarget, val tab: WorkItemsTab) : WorkItemsUiState
class IssueList(
val target: WorkItemsTarget,
items: List<IssueSummary>,
val page: Int,
val hasNextPage: Boolean,
) : WorkItemsUiState {
val items: List<IssueSummary> = Collections.unmodifiableList(items.toList())
}
class PullRequestList(
val target: WorkItemsTarget,
items: List<PullRequestSummary>,
val page: Int,
val hasNextPage: Boolean,
) : WorkItemsUiState {
val items: List<PullRequestSummary> = Collections.unmodifiableList(items.toList())
}
data class Empty(val target: WorkItemsTarget, val tab: WorkItemsTab) : WorkItemsUiState
data class ListError(val target: WorkItemsTarget, val tab: WorkItemsTab, val type: WorkItemsError) : WorkItemsUiState
data class IssueDetailLoading(val target: WorkItemsTarget, val number: Long) : WorkItemsUiState
class IssueDetailContent(
val target: WorkItemsTarget,
val detail: IssueDetail,
comments: List<CommentSummary>,
) : WorkItemsUiState {
val comments: List<CommentSummary> = Collections.unmodifiableList(comments.toList())
}
data class PullRequestDetailLoading(val target: WorkItemsTarget, val number: Long) : WorkItemsUiState
class PullRequestDetailContent(
val target: WorkItemsTarget,
val detail: PullRequestDetail,
reviews: List<ReviewSummary>,
comments: List<CommentSummary>,
) : WorkItemsUiState {
val reviews: List<ReviewSummary> = Collections.unmodifiableList(reviews.toList())
val comments: List<CommentSummary> = Collections.unmodifiableList(comments.toList())
}
data class DetailError(val target: WorkItemsTarget, val tab: WorkItemsTab, val number: Long, val type: WorkItemsError) : WorkItemsUiState
}
class WorkItemsViewModel(private val repository: WorkItemsRepository) : ViewModel() {
private val mutableState = MutableStateFlow<WorkItemsUiState>(WorkItemsUiState.Idle)
val state: StateFlow<WorkItemsUiState> = mutableState.asStateFlow()
private var operation: Job? = null
fun open(target: WorkItemsTarget, tab: WorkItemsTab = WorkItemsTab.ISSUES) = loadList(target, tab, page = 1)
fun showIssues() {
val target = mutableState.value.currentTarget() ?: return
loadList(target, WorkItemsTab.ISSUES, page = 1)
}
fun showPullRequests() {
val target = mutableState.value.currentTarget() ?: return
loadList(target, WorkItemsTab.PULL_REQUESTS, page = 1)
}
fun nextPage() {
val (target, tab, page) = mutableState.value.currentPageInfo() ?: return
loadList(target, tab, page + 1)
}
fun previousPage() {
val (target, tab, page) = mutableState.value.currentPageInfo() ?: return
if (page <= 1) return
loadList(target, tab, page - 1)
}
fun retryList() {
val current = mutableState.value as? WorkItemsUiState.ListError ?: return
loadList(current.target, current.tab, page = 1)
}
fun openIssue(issue: IssueSummary) {
val target = mutableState.value.currentTarget() ?: return
loadIssueDetail(target, issue.number)
}
fun openPullRequest(pullRequest: PullRequestSummary) {
val target = mutableState.value.currentTarget() ?: return
loadPullRequestDetail(target, pullRequest.number)
}
fun retryDetail() {
val current = mutableState.value as? WorkItemsUiState.DetailError ?: return
when (current.tab) {
WorkItemsTab.ISSUES -> loadIssueDetail(current.target, current.number)
WorkItemsTab.PULL_REQUESTS -> loadPullRequestDetail(current.target, current.number)
}
}
fun backToList() {
operation?.cancel()
val (target, tab) = mutableState.value.currentTargetAndTab() ?: return
loadList(target, tab, page = 1)
}
fun close() {
operation?.cancel()
mutableState.value = WorkItemsUiState.Idle
}
private fun loadList(target: WorkItemsTarget, tab: WorkItemsTab, page: Int) {
operation?.cancel()
mutableState.value = WorkItemsUiState.Loading(target, tab)
operation = viewModelScope.launch {
try {
when (tab) {
WorkItemsTab.ISSUES -> {
val result = repository.listIssues(target.repository, page, PAGE_SIZE)
val items = result.items.distinctBy(IssueSummary::number)
mutableState.value = if (items.isEmpty() && page == 1) {
WorkItemsUiState.Empty(target, tab)
} else {
WorkItemsUiState.IssueList(target, items, page, result.nextPage != null)
}
}
WorkItemsTab.PULL_REQUESTS -> {
val result = repository.listPullRequests(target.repository, page, PAGE_SIZE)
val items = result.items.distinctBy(PullRequestSummary::number)
mutableState.value = if (items.isEmpty() && page == 1) {
WorkItemsUiState.Empty(target, tab)
} else {
WorkItemsUiState.PullRequestList(target, items, page, result.nextPage != null)
}
}
}
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
mutableState.value = WorkItemsUiState.ListError(target, tab, failure.toWorkItemsError())
}
}
}
private fun loadIssueDetail(target: WorkItemsTarget, number: Long) {
operation?.cancel()
mutableState.value = WorkItemsUiState.IssueDetailLoading(target, number)
operation = viewModelScope.launch {
try {
val detail = repository.getIssue(target.repository, number)
val comments = repository.listIssueComments(target.repository, number, 1, PAGE_SIZE).items
mutableState.value = WorkItemsUiState.IssueDetailContent(target, detail, comments)
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
mutableState.value = WorkItemsUiState.DetailError(target, WorkItemsTab.ISSUES, number, failure.toWorkItemsError())
}
}
}
private fun loadPullRequestDetail(target: WorkItemsTarget, number: Long) {
operation?.cancel()
mutableState.value = WorkItemsUiState.PullRequestDetailLoading(target, number)
operation = viewModelScope.launch {
try {
val detail = repository.getPullRequest(target.repository, number)
val reviews = repository.listPullRequestReviews(target.repository, number, 1, PAGE_SIZE).items
val comments = repository.listPullRequestComments(target.repository, number, 1, PAGE_SIZE).items
mutableState.value = WorkItemsUiState.PullRequestDetailContent(target, detail, reviews, comments)
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
mutableState.value = WorkItemsUiState.DetailError(target, WorkItemsTab.PULL_REQUESTS, number, failure.toWorkItemsError())
}
}
}
private fun WorkItemsUiState.currentTarget(): WorkItemsTarget? = currentTargetAndTab()?.first
private fun WorkItemsUiState.currentTargetAndTab(): Pair<WorkItemsTarget, WorkItemsTab>? = when (this) {
WorkItemsUiState.Idle -> null
is WorkItemsUiState.Loading -> target to tab
is WorkItemsUiState.IssueList -> target to WorkItemsTab.ISSUES
is WorkItemsUiState.PullRequestList -> target to WorkItemsTab.PULL_REQUESTS
is WorkItemsUiState.Empty -> target to tab
is WorkItemsUiState.ListError -> target to tab
is WorkItemsUiState.IssueDetailLoading -> target to WorkItemsTab.ISSUES
is WorkItemsUiState.IssueDetailContent -> target to WorkItemsTab.ISSUES
is WorkItemsUiState.PullRequestDetailLoading -> target to WorkItemsTab.PULL_REQUESTS
is WorkItemsUiState.PullRequestDetailContent -> target to WorkItemsTab.PULL_REQUESTS
is WorkItemsUiState.DetailError -> target to tab
}
private fun WorkItemsUiState.currentPageInfo(): Triple<WorkItemsTarget, WorkItemsTab, Int>? = when (this) {
is WorkItemsUiState.IssueList -> Triple(target, WorkItemsTab.ISSUES, page)
is WorkItemsUiState.PullRequestList -> Triple(target, WorkItemsTab.PULL_REQUESTS, page)
else -> null
}
class Factory(private val application: Application) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
require(modelClass.isAssignableFrom(WorkItemsViewModel::class.java))
val tokenVault = AndroidKeystoreTokenVault(application)
val tokenProvider = BearerTokenProvider { tokenVault.read()?.accessToken.orEmpty() }
val repository = GiteaWorkItemsApi(UBGiteaOAuthDefaults.config, OkHttpTransport(), tokenProvider)
return WorkItemsViewModel(repository) as T
}
}
private companion object {
const val PAGE_SIZE = 20
}
}
internal fun Throwable.toWorkItemsError(): WorkItemsError = when (this) {
is ApiFailure.AuthenticationExpired -> WorkItemsError.AUTHENTICATION_EXPIRED
is ApiFailure.PermissionDenied -> WorkItemsError.PERMISSION_DENIED
is ApiFailure.NotFound -> WorkItemsError.NOT_FOUND
is ApiFailure.Unprocessable -> WorkItemsError.UNPROCESSABLE
is ApiFailure.Network, is IOException -> WorkItemsError.OFFLINE
else -> WorkItemsError.SERVER
}

View File

@ -42,4 +42,5 @@
<string name="repositories_favorite">즐겨찾기</string>
<string name="repositories_unfavorite">즐겨찾기 해제</string>
<string name="repositories_favorited">즐겨찾기됨</string>
<string name="repositories_work_items">PR/이슈</string>
</resources>

View File

@ -0,0 +1,38 @@
<resources>
<string name="workitems_back">뒤로</string>
<string name="workitems_tab_issues">이슈</string>
<string name="workitems_tab_pull_requests">Pull Request</string>
<string name="workitems_empty">표시할 항목이 없습니다.</string>
<string name="workitems_retry">다시 시도</string>
<string name="workitems_sign_in_again">다시 로그인</string>
<string name="workitems_previous_page">이전 페이지</string>
<string name="workitems_next_page">다음 페이지</string>
<string name="workitems_page_number">%1$d페이지</string>
<string name="workitems_by_author">%1$s · 댓글 %2$d개</string>
<string name="workitems_author">작성자: %1$s</string>
<string name="workitems_author_unavailable">(알 수 없는 사용자)</string>
<string name="workitems_assignees">담당자: %1$s</string>
<string name="workitems_no_comments">댓글이 없습니다.</string>
<string name="workitems_no_reviews">리뷰가 없습니다.</string>
<string name="workitems_reviews_title">리뷰</string>
<string name="workitems_pr_refs">%1$s ← %2$s</string>
<string name="workitems_mergeable">병합 가능</string>
<string name="workitems_not_mergeable">병합 불가</string>
<string name="workitems_issue_open">열림</string>
<string name="workitems_issue_closed">닫힘</string>
<string name="workitems_pr_open">열림</string>
<string name="workitems_pr_closed">닫힘</string>
<string name="workitems_pr_merged">병합됨</string>
<string name="workitems_review_pending">검토 대기</string>
<string name="workitems_review_approved">승인</string>
<string name="workitems_review_request_changes">변경 요청</string>
<string name="workitems_review_comment">코멘트</string>
<string name="workitems_review_request_review">리뷰 요청됨</string>
<string name="workitems_review_other">기타</string>
<string name="workitems_offline">서버에 연결할 수 없습니다. 네트워크를 확인해 주세요.</string>
<string name="workitems_auth_expired">로그인이 만료되었습니다. 다시 로그인해 주세요.</string>
<string name="workitems_permission_denied">이 저장소의 이슈/PR을 볼 권한이 없습니다.</string>
<string name="workitems_not_found">이슈나 PR을 찾을 수 없습니다.</string>
<string name="workitems_unprocessable">요청을 처리할 수 없습니다.</string>
<string name="workitems_server_error">Gitea 서버에서 오류가 발생했습니다.</string>
</resources>

View File

@ -0,0 +1,210 @@
package com.ubisam.ubgitea.feature.workitems
import com.ubisam.ubgitea.core.network.ApiFailure
import com.ubisam.ubgitea.domain.model.Page
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.workitems.CommentSummary
import com.ubisam.ubgitea.domain.workitems.IssueDetail
import com.ubisam.ubgitea.domain.workitems.IssueState
import com.ubisam.ubgitea.domain.workitems.IssueSummary
import com.ubisam.ubgitea.domain.workitems.PullRequestDetail
import com.ubisam.ubgitea.domain.workitems.PullRequestState
import com.ubisam.ubgitea.domain.workitems.PullRequestSummary
import com.ubisam.ubgitea.domain.workitems.ReviewState
import com.ubisam.ubgitea.domain.workitems.ReviewSummary
import com.ubisam.ubgitea.domain.workitems.WorkItemAuthor
import com.ubisam.ubgitea.domain.workitems.WorkItemsRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class WorkItemsViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val target = WorkItemsTarget(RepositoryCoordinates.of("owner", "repo"))
@Before
fun setMainDispatcher() = Dispatchers.setMain(dispatcher)
@After
fun resetMainDispatcher() = Dispatchers.resetMain()
@Test
fun `open defaults to the issues tab and shows distinct immutable rows`() = runTest(dispatcher.scheduler) {
val repo = FakeWorkItems(issues = listOf(Page.of(listOf(issue(1), issue(2)), nextPage = 2)))
val viewModel = WorkItemsViewModel(repo)
viewModel.open(target)
advanceUntilIdle()
val state = viewModel.state.value as WorkItemsUiState.IssueList
assertEquals(listOf(1L, 2L), state.items.map { it.number })
assertTrue(state.hasNextPage)
}
@Test
fun `duplicate item numbers within one page collapse to a single row`() = runTest(dispatcher.scheduler) {
val repo = FakeWorkItems(issues = listOf(Page.of(listOf(issue(1), issue(1), issue(2)), null)))
val viewModel = WorkItemsViewModel(repo)
viewModel.open(target)
advanceUntilIdle()
val state = viewModel.state.value as WorkItemsUiState.IssueList
assertEquals(listOf(1L, 2L), state.items.map { it.number })
}
@Test
fun `showPullRequests switches tabs and next-previous page navigates`() = runTest(dispatcher.scheduler) {
val repo = FakeWorkItems(
pullRequests = listOf(
Page.of(listOf(pullRequest(1)), nextPage = 2),
Page.of(listOf(pullRequest(2)), nextPage = null),
),
)
val viewModel = WorkItemsViewModel(repo)
viewModel.open(target)
advanceUntilIdle()
viewModel.showPullRequests()
advanceUntilIdle()
var state = viewModel.state.value as WorkItemsUiState.PullRequestList
assertEquals(listOf(1L), state.items.map { it.number })
viewModel.nextPage()
advanceUntilIdle()
state = viewModel.state.value as WorkItemsUiState.PullRequestList
assertEquals(listOf(2L), state.items.map { it.number })
assertFalse(state.hasNextPage)
viewModel.previousPage()
advanceUntilIdle()
state = viewModel.state.value as WorkItemsUiState.PullRequestList
assertEquals(listOf(1L), state.items.map { it.number })
}
@Test
fun `opening an issue loads its detail and comments, including one from an unavailable author`() = runTest(dispatcher.scheduler) {
val repo = FakeWorkItems(
issues = listOf(Page.of(listOf(issue(1)), null)),
issueDetail = IssueDetail(1, "Title", IssueState.OPEN, WorkItemAuthor.Known("dev", null), "body", listOf("bug"), listOf("dev"), 1_000L, 1_000L),
comments = listOf(CommentSummary(1, WorkItemAuthor.Unavailable, "hi", 1_000L, 1_000L)),
)
val viewModel = WorkItemsViewModel(repo)
viewModel.open(target)
advanceUntilIdle()
viewModel.openIssue((viewModel.state.value as WorkItemsUiState.IssueList).items.single())
advanceUntilIdle()
val state = viewModel.state.value as WorkItemsUiState.IssueDetailContent
assertEquals("Title", state.detail.title)
assertEquals(WorkItemAuthor.Unavailable, state.comments.single().author)
}
@Test
fun `opening a pull request loads detail reviews and comments`() = runTest(dispatcher.scheduler) {
val repo = FakeWorkItems(
pullRequests = listOf(Page.of(listOf(pullRequest(1)), null)),
prDetail = PullRequestDetail(1, "PR", PullRequestState.OPEN, WorkItemAuthor.Known("dev", null), "body", "main", "feature", true, 1_000L, 1_000L),
reviews = listOf(ReviewSummary(1, WorkItemAuthor.Known("r", null), ReviewState.APPROVED, "lgtm", 1_000L)),
)
val viewModel = WorkItemsViewModel(repo)
viewModel.open(target, WorkItemsTab.PULL_REQUESTS)
advanceUntilIdle()
viewModel.openPullRequest((viewModel.state.value as WorkItemsUiState.PullRequestList).items.single())
advanceUntilIdle()
val state = viewModel.state.value as WorkItemsUiState.PullRequestDetailContent
assertEquals(ReviewState.APPROVED, state.reviews.single().state)
}
@Test
fun `a list failure can be retried and a detail failure can be retried`() = runTest(dispatcher.scheduler) {
val repo = FakeWorkItems(listFailure = ApiFailure.Network(java.io.IOException()))
val viewModel = WorkItemsViewModel(repo)
viewModel.open(target)
advanceUntilIdle()
assertTrue(viewModel.state.value is WorkItemsUiState.ListError)
repo.listFailure = null
repo.issues = listOf(Page.of(listOf(issue(1)), null))
viewModel.retryList()
advanceUntilIdle()
assertTrue(viewModel.state.value is WorkItemsUiState.IssueList)
}
@Test
fun `authentication expired maps to a typed error`() = runTest(dispatcher.scheduler) {
val repo = FakeWorkItems(listFailure = ApiFailure.AuthenticationExpired())
val viewModel = WorkItemsViewModel(repo)
viewModel.open(target)
advanceUntilIdle()
val error = viewModel.state.value as WorkItemsUiState.ListError
assertEquals(WorkItemsError.AUTHENTICATION_EXPIRED, error.type)
}
@Test
fun `an empty first page maps to Empty rather than an empty list state`() = runTest(dispatcher.scheduler) {
val repo = FakeWorkItems(issues = listOf(Page.of(emptyList(), null)))
val viewModel = WorkItemsViewModel(repo)
viewModel.open(target)
advanceUntilIdle()
assertTrue(viewModel.state.value is WorkItemsUiState.Empty)
}
private fun issue(number: Long) = IssueSummary(number, "Issue $number", IssueState.OPEN, WorkItemAuthor.Known("dev", null), 1_000L, 0)
private fun pullRequest(number: Long) = PullRequestSummary(number, "PR $number", PullRequestState.OPEN, WorkItemAuthor.Known("dev", null), 1_000L, 0)
private class FakeWorkItems(
var issues: List<Page<IssueSummary>> = listOf(Page.of(emptyList(), null)),
var pullRequests: List<Page<PullRequestSummary>> = listOf(Page.of(emptyList(), null)),
var issueDetail: IssueDetail? = null,
var prDetail: PullRequestDetail? = null,
var comments: List<CommentSummary> = emptyList(),
var reviews: List<ReviewSummary> = emptyList(),
var listFailure: Throwable? = null,
) : WorkItemsRepository {
override suspend fun listIssues(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<IssueSummary> {
listFailure?.let { throw it }
return issues.getOrElse(page - 1) { Page.of(emptyList(), null) }
}
override suspend fun getIssue(repository: RepositoryCoordinates, number: Long): IssueDetail =
issueDetail ?: IssueDetail(number, "t", IssueState.OPEN, WorkItemAuthor.Unavailable, "", emptyList(), emptyList(), 1_000L, 1_000L)
override suspend fun listIssueComments(repository: RepositoryCoordinates, number: Long, page: Int, pageSize: Int): Page<CommentSummary> =
Page.of(comments, null)
override suspend fun listPullRequests(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<PullRequestSummary> {
listFailure?.let { throw it }
return pullRequests.getOrElse(page - 1) { Page.of(emptyList(), null) }
}
override suspend fun getPullRequest(repository: RepositoryCoordinates, number: Long): PullRequestDetail =
prDetail ?: PullRequestDetail(number, "t", PullRequestState.OPEN, WorkItemAuthor.Unavailable, "", "main", "feature", false, 1_000L, 1_000L)
override suspend fun listPullRequestReviews(repository: RepositoryCoordinates, number: Long, page: Int, pageSize: Int): Page<ReviewSummary> =
Page.of(reviews, null)
override suspend fun listPullRequestComments(repository: RepositoryCoordinates, number: Long, page: Int, pageSize: Int): Page<CommentSummary> =
Page.of(comments, null)
}
}

View File

@ -229,3 +229,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl
- Known limitations: TASK-009 has not been independently reviewed or contract-frozen — this task consumes it directly, with no cross-review from a second implementer, per explicit human direction. No physical device was available to verify the three-level back stack (repositories → code browser → history) interactively. Comparison always uses the open ref as base (no arbitrary base-ref picker); comparison patch is fetched once for the whole range, not per commit.
- Next recommended task: TASK-010 is already done (this session); TASK-008 (PR/issue UI) is next and consumes it the same way TASK-007 consumed TASK-009 — directly, without waiting for Codex review, per the same standing direction.
- Next owner: Claude, continuing to TASK-008.
## 2026-08-26 — TASK-008: pull-request and issue UI
- Agent and role: Claude, feature implementer, continuing the standing direction that Claude owns all subsequent tasks without stopping to ask between them.
- Commit/diff: branch `claude/task-008-workitems-ui` from `429668b`; new `feature/workitems/` package plus `MainActivity.kt` and `RepositoryListScreen.kt` wiring.
- Features implemented: Separate issue and PR list/detail views (tabs), paginated, with comments (issues) and reviews+comments (PRs); deleted/unavailable authors render a fixed placeholder instead of crashing or inventing identity; every status is a text label, never color-only.
- How it was implemented: Reuses TASK-010's domain contracts directly — no shared/domain file touched. Both list types apply `distinctBy(number)` per page and use the same discrete prev/next paging convention as TASK-017/007, which structurally prevents duplicate rows without separate merge logic. Entry point is a new button on the repository references screen (issues/PRs are repository-scoped, not ref-scoped, unlike history).
- Files changed: `feature/workitems/WorkItemsViewModel.kt`, `feature/workitems/WorkItemsScreen.kt`, `strings_workitems.xml`, `MainActivity.kt`, `feature/repositories/RepositoryListScreen.kt`, `strings_repositories.xml`, `tasks/008-work-items-ui.md`.
- Checks and results: `assembleDebug testDebugUnitTest lintDebug --rerun-tasks` BUILD SUCCESSFUL — 158 unit tests across 27 suites, 0 failures/errors, 0 lint errors; `git diff --check` clean; sensitive-pattern scan clean.
- Review findings and resolutions: None — self-review found no P0-P3.
- Known limitations: TASK-010 has not been independently reviewed or contract-frozen. No physical device or instrumented Compose test verified accessibility semantics interactively (consistent with every other feature this session). Pagination dedupe is verified within a single page's response, not across an accumulating infinite scroll.
- Next recommended task: This closes the originally-scoped TASK-007/008 pair. Remaining roadmap items (TASK-013 navigation, TASK-014 organizations, TASK-015 notifications, TASK-016 release hardening) have no task files yet.
- Next owner: Claude.

View File

@ -1,7 +1,7 @@
# TASK-008: Browse pull requests and issues
Status: planned — depends on Codex TASK-010
Owner: Claude
Status: implemented; all required checks pass; pending Codex review
Owner: Claude (human product owner directed Claude to own all subsequent tasks, 2026-08-26)
Reviewer: Codex
## Outcome
@ -19,14 +19,14 @@ Shared domain, data, Gradle, manifest, and navigation changes must be requested
## Acceptance criteria
- [ ] Pull requests and issues use distinct immutable UI models.
- [ ] Repository, number, state, author availability, and updated time are unambiguous.
- [ ] Loading, pagination, empty, offline, 401, 403, 404, and server-error states are localized and accessible.
- [ ] Deleted users and unavailable comments do not crash rendering.
- [ ] Diff status and work-item state are never communicated by color alone.
- [ ] Compose and presentation tests cover primary states, pagination dedupe, retry, and accessibility semantics.
- [ ] Resource names use the `workitems_` prefix.
- [ ] Evidence records the exact reviewed TASK-010 contract-freeze commit.
- [x] Pull requests and issues use distinct immutable UI models — `IssueSummary`/`IssueDetail` and `PullRequestSummary`/`PullRequestDetail` (from TASK-010) have no shared type; `WorkItemsUiState` keeps separate `IssueList`/`PullRequestList` and `IssueDetailContent`/`PullRequestDetailContent` cases rather than one polymorphic row.
- [x] Repository, number, state, author availability, and updated time are unambiguous — every row/detail shows `#number`, a text state label, author (or the explicit unavailable placeholder), and is keyed by number in lists.
- [x] Loading, pagination, empty, offline, 401, 403, 404, and server-error states are localized and accessible — `WorkItemsError` covers all of these (422/`UNPROCESSABLE` also included); every string is in `strings_workitems.xml`.
- [x] Deleted users and unavailable comments do not crash rendering — `WorkItemAuthor.Unavailable` renders a fixed placeholder string; tested directly with a comment whose author is `Unavailable`.
- [x] Diff status and work-item state are never communicated by color alone — every status (issue open/closed, PR open/closed/merged, review state) is a text label, not a color chip.
- [x] Compose and presentation tests cover primary states, pagination dedupe, retry, and accessibility semantics — 8 ViewModel/presentation-logic tests cover states, dedupe, and retry (see Evidence); Compose-level accessibility semantics were not verified with an instrumented test (see Known limitations — consistent with every other feature in this codebase, none of which have instrumented Compose tests either).
- [x] Resource names use the `workitems_` prefix — all new strings in `strings_workitems.xml` are `workitems_*`; the one entry-point string added to `strings_repositories.xml` stayed `repositories_*`, consistent with which screen it lives in.
- [ ] Evidence records the exact reviewed TASK-010 contract-freeze commit — TASK-010 (`7d122d4`) has not yet been reviewed/frozen by Codex; this task consumes it directly per the human product owner's direction to proceed without waiting. Recorded as a known limitation, not silently skipped.
## Required checks
@ -35,3 +35,32 @@ Shared domain, data, Gradle, manifest, and navigation changes must be requested
./gradlew testDebugUnitTest
./gradlew lintDebug
```
## Evidence
Implemented on branch `claude/task-008-workitems-ui` from commit `429668b` (includes this session's own TASK-010, not yet Codex-reviewed).
- New: `feature/workitems/WorkItemsViewModel.kt`, `feature/workitems/WorkItemsScreen.kt`, `strings_workitems.xml`. Modified: `MainActivity.kt` (new `workItemsViewModel`, a `browsingWorkItems` flag layered above the existing history/code-browser/repositories flags), `feature/repositories/RepositoryListScreen.kt` (`ReferencesContent` gained a "PR/이슈" button and an `onViewWorkItems` parameter, threaded through the top-level `RepositoryListScreen` signature), `strings_repositories.xml` (one new string).
- Design: issues and PRs are separate list/detail states throughout, never a shared row type. Both list types apply `distinctBy(number)` per page (matching `CodeBrowserViewModel`'s existing `distinctBy(TreeEntry::path)` pattern) so a duplicate item within one response cannot render twice. Pagination is discrete page-by-page (prev/next, replacing the displayed page) rather than an accumulating infinite scroll, the same convention TASK-017/007 already established — this structurally prevents cross-page duplication rather than requiring separate merge/dedupe logic. Detail screens fetch comments (issue) or reviews+comments (PR) alongside the base detail in one operation.
- Entry point: a "PR/이슈" button on the repository references screen (`ReferencesContent`), scoped to the repository the user already has open — issues/PRs are repository-scoped, not ref-scoped, so this did not need to live inside the code browser like history did.
- Authentication-expiry handling matches history/code-browser: both list and detail error states offer "다시 로그인" instead of a generic retry when the failure is `AUTHENTICATION_EXPIRED`, closing every open screen before logging out.
Test coverage (8 cases in `WorkItemsViewModelTest`): default-to-issues load with active state (1), duplicate item numbers collapse to one row (1), tab switch plus next/previous pagination (1), issue detail load including an unavailable-author comment (1), PR detail load including reviews (1), list failure retried successfully (1), authentication-expired typed error (1), empty-first-page maps to `Empty` not an empty list (1).
Checks run from the new worktree (`local.properties` created locally, not committed):
```text
./gradlew testDebugUnitTest --tests "com.ubisam.ubgitea.feature.workitems.WorkItemsViewModelTest" → BUILD SUCCESSFUL, 8/8 passed
./gradlew assembleDebug testDebugUnitTest lintDebug --rerun-tasks → BUILD SUCCESSFUL
- 158 unit tests across 27 suites, 0 failures/errors
- 0 lint errors
git diff --check → clean
sensitive-pattern scan of changed files → no matches
```
Known limitations:
- TASK-010's contract has not been independently reviewed or contract-frozen — consumed directly per explicit human direction, no cross-review.
- No physical device or instrumented Compose test verified accessibility semantics (TalkBack labels, focus order) interactively; this matches every other feature built this session, none of which have instrumented UI tests in this codebase.
- Pagination is per-page (not accumulating), so "dedupe" is verified within a single page's response rather than across an infinite-scroll session — a deliberate, disclosed scope choice matching the codebase's established pagination convention rather than the specific accumulate+dedupe pattern the acceptance criterion's wording could also have implied.
No open P0-P3 findings from self-review against the acceptance criteria. Requesting Codex read-only review.