53b4f60da1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
347 lines
14 KiB
Swift
347 lines
14 KiB
Swift
import Foundation
|
|
|
|
final class GithubPoller: @unchecked Sendable {
|
|
static let shared = GithubPoller()
|
|
private var timer: DispatchSourceTimer?
|
|
// All properties below are accessed only on the main thread.
|
|
private var pulseInFlight = false
|
|
private var nextPulse: DispatchWorkItem?
|
|
private var tokenGeneration = 0
|
|
private var activityInFlight = false
|
|
private var nextActivity: DispatchWorkItem?
|
|
private init() {}
|
|
|
|
func start() {
|
|
guard timer == nil else { return }
|
|
// Stats poll: every 5 minutes, starting 7 s after launch
|
|
let t = DispatchSource.makeTimerSource(queue: .global(qos: .background))
|
|
t.schedule(deadline: .now() + 7, repeating: 300)
|
|
t.setEventHandler { [weak self] in self?.pollStats() }
|
|
t.resume()
|
|
timer = t
|
|
// First pulse: 10 s after launch
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.scheduleNextPulse(hasPending: false, delay: 10)
|
|
}
|
|
// First activity: 15 s after launch, then every 30 min
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.scheduleNextActivity(delay: 15)
|
|
}
|
|
}
|
|
|
|
/// Pull requests, CI and activity are fetched when the GitHub pill is in
|
|
/// the notch, or when the iPhone sync is on (the iPhone shows GitHub even
|
|
/// when its pill isn't in the notch).
|
|
@MainActor private static var isWanted: Bool {
|
|
if AppState.shared.activeIntegrations.contains("integration_github") { return true }
|
|
#if PHONE_LINK
|
|
return UserDefaults.standard.bool(forKey: "iPhoneSyncEnabled")
|
|
#else
|
|
return false
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Stats (unchanged logic)
|
|
|
|
private func pollStats() {
|
|
guard let token = KeychainStore.shared.get("github-token") else { return }
|
|
fetchUser(token: token)
|
|
}
|
|
|
|
private func fetchUser(token: String) {
|
|
guard let url = URL(string: "https://api.github.com/user") else { return }
|
|
var req = URLRequest(url: url, timeoutInterval: 10)
|
|
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
req.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
|
|
|
|
URLSession.shared.dataTask(with: req) { [weak self] data, response, _ in
|
|
guard let self else { return }
|
|
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
guard let data, code == 200,
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
|
|
|
let publicRepos = (json["public_repos"] as? Int) ?? 0
|
|
let privateOwned = (json["owned_private_repos"] as? Int)
|
|
?? (json["total_private_repos"] as? Int)
|
|
?? 0
|
|
self.fetchStars(token: token, totalRepos: publicRepos + privateOwned)
|
|
}.resume()
|
|
}
|
|
|
|
private func fetchStars(token: String, totalRepos: Int) {
|
|
guard let url = URL(string: "https://api.github.com/user/repos?per_page=100&affiliation=owner&sort=pushed") else { return }
|
|
var req = URLRequest(url: url, timeoutInterval: 15)
|
|
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
req.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
|
|
|
|
URLSession.shared.dataTask(with: req) { data, response, _ in
|
|
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
guard let data, code == 200,
|
|
let repos = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return }
|
|
|
|
let totalStars = repos.reduce(0) { $0 + (($1["stargazers_count"] as? Int) ?? 0) }
|
|
DispatchQueue.main.async {
|
|
AppState.shared.githubStats = GitHubStats(totalRepos: totalRepos, totalStars: totalStars)
|
|
}
|
|
}.resume()
|
|
}
|
|
|
|
// MARK: - Pulse (GraphQL, single chain)
|
|
|
|
/// Dispatches guards + state reads to main, then fires network on background.
|
|
private func pollPulse() {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self, !self.pulseInFlight else { return }
|
|
guard let token = KeychainStore.shared.get("github-token"),
|
|
Self.isWanted else {
|
|
self.scheduleNextPulse(hasPending: false)
|
|
return
|
|
}
|
|
self.pulseInFlight = true
|
|
let gen = self.tokenGeneration
|
|
DispatchQueue.global(qos: .background).async { self.fetchPulse(token: token, generation: gen) }
|
|
}
|
|
}
|
|
|
|
private func fetchPulse(token: String, generation: Int) {
|
|
guard let url = URL(string: "https://api.github.com/graphql") else {
|
|
finishPulse(hasPending: false); return
|
|
}
|
|
var req = URLRequest(url: url, timeoutInterval: 15)
|
|
req.httpMethod = "POST"
|
|
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
guard let body = try? JSONSerialization.data(withJSONObject: ["query": Self.graphQLQuery]) else {
|
|
finishPulse(hasPending: false); return
|
|
}
|
|
req.httpBody = body
|
|
|
|
URLSession.shared.dataTask(with: req) { [weak self] data, response, _ in
|
|
guard let self else { return }
|
|
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
guard let data, code == 200 else {
|
|
self.nbLog("pulse HTTP \(code)")
|
|
self.finishPulse(hasPending: false)
|
|
return
|
|
}
|
|
// Partial GraphQL errors: if "data" is present, parse anyway and log error count.
|
|
// Only discard if "data" is absent or null.
|
|
if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
if let errors = root["errors"] as? [[String: Any]], !errors.isEmpty {
|
|
self.nbLog("pulse GraphQL errors: \(errors.count)")
|
|
}
|
|
guard root["data"] is [String: Any] else {
|
|
self.finishPulse(hasPending: false); return
|
|
}
|
|
}
|
|
guard let pulse = GitHubPulse.parse(data) else {
|
|
self.finishPulse(hasPending: false); return
|
|
}
|
|
DispatchQueue.main.async {
|
|
// Discard stale response if token changed while request was in flight
|
|
guard self.tokenGeneration == generation else { return }
|
|
let old = AppState.shared.githubPulse
|
|
let events = GitHubPulse.events(old: old, new: pulse)
|
|
AppState.shared.githubPulse = pulse
|
|
// Badge and sound only for the pill in the notch, not when the
|
|
// fetch only feeds the iPhone.
|
|
if AppState.shared.activeIntegrations.contains("integration_github") {
|
|
AppState.shared.handleGitHubEvents(events)
|
|
}
|
|
}
|
|
self.finishPulse(hasPending: pulse.hasPending)
|
|
}.resume()
|
|
}
|
|
|
|
/// Refreshes pulse data immediately if the last fetch is older than maxAge seconds (or absent).
|
|
/// No-op when a request is already in flight. Safe to call from any thread.
|
|
func refreshIfStale(maxAge: TimeInterval = 60) {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self, !self.pulseInFlight else { return }
|
|
guard GitHubPulse.isStale(fetchedAt: AppState.shared.githubPulse?.fetchedAt,
|
|
maxAge: maxAge) else { return }
|
|
self.nextPulse?.cancel()
|
|
self.nextPulse = nil
|
|
self.pollPulse()
|
|
}
|
|
}
|
|
|
|
/// Cancels any scheduled next poll, increments tokenGeneration to invalidate in-flight
|
|
/// responses, then fires pollPulse immediately. If a request is already in flight,
|
|
/// does not launch another — finishPulse will reschedule.
|
|
/// Safe to call from any thread.
|
|
func triggerPulseNow() {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.tokenGeneration += 1
|
|
self.nextPulse?.cancel()
|
|
self.nextPulse = nil
|
|
guard !self.pulseInFlight else { return }
|
|
self.pollPulse()
|
|
}
|
|
}
|
|
|
|
private func nbLog(_ message: String) {
|
|
appendAppLog("github.log", message)
|
|
}
|
|
|
|
/// Called from any thread; dispatches cleanup to main then schedules next poll.
|
|
private func finishPulse(hasPending: Bool) {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.pulseInFlight = false
|
|
self.scheduleNextPulse(hasPending: hasPending)
|
|
}
|
|
}
|
|
|
|
/// Cancels any pending scheduled poll and schedules a new one on the main queue.
|
|
/// Must run on the main thread.
|
|
private func scheduleNextPulse(hasPending: Bool, delay: Double? = nil) {
|
|
nextPulse?.cancel()
|
|
let work = DispatchWorkItem { [weak self] in self?.pollPulse() }
|
|
nextPulse = work
|
|
let d = delay ?? (hasPending ? 60.0 : 300.0)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + d, execute: work)
|
|
}
|
|
|
|
// MARK: - Activity (contribution calendar, 30 min cadence)
|
|
|
|
private func pollActivity() {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self, !self.activityInFlight else { return }
|
|
guard let token = KeychainStore.shared.get("github-token"),
|
|
Self.isWanted else {
|
|
self.scheduleNextActivity()
|
|
return
|
|
}
|
|
self.activityInFlight = true
|
|
let gen = self.tokenGeneration
|
|
DispatchQueue.global(qos: .background).async { self.fetchActivity(token: token, generation: gen) }
|
|
}
|
|
}
|
|
|
|
private func fetchActivity(token: String, generation: Int) {
|
|
guard let url = URL(string: "https://api.github.com/graphql") else {
|
|
finishActivity(); return
|
|
}
|
|
var req = URLRequest(url: url, timeoutInterval: 15)
|
|
req.httpMethod = "POST"
|
|
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
guard let body = try? JSONSerialization.data(withJSONObject: ["query": Self.activityQuery]) else {
|
|
finishActivity(); return
|
|
}
|
|
req.httpBody = body
|
|
|
|
URLSession.shared.dataTask(with: req) { [weak self] data, response, _ in
|
|
guard let self else { return }
|
|
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
guard let data, code == 200 else {
|
|
self.nbLog("activity HTTP \(code)")
|
|
self.finishActivity()
|
|
return
|
|
}
|
|
if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
if let errors = root["errors"] as? [[String: Any]], !errors.isEmpty {
|
|
self.nbLog("activity GraphQL errors: \(errors.count)")
|
|
}
|
|
guard root["data"] is [String: Any] else {
|
|
self.finishActivity(); return
|
|
}
|
|
}
|
|
guard let activity = GitHubActivity.parse(data) else {
|
|
self.finishActivity(); return
|
|
}
|
|
DispatchQueue.main.async {
|
|
guard self.tokenGeneration == generation else { return }
|
|
AppState.shared.githubActivity = activity
|
|
}
|
|
self.finishActivity()
|
|
}.resume()
|
|
}
|
|
|
|
/// Refreshes activity data immediately if stale. Safe to call from any thread.
|
|
func refreshActivityIfStale(maxAge: TimeInterval = 300) {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self, !self.activityInFlight else { return }
|
|
guard GitHubPulse.isStale(fetchedAt: AppState.shared.githubActivity?.fetchedAt,
|
|
maxAge: maxAge) else { return }
|
|
self.nextActivity?.cancel()
|
|
self.nextActivity = nil
|
|
self.pollActivity()
|
|
}
|
|
}
|
|
|
|
private func finishActivity() {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.activityInFlight = false
|
|
self.scheduleNextActivity()
|
|
}
|
|
}
|
|
|
|
private func scheduleNextActivity(delay: Double? = nil) {
|
|
nextActivity?.cancel()
|
|
let work = DispatchWorkItem { [weak self] in self?.pollActivity() }
|
|
nextActivity = work
|
|
let d = delay ?? 1800.0 // 30 minutes
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + d, execute: work)
|
|
}
|
|
|
|
// MARK: - GraphQL queries
|
|
|
|
private static let graphQLQuery = """
|
|
query {
|
|
viewer {
|
|
login
|
|
pullRequests(states: OPEN, first: 20, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
nodes {
|
|
number title url isDraft reviewDecision
|
|
repository { nameWithOwner url }
|
|
commits(last: 1) {
|
|
nodes { commit { oid statusCheckRollup { state } } }
|
|
}
|
|
}
|
|
}
|
|
repositories(first: 10, ownerAffiliations: [OWNER], orderBy: {field: PUSHED_AT, direction: DESC}) {
|
|
nodes {
|
|
nameWithOwner url isArchived
|
|
defaultBranchRef {
|
|
name
|
|
target { ... on Commit { oid statusCheckRollup { state } } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
reviewRequested: search(query: "is:pr is:open review-requested:@me archived:false", type: ISSUE, first: 20) {
|
|
issueCount
|
|
nodes {
|
|
... on PullRequest {
|
|
number title url isDraft
|
|
author { login }
|
|
repository { nameWithOwner url }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
private static let activityQuery = """
|
|
query {
|
|
viewer {
|
|
login
|
|
contributionsCollection {
|
|
contributionCalendar {
|
|
totalContributions
|
|
weeks {
|
|
contributionDays {
|
|
date contributionCount contributionLevel weekday
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
}
|