Skip to content

Commit 0f5c2a2

Browse files
committed
fix: detect GitHub rate limit and show toast with 5-minute cooldown
When batchPRLookup hits a rate limit error from the GitHub GraphQL API, throw GhCliError.rateLimited instead of silently returning empty results. BoardStore catches this and: - Shows an error toast so the user knows PR lookups are paused - Sets a 5-minute cooldown (ghRateLimitedUntil) to avoid hammering the API - Also increases minimum active interval from 0s to 30s to reduce API pressure
1 parent e202d2c commit 0f5c2a2

File tree

2 files changed

+42
-10
lines changed

2 files changed

+42
-10
lines changed

Sources/KanbanCodeCore/Adapters/Git/GhCliAdapter.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,11 @@ public final class GhCliAdapter: PRTrackerPort, @unchecked Sendable {
269269
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
270270
let dataObj = root["data"] as? [String: Any],
271271
let repo = dataObj["repository"] as? [String: Any] else {
272+
let combined = result.stderr + result.stdout
273+
if combined.localizedCaseInsensitiveContains("rate limit") || combined.contains("RATE_LIMITED") {
274+
KanbanCodeLog.warn("gh", "batchPRLookup hit rate limit")
275+
throw GhCliError.rateLimited
276+
}
272277
KanbanCodeLog.warn("gh", "batchPRLookup GraphQL failed: \(result.stderr.prefix(200))")
273278
return ([:], [:])
274279
}
@@ -373,6 +378,17 @@ public final class GhCliAdapter: PRTrackerPort, @unchecked Sendable {
373378
}
374379
}
375380

381+
public enum GhCliError: Error, LocalizedError {
382+
case rateLimited
383+
384+
public var errorDescription: String? {
385+
switch self {
386+
case .rateLimited:
387+
return "GitHub API rate limit exceeded — pausing PR lookups for 5 minutes"
388+
}
389+
}
390+
}
391+
376392
/// A GitHub issue for the backlog.
377393
public struct GitHubIssue: Identifiable, Sendable {
378394
public var id: Int { number }

Sources/KanbanCodeCore/UseCases/BoardStore.swift

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ public enum Reducer {
285285
}
286286
link.tmuxLink = TmuxLink(sessionName: tmuxName, extraSessions: extras.isEmpty ? nil : extras)
287287
link.column = .inProgress
288+
link.manualOverrides.column = false // Let automatic assignment take over
288289
link.isLaunching = true
289290
link.updatedAt = .now
290291
state.links[cardId] = link
@@ -303,6 +304,7 @@ public enum Reducer {
303304
}
304305
link.tmuxLink = TmuxLink(sessionName: tmuxName, extraSessions: extras.isEmpty ? nil : extras)
305306
link.column = .inProgress
307+
link.manualOverrides.column = false // Let automatic assignment take over
306308
link.isLaunching = true
307309
link.updatedAt = .now
308310
state.links[cardId] = link
@@ -882,6 +884,7 @@ public final class BoardStore: @unchecked Sendable {
882884
// Dependencies for reconciliation
883885
private var isReconciling = false
884886
private var lastGHLookup: ContinuousClock.Instant = .now - .seconds(600)
887+
private var ghRateLimitedUntil: ContinuousClock.Instant = .now
885888
public var appIsActive: Bool = true
886889
private let discovery: SessionDiscovery
887890
private let coordinationStore: CoordinationStore
@@ -1082,15 +1085,16 @@ public final class BoardStore: @unchecked Sendable {
10821085
}
10831086

10841087
// Fetch PR data via targeted GraphQL — concurrent across repos (max 5)
1085-
// Throttle: every reconcile cycle when active, every 5min when backgrounded/hidden.
1086-
let ghInterval: Duration = appIsActive ? .seconds(0) : .seconds(300)
1088+
// Throttle: 30s when active, 5min when backgrounded/hidden, 5min after rate limit.
1089+
let ghInterval: Duration = ghRateLimitedUntil > .now ? .seconds(300)
1090+
: appIsActive ? .seconds(30) : .seconds(300)
10871091
let shouldFetchPRs = ContinuousClock.now - lastGHLookup >= ghInterval
10881092
var pullRequests: [String: PullRequest] = [:] // branch → PR for reconciler
10891093
var prsByRepoAndNumber: [String: [Int: PullRequest]] = [:] // repo → number → PR
10901094
if let ghAdapter, shouldFetchPRs {
10911095
let t = ContinuousClock.now
10921096
let allRepos = Set(branchesByRepo.keys).union(prNumbersByRepo.keys)
1093-
typealias PRResult = (String, [String: PullRequest], [Int: PullRequest])
1097+
typealias PRResult = (String, [String: PullRequest], [Int: PullRequest], Bool)
10941098
let results: [PRResult] = await withTaskGroup(of: PRResult.self) { group in
10951099
var pending = 0
10961100
var collected: [PRResult] = []
@@ -1107,27 +1111,39 @@ public final class BoardStore: @unchecked Sendable {
11071111

11081112
group.addTask {
11091113
let tBatch = ContinuousClock.now
1110-
let (byBranch, byNumber) = (try? await ghAdapter.batchPRLookup(
1111-
repoRoot: repoRoot, branches: branches, prNumbers: numbers
1112-
)) ?? ([:], [:])
1113-
let repoName = (repoRoot as NSString).lastPathComponent
1114-
KanbanCodeLog.info("reconcile", " batchPRLookup(\(repoName)): \(tBatch.duration(to: .now)) (\(branches.count) branches, \(numbers.count) PRs)")
1115-
return (repoRoot, byBranch, byNumber)
1114+
do {
1115+
let (byBranch, byNumber) = try await ghAdapter.batchPRLookup(
1116+
repoRoot: repoRoot, branches: branches, prNumbers: numbers
1117+
)
1118+
let repoName = (repoRoot as NSString).lastPathComponent
1119+
KanbanCodeLog.info("reconcile", " batchPRLookup(\(repoName)): \(tBatch.duration(to: .now)) (\(branches.count) branches, \(numbers.count) PRs)")
1120+
return (repoRoot, byBranch, byNumber, false)
1121+
} catch is GhCliError {
1122+
return (repoRoot, [:], [:], true)
1123+
} catch {
1124+
return (repoRoot, [:], [:], false)
1125+
}
11161126
}
11171127
pending += 1
11181128
}
11191129
for await result in group { collected.append(result) }
11201130
return collected
11211131
}
11221132

1123-
for (repoRoot, byBranch, byNumber) in results {
1133+
var hitRateLimit = false
1134+
for (repoRoot, byBranch, byNumber, rateLimited) in results {
1135+
if rateLimited { hitRateLimit = true }
11241136
for (branch, pr) in byBranch {
11251137
pullRequests[branch] = pr
11261138
}
11271139
if !byNumber.isEmpty {
11281140
prsByRepoAndNumber[repoRoot] = byNumber
11291141
}
11301142
}
1143+
if hitRateLimit {
1144+
ghRateLimitedUntil = .now + .seconds(300)
1145+
dispatch(.setError("GitHub API rate limit exceeded — pausing PR lookups for 5 minutes"))
1146+
}
11311147
let totalByNumber = prsByRepoAndNumber.values.reduce(0) { $0 + $1.count }
11321148
KanbanCodeLog.info("reconcile", "PR lookup: \(t.duration(to: .now)) (\(pullRequests.count) by branch, \(totalByNumber) by number, \(allRepos.count) repos)")
11331149
lastGHLookup = .now

0 commit comments

Comments
 (0)