From ad534e5a73b382837cf5a46c42fd6d2fe38d0390 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 10:44:40 +0000 Subject: [PATCH 1/5] Add PR detail expansion and per-workflow CI status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PR cards now expand in-place on tap (like early Twitter) showing: - Per-workflow CI breakdown (failed/passed/pending counts) - Retry job status with remaining/total retries - Action buttons: Open PR, Retry, Retry x3, Cancel - CI badge shows compact workflow format (e.g. "2/5wf·3") - GraphQL query fetches individual check runs via statusCheckRollup contexts - Enrich RetryFlakyJob with workflowAttempts and totalRetries - Runner settings now show pending retry count and recent retry results https://claude.ai/code/session_01PqW1eLdPDdSmtQnCfG1HCn --- .../kotlin/com/ghpr/app/data/GhprApiClient.kt | 6 +- .../com/ghpr/app/data/GitHubGraphQLClient.kt | 443 +++--- .../com/ghpr/app/data/OpenPullRequest.kt | 45 +- .../com/ghpr/app/ui/openprs/OpenPrsScreen.kt | 1187 ++++++++++------- .../ghpr/app/ui/openprs/OpenPrsViewModel.kt | 9 + .../ghpr/app/ui/settings/SettingsScreen.kt | 27 + .../ghpr/app/ui/settings/SettingsViewModel.kt | 34 +- 7 files changed, 1098 insertions(+), 653 deletions(-) diff --git a/android/app/src/main/kotlin/com/ghpr/app/data/GhprApiClient.kt b/android/app/src/main/kotlin/com/ghpr/app/data/GhprApiClient.kt index fe225bd..47b5b26 100644 --- a/android/app/src/main/kotlin/com/ghpr/app/data/GhprApiClient.kt +++ b/android/app/src/main/kotlin/com/ghpr/app/data/GhprApiClient.kt @@ -29,9 +29,13 @@ data class RetryFlakyJob( val repoFullName: String, val prNumber: Int, val retriesRemaining: Int, + val workflowAttempts: Map = emptyMap(), val status: String, + val createdAt: String? = null, val updatedAt: String? = null, -) +) { + val totalRetries: Int get() = 3 +} data class RetryFlakyJobsResponse(val ok: Boolean, val jobs: List) data class SubscriptionsResponse(val ok: Boolean, val subscriptions: List) data class RegisterRunnerRequest( diff --git a/android/app/src/main/kotlin/com/ghpr/app/data/GitHubGraphQLClient.kt b/android/app/src/main/kotlin/com/ghpr/app/data/GitHubGraphQLClient.kt index 936caf2..dda1b10 100644 --- a/android/app/src/main/kotlin/com/ghpr/app/data/GitHubGraphQLClient.kt +++ b/android/app/src/main/kotlin/com/ghpr/app/data/GitHubGraphQLClient.kt @@ -1,8 +1,8 @@ -package com.ghpr.app.data - -import android.util.Log -import com.ghpr.app.auth.GitHubOAuthManager -import kotlinx.coroutines.Dispatchers +package com.ghpr.app.data + +import android.util.Log +import com.ghpr.app.auth.GitHubOAuthManager +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -22,9 +22,9 @@ data class FetchOpenPrsResult( val missingRepoScope: Boolean = false, ) -class GitHubGraphQLClient( - private val gitHubOAuthManager: GitHubOAuthManager, -) { +class GitHubGraphQLClient( + private val gitHubOAuthManager: GitHubOAuthManager, +) { private val client = OkHttpClient() private val jsonMediaType = "application/json".toMediaType() private val ssoHeaderRegex = @@ -75,35 +75,54 @@ class GitHubGraphQLClient( val missingRepoScope: Boolean = false, ) - private val prFragment = """ - ... on PullRequest { - number - title - url + private val prFragment = """ + ... on PullRequest { + number + title + url isDraft createdAt updatedAt author { login avatarUrl } - repository { owner { login } name } - reviewThreads(last: 20) { - nodes { - isResolved - isOutdated - } - pageInfo { - hasPreviousPage - startCursor - } - } - latestReviews(first: 20) { - nodes { - state - } - } - commits(last: 1) { - nodes { - commit { - statusCheckRollup { state } + repository { owner { login } name } + reviewThreads(last: 20) { + nodes { + isResolved + isOutdated + } + pageInfo { + hasPreviousPage + startCursor + } + } + latestReviews(first: 20) { + nodes { + state + } + } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + state + contexts(first: 100) { + nodes { + ... on CheckRun { + name + conclusion + checkSuite { + workflowRun { + workflow { name } + } + } + } + ... on StatusContext { + context + state + } + } + } + } } } } @@ -154,15 +173,15 @@ class GitHubGraphQLClient( } val data = json.optJSONObject("data") - val authoredNodes = data?.optJSONObject("authored")?.optJSONArray("nodes") - val reviewNodes = data?.optJSONObject("reviewRequested")?.optJSONArray("nodes") - - val results = mutableListOf() - parseNodes(token, authoredNodes, PrCategory.AUTHORED, results) - parseNodes(token, reviewNodes, PrCategory.REVIEW_REQUESTED, results) - - return BatchResult(results, ssoEntries, missingRepo) - } + val authoredNodes = data?.optJSONObject("authored")?.optJSONArray("nodes") + val reviewNodes = data?.optJSONObject("reviewRequested")?.optJSONArray("nodes") + + val results = mutableListOf() + parseNodes(token, authoredNodes, PrCategory.AUTHORED, results) + parseNodes(token, reviewNodes, PrCategory.REVIEW_REQUESTED, results) + + return BatchResult(results, ssoEntries, missingRepo) + } private data class ApiResponse( val payload: String, @@ -200,13 +219,13 @@ class GitHubGraphQLClient( } } - private suspend fun parseNodes( - token: String, - nodes: JSONArray?, - category: PrCategory, - out: MutableList, - ) { - if (nodes == null) return + private suspend fun parseNodes( + token: String, + nodes: JSONArray?, + category: PrCategory, + out: MutableList, + ) { + if (nodes == null) return for (i in 0 until nodes.length()) { val node = nodes.getJSONObject(i) if (!node.has("number")) continue @@ -215,126 +234,218 @@ class GitHubGraphQLClient( val repo = node.optJSONObject("repository") val commits = node.optJSONObject("commits") ?.optJSONArray("nodes") - val ciState = commits - ?.optJSONObject(0) - ?.optJSONObject("commit") - ?.optJSONObject("statusCheckRollup") - ?.optString("state") - val repoOwner = repo?.optJSONObject("owner")?.optString("login", "").orEmpty() - val repoName = repo?.optString("name", "").orEmpty() - val approvalCount = node.optJSONObject("latestReviews") - ?.optJSONArray("nodes") - ?.let { reviews -> - (0 until reviews.length()).count { idx -> - reviews.optJSONObject(idx)?.optString("state") == "APPROVED" - } - } ?: 0 - val reviewThreads = node.optJSONObject("reviewThreads") - var unresolvedCount = countUnresolvedThreads(reviewThreads?.optJSONArray("nodes")) - if (category == PrCategory.AUTHORED) { - val pageInfo = reviewThreads?.optJSONObject("pageInfo") - val hasPreviousPage = pageInfo?.optBoolean("hasPreviousPage", false) == true - val startCursor = pageInfo?.optString("startCursor").orEmpty() - if (hasPreviousPage && startCursor.isNotBlank() && repoOwner.isNotBlank() && repoName.isNotBlank()) { - unresolvedCount += fetchAdditionalUnresolvedCount( - token = token, - repoOwner = repoOwner, - repoName = repoName, - prNumber = node.getInt("number"), - beforeCursor = startCursor, - ) - } - } - - out.add( - OpenPullRequest( - number = node.getInt("number"), - title = node.getString("title"), + val statusCheckRollup = commits + ?.optJSONObject(0) + ?.optJSONObject("commit") + ?.optJSONObject("statusCheckRollup") + val ciState = statusCheckRollup?.optString("state") + val ciParsed = parseCIContexts(statusCheckRollup) + val repoOwner = repo?.optJSONObject("owner")?.optString("login", "").orEmpty() + val repoName = repo?.optString("name", "").orEmpty() + val approvalCount = node.optJSONObject("latestReviews") + ?.optJSONArray("nodes") + ?.let { reviews -> + (0 until reviews.length()).count { idx -> + reviews.optJSONObject(idx)?.optString("state") == "APPROVED" + } + } ?: 0 + val reviewThreads = node.optJSONObject("reviewThreads") + var unresolvedCount = countUnresolvedThreads(reviewThreads?.optJSONArray("nodes")) + if (category == PrCategory.AUTHORED) { + val pageInfo = reviewThreads?.optJSONObject("pageInfo") + val hasPreviousPage = pageInfo?.optBoolean("hasPreviousPage", false) == true + val startCursor = pageInfo?.optString("startCursor").orEmpty() + if (hasPreviousPage && startCursor.isNotBlank() && repoOwner.isNotBlank() && repoName.isNotBlank()) { + unresolvedCount += fetchAdditionalUnresolvedCount( + token = token, + repoOwner = repoOwner, + repoName = repoName, + prNumber = node.getInt("number"), + beforeCursor = startCursor, + ) + } + } + + out.add( + OpenPullRequest( + number = node.getInt("number"), + title = node.getString("title"), url = node.getString("url"), isDraft = node.optBoolean("isDraft", false), createdAt = node.getString("createdAt"), - updatedAt = node.getString("updatedAt"), - authorLogin = author?.optString("login", "").orEmpty(), - authorAvatarUrl = author?.optString("avatarUrl", "").orEmpty(), - repoOwner = repoOwner, - repoName = repoName, - ciState = ciState, - approvalCount = approvalCount, - unresolvedCount = unresolvedCount, - category = category, - ), - ) - } - } - - private fun countUnresolvedThreads(nodes: JSONArray?): Int { - if (nodes == null) return 0 - var count = 0 - for (i in 0 until nodes.length()) { - val thread = nodes.optJSONObject(i) ?: continue - val unresolved = !thread.optBoolean("isResolved", false) && !thread.optBoolean("isOutdated", false) - if (unresolved) count++ - } - return count - } - - private suspend fun fetchAdditionalUnresolvedCount( - token: String, - repoOwner: String, - repoName: String, - prNumber: Int, - beforeCursor: String, - ): Int { - var unresolvedCount = 0 - var cursor = beforeCursor - var hasPrevious = true - - while (hasPrevious && cursor.isNotBlank()) { - val escapedCursor = cursor.replace("\"", "\\\"") - val query = """ - query { - repository(owner: "${repoOwner.replace("\"", "\\\"")}", name: "${repoName.replace("\"", "\\\"")}") { - pullRequest(number: $prNumber) { - reviewThreads(last: 20, before: "$escapedCursor") { - nodes { - isResolved - isOutdated - } - pageInfo { - hasPreviousPage - startCursor - } - } - } - } - } - """.trimIndent() - - val body = JSONObject().apply { put("query", query) } - .toString() - .toRequestBody(jsonMediaType) - val request = Request.Builder() - .url("https://api.github.com/graphql") - .addHeader("Authorization", "Bearer $token") - .addHeader("Accept", "application/json") - .post(body) - .build() - - val response = executeRequest(request) - val json = JSONObject(response.payload) - val reviewThreads = json.optJSONObject("data") - ?.optJSONObject("repository") - ?.optJSONObject("pullRequest") - ?.optJSONObject("reviewThreads") - ?: break - unresolvedCount += countUnresolvedThreads(reviewThreads.optJSONArray("nodes")) - val pageInfo = reviewThreads.optJSONObject("pageInfo") - hasPrevious = pageInfo?.optBoolean("hasPreviousPage", false) == true - cursor = pageInfo?.optString("startCursor").orEmpty() - } - - return unresolvedCount - } + updatedAt = node.getString("updatedAt"), + authorLogin = author?.optString("login", "").orEmpty(), + authorAvatarUrl = author?.optString("avatarUrl", "").orEmpty(), + repoOwner = repoOwner, + repoName = repoName, + ciState = ciState, + approvalCount = approvalCount, + unresolvedCount = unresolvedCount, + category = category, + checkSuccessCount = ciParsed.successCount, + checkFailureCount = ciParsed.failureCount, + checkPendingCount = ciParsed.pendingCount, + ciWorkflows = ciParsed.workflows, + ciIsRunning = ciParsed.isRunning, + ), + ) + } + } + + private data class CIParsed( + val successCount: Int, + val failureCount: Int, + val pendingCount: Int, + val isRunning: Boolean, + val workflows: List, + ) + + private fun parseCIContexts(rollup: JSONObject?): CIParsed { + val empty = CIParsed(0, 0, 0, false, emptyList()) + val contextsNodes = rollup + ?.optJSONObject("contexts") + ?.optJSONArray("nodes") + ?: return empty + + var successCount = 0 + var failureCount = 0 + var pendingCount = 0 + var isRunning = false + // workflow name -> mutable counts + val workflowMap = mutableMapOf() // [success, failure, pending] + val workflowIsWf = mutableMapOf() + + for (i in 0 until contextsNodes.length()) { + val ctx = contextsNodes.optJSONObject(i) ?: continue + + if (ctx.has("name")) { + // CheckRun + val conclusion = ctx.optString("conclusion", "").ifBlank { null } + val workflowName = ctx.optJSONObject("checkSuite") + ?.optJSONObject("workflowRun") + ?.optJSONObject("workflow") + ?.optString("name") + val groupName = workflowName ?: ctx.optString("name", "check") + val isWf = workflowName != null + + val counts = workflowMap.getOrPut(groupName) { intArrayOf(0, 0, 0) } + workflowIsWf.putIfAbsent(groupName, isWf) + + when (conclusion?.uppercase()) { + "SUCCESS", "NEUTRAL", "SKIPPED" -> { + successCount++ + counts[0]++ + } + "FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE" -> { + failureCount++ + counts[1]++ + } + else -> { + // null conclusion = in progress or queued + pendingCount++ + counts[2]++ + isRunning = true + } + } + } else if (ctx.has("context")) { + // StatusContext + val state = ctx.optString("state", "").uppercase() + val contextName = ctx.optString("context", "status") + val counts = workflowMap.getOrPut(contextName) { intArrayOf(0, 0, 0) } + workflowIsWf.putIfAbsent(contextName, false) + + when (state) { + "SUCCESS" -> { successCount++; counts[0]++ } + "FAILURE", "ERROR" -> { failureCount++; counts[1]++ } + else -> { pendingCount++; counts[2]++; isRunning = true } + } + } + } + + val workflows = workflowMap.map { (name, counts) -> + CIWorkflowInfo( + name = name, + isWorkflow = workflowIsWf[name] ?: false, + successCount = counts[0], + failureCount = counts[1], + pendingCount = counts[2], + ) + }.sortedWith(compareBy( + { if (it.failureCount > 0) 0 else if (it.pendingCount > 0) 1 else 2 }, + { it.name }, + )) + + return CIParsed(successCount, failureCount, pendingCount, isRunning, workflows) + } + + private fun countUnresolvedThreads(nodes: JSONArray?): Int { + if (nodes == null) return 0 + var count = 0 + for (i in 0 until nodes.length()) { + val thread = nodes.optJSONObject(i) ?: continue + val unresolved = !thread.optBoolean("isResolved", false) && !thread.optBoolean("isOutdated", false) + if (unresolved) count++ + } + return count + } + + private suspend fun fetchAdditionalUnresolvedCount( + token: String, + repoOwner: String, + repoName: String, + prNumber: Int, + beforeCursor: String, + ): Int { + var unresolvedCount = 0 + var cursor = beforeCursor + var hasPrevious = true + + while (hasPrevious && cursor.isNotBlank()) { + val escapedCursor = cursor.replace("\"", "\\\"") + val query = """ + query { + repository(owner: "${repoOwner.replace("\"", "\\\"")}", name: "${repoName.replace("\"", "\\\"")}") { + pullRequest(number: $prNumber) { + reviewThreads(last: 20, before: "$escapedCursor") { + nodes { + isResolved + isOutdated + } + pageInfo { + hasPreviousPage + startCursor + } + } + } + } + } + """.trimIndent() + + val body = JSONObject().apply { put("query", query) } + .toString() + .toRequestBody(jsonMediaType) + val request = Request.Builder() + .url("https://api.github.com/graphql") + .addHeader("Authorization", "Bearer $token") + .addHeader("Accept", "application/json") + .post(body) + .build() + + val response = executeRequest(request) + val json = JSONObject(response.payload) + val reviewThreads = json.optJSONObject("data") + ?.optJSONObject("repository") + ?.optJSONObject("pullRequest") + ?.optJSONObject("reviewThreads") + ?: break + unresolvedCount += countUnresolvedThreads(reviewThreads.optJSONArray("nodes")) + val pageInfo = reviewThreads.optJSONObject("pageInfo") + hasPrevious = pageInfo?.optBoolean("hasPreviousPage", false) == true + cursor = pageInfo?.optString("startCursor").orEmpty() + } + + return unresolvedCount + } /** * Probes repos directly via `repository(owner, name)` queries. diff --git a/android/app/src/main/kotlin/com/ghpr/app/data/OpenPullRequest.kt b/android/app/src/main/kotlin/com/ghpr/app/data/OpenPullRequest.kt index 60642a3..58b8bb9 100644 --- a/android/app/src/main/kotlin/com/ghpr/app/data/OpenPullRequest.kt +++ b/android/app/src/main/kotlin/com/ghpr/app/data/OpenPullRequest.kt @@ -5,19 +5,42 @@ enum class PrCategory { REVIEW_REQUESTED, } -data class OpenPullRequest( - val number: Int, - val title: String, - val url: String, +data class CIWorkflowInfo( + val name: String, + val isWorkflow: Boolean, + val successCount: Int, + val failureCount: Int, + val pendingCount: Int, +) { + val totalCount: Int get() = successCount + failureCount + pendingCount + + val status: String + get() = when { + failureCount > 0 -> "FAILURE" + pendingCount > 0 -> "PENDING" + successCount > 0 -> "SUCCESS" + else -> "EXPECTED" + } +} + +data class OpenPullRequest( + val number: Int, + val title: String, + val url: String, val isDraft: Boolean, val createdAt: String, val updatedAt: String, val authorLogin: String, val authorAvatarUrl: String, - val repoOwner: String, - val repoName: String, - val ciState: String?, - val approvalCount: Int = 0, - val unresolvedCount: Int = 0, - val category: PrCategory = PrCategory.AUTHORED, -) + val repoOwner: String, + val repoName: String, + val ciState: String?, + val approvalCount: Int = 0, + val unresolvedCount: Int = 0, + val category: PrCategory = PrCategory.AUTHORED, + val checkSuccessCount: Int = 0, + val checkFailureCount: Int = 0, + val checkPendingCount: Int = 0, + val ciWorkflows: List = emptyList(), + val ciIsRunning: Boolean = false, +) diff --git a/android/app/src/main/kotlin/com/ghpr/app/ui/openprs/OpenPrsScreen.kt b/android/app/src/main/kotlin/com/ghpr/app/ui/openprs/OpenPrsScreen.kt index 5d67d95..8cbde75 100644 --- a/android/app/src/main/kotlin/com/ghpr/app/ui/openprs/OpenPrsScreen.kt +++ b/android/app/src/main/kotlin/com/ghpr/app/ui/openprs/OpenPrsScreen.kt @@ -1,474 +1,713 @@ -package com.ghpr.app.ui.openprs - -import android.content.Intent -import androidx.core.net.toUri -import androidx.compose.animation.core.Animatable -import androidx.compose.foundation.background -import androidx.compose.ui.graphics.Color -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import com.ghpr.app.data.OpenPullRequest -import com.ghpr.app.data.RetryFlakyJob -import com.ghpr.app.data.SsoAuthorizationRequired -import com.ghpr.app.ui.components.EmptyStateView -import com.ghpr.app.ui.components.ErrorStateView -import com.ghpr.app.ui.components.StatusBadge -import com.ghpr.app.ui.theme.LocalGhprStatusColors -import com.ghpr.app.ui.theme.NeoButton -import com.ghpr.app.ui.theme.MonoStyle -import com.ghpr.app.ui.theme.NeoCard -import com.ghpr.app.ui.theme.neoTopBarBorder -import kotlinx.coroutines.launch -import kotlin.math.roundToInt - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun OpenPrsScreen(viewModel: OpenPrsViewModel) { - val state by viewModel.state.collectAsState() - val snackbarHostState = remember { SnackbarHostState() } - - LaunchedEffect(Unit) { viewModel.load() } - - LaunchedEffect(state.retryFlakyMessage) { - state.retryFlakyMessage?.let { message -> - snackbarHostState.showSnackbar(message) - viewModel.clearRetryFlakyMessage() - } - } - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Open PRs") }, - modifier = Modifier.neoTopBarBorder(), - ) - }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - ) { padding -> - PullToRefreshBox( - isRefreshing = state.isRefreshing, - onRefresh = { viewModel.refresh() }, - modifier = Modifier.fillMaxSize().padding(padding), - ) { - when { - state.error != null && !state.isSignedIn -> { - ErrorStateView( - message = state.error!!, - ) - } - !state.isSignedIn -> { - EmptyStateView( - icon = Icons.Default.AccountCircle, - title = "Sign in to view PRs", - subtitle = "Go to Settings and sign in with GitHub to see open pull requests", - ) - } - state.isLoading && !state.isRefreshing -> { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center), - ) - } - state.error != null && !state.isRefreshing -> { - ErrorStateView( - message = state.error!!, - onRetry = { viewModel.load() }, - ) - } - state.authoredPrs.isEmpty() && state.reviewRequestedPrs.isEmpty() && !state.isLoading -> { - if (state.ssoRequired.isNotEmpty()) { - LazyColumn(modifier = Modifier.fillMaxSize()) { - item { SsoBanner(state.ssoRequired) } - } - } else { - EmptyStateView( - icon = Icons.Default.Info, - title = "No open PRs", - subtitle = "Subscribe to repos in the Subs tab to see your PRs and review requests here", - ) - } - } - else -> { - LazyColumn(modifier = Modifier.fillMaxSize()) { - if (state.ssoRequired.isNotEmpty()) { - item { SsoBanner(state.ssoRequired) } - } - if (state.authoredPrs.isNotEmpty()) { - item { SectionHeader("My PRs", state.authoredPrs.size) } - items(state.authoredPrs) { pr -> - val key = OpenPrsViewModel.prKey(pr) - val job = state.retryFlakyJobs[key] - val isRetrySubmitting = state.retryFlakySubmitting.contains(key) - val isCiSubmitting = state.retryCiSubmitting.contains(key) - val hasActiveJob = job != null && job.status == "active" - val isCiFailure = pr.ciState?.uppercase() in listOf("FAILURE", "ERROR") - val swipeEnabled = isCiFailure && !isRetrySubmitting && !isCiSubmitting && !hasActiveJob - SwipeRevealBox( - enabled = swipeEnabled, - onRetryCi = { viewModel.retryCi(pr) }, - onRetryFlaky = { viewModel.retryFlaky(pr) }, - ) { - OpenPrCard( - pr = pr, - showReviewMetrics = true, - onCancelRetryFlaky = { viewModel.cancelRetryFlaky(pr) }, - retryFlakyJob = job, - isRetrySubmitting = isRetrySubmitting, - ) - } - } - } - if (state.reviewRequestedPrs.isNotEmpty()) { - item { SectionHeader("Review Requested", state.reviewRequestedPrs.size) } - items(state.reviewRequestedPrs) { pr -> - OpenPrCard(pr, showReviewMetrics = false) - } - } - } - } - } - } - } -} - -@Composable -private fun SectionHeader(title: String, count: Int) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = count.toString(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - -@Composable -private fun SsoBanner(ssoRequired: List) { - val context = LocalContext.current - val orgNames = ssoRequired.joinToString(", ") { it.orgName } - - NeoCard( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Column(modifier = Modifier.padding(12.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) - Text( - text = "SSO authorization required", - style = MaterialTheme.typography.titleSmall, - ) - } - Text( - text = "Your token needs SSO authorization for: $orgNames", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - ssoRequired.forEach { sso -> - NeoButton( - onClick = { - context.startActivity( - Intent(Intent.ACTION_VIEW, sso.authUrl.toUri()), - ) - }, - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ) { - Text( - text = "Authorize ${sso.orgName}", - style = MaterialTheme.typography.labelMedium, - ) - } - } - } - } - } -} - -@Composable -private fun SwipeRevealBox( - enabled: Boolean, - onRetryCi: () -> Unit, - onRetryFlaky: () -> Unit, - content: @Composable () -> Unit, -) { - if (!enabled) { - content() - return - } - - val revealWidthDp = 140.dp - val density = LocalDensity.current - val revealWidthPx = with(density) { revealWidthDp.toPx() } - val offsetX = remember { Animatable(0f) } - val scope = rememberCoroutineScope() - - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(6.dp)), - ) { - // Background action buttons — right-aligned, revealed when card slides left - Row( - modifier = Modifier - .matchParentSize() - .padding(horizontal = 16.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - // Single retry (blue) - Box( - modifier = Modifier - .width(60.dp) - .fillMaxHeight() - .padding(vertical = 4.dp) - .clip(RoundedCornerShape(6.dp)) - .background(Color(0xFF3B82F6)) - .clickable { - scope.launch { - offsetX.animateTo(0f) - } - onRetryCi() - }, - contentAlignment = Alignment.Center, - ) { - Text( - text = "\uD83D\uDD04", - style = MaterialTheme.typography.titleMedium, - ) - } - // Retry flaky x3 (orange) - Box( - modifier = Modifier - .width(60.dp) - .fillMaxHeight() - .padding(start = 4.dp, top = 4.dp, bottom = 4.dp) - .clip(RoundedCornerShape(6.dp)) - .background(Color(0xFFF59E0B)) - .clickable { - scope.launch { - offsetX.animateTo(0f) - } - onRetryFlaky() - }, - contentAlignment = Alignment.Center, - ) { - Text( - text = "\uD83D\uDD04x3", - style = MaterialTheme.typography.titleMedium, - ) - } - } - - // Foreground card — slides left - Box( - modifier = Modifier - .offset { IntOffset(offsetX.value.roundToInt(), 0) } - .background(MaterialTheme.colorScheme.background) - .pointerInput(Unit) { - detectHorizontalDragGestures( - onDragEnd = { - scope.launch { - // Snap: if past halfway, open; otherwise close - if (offsetX.value < -revealWidthPx / 2) { - offsetX.animateTo(-revealWidthPx) - } else { - offsetX.animateTo(0f) - } - } - }, - onHorizontalDrag = { _, dragAmount -> - scope.launch { - val newOffset = (offsetX.value + dragAmount) - .coerceIn(-revealWidthPx, 0f) - offsetX.snapTo(newOffset) - } - }, - ) - }, - ) { - content() - } - } -} - -@Composable -private fun OpenPrCard( - pr: OpenPullRequest, - showReviewMetrics: Boolean, - onCancelRetryFlaky: (() -> Unit)? = null, - retryFlakyJob: RetryFlakyJob? = null, - isRetrySubmitting: Boolean = false, -) { - val context = LocalContext.current - val statusColors = LocalGhprStatusColors.current - val hasActiveJob = retryFlakyJob != null && retryFlakyJob.status == "active" - - NeoCard( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .clickable { - context.startActivity(Intent(Intent.ACTION_VIEW, pr.url.toUri())) - }, - ) { - Column(modifier = Modifier.padding(12.dp)) { - Text( - text = pr.title, - style = MaterialTheme.typography.titleSmall, - maxLines = 2, - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "${pr.repoOwner}/${pr.repoName}#${pr.number}", - style = MonoStyle.codeSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (pr.isDraft) { - StatusBadge( - text = "Draft", - color = statusColors.pending, - ) - } - pr.ciState?.let { ci -> - val ciColor = when (ci.uppercase()) { - "SUCCESS" -> statusColors.merged - "FAILURE", "ERROR" -> statusColors.closed - else -> statusColors.pending - } - StatusBadge(text = ci.lowercase(), color = ciColor) - if (ci.uppercase() in listOf("FAILURE", "ERROR")) { - when { - isRetrySubmitting -> { - CircularProgressIndicator( - modifier = Modifier - .padding(start = 2.dp) - .height(20.dp) - .width(20.dp), - strokeWidth = 2.dp, - ) - } - hasActiveJob -> { - StatusBadge( - text = "retrying (${retryFlakyJob!!.retriesRemaining} left)", - color = statusColors.pending, - ) - IconButton( - onClick = { onCancelRetryFlaky?.invoke() }, - modifier = Modifier.padding(start = 2.dp), - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Cancel retry", - tint = statusColors.closed, - ) - } - } - } - } - } - } - } - Text( - text = pr.authorLogin, - style = MonoStyle.codeSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 2.dp), - ) - if (showReviewMetrics) { - Row( - modifier = Modifier.padding(top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - StatusBadge( - text = "approved ${pr.approvalCount}", - color = statusColors.success, - ) - StatusBadge( - text = "unresolved ${pr.unresolvedCount}", - color = statusColors.pending, - ) - } - } - } - } -} +package com.ghpr.app.ui.openprs + +import android.content.Intent +import androidx.core.net.toUri +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.ui.graphics.Color +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.ghpr.app.data.CIWorkflowInfo +import com.ghpr.app.data.OpenPullRequest +import com.ghpr.app.data.RetryFlakyJob +import com.ghpr.app.data.SsoAuthorizationRequired +import com.ghpr.app.ui.components.EmptyStateView +import com.ghpr.app.ui.components.ErrorStateView +import com.ghpr.app.ui.components.StatusBadge +import com.ghpr.app.ui.theme.LocalGhprStatusColors +import com.ghpr.app.ui.theme.NeoButton +import com.ghpr.app.ui.theme.MonoStyle +import com.ghpr.app.ui.theme.NeoCard +import com.ghpr.app.ui.theme.neoTopBarBorder +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OpenPrsScreen(viewModel: OpenPrsViewModel) { + val state by viewModel.state.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(Unit) { viewModel.load() } + + LaunchedEffect(state.retryFlakyMessage) { + state.retryFlakyMessage?.let { message -> + snackbarHostState.showSnackbar(message) + viewModel.clearRetryFlakyMessage() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Open PRs") }, + modifier = Modifier.neoTopBarBorder(), + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + PullToRefreshBox( + isRefreshing = state.isRefreshing, + onRefresh = { viewModel.refresh() }, + modifier = Modifier.fillMaxSize().padding(padding), + ) { + when { + state.error != null && !state.isSignedIn -> { + ErrorStateView( + message = state.error!!, + ) + } + !state.isSignedIn -> { + EmptyStateView( + icon = Icons.Default.AccountCircle, + title = "Sign in to view PRs", + subtitle = "Go to Settings and sign in with GitHub to see open pull requests", + ) + } + state.isLoading && !state.isRefreshing -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + ) + } + state.error != null && !state.isRefreshing -> { + ErrorStateView( + message = state.error!!, + onRetry = { viewModel.load() }, + ) + } + state.authoredPrs.isEmpty() && state.reviewRequestedPrs.isEmpty() && !state.isLoading -> { + if (state.ssoRequired.isNotEmpty()) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { SsoBanner(state.ssoRequired) } + } + } else { + EmptyStateView( + icon = Icons.Default.Info, + title = "No open PRs", + subtitle = "Subscribe to repos in the Subs tab to see your PRs and review requests here", + ) + } + } + else -> { + LazyColumn(modifier = Modifier.fillMaxSize()) { + if (state.ssoRequired.isNotEmpty()) { + item { SsoBanner(state.ssoRequired) } + } + if (state.authoredPrs.isNotEmpty()) { + item { SectionHeader("My PRs", state.authoredPrs.size) } + items( + state.authoredPrs, + key = { OpenPrsViewModel.prKey(it) }, + ) { pr -> + val key = OpenPrsViewModel.prKey(pr) + val job = state.retryFlakyJobs[key] + val isRetrySubmitting = state.retryFlakySubmitting.contains(key) + val isCiSubmitting = state.retryCiSubmitting.contains(key) + val hasActiveJob = job != null && job.status == "active" + val isCiFailure = pr.ciState?.uppercase() in listOf("FAILURE", "ERROR") + val isExpanded = state.expandedPrKey == key + val swipeEnabled = isCiFailure && !isRetrySubmitting && !isCiSubmitting && !hasActiveJob && !isExpanded + SwipeRevealBox( + enabled = swipeEnabled, + onRetryCi = { viewModel.retryCi(pr) }, + onRetryFlaky = { viewModel.retryFlaky(pr) }, + ) { + Column { + OpenPrCard( + pr = pr, + showReviewMetrics = true, + isExpanded = isExpanded, + onToggleExpand = { viewModel.toggleExpanded(pr) }, + retryFlakyJob = job, + isRetrySubmitting = isRetrySubmitting, + ) + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + PrExpandedDetail( + pr = pr, + retryFlakyJob = job, + isRetrySubmitting = isRetrySubmitting, + isCiSubmitting = isCiSubmitting, + onRetryCi = { viewModel.retryCi(pr) }, + onRetryFlaky = { viewModel.retryFlaky(pr) }, + onCancelRetryFlaky = { viewModel.cancelRetryFlaky(pr) }, + ) + } + } + } + } + } + if (state.reviewRequestedPrs.isNotEmpty()) { + item { SectionHeader("Review Requested", state.reviewRequestedPrs.size) } + items( + state.reviewRequestedPrs, + key = { OpenPrsViewModel.prKey(it) }, + ) { pr -> + val key = OpenPrsViewModel.prKey(pr) + val isExpanded = state.expandedPrKey == key + Column { + OpenPrCard( + pr = pr, + showReviewMetrics = false, + isExpanded = isExpanded, + onToggleExpand = { viewModel.toggleExpanded(pr) }, + ) + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + PrExpandedDetail( + pr = pr, + retryFlakyJob = null, + isRetrySubmitting = false, + isCiSubmitting = false, + onRetryCi = null, + onRetryFlaky = null, + onCancelRetryFlaky = null, + ) + } + } + } + } + } + } + } + } + } +} + +@Composable +private fun SectionHeader(title: String, count: Int) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = count.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun SsoBanner(ssoRequired: List) { + val context = LocalContext.current + val orgNames = ssoRequired.joinToString(", ") { it.orgName } + + NeoCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Text( + text = "SSO authorization required", + style = MaterialTheme.typography.titleSmall, + ) + } + Text( + text = "Your token needs SSO authorization for: $orgNames", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ssoRequired.forEach { sso -> + NeoButton( + onClick = { + context.startActivity( + Intent(Intent.ACTION_VIEW, sso.authUrl.toUri()), + ) + }, + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) { + Text( + text = "Authorize ${sso.orgName}", + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + } + } +} + +@Composable +private fun SwipeRevealBox( + enabled: Boolean, + onRetryCi: () -> Unit, + onRetryFlaky: () -> Unit, + content: @Composable () -> Unit, +) { + if (!enabled) { + content() + return + } + + val revealWidthDp = 140.dp + val density = LocalDensity.current + val revealWidthPx = with(density) { revealWidthDp.toPx() } + val offsetX = remember { Animatable(0f) } + val scope = rememberCoroutineScope() + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)), + ) { + // Background action buttons — right-aligned, revealed when card slides left + Row( + modifier = Modifier + .matchParentSize() + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + // Single retry (blue) + Box( + modifier = Modifier + .width(60.dp) + .fillMaxHeight() + .padding(vertical = 4.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Color(0xFF3B82F6)) + .clickable { + scope.launch { + offsetX.animateTo(0f) + } + onRetryCi() + }, + contentAlignment = Alignment.Center, + ) { + Text( + text = "\uD83D\uDD04", + style = MaterialTheme.typography.titleMedium, + ) + } + // Retry flaky x3 (orange) + Box( + modifier = Modifier + .width(60.dp) + .fillMaxHeight() + .padding(start = 4.dp, top = 4.dp, bottom = 4.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Color(0xFFF59E0B)) + .clickable { + scope.launch { + offsetX.animateTo(0f) + } + onRetryFlaky() + }, + contentAlignment = Alignment.Center, + ) { + Text( + text = "\uD83D\uDD04x3", + style = MaterialTheme.typography.titleMedium, + ) + } + } + + // Foreground card — slides left + Box( + modifier = Modifier + .offset { IntOffset(offsetX.value.roundToInt(), 0) } + .background(MaterialTheme.colorScheme.background) + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragEnd = { + scope.launch { + // Snap: if past halfway, open; otherwise close + if (offsetX.value < -revealWidthPx / 2) { + offsetX.animateTo(-revealWidthPx) + } else { + offsetX.animateTo(0f) + } + } + }, + onHorizontalDrag = { _, dragAmount -> + scope.launch { + val newOffset = (offsetX.value + dragAmount) + .coerceIn(-revealWidthPx, 0f) + offsetX.snapTo(newOffset) + } + }, + ) + }, + ) { + content() + } + } +} + +@Composable +private fun OpenPrCard( + pr: OpenPullRequest, + showReviewMetrics: Boolean, + isExpanded: Boolean = false, + onToggleExpand: (() -> Unit)? = null, + retryFlakyJob: RetryFlakyJob? = null, + isRetrySubmitting: Boolean = false, +) { + val statusColors = LocalGhprStatusColors.current + val hasActiveJob = retryFlakyJob != null && retryFlakyJob.status == "active" + + NeoCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .clickable { onToggleExpand?.invoke() }, + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = pr.title, + style = MaterialTheme.typography.titleSmall, + maxLines = 2, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${pr.repoOwner}/${pr.repoName}#${pr.number}", + style = MonoStyle.codeSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (pr.isDraft) { + StatusBadge( + text = "Draft", + color = statusColors.pending, + ) + } + pr.ciState?.let { ci -> + val ciColor = when (ci.uppercase()) { + "SUCCESS" -> statusColors.merged + "FAILURE", "ERROR" -> statusColors.closed + else -> statusColors.pending + } + // Show compact workflow count format + val ciText = ciStatusText(pr) + StatusBadge(text = ciText, color = ciColor) + if (pr.ciIsRunning) { + CircularProgressIndicator( + modifier = Modifier + .padding(start = 2.dp) + .size(16.dp), + strokeWidth = 2.dp, + ) + } + if (!isExpanded && ci.uppercase() in listOf("FAILURE", "ERROR")) { + when { + isRetrySubmitting -> { + CircularProgressIndicator( + modifier = Modifier + .padding(start = 2.dp) + .height(20.dp) + .width(20.dp), + strokeWidth = 2.dp, + ) + } + hasActiveJob -> { + StatusBadge( + text = "${retryFlakyJob!!.retriesRemaining}/${retryFlakyJob.totalRetries}", + color = statusColors.pending, + ) + } + } + } + } + } + } + Text( + text = pr.authorLogin, + style = MonoStyle.codeSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + if (showReviewMetrics) { + Row( + modifier = Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + StatusBadge( + text = "approved ${pr.approvalCount}", + color = statusColors.success, + ) + StatusBadge( + text = "unresolved ${pr.unresolvedCount}", + color = statusColors.pending, + ) + } + } + } + } +} + +private fun ciStatusText(pr: OpenPullRequest): String { + val ci = pr.ciState?.uppercase() ?: return "ci" + val workflows = pr.ciWorkflows + if (workflows.isEmpty()) return ci.lowercase() + val totalWf = workflows.size + return when (ci) { + "FAILURE", "ERROR" -> { + val failedWf = workflows.count { it.failureCount > 0 } + val totalFailedTasks = workflows.sumOf { it.failureCount } + "${failedWf}/${totalWf}wf\u00B7${totalFailedTasks}" + } + "PENDING" -> { + val doneWf = workflows.count { it.status == "SUCCESS" || it.status == "FAILURE" } + "${doneWf}/${totalWf}wf" + } + "SUCCESS" -> "${totalWf}wf" + else -> ci.lowercase() + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun PrExpandedDetail( + pr: OpenPullRequest, + retryFlakyJob: RetryFlakyJob?, + isRetrySubmitting: Boolean, + isCiSubmitting: Boolean, + onRetryCi: (() -> Unit)?, + onRetryFlaky: (() -> Unit)?, + onCancelRetryFlaky: (() -> Unit)?, +) { + val context = LocalContext.current + val statusColors = LocalGhprStatusColors.current + val hasActiveJob = retryFlakyJob != null && retryFlakyJob.status == "active" + val isCiFailure = pr.ciState?.uppercase() in listOf("FAILURE", "ERROR") + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 32.dp, end = 16.dp, bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Workflow breakdown + if (pr.ciWorkflows.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + pr.ciWorkflows.forEach { wf -> + WorkflowStatusRow(wf) + } + } + } else if (pr.ciState != null) { + Text( + text = "No workflow details", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Retry job info + if (retryFlakyJob != null) { + val statusText = when (retryFlakyJob.status) { + "active" -> "Retrying: ${retryFlakyJob.retriesRemaining}/${retryFlakyJob.totalRetries} remaining" + "completed" -> "Retry completed" + "exhausted" -> "Retries exhausted" + "cancelled" -> "Retry cancelled" + else -> "Retry: ${retryFlakyJob.status}" + } + val statusColor = when (retryFlakyJob.status) { + "active" -> statusColors.pending + "completed" -> statusColors.merged + else -> statusColors.closed + } + Text( + text = statusText, + style = MonoStyle.codeSmall, + color = statusColor, + ) + retryFlakyJob.updatedAt?.let { updatedAt -> + Text( + text = "Last retry: $updatedAt", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Action buttons + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + NeoButton( + onClick = { + context.startActivity(Intent(Intent.ACTION_VIEW, pr.url.toUri())) + }, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Icon( + Icons.Default.OpenInBrowser, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Open PR", style = MaterialTheme.typography.labelMedium) + } + if (onRetryCi != null && isCiFailure) { + NeoButton( + onClick = onRetryCi, + enabled = !isCiSubmitting && !isRetrySubmitting, + containerColor = Color(0xFF3B82F6), + contentColor = Color.White, + ) { + Text("Retry", style = MaterialTheme.typography.labelMedium) + } + } + if (onRetryFlaky != null && isCiFailure) { + NeoButton( + onClick = onRetryFlaky, + enabled = !isRetrySubmitting && !isCiSubmitting && !hasActiveJob, + containerColor = Color(0xFFF59E0B), + contentColor = Color.White, + ) { + Text("Retry x3", style = MaterialTheme.typography.labelMedium) + } + } + if (hasActiveJob && onCancelRetryFlaky != null) { + NeoButton( + onClick = onCancelRetryFlaky, + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) { + Icon( + Icons.Default.Close, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Cancel", style = MaterialTheme.typography.labelMedium) + } + } + } + } +} + +@Composable +private fun WorkflowStatusRow(wf: CIWorkflowInfo) { + val statusColors = LocalGhprStatusColors.current + val color = when { + wf.failureCount > 0 -> statusColors.closed + wf.pendingCount > 0 -> statusColors.pending + else -> statusColors.merged + } + val statusText = when { + wf.failureCount > 0 -> "${wf.failureCount} failed" + wf.pendingCount > 0 -> "${wf.pendingCount} pending" + else -> "${wf.successCount} passed" + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = wf.name, + style = MonoStyle.codeSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + maxLines = 1, + ) + StatusBadge( + text = statusText, + color = color, + ) + if (wf.totalCount > 1) { + Text( + text = "${wf.successCount}/${wf.totalCount}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/android/app/src/main/kotlin/com/ghpr/app/ui/openprs/OpenPrsViewModel.kt b/android/app/src/main/kotlin/com/ghpr/app/ui/openprs/OpenPrsViewModel.kt index 2ed9ea7..c740ba0 100644 --- a/android/app/src/main/kotlin/com/ghpr/app/ui/openprs/OpenPrsViewModel.kt +++ b/android/app/src/main/kotlin/com/ghpr/app/ui/openprs/OpenPrsViewModel.kt @@ -32,6 +32,8 @@ data class OpenPrsUiState( val retryFlakySubmitting: Set = emptySet(), /** PR keys currently submitting a retry-ci request */ val retryCiSubmitting: Set = emptySet(), + /** Currently expanded PR key (null = none expanded) */ + val expandedPrKey: String? = null, ) class OpenPrsViewModel( @@ -238,6 +240,13 @@ class OpenPrsViewModel( } } + fun toggleExpanded(pr: OpenPullRequest) { + val key = prKey(pr) + _state.value = _state.value.copy( + expandedPrKey = if (_state.value.expandedPrKey == key) null else key, + ) + } + fun clearRetryFlakyMessage() { _state.value = _state.value.copy(retryFlakyMessage = null) } diff --git a/android/app/src/main/kotlin/com/ghpr/app/ui/settings/SettingsScreen.kt b/android/app/src/main/kotlin/com/ghpr/app/ui/settings/SettingsScreen.kt index d55176e..237546f 100644 --- a/android/app/src/main/kotlin/com/ghpr/app/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/kotlin/com/ghpr/app/ui/settings/SettingsScreen.kt @@ -231,6 +231,33 @@ fun SettingsScreen(viewModel: SettingsViewModel) { InfoRow("Status", state.runnerPollingStatus) InfoRow("Last poll", state.runnerLastPollAt ?: "N/A") InfoRow("Last seen", state.runnerLastSeenAt ?: "N/A") + InfoRow("PRs pending retry", state.retryPendingCount.toString()) + if (state.recentRetryResults.isNotEmpty()) { + Text( + text = "Recent retries:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + state.recentRetryResults.forEach { job -> + val repoShort = job.repoFullName.substringAfter("/") + val statusLabel = when (job.status) { + "completed" -> "ok" + "exhausted" -> "exhausted" + "cancelled" -> "cancelled" + else -> job.status + } + val color = when (job.status) { + "completed" -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.error + } + Text( + text = "$repoShort#${job.prNumber} \u2014 $statusLabel", + style = MonoStyle.codeSmall, + color = color, + ) + } + } val pollingError = state.runnerPollingError if (!pollingError.isNullOrBlank()) { Text( diff --git a/android/app/src/main/kotlin/com/ghpr/app/ui/settings/SettingsViewModel.kt b/android/app/src/main/kotlin/com/ghpr/app/ui/settings/SettingsViewModel.kt index d516ba4..0b0628b 100644 --- a/android/app/src/main/kotlin/com/ghpr/app/ui/settings/SettingsViewModel.kt +++ b/android/app/src/main/kotlin/com/ghpr/app/ui/settings/SettingsViewModel.kt @@ -10,6 +10,7 @@ import com.ghpr.app.data.DataStorePollingModeStore import com.ghpr.app.data.DataStoreRefreshSettingsStore import com.ghpr.app.data.GhprApiClient import com.ghpr.app.data.RegisterRunnerRequest +import com.ghpr.app.data.RetryFlakyJob import com.ghpr.app.data.RunnerStatusResponse import com.ghpr.app.data.PollingMode import com.ghpr.app.data.PollingScheduler @@ -43,6 +44,8 @@ data class SettingsUiState( val runnerRegistering: Boolean = false, val runnerRevoking: Boolean = false, val showRevokeRunnerConfirmDialog: Boolean = false, + val retryPendingCount: Int = 0, + val recentRetryResults: List = emptyList(), ) class SettingsViewModel( @@ -67,6 +70,7 @@ class SettingsViewModel( private val _runnerRegistering = MutableStateFlow(false) private val _runnerRevoking = MutableStateFlow(false) private val _showRevokeRunnerConfirmDialog = MutableStateFlow(false) + private val _retryJobs = MutableStateFlow>(emptyList()) init { viewModelScope.launch { @@ -74,11 +78,23 @@ class SettingsViewModel( .filter { it is GitHubAuthState.SignedIn } .collect { refreshRunnerPollingStatus() + loadRetryJobs() } } } - val state: StateFlow = combine( + private fun loadRetryJobs() { + viewModelScope.launch { + runCatching { apiClient.api.listRetryFlakyJobs() } + .onSuccess { response -> + if (response.isSuccessful) { + _retryJobs.value = response.body()?.jobs.orEmpty() + } + } + } + } + + private val baseState = combine( gitHubOAuthManager.authState, refreshInterval, notificationSettingsStore.notificationsEnabled, @@ -90,6 +106,7 @@ class SettingsViewModel( _runnerRevoking, _showRevokeRunnerConfirmDialog, ) { values -> + @Suppress("UNCHECKED_CAST") val authState = values[0] as GitHubAuthState val interval = values[1] as Int val notifEnabled = values[2] as Boolean @@ -128,6 +145,21 @@ class SettingsViewModel( runnerRevoking = revoking, showRevokeRunnerConfirmDialog = showRevokeDialog, ) + } + + val state: StateFlow = combine( + baseState, + _retryJobs, + ) { base, jobs -> + val pendingCount = jobs.count { it.status == "active" } + val recentResults = jobs + .filter { it.status in listOf("completed", "exhausted", "cancelled") } + .sortedByDescending { it.updatedAt } + .take(5) + base.copy( + retryPendingCount = pendingCount, + recentRetryResults = recentResults, + ) }.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), From 02bd7d78bc8093ac006e6c732177ba662fa05c72 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 11:57:21 +0000 Subject: [PATCH 2/5] Add unit tests for PR detail expansion and CI workflow parsing - Add JUnit 5 test infrastructure to app module (build.gradle.kts) - Extract parseCIContexts to top-level internal function for testability - Change ciStatusText to internal visibility for testability - CIWorkflowInfoTest: status/totalCount computed properties - RetryFlakyJobTest: totalRetries, workflowAttempts, defaults - ParseCIContextsTest: CheckRun/StatusContext parsing, grouping, sorting - CiStatusTextTest: compact workflow count formatting for all CI states - OpenPrsUiStateTest: expand/collapse state, prKey, swipe disable logic - SettingsRetryStatsTest: pending count, recent results filtering/sorting https://claude.ai/code/session_01PqW1eLdPDdSmtQnCfG1HCn --- android/app/build.gradle.kts | 10 + .../com/ghpr/app/data/GitHubGraphQLClient.kt | 170 ++++++------ .../com/ghpr/app/ui/openprs/OpenPrsScreen.kt | 2 +- .../com/ghpr/app/data/CIWorkflowInfoTest.kt | 49 ++++ .../com/ghpr/app/data/ParseCIContextsTest.kt | 261 ++++++++++++++++++ .../com/ghpr/app/data/RetryFlakyJobTest.kt | 71 +++++ .../ghpr/app/ui/openprs/CiStatusTextTest.kt | 103 +++++++ .../ghpr/app/ui/openprs/OpenPrsUiStateTest.kt | 105 +++++++ .../app/ui/settings/SettingsRetryStatsTest.kt | 156 +++++++++++ 9 files changed, 840 insertions(+), 87 deletions(-) create mode 100644 android/app/src/test/kotlin/com/ghpr/app/data/CIWorkflowInfoTest.kt create mode 100644 android/app/src/test/kotlin/com/ghpr/app/data/ParseCIContextsTest.kt create mode 100644 android/app/src/test/kotlin/com/ghpr/app/data/RetryFlakyJobTest.kt create mode 100644 android/app/src/test/kotlin/com/ghpr/app/ui/openprs/CiStatusTextTest.kt create mode 100644 android/app/src/test/kotlin/com/ghpr/app/ui/openprs/OpenPrsUiStateTest.kt create mode 100644 android/app/src/test/kotlin/com/ghpr/app/ui/settings/SettingsRetryStatsTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c992e84..0bcef39 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -100,7 +100,17 @@ android { } } +tasks.withType { + useJUnitPlatform() +} + dependencies { + testImplementation(platform("org.junit:junit-bom:5.11.3")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("org.json:json:20231013") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") + implementation(project(":core-domain")) // Firebase diff --git a/android/app/src/main/kotlin/com/ghpr/app/data/GitHubGraphQLClient.kt b/android/app/src/main/kotlin/com/ghpr/app/data/GitHubGraphQLClient.kt index dda1b10..5560f96 100644 --- a/android/app/src/main/kotlin/com/ghpr/app/data/GitHubGraphQLClient.kt +++ b/android/app/src/main/kotlin/com/ghpr/app/data/GitHubGraphQLClient.kt @@ -16,6 +16,14 @@ import org.json.JSONObject private const val TAG = "GitHubGraphQL" +internal data class CIParsed( + val successCount: Int, + val failureCount: Int, + val pendingCount: Int, + val isRunning: Boolean, + val workflows: List, +) + data class FetchOpenPrsResult( val pullRequests: List = emptyList(), val ssoRequired: List = emptyList(), @@ -292,92 +300,6 @@ class GitHubGraphQLClient( } } - private data class CIParsed( - val successCount: Int, - val failureCount: Int, - val pendingCount: Int, - val isRunning: Boolean, - val workflows: List, - ) - - private fun parseCIContexts(rollup: JSONObject?): CIParsed { - val empty = CIParsed(0, 0, 0, false, emptyList()) - val contextsNodes = rollup - ?.optJSONObject("contexts") - ?.optJSONArray("nodes") - ?: return empty - - var successCount = 0 - var failureCount = 0 - var pendingCount = 0 - var isRunning = false - // workflow name -> mutable counts - val workflowMap = mutableMapOf() // [success, failure, pending] - val workflowIsWf = mutableMapOf() - - for (i in 0 until contextsNodes.length()) { - val ctx = contextsNodes.optJSONObject(i) ?: continue - - if (ctx.has("name")) { - // CheckRun - val conclusion = ctx.optString("conclusion", "").ifBlank { null } - val workflowName = ctx.optJSONObject("checkSuite") - ?.optJSONObject("workflowRun") - ?.optJSONObject("workflow") - ?.optString("name") - val groupName = workflowName ?: ctx.optString("name", "check") - val isWf = workflowName != null - - val counts = workflowMap.getOrPut(groupName) { intArrayOf(0, 0, 0) } - workflowIsWf.putIfAbsent(groupName, isWf) - - when (conclusion?.uppercase()) { - "SUCCESS", "NEUTRAL", "SKIPPED" -> { - successCount++ - counts[0]++ - } - "FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE" -> { - failureCount++ - counts[1]++ - } - else -> { - // null conclusion = in progress or queued - pendingCount++ - counts[2]++ - isRunning = true - } - } - } else if (ctx.has("context")) { - // StatusContext - val state = ctx.optString("state", "").uppercase() - val contextName = ctx.optString("context", "status") - val counts = workflowMap.getOrPut(contextName) { intArrayOf(0, 0, 0) } - workflowIsWf.putIfAbsent(contextName, false) - - when (state) { - "SUCCESS" -> { successCount++; counts[0]++ } - "FAILURE", "ERROR" -> { failureCount++; counts[1]++ } - else -> { pendingCount++; counts[2]++; isRunning = true } - } - } - } - - val workflows = workflowMap.map { (name, counts) -> - CIWorkflowInfo( - name = name, - isWorkflow = workflowIsWf[name] ?: false, - successCount = counts[0], - failureCount = counts[1], - pendingCount = counts[2], - ) - }.sortedWith(compareBy( - { if (it.failureCount > 0) 0 else if (it.pendingCount > 0) 1 else 2 }, - { it.name }, - )) - - return CIParsed(successCount, failureCount, pendingCount, isRunning, workflows) - } - private fun countUnresolvedThreads(nodes: JSONArray?): Int { if (nodes == null) return 0 var count = 0 @@ -510,3 +432,79 @@ class GitHubGraphQLClient( } } } + +internal fun parseCIContexts(rollup: JSONObject?): CIParsed { + val empty = CIParsed(0, 0, 0, false, emptyList()) + val contextsNodes = rollup + ?.optJSONObject("contexts") + ?.optJSONArray("nodes") + ?: return empty + + var successCount = 0 + var failureCount = 0 + var pendingCount = 0 + var isRunning = false + val workflowMap = mutableMapOf() // [success, failure, pending] + val workflowIsWf = mutableMapOf() + + for (i in 0 until contextsNodes.length()) { + val ctx = contextsNodes.optJSONObject(i) ?: continue + + if (ctx.has("name")) { + // CheckRun + val conclusion = ctx.optString("conclusion", "").ifBlank { null } + val workflowName = ctx.optJSONObject("checkSuite") + ?.optJSONObject("workflowRun") + ?.optJSONObject("workflow") + ?.optString("name") + val groupName = workflowName ?: ctx.optString("name", "check") + val isWf = workflowName != null + + val counts = workflowMap.getOrPut(groupName) { intArrayOf(0, 0, 0) } + workflowIsWf.putIfAbsent(groupName, isWf) + + when (conclusion?.uppercase()) { + "SUCCESS", "NEUTRAL", "SKIPPED" -> { + successCount++ + counts[0]++ + } + "FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE" -> { + failureCount++ + counts[1]++ + } + else -> { + pendingCount++ + counts[2]++ + isRunning = true + } + } + } else if (ctx.has("context")) { + // StatusContext + val state = ctx.optString("state", "").uppercase() + val contextName = ctx.optString("context", "status") + val counts = workflowMap.getOrPut(contextName) { intArrayOf(0, 0, 0) } + workflowIsWf.putIfAbsent(contextName, false) + + when (state) { + "SUCCESS" -> { successCount++; counts[0]++ } + "FAILURE", "ERROR" -> { failureCount++; counts[1]++ } + else -> { pendingCount++; counts[2]++; isRunning = true } + } + } + } + + val workflows = workflowMap.map { (name, counts) -> + CIWorkflowInfo( + name = name, + isWorkflow = workflowIsWf[name] ?: false, + successCount = counts[0], + failureCount = counts[1], + pendingCount = counts[2], + ) + }.sortedWith(compareBy( + { if (it.failureCount > 0) 0 else if (it.pendingCount > 0) 1 else 2 }, + { it.name }, + )) + + return CIParsed(successCount, failureCount, pendingCount, isRunning, workflows) +} diff --git a/android/app/src/main/kotlin/com/ghpr/app/ui/openprs/OpenPrsScreen.kt b/android/app/src/main/kotlin/com/ghpr/app/ui/openprs/OpenPrsScreen.kt index 8cbde75..b9417f2 100644 --- a/android/app/src/main/kotlin/com/ghpr/app/ui/openprs/OpenPrsScreen.kt +++ b/android/app/src/main/kotlin/com/ghpr/app/ui/openprs/OpenPrsScreen.kt @@ -529,7 +529,7 @@ private fun OpenPrCard( } } -private fun ciStatusText(pr: OpenPullRequest): String { +internal fun ciStatusText(pr: OpenPullRequest): String { val ci = pr.ciState?.uppercase() ?: return "ci" val workflows = pr.ciWorkflows if (workflows.isEmpty()) return ci.lowercase() diff --git a/android/app/src/test/kotlin/com/ghpr/app/data/CIWorkflowInfoTest.kt b/android/app/src/test/kotlin/com/ghpr/app/data/CIWorkflowInfoTest.kt new file mode 100644 index 0000000..84015fe --- /dev/null +++ b/android/app/src/test/kotlin/com/ghpr/app/data/CIWorkflowInfoTest.kt @@ -0,0 +1,49 @@ +package com.ghpr.app.data + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CIWorkflowInfoTest { + + @Test + fun `totalCount sums all counts`() { + val wf = CIWorkflowInfo("Build", isWorkflow = true, successCount = 3, failureCount = 1, pendingCount = 2) + assertEquals(6, wf.totalCount) + } + + @Test + fun `totalCount is zero when all counts are zero`() { + val wf = CIWorkflowInfo("Build", isWorkflow = true, successCount = 0, failureCount = 0, pendingCount = 0) + assertEquals(0, wf.totalCount) + } + + @Test + fun `status is FAILURE when failureCount is positive`() { + val wf = CIWorkflowInfo("Build", isWorkflow = true, successCount = 5, failureCount = 1, pendingCount = 0) + assertEquals("FAILURE", wf.status) + } + + @Test + fun `status is FAILURE when both failure and pending are positive`() { + val wf = CIWorkflowInfo("Build", isWorkflow = true, successCount = 0, failureCount = 2, pendingCount = 3) + assertEquals("FAILURE", wf.status) + } + + @Test + fun `status is PENDING when pending positive and no failures`() { + val wf = CIWorkflowInfo("Build", isWorkflow = true, successCount = 2, failureCount = 0, pendingCount = 1) + assertEquals("PENDING", wf.status) + } + + @Test + fun `status is SUCCESS when only success counts are positive`() { + val wf = CIWorkflowInfo("Build", isWorkflow = true, successCount = 4, failureCount = 0, pendingCount = 0) + assertEquals("SUCCESS", wf.status) + } + + @Test + fun `status is EXPECTED when all counts are zero`() { + val wf = CIWorkflowInfo("Build", isWorkflow = true, successCount = 0, failureCount = 0, pendingCount = 0) + assertEquals("EXPECTED", wf.status) + } +} diff --git a/android/app/src/test/kotlin/com/ghpr/app/data/ParseCIContextsTest.kt b/android/app/src/test/kotlin/com/ghpr/app/data/ParseCIContextsTest.kt new file mode 100644 index 0000000..98623e0 --- /dev/null +++ b/android/app/src/test/kotlin/com/ghpr/app/data/ParseCIContextsTest.kt @@ -0,0 +1,261 @@ +package com.ghpr.app.data + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ParseCIContextsTest { + + @Test + fun `returns empty result for null rollup`() { + val result = parseCIContexts(null) + assertEquals(0, result.successCount) + assertEquals(0, result.failureCount) + assertEquals(0, result.pendingCount) + assertFalse(result.isRunning) + assertTrue(result.workflows.isEmpty()) + } + + @Test + fun `returns empty result for rollup without contexts`() { + val rollup = JSONObject().put("state", "SUCCESS") + val result = parseCIContexts(rollup) + assertEquals(0, result.successCount) + assertEquals(0, result.failureCount) + assertEquals(0, result.pendingCount) + assertTrue(result.workflows.isEmpty()) + } + + @Test + fun `counts CheckRun successes`() { + val rollup = buildRollup( + checkRun("lint", "SUCCESS", "Build"), + checkRun("test", "SUCCESS", "Build"), + ) + val result = parseCIContexts(rollup) + assertEquals(2, result.successCount) + assertEquals(0, result.failureCount) + assertEquals(0, result.pendingCount) + assertFalse(result.isRunning) + assertEquals(1, result.workflows.size) + assertEquals("Build", result.workflows[0].name) + assertEquals(2, result.workflows[0].successCount) + assertTrue(result.workflows[0].isWorkflow) + } + + @Test + fun `counts CheckRun failures`() { + val rollup = buildRollup( + checkRun("lint", "SUCCESS", "Build"), + checkRun("test", "FAILURE", "Build"), + checkRun("e2e", "TIMED_OUT", "Test"), + ) + val result = parseCIContexts(rollup) + assertEquals(1, result.successCount) + assertEquals(2, result.failureCount) + assertEquals(0, result.pendingCount) + val buildWf = result.workflows.find { it.name == "Build" }!! + assertEquals(1, buildWf.successCount) + assertEquals(1, buildWf.failureCount) + val testWf = result.workflows.find { it.name == "Test" }!! + assertEquals(0, testWf.successCount) + assertEquals(1, testWf.failureCount) + } + + @Test + fun `null conclusion treated as pending and sets isRunning`() { + val rollup = buildRollup( + checkRun("lint", null, "Build"), + ) + val result = parseCIContexts(rollup) + assertEquals(0, result.successCount) + assertEquals(0, result.failureCount) + assertEquals(1, result.pendingCount) + assertTrue(result.isRunning) + assertEquals("PENDING", result.workflows[0].status) + } + + @Test + fun `blank conclusion treated as pending`() { + val rollup = buildRollup( + checkRun("lint", "", "Build"), + ) + val result = parseCIContexts(rollup) + assertEquals(1, result.pendingCount) + assertTrue(result.isRunning) + } + + @Test + fun `NEUTRAL and SKIPPED are treated as success`() { + val rollup = buildRollup( + checkRun("skip-check", "NEUTRAL", "CI"), + checkRun("skipped-job", "SKIPPED", "CI"), + ) + val result = parseCIContexts(rollup) + assertEquals(2, result.successCount) + assertEquals(0, result.failureCount) + } + + @Test + fun `CANCELLED and ACTION_REQUIRED and STARTUP_FAILURE are treated as failure`() { + val rollup = buildRollup( + checkRun("a", "CANCELLED", "CI"), + checkRun("b", "ACTION_REQUIRED", "CI"), + checkRun("c", "STARTUP_FAILURE", "CI"), + ) + val result = parseCIContexts(rollup) + assertEquals(0, result.successCount) + assertEquals(3, result.failureCount) + } + + @Test + fun `CheckRun without workflow uses check name as group`() { + val rollup = buildRollup( + checkRunNoWorkflow("codecov", "SUCCESS"), + ) + val result = parseCIContexts(rollup) + assertEquals(1, result.workflows.size) + assertEquals("codecov", result.workflows[0].name) + assertFalse(result.workflows[0].isWorkflow) + } + + @Test + fun `StatusContext SUCCESS is counted`() { + val rollup = buildRollup( + statusContext("ci/circleci", "SUCCESS"), + ) + val result = parseCIContexts(rollup) + assertEquals(1, result.successCount) + assertEquals(0, result.failureCount) + assertEquals(1, result.workflows.size) + assertEquals("ci/circleci", result.workflows[0].name) + assertFalse(result.workflows[0].isWorkflow) + } + + @Test + fun `StatusContext FAILURE and ERROR are counted as failure`() { + val rollup = buildRollup( + statusContext("ci/travis", "FAILURE"), + statusContext("ci/jenkins", "ERROR"), + ) + val result = parseCIContexts(rollup) + assertEquals(0, result.successCount) + assertEquals(2, result.failureCount) + } + + @Test + fun `StatusContext PENDING is counted as pending`() { + val rollup = buildRollup( + statusContext("ci/deploy", "PENDING"), + ) + val result = parseCIContexts(rollup) + assertEquals(1, result.pendingCount) + assertTrue(result.isRunning) + } + + @Test + fun `mixed CheckRun and StatusContext are all counted`() { + val rollup = buildRollup( + checkRun("lint", "SUCCESS", "Build"), + checkRun("test", "FAILURE", "Build"), + statusContext("ci/external", "SUCCESS"), + checkRun("e2e", null, "E2E"), + ) + val result = parseCIContexts(rollup) + assertEquals(2, result.successCount) // lint + ci/external + assertEquals(1, result.failureCount) // test + assertEquals(1, result.pendingCount) // e2e (null conclusion) + assertTrue(result.isRunning) + assertEquals(3, result.workflows.size) // Build, ci/external, E2E + } + + @Test + fun `workflows sorted by failure first then pending then success then by name`() { + val rollup = buildRollup( + checkRun("a", "SUCCESS", "Zebra"), + checkRun("b", null, "Middle"), + checkRun("c", "FAILURE", "Alpha"), + ) + val result = parseCIContexts(rollup) + assertEquals("Alpha", result.workflows[0].name) // failure first + assertEquals("Middle", result.workflows[1].name) // pending second + assertEquals("Zebra", result.workflows[2].name) // success third + } + + @Test + fun `multiple check runs grouped under same workflow`() { + val rollup = buildRollup( + checkRun("lint", "SUCCESS", "CI"), + checkRun("test", "SUCCESS", "CI"), + checkRun("build", "FAILURE", "CI"), + ) + val result = parseCIContexts(rollup) + assertEquals(1, result.workflows.size) + val ci = result.workflows[0] + assertEquals("CI", ci.name) + assertEquals(2, ci.successCount) + assertEquals(1, ci.failureCount) + assertEquals(3, ci.totalCount) + } + + @Test + fun `empty contexts nodes array returns empty result`() { + val rollup = JSONObject().apply { + put("contexts", JSONObject().apply { + put("nodes", JSONArray()) + }) + } + val result = parseCIContexts(rollup) + assertEquals(0, result.successCount) + assertEquals(0, result.failureCount) + assertEquals(0, result.pendingCount) + assertFalse(result.isRunning) + assertTrue(result.workflows.isEmpty()) + } + + // --- Helper builders --- + + private fun checkRun(name: String, conclusion: String?, workflowName: String): JSONObject { + return JSONObject().apply { + put("name", name) + if (conclusion != null) put("conclusion", conclusion) else put("conclusion", JSONObject.NULL) + put("checkSuite", JSONObject().apply { + put("workflowRun", JSONObject().apply { + put("workflow", JSONObject().apply { + put("name", workflowName) + }) + }) + }) + } + } + + private fun checkRunNoWorkflow(name: String, conclusion: String?): JSONObject { + return JSONObject().apply { + put("name", name) + if (conclusion != null) put("conclusion", conclusion) else put("conclusion", JSONObject.NULL) + put("checkSuite", JSONObject().apply { + put("workflowRun", JSONObject.NULL) + }) + } + } + + private fun statusContext(context: String, state: String): JSONObject { + return JSONObject().apply { + put("context", context) + put("state", state) + } + } + + private fun buildRollup(vararg nodes: JSONObject): JSONObject { + val nodesArray = JSONArray() + nodes.forEach { nodesArray.put(it) } + return JSONObject().apply { + put("contexts", JSONObject().apply { + put("nodes", nodesArray) + }) + } + } +} diff --git a/android/app/src/test/kotlin/com/ghpr/app/data/RetryFlakyJobTest.kt b/android/app/src/test/kotlin/com/ghpr/app/data/RetryFlakyJobTest.kt new file mode 100644 index 0000000..a2e37a4 --- /dev/null +++ b/android/app/src/test/kotlin/com/ghpr/app/data/RetryFlakyJobTest.kt @@ -0,0 +1,71 @@ +package com.ghpr.app.data + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class RetryFlakyJobTest { + + @Test + fun `totalRetries is always 3`() { + val job = RetryFlakyJob( + id = 1, + repoFullName = "owner/repo", + prNumber = 42, + retriesRemaining = 2, + status = "active", + ) + assertEquals(3, job.totalRetries) + } + + @Test + fun `totalRetries is 3 even when retriesRemaining is 0`() { + val job = RetryFlakyJob( + id = 1, + repoFullName = "owner/repo", + prNumber = 42, + retriesRemaining = 0, + status = "exhausted", + ) + assertEquals(3, job.totalRetries) + } + + @Test + fun `workflowAttempts defaults to empty map`() { + val job = RetryFlakyJob( + id = 1, + repoFullName = "owner/repo", + prNumber = 42, + retriesRemaining = 3, + status = "active", + ) + assertEquals(emptyMap(), job.workflowAttempts) + } + + @Test + fun `workflowAttempts preserves values when provided`() { + val attempts = mapOf("Build" to 2, "Test" to 1) + val job = RetryFlakyJob( + id = 1, + repoFullName = "owner/repo", + prNumber = 42, + retriesRemaining = 1, + workflowAttempts = attempts, + status = "active", + ) + assertEquals(2, job.workflowAttempts["Build"]) + assertEquals(1, job.workflowAttempts["Test"]) + } + + @Test + fun `createdAt and updatedAt default to null`() { + val job = RetryFlakyJob( + id = 1, + repoFullName = "owner/repo", + prNumber = 42, + retriesRemaining = 3, + status = "active", + ) + assertEquals(null, job.createdAt) + assertEquals(null, job.updatedAt) + } +} diff --git a/android/app/src/test/kotlin/com/ghpr/app/ui/openprs/CiStatusTextTest.kt b/android/app/src/test/kotlin/com/ghpr/app/ui/openprs/CiStatusTextTest.kt new file mode 100644 index 0000000..d7d3ecf --- /dev/null +++ b/android/app/src/test/kotlin/com/ghpr/app/ui/openprs/CiStatusTextTest.kt @@ -0,0 +1,103 @@ +package com.ghpr.app.ui.openprs + +import com.ghpr.app.data.CIWorkflowInfo +import com.ghpr.app.data.OpenPullRequest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CiStatusTextTest { + + private fun pr( + ciState: String?, + ciWorkflows: List = emptyList(), + ) = OpenPullRequest( + number = 1, + title = "Test PR", + url = "https://github.com/owner/repo/pull/1", + isDraft = false, + createdAt = "2024-01-01T00:00:00Z", + updatedAt = "2024-01-01T00:00:00Z", + authorLogin = "user", + authorAvatarUrl = "", + repoOwner = "owner", + repoName = "repo", + ciState = ciState, + ciWorkflows = ciWorkflows, + ) + + @Test + fun `returns ci when ciState is null`() { + assertEquals("ci", ciStatusText(pr(ciState = null))) + } + + @Test + fun `returns lowercase ciState when no workflows`() { + assertEquals("failure", ciStatusText(pr(ciState = "FAILURE"))) + assertEquals("success", ciStatusText(pr(ciState = "SUCCESS"))) + assertEquals("pending", ciStatusText(pr(ciState = "PENDING"))) + } + + @Test + fun `shows total workflow count for SUCCESS`() { + val workflows = listOf( + CIWorkflowInfo("Build", isWorkflow = true, successCount = 3, failureCount = 0, pendingCount = 0), + CIWorkflowInfo("Test", isWorkflow = true, successCount = 5, failureCount = 0, pendingCount = 0), + ) + assertEquals("2wf", ciStatusText(pr(ciState = "SUCCESS", ciWorkflows = workflows))) + } + + @Test + fun `shows failed workflow count and total failed tasks for FAILURE`() { + val workflows = listOf( + CIWorkflowInfo("Build", isWorkflow = true, successCount = 2, failureCount = 1, pendingCount = 0), + CIWorkflowInfo("Test", isWorkflow = true, successCount = 0, failureCount = 3, pendingCount = 0), + CIWorkflowInfo("Lint", isWorkflow = true, successCount = 4, failureCount = 0, pendingCount = 0), + ) + // 2 workflows have failures, 3 total workflows, 4 total failed tasks (1+3) + assertEquals("2/3wf\u00B74", ciStatusText(pr(ciState = "FAILURE", ciWorkflows = workflows))) + } + + @Test + fun `shows failed workflow count for ERROR state`() { + val workflows = listOf( + CIWorkflowInfo("Build", isWorkflow = true, successCount = 0, failureCount = 2, pendingCount = 0), + ) + assertEquals("1/1wf\u00B72", ciStatusText(pr(ciState = "ERROR", ciWorkflows = workflows))) + } + + @Test + fun `shows done workflow count for PENDING`() { + val workflows = listOf( + CIWorkflowInfo("Build", isWorkflow = true, successCount = 3, failureCount = 0, pendingCount = 0), + CIWorkflowInfo("Test", isWorkflow = true, successCount = 0, failureCount = 1, pendingCount = 0), + CIWorkflowInfo("Deploy", isWorkflow = true, successCount = 0, failureCount = 0, pendingCount = 2), + ) + // Build (SUCCESS) and Test (FAILURE) are done, Deploy (PENDING) is not + assertEquals("2/3wf", ciStatusText(pr(ciState = "PENDING", ciWorkflows = workflows))) + } + + @Test + fun `returns lowercase for unknown state with workflows`() { + val workflows = listOf( + CIWorkflowInfo("Build", isWorkflow = true, successCount = 1, failureCount = 0, pendingCount = 0), + ) + assertEquals("expected", ciStatusText(pr(ciState = "EXPECTED", ciWorkflows = workflows))) + } + + @Test + fun `single workflow failure shows 1 of 1`() { + val workflows = listOf( + CIWorkflowInfo("CI", isWorkflow = true, successCount = 0, failureCount = 1, pendingCount = 0), + ) + assertEquals("1/1wf\u00B71", ciStatusText(pr(ciState = "FAILURE", ciWorkflows = workflows))) + } + + @Test + fun `all workflows passing shows 0 failed for FAILURE state`() { + // Edge case: ciState is FAILURE but all workflows show success (stale data) + val workflows = listOf( + CIWorkflowInfo("Build", isWorkflow = true, successCount = 3, failureCount = 0, pendingCount = 0), + ) + assertEquals("0/1wf\u00B70", ciStatusText(pr(ciState = "FAILURE", ciWorkflows = workflows))) + } +} diff --git a/android/app/src/test/kotlin/com/ghpr/app/ui/openprs/OpenPrsUiStateTest.kt b/android/app/src/test/kotlin/com/ghpr/app/ui/openprs/OpenPrsUiStateTest.kt new file mode 100644 index 0000000..4376ae8 --- /dev/null +++ b/android/app/src/test/kotlin/com/ghpr/app/ui/openprs/OpenPrsUiStateTest.kt @@ -0,0 +1,105 @@ +package com.ghpr.app.ui.openprs + +import com.ghpr.app.data.OpenPullRequest +import com.ghpr.app.data.PrCategory +import com.ghpr.app.data.RetryFlakyJob +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class OpenPrsUiStateTest { + + private fun pr(owner: String = "owner", repo: String = "repo", number: Int = 1) = + OpenPullRequest( + number = number, + title = "PR #$number", + url = "https://github.com/$owner/$repo/pull/$number", + isDraft = false, + createdAt = "2024-01-01T00:00:00Z", + updatedAt = "2024-01-01T00:00:00Z", + authorLogin = "user", + authorAvatarUrl = "", + repoOwner = owner, + repoName = repo, + ciState = "FAILURE", + category = PrCategory.AUTHORED, + ) + + @Test + fun `expandedPrKey defaults to null`() { + val state = OpenPrsUiState() + assertNull(state.expandedPrKey) + } + + @Test + fun `expanding a PR sets expandedPrKey`() { + val state = OpenPrsUiState() + val updated = state.copy(expandedPrKey = "owner/repo#1") + assertEquals("owner/repo#1", updated.expandedPrKey) + } + + @Test + fun `collapsing the same PR sets expandedPrKey to null`() { + val state = OpenPrsUiState(expandedPrKey = "owner/repo#1") + val key = "owner/repo#1" + val newKey = if (state.expandedPrKey == key) null else key + val updated = state.copy(expandedPrKey = newKey) + assertNull(updated.expandedPrKey) + } + + @Test + fun `expanding a different PR replaces the key`() { + val state = OpenPrsUiState(expandedPrKey = "owner/repo#1") + val key = "owner/repo#2" + val newKey = if (state.expandedPrKey == key) null else key + val updated = state.copy(expandedPrKey = newKey) + assertEquals("owner/repo#2", updated.expandedPrKey) + } + + @Test + fun `prKey generates correct key format`() { + val testPr = pr(owner = "acme", repo = "widgets", number = 42) + assertEquals("acme/widgets#42", OpenPrsViewModel.prKey(testPr)) + } + + @Test + fun `retryFlakyJobs keyed by prKey`() { + val job = RetryFlakyJob( + id = 1, + repoFullName = "owner/repo", + prNumber = 42, + retriesRemaining = 2, + status = "active", + ) + val jobs = mapOf("owner/repo#42" to job) + val state = OpenPrsUiState(retryFlakyJobs = jobs) + assertEquals(job, state.retryFlakyJobs["owner/repo#42"]) + assertNull(state.retryFlakyJobs["other/repo#1"]) + } + + @Test + fun `swipe should be disabled when PR is expanded`() { + val testPr = pr() + val key = OpenPrsViewModel.prKey(testPr) + val state = OpenPrsUiState(expandedPrKey = key) + + val isExpanded = state.expandedPrKey == key + val isCiFailure = testPr.ciState?.uppercase() in listOf("FAILURE", "ERROR") + val swipeEnabled = isCiFailure && !isExpanded + + assertEquals(false, swipeEnabled) + } + + @Test + fun `swipe should be enabled when PR is collapsed and CI failed`() { + val testPr = pr() + val key = OpenPrsViewModel.prKey(testPr) + val state = OpenPrsUiState(expandedPrKey = null) + + val isExpanded = state.expandedPrKey == key + val isCiFailure = testPr.ciState?.uppercase() in listOf("FAILURE", "ERROR") + val swipeEnabled = isCiFailure && !isExpanded + + assertEquals(true, swipeEnabled) + } +} diff --git a/android/app/src/test/kotlin/com/ghpr/app/ui/settings/SettingsRetryStatsTest.kt b/android/app/src/test/kotlin/com/ghpr/app/ui/settings/SettingsRetryStatsTest.kt new file mode 100644 index 0000000..de3bba9 --- /dev/null +++ b/android/app/src/test/kotlin/com/ghpr/app/ui/settings/SettingsRetryStatsTest.kt @@ -0,0 +1,156 @@ +package com.ghpr.app.ui.settings + +import com.ghpr.app.data.RetryFlakyJob +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * Tests for the retry stats computation logic used in SettingsViewModel. + * This tests the same filtering and counting logic that SettingsViewModel + * applies in its state combine flow. + */ +class SettingsRetryStatsTest { + + private fun job( + id: Int, + repo: String = "owner/repo", + pr: Int = id, + status: String, + updatedAt: String? = null, + ) = RetryFlakyJob( + id = id, + repoFullName = repo, + prNumber = pr, + retriesRemaining = if (status == "active") 2 else 0, + status = status, + updatedAt = updatedAt, + ) + + /** Mirrors the logic in SettingsViewModel.state combine block */ + private fun computeStats(jobs: List): Pair> { + val pendingCount = jobs.count { it.status == "active" } + val recentResults = jobs + .filter { it.status in listOf("completed", "exhausted", "cancelled") } + .sortedByDescending { it.updatedAt } + .take(5) + return pendingCount to recentResults + } + + @Test + fun `empty jobs list gives zero pending and empty results`() { + val (pending, recent) = computeStats(emptyList()) + assertEquals(0, pending) + assertEquals(0, recent.size) + } + + @Test + fun `counts active jobs as pending`() { + val jobs = listOf( + job(1, status = "active"), + job(2, status = "active"), + job(3, status = "completed"), + ) + val (pending, _) = computeStats(jobs) + assertEquals(2, pending) + } + + @Test + fun `active jobs are not in recent results`() { + val jobs = listOf( + job(1, status = "active"), + ) + val (_, recent) = computeStats(jobs) + assertEquals(0, recent.size) + } + + @Test + fun `completed jobs appear in recent results`() { + val jobs = listOf( + job(1, status = "completed", updatedAt = "2024-03-20 10:00:00"), + ) + val (_, recent) = computeStats(jobs) + assertEquals(1, recent.size) + assertEquals("completed", recent[0].status) + } + + @Test + fun `exhausted jobs appear in recent results`() { + val jobs = listOf( + job(1, status = "exhausted", updatedAt = "2024-03-20 10:00:00"), + ) + val (_, recent) = computeStats(jobs) + assertEquals(1, recent.size) + assertEquals("exhausted", recent[0].status) + } + + @Test + fun `cancelled jobs appear in recent results`() { + val jobs = listOf( + job(1, status = "cancelled", updatedAt = "2024-03-20 10:00:00"), + ) + val (_, recent) = computeStats(jobs) + assertEquals(1, recent.size) + assertEquals("cancelled", recent[0].status) + } + + @Test + fun `recent results sorted by updatedAt descending`() { + val jobs = listOf( + job(1, status = "completed", updatedAt = "2024-03-18 10:00:00"), + job(2, status = "exhausted", updatedAt = "2024-03-20 10:00:00"), + job(3, status = "completed", updatedAt = "2024-03-19 10:00:00"), + ) + val (_, recent) = computeStats(jobs) + assertEquals(3, recent.size) + assertEquals(2, recent[0].id) // most recent + assertEquals(3, recent[1].id) + assertEquals(1, recent[2].id) // oldest + } + + @Test + fun `recent results capped at 5`() { + val jobs = (1..10).map { i -> + job(i, status = "completed", updatedAt = "2024-03-${10 + i} 10:00:00") + } + val (_, recent) = computeStats(jobs) + assertEquals(5, recent.size) + // Should be the 5 most recent (ids 10, 9, 8, 7, 6) + assertEquals(10, recent[0].id) + assertEquals(6, recent[4].id) + } + + @Test + fun `mixed statuses correctly categorized`() { + val jobs = listOf( + job(1, status = "active"), + job(2, status = "completed", updatedAt = "2024-03-20 10:00:00"), + job(3, status = "exhausted", updatedAt = "2024-03-19 10:00:00"), + job(4, status = "active"), + job(5, status = "cancelled", updatedAt = "2024-03-18 10:00:00"), + ) + val (pending, recent) = computeStats(jobs) + assertEquals(2, pending) + assertEquals(3, recent.size) + } + + @Test + fun `SettingsUiState defaults for retry fields`() { + val state = SettingsUiState() + assertEquals(0, state.retryPendingCount) + assertEquals(emptyList(), state.recentRetryResults) + } + + @Test + fun `SettingsUiState copy with retry data`() { + val jobs = listOf( + job(1, repo = "acme/widgets", pr = 42, status = "completed", updatedAt = "2024-03-20 10:00:00"), + ) + val state = SettingsUiState().copy( + retryPendingCount = 2, + recentRetryResults = jobs, + ) + assertEquals(2, state.retryPendingCount) + assertEquals(1, state.recentRetryResults.size) + assertEquals("acme/widgets", state.recentRetryResults[0].repoFullName) + } +} From e5f7538bcf06fedbf66f83dc8d1209e550f3483b Mon Sep 17 00:00:00 2001 From: xiaocang Date: Fri, 20 Mar 2026 22:12:40 +0800 Subject: [PATCH 3/5] Simplify PR detail expansion: deduplicate animation specs, hoist shapes, consolidate ActionPill into StatusBadge Extract duplicated AnimatedVisibility enter/exit transitions into file-level constants, hoist RoundedCornerShape allocations out of composables, and replace the private ActionPill with a clickable StatusBadge overload to eliminate near-identical code. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/ghpr/app/ui/components/StatusBadge.kt | 25 ++ .../com/ghpr/app/ui/openprs/OpenPrsScreen.kt | 281 ++++++++++-------- 2 files changed, 187 insertions(+), 119 deletions(-) diff --git a/android/app/src/main/kotlin/com/ghpr/app/ui/components/StatusBadge.kt b/android/app/src/main/kotlin/com/ghpr/app/ui/components/StatusBadge.kt index e272359..06a57c2 100644 --- a/android/app/src/main/kotlin/com/ghpr/app/ui/components/StatusBadge.kt +++ b/android/app/src/main/kotlin/com/ghpr/app/ui/components/StatusBadge.kt @@ -2,6 +2,7 @@ package com.ghpr.app.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme @@ -10,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.ghpr.app.data.NotificationEventMapper import com.ghpr.app.ui.theme.LocalGhprStatusColors @@ -35,6 +37,29 @@ fun StatusBadge( ) } +@Composable +fun StatusBadge( + text: String, + color: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val displayColor = if (enabled) color else color.copy(alpha = 0.3f) + Text( + text = text, + style = MonoStyle.codeBold, + color = displayColor, + textAlign = TextAlign.Center, + modifier = modifier + .clip(BadgeShape) + .border(2.dp, displayColor, BadgeShape) + .background(displayColor.copy(alpha = 0.25f)) + .then(if (enabled) Modifier.clickable { onClick() } else Modifier) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) +} + @Composable fun actionStatusColor(action: String): Color { val statusColors = LocalGhprStatusColors.current diff --git a/android/app/src/main/kotlin/com/ghpr/app/ui/openprs/OpenPrsScreen.kt b/android/app/src/main/kotlin/com/ghpr/app/ui/openprs/OpenPrsScreen.kt index b9417f2..806cfaa 100644 --- a/android/app/src/main/kotlin/com/ghpr/app/ui/openprs/OpenPrsScreen.kt +++ b/android/app/src/main/kotlin/com/ghpr/app/ui/openprs/OpenPrsScreen.kt @@ -4,19 +4,22 @@ import android.content.Intent import androidx.core.net.toUri import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.ui.graphics.Color import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -31,13 +34,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -54,9 +54,13 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.ghpr.app.data.CIWorkflowInfo @@ -67,6 +71,7 @@ import com.ghpr.app.ui.components.EmptyStateView import com.ghpr.app.ui.components.ErrorStateView import com.ghpr.app.ui.components.StatusBadge import com.ghpr.app.ui.theme.LocalGhprStatusColors +import com.ghpr.app.ui.theme.LocalNeoBrutalColors import com.ghpr.app.ui.theme.NeoButton import com.ghpr.app.ui.theme.MonoStyle import com.ghpr.app.ui.theme.NeoCard @@ -74,6 +79,16 @@ import com.ghpr.app.ui.theme.neoTopBarBorder import kotlinx.coroutines.launch import kotlin.math.roundToInt +private val DetailEnterTransition = + expandVertically(animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f)) + + fadeIn(animationSpec = tween(150, delayMillis = 30)) + +private val DetailExitTransition = + fadeOut(animationSpec = tween(100)) + + shrinkVertically(animationSpec = tween(180, easing = FastOutSlowInEasing)) + +private val ShallowCardShape = RoundedCornerShape(6.dp) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun OpenPrsScreen(viewModel: OpenPrsViewModel) { @@ -175,8 +190,8 @@ fun OpenPrsScreen(viewModel: OpenPrsViewModel) { ) AnimatedVisibility( visible = isExpanded, - enter = expandVertically(), - exit = shrinkVertically(), + enter = DetailEnterTransition, + exit = DetailExitTransition, ) { PrExpandedDetail( pr = pr, @@ -209,8 +224,8 @@ fun OpenPrsScreen(viewModel: OpenPrsViewModel) { ) AnimatedVisibility( visible = isExpanded, - enter = expandVertically(), - exit = shrinkVertically(), + enter = DetailEnterTransition, + exit = DetailExitTransition, ) { PrExpandedDetail( pr = pr, @@ -549,7 +564,6 @@ internal fun ciStatusText(pr: OpenPullRequest): String { } } -@OptIn(ExperimentalLayoutApi::class) @Composable private fun PrExpandedDetail( pr: OpenPullRequest, @@ -562,111 +576,144 @@ private fun PrExpandedDetail( ) { val context = LocalContext.current val statusColors = LocalGhprStatusColors.current + val neo = LocalNeoBrutalColors.current val hasActiveJob = retryFlakyJob != null && retryFlakyJob.status == "active" val isCiFailure = pr.ciState?.uppercase() in listOf("FAILURE", "ERROR") + val failedWorkflows = pr.ciWorkflows.filter { it.failureCount > 0 } + val borderColor = neo.border.copy(alpha = 0.5f) + val shallowBorderColor = neo.border.copy(alpha = 0.4f) + val shallowShadowColor = neo.shadow.copy(alpha = 0.3f) - Column( + // Outer: left vertical line + spacing + Box( modifier = Modifier .fillMaxWidth() - .padding(start = 32.dp, end = 16.dp, bottom = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Workflow breakdown - if (pr.ciWorkflows.isNotEmpty()) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - pr.ciWorkflows.forEach { wf -> - WorkflowStatusRow(wf) - } - } - } else if (pr.ciState != null) { - Text( - text = "No workflow details", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - // Retry job info - if (retryFlakyJob != null) { - val statusText = when (retryFlakyJob.status) { - "active" -> "Retrying: ${retryFlakyJob.retriesRemaining}/${retryFlakyJob.totalRetries} remaining" - "completed" -> "Retry completed" - "exhausted" -> "Retries exhausted" - "cancelled" -> "Retry cancelled" - else -> "Retry: ${retryFlakyJob.status}" - } - val statusColor = when (retryFlakyJob.status) { - "active" -> statusColors.pending - "completed" -> statusColors.merged - else -> statusColors.closed - } - Text( - text = statusText, - style = MonoStyle.codeSmall, - color = statusColor, - ) - retryFlakyJob.updatedAt?.let { updatedAt -> - Text( - text = "Last retry: $updatedAt", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + .padding(start = 32.dp, end = 16.dp, bottom = 8.dp) + .drawBehind { + // Left vertical line + drawLine( + color = borderColor, + start = Offset(0f, 0f), + end = Offset(0f, size.height), + strokeWidth = 2.dp.toPx(), + cap = StrokeCap.Round, ) } - } - - // Action buttons - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + .padding(start = 14.dp), + ) { + // Shallow card: shadow + border + bg + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 2.dp, end = 2.dp), ) { - NeoButton( - onClick = { - context.startActivity(Intent(Intent.ACTION_VIEW, pr.url.toUri())) - }, - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurface, + // Shadow layer + Box( + modifier = Modifier + .matchParentSize() + .offset(x = 2.dp, y = 2.dp) + .background(shallowShadowColor, ShallowCardShape), + ) + // Content layer + Box( + modifier = Modifier + .background(neo.cardBg, ShallowCardShape) + .border(1.5.dp, shallowBorderColor, ShallowCardShape) + .padding(10.dp), ) { - Icon( - Icons.Default.OpenInBrowser, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text("Open PR", style = MaterialTheme.typography.labelMedium) - } - if (onRetryCi != null && isCiFailure) { - NeoButton( - onClick = onRetryCi, - enabled = !isCiSubmitting && !isRetrySubmitting, - containerColor = Color(0xFF3B82F6), - contentColor = Color.White, - ) { - Text("Retry", style = MaterialTheme.typography.labelMedium) - } - } - if (onRetryFlaky != null && isCiFailure) { - NeoButton( - onClick = onRetryFlaky, - enabled = !isRetrySubmitting && !isCiSubmitting && !hasActiveJob, - containerColor = Color(0xFFF59E0B), - contentColor = Color.White, - ) { - Text("Retry x3", style = MaterialTheme.typography.labelMedium) - } - } - if (hasActiveJob && onCancelRetryFlaky != null) { - NeoButton( - onClick = onCancelRetryFlaky, - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Icon( - Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text("Cancel", style = MaterialTheme.typography.labelMedium) + // Only show failed workflows + if (failedWorkflows.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + failedWorkflows.forEach { wf -> + WorkflowStatusRow(wf) + } + } + } else if (pr.ciState != null && pr.ciWorkflows.isEmpty()) { + Text( + text = "No workflow details", + style = MonoStyle.codeSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Retry job info + if (retryFlakyJob != null) { + val statusText = when (retryFlakyJob.status) { + "active" -> "\uD83D\uDD04 ${retryFlakyJob.retriesRemaining}/${retryFlakyJob.totalRetries} left" + "completed" -> "\u2705 done" + "exhausted" -> "\u274C exhausted" + "cancelled" -> "\u23F9 cancelled" + else -> retryFlakyJob.status + } + val statusColor = when (retryFlakyJob.status) { + "active" -> statusColors.pending + "completed" -> statusColors.merged + else -> statusColors.closed + } + Text( + text = statusText, + style = MonoStyle.codeSmall, + color = statusColor, + ) + } + + // Action row — StatusBadge-style pills + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Open PR + StatusBadge( + text = "\uD83D\uDD17 open", + color = statusColors.link, + modifier = Modifier.weight(1f), + onClick = { + context.startActivity(Intent(Intent.ACTION_VIEW, pr.url.toUri())) + }, + ) + + if (onRetryCi != null && isCiFailure) { + val enabled = !isCiSubmitting && !isRetrySubmitting + StatusBadge( + text = "\uD83D\uDD04", + color = Color(0xFF3B82F6), + modifier = Modifier.weight(1f), + enabled = enabled, + onClick = { onRetryCi() }, + ) + } + + if (onRetryFlaky != null && isCiFailure) { + val enabled = !isRetrySubmitting && !isCiSubmitting && !hasActiveJob + StatusBadge( + text = "\uD83D\uDD04x3", + color = Color(0xFFF59E0B), + modifier = Modifier.weight(1f), + enabled = enabled, + onClick = { onRetryFlaky() }, + ) + } + + if (hasActiveJob && onCancelRetryFlaky != null) { + StatusBadge( + text = "\u23F9", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.weight(1f), + onClick = { onCancelRetryFlaky() }, + ) + } + + if (isCiSubmitting || isRetrySubmitting) { + CircularProgressIndicator( + modifier = Modifier.weight(1f).size(14.dp), + strokeWidth = 1.5.dp, + ) + } + } } } } @@ -681,31 +728,27 @@ private fun WorkflowStatusRow(wf: CIWorkflowInfo) { wf.pendingCount > 0 -> statusColors.pending else -> statusColors.merged } - val statusText = when { - wf.failureCount > 0 -> "${wf.failureCount} failed" - wf.pendingCount > 0 -> "${wf.pendingCount} pending" - else -> "${wf.successCount} passed" - } Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { + Text( + text = "\u274C", + style = MonoStyle.codeSmall, + ) Text( text = wf.name, style = MonoStyle.codeSmall, - color = MaterialTheme.colorScheme.onSurface, + color = color, modifier = Modifier.weight(1f), maxLines = 1, - ) - StatusBadge( - text = statusText, - color = color, + overflow = TextOverflow.Ellipsis, ) if (wf.totalCount > 1) { Text( - text = "${wf.successCount}/${wf.totalCount}", - style = MaterialTheme.typography.bodySmall, + text = "${wf.failureCount}/${wf.totalCount}", + style = MonoStyle.codeSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } From 8c5c9577e47cfb1c1cf3e7994cc45b2536eb60f6 Mon Sep 17 00:00:00 2001 From: xiaocang Date: Fri, 20 Mar 2026 22:16:08 +0800 Subject: [PATCH 4/5] Add target/ and server/wrangler-local.toml to .gitignore Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 2ebf9e1..b57f9d1 100644 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,11 @@ worker-runner/.wrangler/ # Gradle user home cache .gradle-user-home/ +# Rust +target/ + +# Cloudflare +server/wrangler-local.toml + # OS .DS_Store From 2887f6831000aa32c0f8bd3f6feb5831ca49654f Mon Sep 17 00:00:00 2001 From: xiaocang Date: Fri, 20 Mar 2026 23:00:51 +0800 Subject: [PATCH 5/5] Fix Copilot review comments: CI label, expanded detail, truncation, isWorkflow, sign-out - Change misleading "wf" suffix to "ci" in ciStatusText() since counts include non-workflow StatusContext checks - Show all workflows in expanded detail (not just failed), with status-appropriate icons (cross/hourglass/checkmark) - Detect GraphQL pagination truncation (>100 checks) via pageInfo.hasNextPage and append "+" to CI counts - Fix workflowIsWf so true wins over false when same name appears as both workflow and non-workflow entry - Clear _retryJobs on sign-out alongside runnerStatus Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/ghpr/app/data/GitHubGraphQLClient.kt | 15 ++++---- .../com/ghpr/app/data/OpenPullRequest.kt | 1 + .../com/ghpr/app/ui/openprs/OpenPrsScreen.kt | 22 +++++++----- .../ghpr/app/ui/settings/SettingsViewModel.kt | 1 + .../com/ghpr/app/data/ParseCIContextsTest.kt | 36 +++++++++++++++++++ .../ghpr/app/ui/openprs/CiStatusTextTest.kt | 24 +++++++++---- 6 files changed, 78 insertions(+), 21 deletions(-) diff --git a/android/app/src/main/kotlin/com/ghpr/app/data/GitHubGraphQLClient.kt b/android/app/src/main/kotlin/com/ghpr/app/data/GitHubGraphQLClient.kt index 5560f96..9b19cc7 100644 --- a/android/app/src/main/kotlin/com/ghpr/app/data/GitHubGraphQLClient.kt +++ b/android/app/src/main/kotlin/com/ghpr/app/data/GitHubGraphQLClient.kt @@ -22,6 +22,7 @@ internal data class CIParsed( val pendingCount: Int, val isRunning: Boolean, val workflows: List, + val truncated: Boolean = false, ) data class FetchOpenPrsResult( @@ -114,6 +115,7 @@ class GitHubGraphQLClient( statusCheckRollup { state contexts(first: 100) { + pageInfo { hasNextPage } nodes { ... on CheckRun { name @@ -295,6 +297,7 @@ class GitHubGraphQLClient( checkPendingCount = ciParsed.pendingCount, ciWorkflows = ciParsed.workflows, ciIsRunning = ciParsed.isRunning, + ciTruncated = ciParsed.truncated, ), ) } @@ -435,10 +438,10 @@ class GitHubGraphQLClient( internal fun parseCIContexts(rollup: JSONObject?): CIParsed { val empty = CIParsed(0, 0, 0, false, emptyList()) - val contextsNodes = rollup - ?.optJSONObject("contexts") - ?.optJSONArray("nodes") - ?: return empty + val contexts = rollup?.optJSONObject("contexts") ?: return empty + val contextsNodes = contexts.optJSONArray("nodes") ?: return empty + val truncated = contexts.optJSONObject("pageInfo") + ?.optBoolean("hasNextPage", false) ?: false var successCount = 0 var failureCount = 0 @@ -461,7 +464,7 @@ internal fun parseCIContexts(rollup: JSONObject?): CIParsed { val isWf = workflowName != null val counts = workflowMap.getOrPut(groupName) { intArrayOf(0, 0, 0) } - workflowIsWf.putIfAbsent(groupName, isWf) + workflowIsWf[groupName] = (workflowIsWf[groupName] == true) || isWf when (conclusion?.uppercase()) { "SUCCESS", "NEUTRAL", "SKIPPED" -> { @@ -506,5 +509,5 @@ internal fun parseCIContexts(rollup: JSONObject?): CIParsed { { it.name }, )) - return CIParsed(successCount, failureCount, pendingCount, isRunning, workflows) + return CIParsed(successCount, failureCount, pendingCount, isRunning, workflows, truncated) } diff --git a/android/app/src/main/kotlin/com/ghpr/app/data/OpenPullRequest.kt b/android/app/src/main/kotlin/com/ghpr/app/data/OpenPullRequest.kt index 58b8bb9..0959cf5 100644 --- a/android/app/src/main/kotlin/com/ghpr/app/data/OpenPullRequest.kt +++ b/android/app/src/main/kotlin/com/ghpr/app/data/OpenPullRequest.kt @@ -43,4 +43,5 @@ data class OpenPullRequest( val checkPendingCount: Int = 0, val ciWorkflows: List = emptyList(), val ciIsRunning: Boolean = false, + val ciTruncated: Boolean = false, ) diff --git a/android/app/src/main/kotlin/com/ghpr/app/ui/openprs/OpenPrsScreen.kt b/android/app/src/main/kotlin/com/ghpr/app/ui/openprs/OpenPrsScreen.kt index 806cfaa..3a2757b 100644 --- a/android/app/src/main/kotlin/com/ghpr/app/ui/openprs/OpenPrsScreen.kt +++ b/android/app/src/main/kotlin/com/ghpr/app/ui/openprs/OpenPrsScreen.kt @@ -549,17 +549,18 @@ internal fun ciStatusText(pr: OpenPullRequest): String { val workflows = pr.ciWorkflows if (workflows.isEmpty()) return ci.lowercase() val totalWf = workflows.size + val suffix = if (pr.ciTruncated) "ci+" else "ci" return when (ci) { "FAILURE", "ERROR" -> { val failedWf = workflows.count { it.failureCount > 0 } val totalFailedTasks = workflows.sumOf { it.failureCount } - "${failedWf}/${totalWf}wf\u00B7${totalFailedTasks}" + "${failedWf}/${totalWf}${suffix}\u00B7${totalFailedTasks}" } "PENDING" -> { val doneWf = workflows.count { it.status == "SUCCESS" || it.status == "FAILURE" } - "${doneWf}/${totalWf}wf" + "${doneWf}/${totalWf}${suffix}" } - "SUCCESS" -> "${totalWf}wf" + "SUCCESS" -> "${totalWf}${suffix}" else -> ci.lowercase() } } @@ -579,7 +580,6 @@ private fun PrExpandedDetail( val neo = LocalNeoBrutalColors.current val hasActiveJob = retryFlakyJob != null && retryFlakyJob.status == "active" val isCiFailure = pr.ciState?.uppercase() in listOf("FAILURE", "ERROR") - val failedWorkflows = pr.ciWorkflows.filter { it.failureCount > 0 } val borderColor = neo.border.copy(alpha = 0.5f) val shallowBorderColor = neo.border.copy(alpha = 0.4f) val shallowShadowColor = neo.shadow.copy(alpha = 0.3f) @@ -624,14 +624,13 @@ private fun PrExpandedDetail( Column( verticalArrangement = Arrangement.spacedBy(6.dp), ) { - // Only show failed workflows - if (failedWorkflows.isNotEmpty()) { + if (pr.ciWorkflows.isNotEmpty()) { Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { - failedWorkflows.forEach { wf -> + pr.ciWorkflows.forEach { wf -> WorkflowStatusRow(wf) } } - } else if (pr.ciState != null && pr.ciWorkflows.isEmpty()) { + } else if (pr.ciState != null) { Text( text = "No workflow details", style = MonoStyle.codeSmall, @@ -728,13 +727,18 @@ private fun WorkflowStatusRow(wf: CIWorkflowInfo) { wf.pendingCount > 0 -> statusColors.pending else -> statusColors.merged } + val icon = when { + wf.failureCount > 0 -> "\u274C" + wf.pendingCount > 0 -> "\u23F3" + else -> "\u2705" + } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - text = "\u274C", + text = icon, style = MonoStyle.codeSmall, ) Text( diff --git a/android/app/src/main/kotlin/com/ghpr/app/ui/settings/SettingsViewModel.kt b/android/app/src/main/kotlin/com/ghpr/app/ui/settings/SettingsViewModel.kt index 0b0628b..f834306 100644 --- a/android/app/src/main/kotlin/com/ghpr/app/ui/settings/SettingsViewModel.kt +++ b/android/app/src/main/kotlin/com/ghpr/app/ui/settings/SettingsViewModel.kt @@ -186,6 +186,7 @@ class SettingsViewModel( pollingScheduler.cancelClientPolling() pollingModeStore.setPollingMode(PollingMode.OFF) runnerStatus.value = null + _retryJobs.value = emptyList() } gitHubOAuthManager.signOut() } diff --git a/android/app/src/test/kotlin/com/ghpr/app/data/ParseCIContextsTest.kt b/android/app/src/test/kotlin/com/ghpr/app/data/ParseCIContextsTest.kt index 98623e0..0fb2146 100644 --- a/android/app/src/test/kotlin/com/ghpr/app/data/ParseCIContextsTest.kt +++ b/android/app/src/test/kotlin/com/ghpr/app/data/ParseCIContextsTest.kt @@ -216,6 +216,42 @@ class ParseCIContextsTest { assertTrue(result.workflows.isEmpty()) } + @Test + fun `isWorkflow true wins when non-workflow entry is seen first`() { + val rollup = buildRollup( + checkRunNoWorkflow("CI", "SUCCESS"), + checkRun("lint", "SUCCESS", "CI"), + ) + val result = parseCIContexts(rollup) + assertEquals(1, result.workflows.size) + val ci = result.workflows[0] + assertEquals("CI", ci.name) + assertTrue(ci.isWorkflow) + } + + @Test + fun `truncated is false when pageInfo is absent`() { + val rollup = buildRollup( + checkRun("lint", "SUCCESS", "Build"), + ) + val result = parseCIContexts(rollup) + assertFalse(result.truncated) + } + + @Test + fun `truncated is true when hasNextPage is true`() { + val rollup = JSONObject().apply { + put("contexts", JSONObject().apply { + put("pageInfo", JSONObject().put("hasNextPage", true)) + put("nodes", JSONArray().apply { + put(checkRun("lint", "SUCCESS", "Build")) + }) + }) + } + val result = parseCIContexts(rollup) + assertTrue(result.truncated) + } + // --- Helper builders --- private fun checkRun(name: String, conclusion: String?, workflowName: String): JSONObject { diff --git a/android/app/src/test/kotlin/com/ghpr/app/ui/openprs/CiStatusTextTest.kt b/android/app/src/test/kotlin/com/ghpr/app/ui/openprs/CiStatusTextTest.kt index d7d3ecf..b5c171e 100644 --- a/android/app/src/test/kotlin/com/ghpr/app/ui/openprs/CiStatusTextTest.kt +++ b/android/app/src/test/kotlin/com/ghpr/app/ui/openprs/CiStatusTextTest.kt @@ -10,6 +10,7 @@ class CiStatusTextTest { private fun pr( ciState: String?, ciWorkflows: List = emptyList(), + ciTruncated: Boolean = false, ) = OpenPullRequest( number = 1, title = "Test PR", @@ -23,6 +24,7 @@ class CiStatusTextTest { repoName = "repo", ciState = ciState, ciWorkflows = ciWorkflows, + ciTruncated = ciTruncated, ) @Test @@ -43,7 +45,7 @@ class CiStatusTextTest { CIWorkflowInfo("Build", isWorkflow = true, successCount = 3, failureCount = 0, pendingCount = 0), CIWorkflowInfo("Test", isWorkflow = true, successCount = 5, failureCount = 0, pendingCount = 0), ) - assertEquals("2wf", ciStatusText(pr(ciState = "SUCCESS", ciWorkflows = workflows))) + assertEquals("2ci", ciStatusText(pr(ciState = "SUCCESS", ciWorkflows = workflows))) } @Test @@ -54,7 +56,7 @@ class CiStatusTextTest { CIWorkflowInfo("Lint", isWorkflow = true, successCount = 4, failureCount = 0, pendingCount = 0), ) // 2 workflows have failures, 3 total workflows, 4 total failed tasks (1+3) - assertEquals("2/3wf\u00B74", ciStatusText(pr(ciState = "FAILURE", ciWorkflows = workflows))) + assertEquals("2/3ci\u00B74", ciStatusText(pr(ciState = "FAILURE", ciWorkflows = workflows))) } @Test @@ -62,7 +64,7 @@ class CiStatusTextTest { val workflows = listOf( CIWorkflowInfo("Build", isWorkflow = true, successCount = 0, failureCount = 2, pendingCount = 0), ) - assertEquals("1/1wf\u00B72", ciStatusText(pr(ciState = "ERROR", ciWorkflows = workflows))) + assertEquals("1/1ci\u00B72", ciStatusText(pr(ciState = "ERROR", ciWorkflows = workflows))) } @Test @@ -73,7 +75,7 @@ class CiStatusTextTest { CIWorkflowInfo("Deploy", isWorkflow = true, successCount = 0, failureCount = 0, pendingCount = 2), ) // Build (SUCCESS) and Test (FAILURE) are done, Deploy (PENDING) is not - assertEquals("2/3wf", ciStatusText(pr(ciState = "PENDING", ciWorkflows = workflows))) + assertEquals("2/3ci", ciStatusText(pr(ciState = "PENDING", ciWorkflows = workflows))) } @Test @@ -89,7 +91,7 @@ class CiStatusTextTest { val workflows = listOf( CIWorkflowInfo("CI", isWorkflow = true, successCount = 0, failureCount = 1, pendingCount = 0), ) - assertEquals("1/1wf\u00B71", ciStatusText(pr(ciState = "FAILURE", ciWorkflows = workflows))) + assertEquals("1/1ci\u00B71", ciStatusText(pr(ciState = "FAILURE", ciWorkflows = workflows))) } @Test @@ -98,6 +100,16 @@ class CiStatusTextTest { val workflows = listOf( CIWorkflowInfo("Build", isWorkflow = true, successCount = 3, failureCount = 0, pendingCount = 0), ) - assertEquals("0/1wf\u00B70", ciStatusText(pr(ciState = "FAILURE", ciWorkflows = workflows))) + assertEquals("0/1ci\u00B70", ciStatusText(pr(ciState = "FAILURE", ciWorkflows = workflows))) + } + + @Test + fun `appends plus when ci is truncated`() { + val workflows = listOf( + CIWorkflowInfo("Build", isWorkflow = true, successCount = 3, failureCount = 0, pendingCount = 0), + CIWorkflowInfo("Test", isWorkflow = true, successCount = 5, failureCount = 0, pendingCount = 0), + ) + assertEquals("2ci+", ciStatusText(pr(ciState = "SUCCESS", ciWorkflows = workflows, ciTruncated = true))) + assertEquals("2/2ci+", ciStatusText(pr(ciState = "PENDING", ciWorkflows = workflows, ciTruncated = true))) } }