Skip to content
Open
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
40 changes: 38 additions & 2 deletions Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public struct AppConfig: Codable, Sendable, Equatable {
public var remoteControlEnabled: Bool
public var managerAutoPermissionMode: Bool
public var telemetry: TelemetryConfig
public var autoRespond: AutoRespondSettings

public init(
workspaces: [WorkspaceInfo] = [],
Expand All @@ -21,7 +22,8 @@ public struct AppConfig: Codable, Sendable, Equatable {
sidebar: SidebarSettings = SidebarSettings(),
remoteControlEnabled: Bool = false,
managerAutoPermissionMode: Bool = true,
telemetry: TelemetryConfig = TelemetryConfig()
telemetry: TelemetryConfig = TelemetryConfig(),
autoRespond: AutoRespondSettings = AutoRespondSettings()
) {
self.workspaces = workspaces
self.defaults = defaults
Expand All @@ -30,6 +32,7 @@ public struct AppConfig: Codable, Sendable, Equatable {
self.remoteControlEnabled = remoteControlEnabled
self.managerAutoPermissionMode = managerAutoPermissionMode
self.telemetry = telemetry
self.autoRespond = autoRespond
}

public init(from decoder: Decoder) throws {
Expand All @@ -41,10 +44,43 @@ public struct AppConfig: Codable, Sendable, Equatable {
remoteControlEnabled = try container.decodeIfPresent(Bool.self, forKey: .remoteControlEnabled) ?? false
managerAutoPermissionMode = try container.decodeIfPresent(Bool.self, forKey: .managerAutoPermissionMode) ?? true
telemetry = try container.decodeIfPresent(TelemetryConfig.self, forKey: .telemetry) ?? TelemetryConfig()
autoRespond = try container.decodeIfPresent(AutoRespondSettings.self, forKey: .autoRespond) ?? AutoRespondSettings()
}

private enum CodingKeys: String, CodingKey {
case workspaces, defaults, notifications, sidebar, remoteControlEnabled, managerAutoPermissionMode, telemetry
case workspaces, defaults, notifications, sidebar, remoteControlEnabled, managerAutoPermissionMode, telemetry, autoRespond
}
}

/// Opt-in settings that let Crow type instructions into a session's managed
/// Claude Code terminal when a watched PR transitions into a state that
/// usually requires action. Both flags default off — typing into a terminal
/// unprompted is intrusive, so the user must explicitly enable each.
public struct AutoRespondSettings: Codable, Sendable, Equatable {
/// Inject a "fix the review feedback" prompt when a PR transitions into
/// `reviewStatus == .changesRequested`.
public var respondToChangesRequested: Bool
/// Inject a "fix the failing checks" prompt when a PR transitions into
/// `checksPass == .failing` (keyed on the head SHA, so re-runs of the
/// same commit don't re-fire).
public var respondToFailedChecks: Bool

public init(
respondToChangesRequested: Bool = false,
respondToFailedChecks: Bool = false
) {
self.respondToChangesRequested = respondToChangesRequested
self.respondToFailedChecks = respondToFailedChecks
}

public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
respondToChangesRequested = try c.decodeIfPresent(Bool.self, forKey: .respondToChangesRequested) ?? false
respondToFailedChecks = try c.decodeIfPresent(Bool.self, forKey: .respondToFailedChecks) ?? false
}

private enum CodingKeys: String, CodingKey {
case respondToChangesRequested, respondToFailedChecks
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ public enum NotificationEvent: String, Codable, Sendable, CaseIterable, Identifi
case taskComplete
case agentWaiting
case reviewRequested
case changesRequested
case checksFailing

public var id: String { rawValue }

Expand All @@ -17,6 +19,8 @@ public enum NotificationEvent: String, Codable, Sendable, CaseIterable, Identifi
case .taskComplete: "Task Complete"
case .agentWaiting: "Agent Waiting"
case .reviewRequested: "Review Requested"
case .changesRequested: "Changes Requested"
case .checksFailing: "CI Failing"
}
}

Expand All @@ -25,6 +29,8 @@ public enum NotificationEvent: String, Codable, Sendable, CaseIterable, Identifi
case .taskComplete: "Claude finished responding"
case .agentWaiting: "Claude needs your input or permission"
case .reviewRequested: "Someone requested your review on a PR"
case .changesRequested: "A reviewer requested changes on your PR"
case .checksFailing: "CI checks started failing on your PR"
}
}

Expand All @@ -33,6 +39,8 @@ public enum NotificationEvent: String, Codable, Sendable, CaseIterable, Identifi
case .taskComplete: "Glass"
case .agentWaiting: "Funk"
case .reviewRequested: "Glass"
case .changesRequested: "Funk"
case .checksFailing: "Sosumi"
}
}

Expand Down
22 changes: 20 additions & 2 deletions Packages/CrowCore/Sources/CrowCore/Models/PRStatus.swift
Original file line number Diff line number Diff line change
@@ -1,22 +1,40 @@
import Foundation

/// Status of a pull request associated with a session.
public struct PRStatus: Codable, Sendable {
public struct PRStatus: Codable, Sendable, Equatable {
public var checksPass: CheckStatus
public var reviewStatus: ReviewStatus
public var mergeable: MergeStatus
public var failedCheckNames: [String]
/// Head commit SHA. Used to dedupe per-commit transition events
/// (e.g. don't re-fire "checks failing" when the same commit is re-run).
public var headSha: String?

public init(
checksPass: CheckStatus = .unknown,
reviewStatus: ReviewStatus = .unknown,
mergeable: MergeStatus = .unknown,
failedCheckNames: [String] = []
failedCheckNames: [String] = [],
headSha: String? = nil
) {
self.checksPass = checksPass
self.reviewStatus = reviewStatus
self.mergeable = mergeable
self.failedCheckNames = failedCheckNames
self.headSha = headSha
}

public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
checksPass = try c.decodeIfPresent(CheckStatus.self, forKey: .checksPass) ?? .unknown
reviewStatus = try c.decodeIfPresent(ReviewStatus.self, forKey: .reviewStatus) ?? .unknown
mergeable = try c.decodeIfPresent(MergeStatus.self, forKey: .mergeable) ?? .unknown
failedCheckNames = try c.decodeIfPresent([String].self, forKey: .failedCheckNames) ?? []
headSha = try c.decodeIfPresent(String.self, forKey: .headSha)
}

private enum CodingKeys: String, CodingKey {
case checksPass, reviewStatus, mergeable, failedCheckNames, headSha
}

public enum CheckStatus: String, Codable, Sendable {
Expand Down
102 changes: 102 additions & 0 deletions Packages/CrowCore/Sources/CrowCore/Models/PRStatusTransition.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import Foundation

/// A detected change in a session's `PRStatus` that warrants user attention.
///
/// Computed by `IssueTracker` once per polling cycle by comparing the new
/// `PRStatus` against the previous one. Pure value type — no UI/AppState deps —
/// so the comparison logic stays unit-testable.
public struct PRStatusTransition: Sendable, Equatable {
public enum Kind: String, Sendable, Equatable {
/// A reviewer transitioned the PR into "changes requested".
case changesRequested
/// At least one CI/CD check newly transitioned to failing on the
/// current head commit.
case checksFailing
}

public let kind: Kind
public let sessionID: UUID
public let prURL: String
public let prNumber: Int?
/// Head commit SHA at the moment of the transition. Used as part of the
/// dedupe key for `.checksFailing` so re-runs on the same commit don't
/// re-fire.
public let headSha: String?
/// Names of failing checks (empty for `.changesRequested`).
public let failedCheckNames: [String]

public init(
kind: Kind,
sessionID: UUID,
prURL: String,
prNumber: Int? = nil,
headSha: String? = nil,
failedCheckNames: [String] = []
) {
self.kind = kind
self.sessionID = sessionID
self.prURL = prURL
self.prNumber = prNumber
self.headSha = headSha
self.failedCheckNames = failedCheckNames
}

/// Stable key used to suppress duplicate fires across polling cycles.
/// `.changesRequested` keys on `(session, kind)` — the rule re-arms when
/// the status moves away from `.changesRequested`. `.checksFailing` keys
/// on `(session, kind, headSha)` so a new commit can re-fire.
public var dedupeKey: String {
switch kind {
case .changesRequested:
return "\(sessionID.uuidString)|changesRequested"
case .checksFailing:
return "\(sessionID.uuidString)|checksFailing|\(headSha ?? "")"
}
}
}

extension PRStatus {
/// Compute the transitions implied by moving from `old` to `new`.
///
/// Returns an empty array on the first observation (`old == nil`) so
/// existing PR state never triggers a fire-on-startup. Otherwise emits
/// at most one `.changesRequested` and one `.checksFailing`, in that order.
///
/// - Note: This is the pure piece of the transition pipeline; the
/// stateful dedupe (across polls) lives in `IssueTracker`.
public static func transitions(
from old: PRStatus?,
to new: PRStatus,
sessionID: UUID,
prURL: String,
prNumber: Int?
) -> [PRStatusTransition] {
guard let old else { return [] } // first observation — never fire

var out: [PRStatusTransition] = []

if old.reviewStatus != .changesRequested && new.reviewStatus == .changesRequested {
out.append(PRStatusTransition(
kind: .changesRequested,
sessionID: sessionID,
prURL: prURL,
prNumber: prNumber,
headSha: new.headSha,
failedCheckNames: []
))
}

if old.checksPass != .failing && new.checksPass == .failing {
out.append(PRStatusTransition(
kind: .checksFailing,
sessionID: sessionID,
prURL: prURL,
prNumber: prNumber,
headSha: new.headSha,
failedCheckNames: new.failedCheckNames
))
}

return out
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Testing
@testable import CrowCore

@Test func notificationEventAllCasesCount() {
#expect(NotificationEvent.allCases.count == 3)
#expect(NotificationEvent.allCases.count == 5)
}

@Test func notificationEventDefaultSoundsNonEmpty() {
Expand Down Expand Up @@ -62,3 +62,15 @@ import Testing
#expect(decoded == event)
}
}

// MARK: - PR transition events

@Test func changesRequestedAndChecksFailingArePresent() {
#expect(NotificationEvent.allCases.contains(.changesRequested))
#expect(NotificationEvent.allCases.contains(.checksFailing))
}

@Test func prTransitionEventsHaveDistinctDefaultSounds() {
// Different default sounds so the two events are audibly distinct.
#expect(NotificationEvent.changesRequested.defaultSound != NotificationEvent.checksFailing.defaultSound)
}
Loading
Loading