Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion MonitorLizard/Models/BuildStatus.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import SwiftUI

enum BuildStatus: String, Codable, Hashable {
enum BuildStatus: String, Codable, Hashable, CaseIterable {
case conflict
case notStarted
case pending
case success
case failure
Expand All @@ -12,6 +13,7 @@ enum BuildStatus: String, Codable, Hashable {
var icon: String {
switch self {
case .conflict: return "❗"
case .notStarted: return "🛑"
case .success: return "✅"
case .failure: return "❌"
case .error: return "⚠️"
Expand All @@ -21,9 +23,20 @@ enum BuildStatus: String, Codable, Hashable {
}
}

var systemImageName: String? {
switch self {
case .notStarted: return "play.slash"
case .pending: return "gear"
case .success: return "gear.badge.checkmark"
case .failure, .error: return "gear.badge.xmark"
case .conflict, .unknown, .inactive: return nil
}
}

var color: Color {
switch self {
case .conflict: return .purple
case .notStarted: return .gray
case .success: return .green
case .failure: return .red
case .error: return .orange
Expand All @@ -36,6 +49,7 @@ enum BuildStatus: String, Codable, Hashable {
var displayName: String {
switch self {
case .conflict: return "Merge Conflict"
case .notStarted: return "Not started"
case .success: return "Success"
case .failure: return "Failed"
case .error: return "Error"
Expand Down
191 changes: 160 additions & 31 deletions MonitorLizard/Models/PullRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,93 @@ struct PullRequest: Identifiable, Hashable {
}
}

enum NonBlockingCheckState: Hashable {
case failed
case waitingForApproval
case running
case queued
case pending
case passed
}

struct NonBlockingCheckSummary: Hashable {
struct Segment: Identifiable, Hashable {
let state: NonBlockingCheckState
let count: Int

var id: NonBlockingCheckState { state }

var text: String {
switch state {
case .failed:
return count == 1 ? "1 failed" : "\(count) failed"
case .waitingForApproval:
return count == 1 ? "1 waiting for approval" : "\(count) waiting for approval"
case .running:
return count == 1 ? "1 running" : "\(count) running"
case .queued:
return count == 1 ? "1 queued" : "\(count) queued"
case .pending:
return count == 1 ? "1 pending" : "\(count) pending"
case .passed:
return count == 1 ? "1 passed" : "\(count) passed"
}
}
}

let segments: [Segment]
}

extension PullRequest {
var nonBlockingCheckSummary: NonBlockingCheckSummary? {
let nonBlockingChecks = statusChecks.filter(\.isNonBlocking)
guard !nonBlockingChecks.isEmpty else { return nil }

let counts = Dictionary(grouping: nonBlockingChecks, by: nonBlockingCheckState(for:))
.mapValues(\.count)
let needsAttention = [
NonBlockingCheckState.failed,
.waitingForApproval,
.running,
.queued,
.pending,
].contains { (counts[$0] ?? 0) > 0 }
guard needsAttention else { return nil }

let segments = [
NonBlockingCheckState.failed,
.waitingForApproval,
.running,
.queued,
.pending,
].compactMap { state -> NonBlockingCheckSummary.Segment? in
guard let count = counts[state], count > 0 else { return nil }
return NonBlockingCheckSummary.Segment(state: state, count: count)
}

return NonBlockingCheckSummary(segments: segments)
}

private func nonBlockingCheckState(for check: StatusCheck) -> NonBlockingCheckState {
switch check.status {
case .failure, .error:
return .failed
case .waiting:
return .waitingForApproval
case .running:
return .running
case .queued:
return .queued
case .pending:
return .pending
case .success:
return .passed
case .skipped:
return .passed
}
}
}

/// Identifies a specific PR for use in batch status requests.
struct PRStatusRequest: Hashable {
let owner: String
Expand Down Expand Up @@ -125,56 +212,51 @@ struct GHPRSearchResponse: Codable {
}
}

/// Combined response for `gh pr view --json number,title,url,author,updatedAt,labels,isDraft,headRefName,statusCheckRollup,mergeable,mergeStateStatus,reviewDecision,latestReviews,reviewRequests,state`
struct GHPRViewResponse: Codable {
let number: Int
let title: String
let url: String
let author: Author
let updatedAt: String
let labels: [Label]
let isDraft: Bool
let headRefName: String
let statusCheckRollup: [GHPRDetailResponse.StatusCheck]?
let mergeable: String?
let mergeStateStatus: String?
let reviewDecision: String?
let latestReviews: [GHPRDetailResponse.Review]?
let reviewRequests: [GHPRDetailResponse.ReviewRequest]?
let state: String

struct Author: Codable {
let login: String
}

struct Label: Codable {
let id: String?
let name: String
let color: String
}
}

/// Response structure for a single PR node inside a `gh api graphql` batch query.
/// Unlike GHPRDetailResponse (used with `gh pr view --json`), review connections use
/// `{ nodes: [...] }` format as returned by the raw GraphQL API.
struct BatchPRStatusResponse: Codable {
let number: Int?
let title: String?
let url: String?
let author: Author?
let updatedAt: String?
let labels: LabelConnection?
let isDraft: Bool?
let state: String?
let headRefName: String
let statusCheckRollup: StatusCheckRollupWrapper?
let mergeable: String?
let mergeStateStatus: String?
let reviewDecision: String?
let latestReviews: ReviewConnection?
let reviewRequests: ReviewRequestConnection?
let baseRef: BaseRef?

/// Wraps the raw GraphQL `statusCheckRollup { contexts { nodes [...] } }` shape.
struct StatusCheckRollupWrapper: Codable {
let state: String?
let contexts: Contexts?

struct Contexts: Codable {
let nodes: [GHPRDetailResponse.StatusCheck]?
}
}

struct Author: Codable {
let login: String
}

struct LabelConnection: Codable {
let nodes: [Label]?

struct Label: Codable {
let id: String
let name: String
let color: String
}
}

struct ReviewConnection: Codable {
let nodes: [GHPRDetailResponse.Review]?
}
Expand All @@ -191,6 +273,26 @@ struct BatchPRStatusResponse: Codable {
}
}

struct BaseRef: Codable {
let branchProtectionRule: BranchProtectionRule?
}

struct BranchProtectionRule: Codable {
let requiredStatusCheckContexts: [String]?
let requiredStatusChecks: [RequiredStatusCheck]?

struct RequiredStatusCheck: Codable {
let context: String
}
}

var requiredStatusCheckContexts: [String]? {
guard let rule = baseRef?.branchProtectionRule else { return nil }
let contexts = rule.requiredStatusCheckContexts ?? []
let checkContexts = rule.requiredStatusChecks?.map(\.context) ?? []
return Array(Set(contexts + checkContexts))
}

/// Converts to GHPRDetailResponse so existing status-parsing logic can be reused.
func toDetailResponse() -> GHPRDetailResponse {
let flatRequests = reviewRequests?.nodes?.map {
Expand All @@ -199,11 +301,13 @@ struct BatchPRStatusResponse: Codable {
return GHPRDetailResponse(
headRefName: headRefName,
statusCheckRollup: statusCheckRollup?.contexts?.nodes,
statusCheckRollupState: statusCheckRollup?.state,
mergeable: mergeable,
mergeStateStatus: mergeStateStatus,
reviewDecision: reviewDecision,
latestReviews: latestReviews?.nodes,
reviewRequests: flatRequests
reviewRequests: flatRequests,
requiredStatusCheckContexts: requiredStatusCheckContexts
)
}
}
Expand All @@ -221,11 +325,35 @@ struct BatchGraphQLResponse: Codable {
struct GHPRDetailResponse: Codable {
let headRefName: String
let statusCheckRollup: [StatusCheck]?
let statusCheckRollupState: String?
let mergeable: String?
let mergeStateStatus: String?
let reviewDecision: String?
let latestReviews: [Review]?
let reviewRequests: [ReviewRequest]?
let requiredStatusCheckContexts: [String]?

init(
headRefName: String,
statusCheckRollup: [StatusCheck]?,
statusCheckRollupState: String? = nil,
mergeable: String?,
mergeStateStatus: String?,
reviewDecision: String?,
latestReviews: [Review]?,
reviewRequests: [ReviewRequest]?,
requiredStatusCheckContexts: [String]? = nil
) {
self.headRefName = headRefName
self.statusCheckRollup = statusCheckRollup
self.statusCheckRollupState = statusCheckRollupState
self.mergeable = mergeable
self.mergeStateStatus = mergeStateStatus
self.reviewDecision = reviewDecision
self.latestReviews = latestReviews
self.reviewRequests = reviewRequests
self.requiredStatusCheckContexts = requiredStatusCheckContexts
}

struct Review: Codable {
let author: ReviewAuthor?
Expand All @@ -249,9 +377,10 @@ struct GHPRDetailResponse: Codable {
let __typename: String
let detailsUrl: String?
let targetUrl: String?
let isRequired: Bool?

private enum CodingKeys: String, CodingKey {
case name, context, status, state, conclusion, __typename, detailsUrl, targetUrl
case name, context, status, state, conclusion, __typename, detailsUrl, targetUrl, isRequired
}
}
}
21 changes: 20 additions & 1 deletion MonitorLizard/Models/StatusCheck.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import SwiftUI

enum CheckStatus: String, Codable {
case pending
case queued
case running
case waiting
case success
case failure
case error
Expand All @@ -18,6 +21,12 @@ enum CheckStatus: String, Codable {
switch self {
case .pending:
return "⏳"
case .queued:
return "⏳"
case .running:
return "🔄"
case .waiting:
return "⏸"
case .success:
return "✅"
case .failure:
Expand All @@ -33,6 +42,12 @@ enum CheckStatus: String, Codable {
switch self {
case .pending:
return .orange
case .queued:
return .blue
case .running:
return .blue
case .waiting:
return .orange
case .success:
return .green
case .failure:
Expand All @@ -50,11 +65,15 @@ struct StatusCheck: Identifiable, Codable, Hashable {
let name: String
let status: CheckStatus
let detailsUrl: String?
let isRequired: Bool?
let isNonBlocking: Bool

init(id: String, name: String, status: CheckStatus, detailsUrl: String?) {
init(id: String, name: String, status: CheckStatus, detailsUrl: String?, isRequired: Bool? = nil, isNonBlocking: Bool = false) {
self.id = id
self.name = name
self.status = status
self.detailsUrl = detailsUrl
self.isRequired = isRequired
self.isNonBlocking = isNonBlocking
}
}
Loading