Import Coucou 0.1.9 (Louis-CFM/coucou@83708fe), Mac app only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
maksarsanjeev
2026-10-06 19:34:32 +03:00
commit 53b4f60da1
158 changed files with 29442 additions and 0 deletions
+346
View File
@@ -0,0 +1,346 @@
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
}
}
}
}
}
}
"""
}