Skip to content
Merged
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
126 changes: 126 additions & 0 deletions Packages/Sources/RxCodeCore/Models/AutopilotModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,129 @@ public struct AutopilotInstallUrlResponse: Codable, Sendable {
}
}

// MARK: - CI Status DTOs

/// One repo+branch lookup sent in the `POST /api/v1/ci-status/batch` body.
/// `branch` is optional; when nil autopilot reports the latest run on any
/// branch and echoes back which branch it matched.
public struct CIStatusQuery: Codable, Sendable, Hashable {
public let owner: String
public let repo: String
public let branch: String?

public init(owner: String, repo: String, branch: String?) {
self.owner = owner
self.repo = repo
self.branch = branch
}
}

public struct CIStatusBatchRequest: Codable, Sendable {
public let repos: [CIStatusQuery]

public init(repos: [CIStatusQuery]) {
self.repos = repos
}
}

/// Aggregated CI state for a repo+branch. Decodes leniently — unknown raw
/// values fall back to `.unknown` so a server-side addition never breaks
/// the client.
public enum CIOverallState: String, Codable, Sendable, Hashable {
case success
case failure
case pending
case unknown

public init(from decoder: Decoder) throws {
let raw = try decoder.singleValueContainer().decode(String.self)
self = CIOverallState(rawValue: raw) ?? .unknown
}
}

/// Latest run for a single workflow definition on the queried branch.
public struct CIWorkflowStatus: Codable, Sendable, Hashable {
public let workflowName: String?
public let workflowId: Int?
public let status: String?
public let conclusion: String?
public let htmlUrl: String?
public let runNumber: Int?
public let updatedAt: String?

public init(
workflowName: String?,
workflowId: Int?,
status: String?,
conclusion: String?,
htmlUrl: String?,
runNumber: Int?,
updatedAt: String?
) {
self.workflowName = workflowName
self.workflowId = workflowId
self.status = status
self.conclusion = conclusion
self.htmlUrl = htmlUrl
self.runNumber = runNumber
self.updatedAt = updatedAt
}
}

/// A failing workflow surfaced for quick linking in the UI / fix prompt.
public struct CIFailingWorkflow: Codable, Sendable, Hashable {
public let workflowName: String?
public let htmlUrl: String?

public init(workflowName: String?, htmlUrl: String?) {
self.workflowName = workflowName
self.htmlUrl = htmlUrl
}
}

/// Per-repo result from `POST /api/v1/ci-status/batch`.
public struct ProjectCIStatus: Codable, Sendable, Hashable {
public let owner: String
public let repo: String
public let branch: String?
public let found: Bool
public let overallState: CIOverallState
public let lastUpdated: String?
public let headSha: String?
public let prNumber: Int?
public let workflows: [CIWorkflowStatus]
public let failing: [CIFailingWorkflow]

public init(
owner: String,
repo: String,
branch: String?,
found: Bool,
overallState: CIOverallState,
lastUpdated: String?,
headSha: String?,
prNumber: Int?,
workflows: [CIWorkflowStatus],
failing: [CIFailingWorkflow]
) {
self.owner = owner
self.repo = repo
self.branch = branch
self.found = found
self.overallState = overallState
self.lastUpdated = lastUpdated
self.headSha = headSha
self.prNumber = prNumber
self.workflows = workflows
self.failing = failing
}
}

public struct CIStatusBatchResponse: Codable, Sendable {
public let results: [ProjectCIStatus]

public init(results: [ProjectCIStatus]) {
self.results = results
}
}

2 changes: 1 addition & 1 deletion RxCode.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1367,7 +1367,6 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = RxCode/RxCode.entitlements;
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = RxCode;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 24;
DEVELOPMENT_TEAM = P9KK452K8P;
Expand All @@ -1386,6 +1385,7 @@
MARKETING_VERSION = 1.8.0;
PRODUCT_BUNDLE_IDENTIFIER = com.rxlab.RxCode;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = RxCode;
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
Expand Down
218 changes: 218 additions & 0 deletions RxCode/App/AppState+CIStatus.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import Foundation
import RxCodeCore
import os

extension AppState {

/// How often the CI poller refreshes GitHub Actions status for open projects.
static let ciStatusPollInterval: TimeInterval = 10

private static let ciSignaturesDefaultsKey = "ciHandledFailureSignatures"

// MARK: - Poller lifecycle

/// Start (or restart) the periodic CI-status poll. Idempotent — a prior loop
/// is cancelled first. Safe to call before sign-in; ticks no-op until signed in.
func startCIStatusPoller() {
ciStatusPollerTask?.cancel()
loadHandledCIFailureSignatures()
ciStatusPollerTask = Task { [weak self] in
while !Task.isCancelled {
await self?.refreshCIStatusOnce()
let interval = AppState.ciStatusPollInterval
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
}
}
}

func stopCIStatusPoller() {
ciStatusPollerTask?.cancel()
ciStatusPollerTask = nil
}

// MARK: - Display helpers

/// True when any open project's current branch is red on CI. Drives the
/// menubar failure indicator.
var anyCIFailing: Bool {
ciStatusByProject.values.contains { $0.overallState == .failure }
}

/// Projects that have a known CI status, paired with it, sorted failures
/// first then by project name. Used by the menubar popover and briefing.
func ciStatusList() -> [(project: Project, status: ProjectCIStatus)] {
projects
.compactMap { project in
ciStatusByProject[project.id].map { (project, $0) }
}
.sorted { lhs, rhs in
if (lhs.1.overallState == .failure) != (rhs.1.overallState == .failure) {
return lhs.1.overallState == .failure
}
return lhs.0.name.localizedCaseInsensitiveCompare(rhs.0.name) == .orderedAscending
}
}

// MARK: - Refresh

/// One poll cycle: resolve each GitHub project's current branch, batch-query
/// autopilot, update `ciStatusByProject`, and react to new failures.
func refreshCIStatusOnce() async {
guard isSignedIn else { return }

// Build one query per project that has a GitHub repo and a resolvable
// current branch. Keep the originating project alongside each query so we
// can map the response back without ambiguity.
var pairs: [(project: Project, query: CIStatusQuery)] = []
for project in projects {
guard let slug = project.gitHubRepo else { continue }
let parts = slug.split(separator: "/", maxSplits: 1).map(String.init)
guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { continue }
guard let branch = await GitHelper.currentBranch(at: project.path) else { continue }
pairs.append((project, CIStatusQuery(owner: parts[0], repo: parts[1], branch: branch)))
}

guard !pairs.isEmpty else {
if !ciStatusByProject.isEmpty {
ciStatusByProject = [:]
ciStatusRevision &+= 1
}
return
}

let response: CIStatusBatchResponse
do {
response = try await autopilot.batchCIStatus(pairs.map(\.query))
} catch {
logger.error("CI status fetch failed: \(error.localizedDescription)")
return
}

// Index results by owner/repo/branch (and a looser owner/repo fallback in
// case the server echoes a different/absent branch).
var byKey: [String: ProjectCIStatus] = [:]
var byRepo: [String: ProjectCIStatus] = [:]
for result in response.results {
byKey[Self.ciKey(owner: result.owner, repo: result.repo, branch: result.branch)] = result
byRepo[Self.ciRepoKey(owner: result.owner, repo: result.repo)] = result
}

var next: [UUID: ProjectCIStatus] = [:]
for pair in pairs {
let key = Self.ciKey(owner: pair.query.owner, repo: pair.query.repo, branch: pair.query.branch)
let repoKey = Self.ciRepoKey(owner: pair.query.owner, repo: pair.query.repo)
guard let status = byKey[key] ?? byRepo[repoKey] else { continue }
Comment on lines +91 to +104
next[pair.project.id] = status
await handleCITransition(for: pair.project, status: status)
}

// Drop status for projects no longer queried (e.g. removed).
ciStatusByProject = next
ciStatusRevision &+= 1
}

// MARK: - Failure handling

/// Notify (always) and, when enabled, silently spawn a fix thread — but only
/// once per distinct failure (keyed by head sha / latest failing run).
private func handleCITransition(for project: Project, status: ProjectCIStatus) async {
guard status.overallState == .failure else {
// Reset so a future failure on this project fires again.
if lastHandledCIFailureSignature[project.id] != nil {
lastHandledCIFailureSignature[project.id] = nil
persistHandledCIFailureSignatures()
}
return
}

let signature = Self.ciFailureSignature(status)
guard lastHandledCIFailureSignature[project.id] != signature else { return }
lastHandledCIFailureSignature[project.id] = signature
persistHandledCIFailureSignatures()

let failingNames = status.failing.compactMap(\.workflowName)

if notificationsEnabled {
await NotificationService.shared.postCIFailed(
projectName: project.name,
projectId: project.id,
failingWorkflowNames: failingNames
)
}

if enableAutoCIFix {
await startAutoCIFix(for: project, status: status)
}
}

/// Fire-and-forget fix thread using the project's default agent/model/permission.
private func startAutoCIFix(for project: Project, status: ProjectCIStatus) async {
let branch = status.branch ?? "the current branch"
let prLine = status.prNumber.map { "PR #\($0) — " } ?? ""
let failingList = status.failing
.map { wf in
let name = wf.workflowName ?? "workflow"
if let url = wf.htmlUrl { return "- \(name): \(url)" }
return "- \(name)"
}
.joined(separator: "\n")

let prompt = """
GitHub Actions CI is failing on branch `\(branch)`. \(prLine)Please fix it.

Failing workflow run(s):
\(failingList.isEmpty ? "- (see the GitHub Actions tab for details)" : failingList)

Investigate the failure (read the run logs at the URLs above if helpful), \
find the root cause, apply a fix, and run the relevant checks locally to \
confirm CI will pass before finishing.
"""

do {
_ = try await sendCrossProject(
projectId: project.id,
threadId: nil,
prompt: prompt,
waitForResponse: false
)
logger.info("Started auto CI-fix thread for project \(project.name, privacy: .public)")
} catch {
logger.error("Failed to start auto CI-fix thread: \(error.localizedDescription)")
}
}

// MARK: - Helpers

private static func ciKey(owner: String, repo: String, branch: String?) -> String {
"\(owner.lowercased())/\(repo.lowercased())#\(branch ?? "")"
}

private static func ciRepoKey(owner: String, repo: String) -> String {
"\(owner.lowercased())/\(repo.lowercased())"
}

/// A value that changes when the failure changes commit/run, so we re-notify on
/// genuinely new failures but stay quiet while the same red state persists.
private static func ciFailureSignature(_ status: ProjectCIStatus) -> String {
if let sha = status.headSha, !sha.isEmpty { return "sha:\(sha)" }
let maxRun = status.failing.isEmpty
? status.workflows.compactMap(\.runNumber).max()
: status.workflows.filter { $0.conclusion != nil && $0.conclusion != "success" }.compactMap(\.runNumber).max()
if let maxRun { return "run:\(maxRun)" }
return "updated:\(status.lastUpdated ?? "")"
}

private func loadHandledCIFailureSignatures() {
guard let raw = UserDefaults.standard.dictionary(forKey: Self.ciSignaturesDefaultsKey) as? [String: String] else { return }
var restored: [UUID: String] = [:]
for (key, value) in raw {
if let id = UUID(uuidString: key) { restored[id] = value }
}
lastHandledCIFailureSignature = restored
}

private func persistHandledCIFailureSignatures() {
let raw = Dictionary(uniqueKeysWithValues: lastHandledCIFailureSignature.map { ($0.key.uuidString, $0.value) })
UserDefaults.standard.set(raw, forKey: Self.ciSignaturesDefaultsKey)
}
}
5 changes: 5 additions & 0 deletions RxCode/App/AppState+Lifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ extension AppState {
Task { [weak self] in await self?.loadRepos() }
}

// Periodically pull GitHub Actions CI status for open projects (no-ops
// until signed in). Notifies on failure and, when enabled, auto-starts a
// fix thread.
startCIStatusPoller()

// React to RxAuthSwift session expiry by clearing autopilot repos.
// `isSignedIn`/`rxUser` are computed from the manager, so they update
// automatically when `OAuthManager` flips to `.unauthenticated`.
Expand Down
Loading
Loading