diff --git a/supacode/Clients/Github/PullRequestMergeReadiness.swift b/supacode/Clients/Github/PullRequestMergeReadiness.swift index 659dd738..66774459 100644 --- a/supacode/Clients/Github/PullRequestMergeReadiness.swift +++ b/supacode/Clients/Github/PullRequestMergeReadiness.swift @@ -4,6 +4,7 @@ nonisolated enum PullRequestMergeBlockingReason: Equatable, Hashable { case mergeConflicts case changesRequested case checksFailed(Int) + case checksPending(Int) case blocked } @@ -29,6 +30,10 @@ nonisolated struct PullRequestMergeReadiness: Equatable, Hashable { self.blockingReason = .checksFailed(breakdown.failed) return } + if breakdown.inProgress > 0 { + self.blockingReason = .checksPending(breakdown.inProgress) + return + } if mergeable == "MERGEABLE" { self.blockingReason = nil @@ -57,6 +62,9 @@ nonisolated struct PullRequestMergeReadiness: Equatable, Hashable { case .checksFailed(let count): let checksLabel = count == 1 ? "check" : "checks" return "\(count) \(checksLabel) failed" + case .checksPending(let count): + let checksLabel = count == 1 ? "check" : "checks" + return "\(count) \(checksLabel) running" case .blocked: return "Blocked" } diff --git a/supacode/Features/Repositories/Views/WorktreeRow.swift b/supacode/Features/Repositories/Views/WorktreeRow.swift index f3f3be28..5de0524e 100644 --- a/supacode/Features/Repositories/Views/WorktreeRow.swift +++ b/supacode/Features/Repositories/Views/WorktreeRow.swift @@ -288,11 +288,22 @@ private struct WorktreeRowInfoView: View { } else if let mergeReadiness { appendSeparator() var segment = AttributedString(mergeReadiness.label) - segment.foregroundColor = mergeReadiness.isBlocking ? .red : .green + segment.foregroundColor = mergeStatusColor(mergeReadiness) result.append(segment) } return Text(result) } + + private func mergeStatusColor(_ readiness: PullRequestMergeReadiness) -> Color { + switch readiness.blockingReason { + case .none: + return .green + case .mergeConflicts, .changesRequested, .checksFailed, .blocked: + return .red + case .checksPending: + return .yellow + } + } } // MARK: - Previews diff --git a/supacodeTests/PullRequestMergeReadinessTests.swift b/supacodeTests/PullRequestMergeReadinessTests.swift index e6ba180d..1e047760 100644 --- a/supacodeTests/PullRequestMergeReadinessTests.swift +++ b/supacodeTests/PullRequestMergeReadinessTests.swift @@ -72,6 +72,74 @@ struct PullRequestMergeReadinessTests { #expect(readiness.blockingReason == .blocked) #expect(readiness.label == "Blocked") } + + @Test func mergeReadinessUsesChecksPendingWhenInProgressAndNoFailures() { + let pullRequest = makePullRequest( + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + checks: [ + GithubPullRequestStatusCheck(status: "PENDING", conclusion: nil, state: nil), + GithubPullRequestStatusCheck(status: "PENDING", conclusion: nil, state: nil), + GithubPullRequestStatusCheck(status: "COMPLETED", conclusion: "SUCCESS", state: nil), + ] + ) + + let readiness = PullRequestMergeReadiness(pullRequest: pullRequest) + + #expect(readiness.blockingReason == .checksPending(2)) + #expect(readiness.label == "2 checks running") + #expect(readiness.isBlocking) + } + + @Test func mergeReadinessSingleCheckPending() { + let pullRequest = makePullRequest( + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + checks: [ + GithubPullRequestStatusCheck(status: "PENDING", conclusion: nil, state: nil), + ] + ) + + let readiness = PullRequestMergeReadiness(pullRequest: pullRequest) + + #expect(readiness.blockingReason == .checksPending(1)) + #expect(readiness.label == "1 check running") + } + + @Test func mergeReadinessChecksPendingTakesPriorityOverMergeable() { + // When checks are in progress but mergeable is already true, + // we should still show "checks running" rather than "Mergeable" + let pullRequest = makePullRequest( + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + checks: [ + GithubPullRequestStatusCheck(status: "IN_PROGRESS", conclusion: nil, state: nil), + ] + ) + + let readiness = PullRequestMergeReadiness(pullRequest: pullRequest) + + #expect(readiness.blockingReason == .checksPending(1)) + #expect(readiness.label == "1 check running") + } + + @Test func mergeReadinessChecksFailedTakesPriorityOverPending() { + // When there are both failed and in-progress checks, + // failed should take priority + let pullRequest = makePullRequest( + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + checks: [ + GithubPullRequestStatusCheck(status: "COMPLETED", conclusion: "FAILURE", state: nil), + GithubPullRequestStatusCheck(status: "PENDING", conclusion: nil, state: nil), + ] + ) + + let readiness = PullRequestMergeReadiness(pullRequest: pullRequest) + + #expect(readiness.blockingReason == .checksFailed(1)) + #expect(readiness.label == "1 check failed") + } } private func makePullRequest( -- 2.51.2 From 27ca2086050e87b75662c4dba669f863a4ce2700 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 24 Jun 2026 18:23:16 +0900 Subject: [PATCH 2/2] Treat expected checks as pending in merge readiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on #496. The pending detection only counted breakdown.inProgress, so a PR whose only outstanding check is an 'expected' commit-status required context (declared but not yet reported) would fall through to a green "Mergeable" label — the exact class of bug #462 fixes. Fold breakdown.expected into the pending count so these PRs also show the yellow "N checks running" state. Adds tests for an expected-only check and a mixed in-progress + expected count. --- .../Github/PullRequestMergeReadiness.swift | 8 +++-- .../PullRequestMergeReadinessTests.swift | 36 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/supacode/Clients/Github/PullRequestMergeReadiness.swift b/supacode/Clients/Github/PullRequestMergeReadiness.swift index 66774459..dbee437c 100644 --- a/supacode/Clients/Github/PullRequestMergeReadiness.swift +++ b/supacode/Clients/Github/PullRequestMergeReadiness.swift @@ -30,8 +30,12 @@ nonisolated struct PullRequestMergeReadiness: Equatable, Hashable { self.blockingReason = .checksFailed(breakdown.failed) return } - if breakdown.inProgress > 0 { - self.blockingReason = .checksPending(breakdown.inProgress) + // `expected` checks (legacy commit-status required contexts that have not + // reported yet) are still in flight, so treat them like in-progress checks + // rather than letting the PR fall through to a green "Mergeable". + let pendingChecks = breakdown.inProgress + breakdown.expected + if pendingChecks > 0 { + self.blockingReason = .checksPending(pendingChecks) return } diff --git a/supacodeTests/PullRequestMergeReadinessTests.swift b/supacodeTests/PullRequestMergeReadinessTests.swift index 1e047760..2ad19bc5 100644 --- a/supacodeTests/PullRequestMergeReadinessTests.swift +++ b/supacodeTests/PullRequestMergeReadinessTests.swift @@ -123,6 +123,42 @@ struct PullRequestMergeReadinessTests { #expect(readiness.label == "1 check running") } + @Test func mergeReadinessTreatsExpectedChecksAsPending() { + // A required commit-status context that has not reported yet shows up as an + // `expected` check. It is still in flight, so the PR must not fall through to + // a green "Mergeable" label. + let pullRequest = makePullRequest( + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + checks: [ + GithubPullRequestStatusCheck(status: nil, conclusion: nil, state: "EXPECTED") + ] + ) + + let readiness = PullRequestMergeReadiness(pullRequest: pullRequest) + + #expect(readiness.blockingReason == .checksPending(1)) + #expect(readiness.label == "1 check running") + #expect(readiness.isBlocking) + } + + @Test func mergeReadinessCountsInProgressAndExpectedChecksTogether() { + let pullRequest = makePullRequest( + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + checks: [ + GithubPullRequestStatusCheck(status: "IN_PROGRESS", conclusion: nil, state: nil), + GithubPullRequestStatusCheck(status: nil, conclusion: nil, state: "EXPECTED"), + GithubPullRequestStatusCheck(status: "COMPLETED", conclusion: "SUCCESS", state: nil), + ] + ) + + let readiness = PullRequestMergeReadiness(pullRequest: pullRequest) + + #expect(readiness.blockingReason == .checksPending(2)) + #expect(readiness.label == "2 checks running") + } + @Test func mergeReadinessChecksFailedTakesPriorityOverPending() { // When there are both failed and in-progress checks, // failed should take priority