53b4f60da1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
245 lines
9.6 KiB
Swift
245 lines
9.6 KiB
Swift
import Foundation
|
|
|
|
// MARK: - CIState
|
|
|
|
enum CIState: String, Equatable {
|
|
case pending, success, failure, unknown
|
|
|
|
init(rawGitHub: String?) {
|
|
guard let s = rawGitHub else { self = .unknown; return }
|
|
switch s.uppercased() {
|
|
case "PENDING", "EXPECTED": self = .pending
|
|
case "SUCCESS": self = .success
|
|
case "ERROR", "FAILURE": self = .failure
|
|
default: self = .unknown
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - ReviewState
|
|
|
|
enum ReviewState: String, Equatable {
|
|
case approved, changesRequested, pending, unknown
|
|
|
|
init(rawGitHub: String?) {
|
|
guard let s = rawGitHub else { self = .unknown; return }
|
|
switch s.uppercased() {
|
|
case "APPROVED": self = .approved
|
|
case "CHANGES_REQUESTED": self = .changesRequested
|
|
case "REVIEW_REQUIRED": self = .pending
|
|
default: self = .unknown
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - GitHubPR
|
|
|
|
struct GitHubPR: Equatable {
|
|
var id: String // "owner/repo#num"
|
|
var title: String
|
|
var url: String
|
|
var repo: String
|
|
var number: Int
|
|
var isDraft: Bool
|
|
var ci: CIState
|
|
var review: ReviewState
|
|
var headSha: String? = nil // oid of last commit; nil if not fetched
|
|
}
|
|
|
|
// MARK: - GitHubRepoCI
|
|
|
|
struct GitHubRepoCI: Equatable {
|
|
var repo: String
|
|
var url: String
|
|
var branch: String
|
|
var ci: CIState
|
|
var headSha: String? = nil // oid of HEAD commit; nil if not fetched
|
|
}
|
|
|
|
// MARK: - GitHubDetailSection
|
|
|
|
enum GitHubDetailSection { case myPRs, toReview, mainCI, activity }
|
|
|
|
// MARK: - GitHubEvent
|
|
|
|
enum GitHubEvent: Equatable {
|
|
case ciFailed(prId: String)
|
|
case ciPassed(prId: String)
|
|
case mainFailed(repo: String)
|
|
case reviewRequested(prId: String)
|
|
}
|
|
|
|
// MARK: - GitHubPulse
|
|
|
|
struct GitHubPulse: Equatable {
|
|
var login: String
|
|
var myPRs: [GitHubPR]
|
|
var toReview: [GitHubPR]
|
|
var mainCI: [GitHubRepoCI]
|
|
var fetchedAt: Date
|
|
|
|
/// True when at least one PR CI or default-branch CI is pending — triggers 60 s poll cadence.
|
|
var hasPending: Bool {
|
|
myPRs.contains { $0.ci == .pending } ||
|
|
mainCI.contains { $0.ci == .pending }
|
|
}
|
|
|
|
// MARK: - Parse
|
|
|
|
static func parse(_ data: Data) -> GitHubPulse? {
|
|
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let dataNode = root["data"] as? [String: Any],
|
|
let viewer = dataNode["viewer"] as? [String: Any] else { return nil }
|
|
|
|
let login = viewer["login"] as? String ?? ""
|
|
|
|
// My PRs
|
|
var myPRs: [GitHubPR] = []
|
|
if let prConn = viewer["pullRequests"] as? [String: Any],
|
|
let nodes = prConn["nodes"] as? [[String: Any]] {
|
|
var seenPR = Set<String>()
|
|
for node in nodes {
|
|
guard let number = node["number"] as? Int,
|
|
let title = node["title"] as? String,
|
|
let url = node["url"] as? String,
|
|
let repoNode = node["repository"] as? [String: Any],
|
|
let repo = repoNode["nameWithOwner"] as? String else { continue }
|
|
let id = "\(repo)#\(number)"
|
|
guard seenPR.insert(id).inserted else { continue } // skip duplicates
|
|
let isDraft = node["isDraft"] as? Bool ?? false
|
|
let reviewDecision = node["reviewDecision"] as? String
|
|
let (ciRaw, headSha): (String?, String?) = {
|
|
guard let commits = node["commits"] as? [String: Any],
|
|
let cNodes = commits["nodes"] as? [[String: Any]],
|
|
let last = cNodes.last,
|
|
let commit = last["commit"] as? [String: Any] else { return (nil, nil) }
|
|
let rollup = commit["statusCheckRollup"] as? [String: Any]
|
|
return (rollup?["state"] as? String, commit["oid"] as? String)
|
|
}()
|
|
myPRs.append(GitHubPR(
|
|
id: id, title: title, url: url, repo: repo, number: number,
|
|
isDraft: isDraft, ci: CIState(rawGitHub: ciRaw),
|
|
review: ReviewState(rawGitHub: reviewDecision), headSha: headSha
|
|
))
|
|
}
|
|
}
|
|
|
|
// Main CI for recently-pushed repos (client-side filter: skip archived)
|
|
var mainCI: [GitHubRepoCI] = []
|
|
if let repoConn = viewer["repositories"] as? [String: Any],
|
|
let nodes = repoConn["nodes"] as? [[String: Any]] {
|
|
for node in nodes {
|
|
let isArchived = node["isArchived"] as? Bool ?? false
|
|
if isArchived { continue }
|
|
guard let repo = node["nameWithOwner"] as? String,
|
|
let url = node["url"] as? String,
|
|
let branchRef = node["defaultBranchRef"] as? [String: Any],
|
|
let branch = branchRef["name"] as? String else { continue }
|
|
let (ciRaw, headSha): (String?, String?) = {
|
|
guard let target = branchRef["target"] as? [String: Any] else { return (nil, nil) }
|
|
let rollup = target["statusCheckRollup"] as? [String: Any]
|
|
return (rollup?["state"] as? String, target["oid"] as? String)
|
|
}()
|
|
mainCI.append(GitHubRepoCI(repo: repo, url: url, branch: branch,
|
|
ci: CIState(rawGitHub: ciRaw), headSha: headSha))
|
|
}
|
|
}
|
|
|
|
// To review
|
|
var toReview: [GitHubPR] = []
|
|
if let searchResult = dataNode["reviewRequested"] as? [String: Any],
|
|
let nodes = searchResult["nodes"] as? [[String: Any]] {
|
|
var seenReview = Set<String>()
|
|
for node in nodes {
|
|
guard let number = node["number"] as? Int,
|
|
let title = node["title"] as? String,
|
|
let url = node["url"] as? String,
|
|
let repoNode = node["repository"] as? [String: Any],
|
|
let repo = repoNode["nameWithOwner"] as? String else { continue }
|
|
let id = "\(repo)#\(number)"
|
|
guard seenReview.insert(id).inserted else { continue } // skip duplicates
|
|
let isDraft = node["isDraft"] as? Bool ?? false
|
|
toReview.append(GitHubPR(
|
|
id: id,
|
|
title: title,
|
|
url: url,
|
|
repo: repo,
|
|
number: number,
|
|
isDraft: isDraft,
|
|
ci: .unknown,
|
|
review: .pending
|
|
))
|
|
}
|
|
}
|
|
|
|
return GitHubPulse(login: login, myPRs: myPRs, toReview: toReview,
|
|
mainCI: mainCI, fetchedAt: Date())
|
|
}
|
|
|
|
// MARK: - Events
|
|
|
|
/// Returns events comparing old → new. If old is nil (first poll after launch) returns empty — no
|
|
/// alerts on initial load, only on subsequent changes.
|
|
///
|
|
/// headSha logic (catches fast CIs missed between polls):
|
|
/// - Same SHA (or both nil): classic transition rules apply.
|
|
/// - Different SHA or PR/repo absent in old: fire immediately if CI is already done.
|
|
/// If still pending, nothing — next poll with the same SHA will catch the result.
|
|
/// - Main CI: no "green" alert, only .mainFailed on new failure.
|
|
static func events(old: GitHubPulse?, new: GitHubPulse) -> [GitHubEvent] {
|
|
guard let old else { return [] }
|
|
|
|
var result: [GitHubEvent] = []
|
|
|
|
// PR CI transitions
|
|
let oldPRmap = Dictionary(old.myPRs.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
|
for pr in new.myPRs {
|
|
if let prev = oldPRmap[pr.id], prev.headSha == pr.headSha {
|
|
// Same commit: classic state-transition rules
|
|
if pr.ci == .failure && prev.ci != .failure {
|
|
result.append(.ciFailed(prId: pr.id))
|
|
} else if pr.ci == .success && prev.ci == .pending {
|
|
result.append(.ciPassed(prId: pr.id))
|
|
}
|
|
} else {
|
|
// New PR or new commit: alert if CI already finished
|
|
if pr.ci == .success { result.append(.ciPassed(prId: pr.id)) }
|
|
else if pr.ci == .failure { result.append(.ciFailed(prId: pr.id)) }
|
|
// pending → nothing; same-SHA rule catches it next poll
|
|
}
|
|
}
|
|
|
|
// Default-branch CI
|
|
let oldRepoMap = Dictionary(old.mainCI.map { ($0.repo, $0) }, uniquingKeysWith: { a, _ in a })
|
|
for repo in new.mainCI {
|
|
if let prev = oldRepoMap[repo.repo], prev.headSha == repo.headSha {
|
|
// Same commit: classic rule (failure transition only)
|
|
if repo.ci == .failure && prev.ci != .failure {
|
|
result.append(.mainFailed(repo: repo.repo))
|
|
}
|
|
} else {
|
|
// New repo or new commit: only alert on failure (no "green" event for main)
|
|
if repo.ci == .failure { result.append(.mainFailed(repo: repo.repo)) }
|
|
}
|
|
}
|
|
|
|
// New review requests
|
|
let oldReviewIds = Set(old.toReview.map { $0.id })
|
|
for pr in new.toReview {
|
|
if !oldReviewIds.contains(pr.id) {
|
|
result.append(.reviewRequested(prId: pr.id))
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// MARK: - Staleness
|
|
|
|
/// Pure predicate: true when fetchedAt is nil or older than maxAge seconds before now.
|
|
static func isStale(fetchedAt: Date?, now: Date = Date(), maxAge: TimeInterval) -> Bool {
|
|
guard let t = fetchedAt else { return true }
|
|
return now.timeIntervalSince(t) > maxAge
|
|
}
|
|
}
|