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
3 changes: 2 additions & 1 deletion MonitorLizard/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@ enum Constants {
static let settingsWindowHeight = 500.0

// Voice announcement
static let defaultVoiceAnnouncementText = "Build ready for Q A"
static let defaultVoiceAnnouncementTextBuildComplete = "Build ready for Q A"
static let defaultVoiceAnnouncementTextPRUpdated = "PR updated"
}
9 changes: 9 additions & 0 deletions MonitorLizard/DependencyRegistration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,11 @@ private struct UnimplementedWatchlistService: WatchlistServicing {
return []
}

func checkForUpdates(currentPRs: [PullRequest]) -> [PullRequest] {
reportIssue("Unimplemented: WatchlistServicing.checkForUpdates called without a test override")
return []
}

func getWatchedStatus(for prId: String) -> WatchlistService.WatchedPRInfo? {
reportIssue("Unimplemented: WatchlistServicing.getWatchedStatus called without a test override")
return nil
Expand All @@ -239,6 +244,10 @@ private struct UnimplementedNotificationService: NotificationServicing {
func notifyBuildComplete(pr: PullRequest, status: BuildStatus) {
reportIssue("Unimplemented: NotificationServicing.notifyBuildComplete called without a test override")
}

func notifyPRUpdated(pr: PullRequest) {
reportIssue("Unimplemented: NotificationServicing.notifyPRUpdated called without a test override")
}
}

private struct UnimplementedPRCacheService: PRCacheServicing {
Expand Down
3 changes: 2 additions & 1 deletion MonitorLizard/MonitorLizardApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ struct MonitorLizardApp: App {
PreferenceKeys.enableSounds.rawValue: true,
PreferenceKeys.enableVoice.rawValue: true,
PreferenceKeys.showNotifications.rawValue: true,
PreferenceKeys.voiceAnnouncementText.rawValue: Constants.defaultVoiceAnnouncementText,
PreferenceKeys.voiceAnnouncementTextBuildComplete.rawValue: Constants.defaultVoiceAnnouncementTextBuildComplete,
PreferenceKeys.voiceAnnouncementTextPRUpdated.rawValue: Constants.defaultVoiceAnnouncementTextPRUpdated,
PreferenceKeys.enableInactiveBranchDetection.rawValue: false,
PreferenceKeys.hideInactivePRs.rawValue: false,
PreferenceKeys.inactiveBranchThresholdDays.rawValue: Constants.defaultInactiveBranchThreshold,
Expand Down
3 changes: 2 additions & 1 deletion MonitorLizard/PreferenceKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ enum PreferenceKeys: String, Sendable {
case showReviewPRs
case enableSounds
case enableVoice
case voiceAnnouncementText
case voiceAnnouncementTextBuildComplete
case voiceAnnouncementTextPRUpdated
case showNotifications
case enableInactiveBranchDetection
case hideInactivePRs
Expand Down
81 changes: 50 additions & 31 deletions MonitorLizard/Services/NotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import UserNotifications
protocol NotificationServicing: Sendable {
func requestAuthorization() async throws
func notifyBuildComplete(pr: PullRequest, status: BuildStatus)
func notifyPRUpdated(pr: PullRequest)
}

/// Posts system notifications, plays sounds, and speaks announcements for build completions.
Expand Down Expand Up @@ -34,62 +35,80 @@ final class NotificationService: NotificationServicing, @unchecked Sendable {
return defaults.bool(forKey: PreferenceKeys.showNotifications)
}

private var voiceAnnouncementText: String {
private var voiceAnnouncementTextBuildComplete: String {
assertMainThread()
return defaults.string(forKey: PreferenceKeys.voiceAnnouncementText) ?? Constants.defaultVoiceAnnouncementText
return defaults.string(forKey: PreferenceKeys.voiceAnnouncementTextBuildComplete) ?? Constants.defaultVoiceAnnouncementTextBuildComplete
}

private var voiceAnnouncementTextPRUpdated: String {
assertMainThread()
return defaults.string(forKey: PreferenceKeys.voiceAnnouncementTextPRUpdated) ?? Constants.defaultVoiceAnnouncementTextPRUpdated
}

func requestAuthorization() async throws {
let center = UNUserNotificationCenter.current()
try await center.requestAuthorization(options: [.alert, .sound, .badge])
}

func notifyBuildComplete(pr: PullRequest, status: BuildStatus) {
if notificationsEnabled {
showNotification(pr: pr, status: status)
}

if soundsEnabled {
playSound(for: status)
}
func notifyPRUpdated(pr: PullRequest) {
let content = UNMutableNotificationContent()
content.title = "PR Updated"
content.subtitle = pr.displayTitle
content.body = "\(pr.repository.name) #\(pr.number)"
content.sound = .default

if voiceEnabled && status == .success {
speak(text: voiceAnnouncementText)
}
notify(
identifier: "\(pr.id)-updated",
content: content,
soundName: "Glass",
speakText: voiceAnnouncementTextPRUpdated
)
}

private func showNotification(pr: PullRequest, status: BuildStatus) {
func notifyBuildComplete(pr: PullRequest, status: BuildStatus) {
let content = UNMutableNotificationContent()
content.title = "\(status.icon) Build \(status.displayName)"
content.subtitle = pr.title
content.body = "PR #\(pr.number) in \(pr.repository.name)"
content.sound = status == .success ? .default : .defaultCritical

let request = UNNotificationRequest(
identifier: pr.id,
content: content,
trigger: nil
)

UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Error showing notification: \(error.localizedDescription)")
}
}
}

private func playSound(for status: BuildStatus) {
let soundName: String

let soundName: String?
switch status {
case .success:
soundName = "Glass"
case .failure, .error:
soundName = "Basso"
default:
return
soundName = nil
}

notify(
identifier: "\(pr.id)-build",
content: content,
soundName: soundName,
speakText: status == .success ? voiceAnnouncementTextBuildComplete : nil
)
}

private func notify(identifier: String, content: UNMutableNotificationContent, soundName: String?, speakText: String?) {
if notificationsEnabled {
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Error showing notification: \(error.localizedDescription)")
}
}
}
if soundsEnabled, let soundName {
play(soundNamed: soundName)
}
if voiceEnabled, let speakText {
speak(text: speakText)
}
}


private func play(soundNamed soundName: String) {
if let soundURL = NSSound(named: soundName) {
soundURL.play()
} else if let soundPath = Bundle.main.path(forResource: soundName, ofType: "aiff") {
Expand Down
21 changes: 20 additions & 1 deletion MonitorLizard/Services/WatchlistService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ protocol WatchlistServicing: Sendable {
func unwatch(_ pr: PullRequest)
func isWatched(_ pr: PullRequest) -> Bool
func checkForCompletions(currentPRs: [PullRequest]) -> [PullRequest]
func checkForUpdates(currentPRs: [PullRequest]) -> [PullRequest]
func clearAll()
func getWatchedStatus(for prId: String) -> WatchlistService.WatchedPRInfo?
}
Expand Down Expand Up @@ -66,7 +67,7 @@ final class WatchlistService: WatchlistServicing, @unchecked Sendable {
completed.append(pr)
}

if watched.lastStatus != pr.buildStatus || watched.lastUpdatedAt != pr.updatedAt {
if watched.lastStatus != pr.buildStatus {
watchedPRs[pr.id] = WatchedPRInfo(
lastStatus: pr.buildStatus,
timestamp: Date(),
Expand All @@ -87,6 +88,24 @@ final class WatchlistService: WatchlistServicing, @unchecked Sendable {
return completed
}

/// Check for watched PRs whose updatedAt has changed since last check.
/// Returns PRs that were updated (new comment, push, review, etc.)
func checkForUpdates(currentPRs: [PullRequest]) -> [PullRequest] {
var updated: [PullRequest] = []
for pr in currentPRs {
guard let watched = watchedPRs[pr.id] else { continue }
if pr.updatedAt > watched.lastUpdatedAt {
updated.append(pr)
watchedPRs[pr.id] = WatchedPRInfo(
lastStatus: watched.lastStatus,
timestamp: watched.timestamp,
lastUpdatedAt: pr.updatedAt
)
}
}
return updated
}

func getWatchedStatus(for prId: String) -> WatchedPRInfo? {
assertMainThread()
return watchedPRs[prId]
Expand Down
8 changes: 8 additions & 0 deletions MonitorLizard/ViewModels/PRMonitorViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,14 @@ class PRMonitorViewModel: ObservableObject {
notificationService.notifyBuildComplete(pr: pr, status: pr.buildStatus)
}

// Check for watched PR updates (new comments, reviews, pushes, etc.)
// Exclude PRs that already triggered a build completion notification.
let completedIDs = Set(completed.map { $0.id })
let updated = watchlistService.checkForUpdates(currentPRs: dedupedPRs + fetchedOther)
for pr in updated where !completedIDs.contains(pr.id) {
notificationService.notifyPRUpdated(pr: pr)
}

unsortedPullRequests = applyCustomNames(dedupedPRs.map { pr in
var updated = pr
updated.isWatched = watchlistService.isWatched(pr)
Expand Down
44 changes: 33 additions & 11 deletions MonitorLizard/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import SwiftUI
struct SettingsView: View {
@Dependency(UserDefaultsStore.self) private var defaults

@State private var enableInactiveBranchDetectionValue: Bool = UserDefaults.standard.bool(forKey: PreferenceKeys.enableInactiveBranchDetection.rawValue)
@State private var hideInactivePRsValue: Bool = UserDefaults.standard.bool(forKey: PreferenceKeys.hideInactivePRs.rawValue)
@State private var inactiveBranchThresholdDaysValue: Int = UserDefaults.standard.object(forKey: PreferenceKeys.inactiveBranchThresholdDays.rawValue) as? Int ?? Constants.defaultInactiveBranchThreshold

private var refreshInterval: Binding<Int> {
Binding(
get: { defaults.object(forKey: PreferenceKeys.refreshInterval) as? Int ?? Constants.defaultRefreshInterval },
Expand Down Expand Up @@ -39,10 +43,17 @@ struct SettingsView: View {
)
}

private var voiceAnnouncementText: Binding<String> {
private var voiceAnnouncementTextBuildComplete: Binding<String> {
Binding(
get: { defaults.string(forKey: PreferenceKeys.voiceAnnouncementText) ?? Constants.defaultVoiceAnnouncementText },
set: { defaults.set($0, forKey: PreferenceKeys.voiceAnnouncementText) }
get: { defaults.string(forKey: PreferenceKeys.voiceAnnouncementTextBuildComplete) ?? Constants.defaultVoiceAnnouncementTextBuildComplete },
set: { defaults.set($0, forKey: PreferenceKeys.voiceAnnouncementTextBuildComplete) }
)
}

private var voiceAnnouncementTextPRUpdated: Binding<String> {
Binding(
get: { defaults.string(forKey: PreferenceKeys.voiceAnnouncementTextPRUpdated) ?? Constants.defaultVoiceAnnouncementTextPRUpdated },
set: { defaults.set($0, forKey: PreferenceKeys.voiceAnnouncementTextPRUpdated) }
)
}

Expand All @@ -55,22 +66,22 @@ struct SettingsView: View {

private var enableInactiveBranchDetection: Binding<Bool> {
Binding(
get: { defaults.bool(forKey: PreferenceKeys.enableInactiveBranchDetection) },
set: { defaults.set($0, forKey: PreferenceKeys.enableInactiveBranchDetection) }
get: { enableInactiveBranchDetectionValue },
set: { enableInactiveBranchDetectionValue = $0; defaults.set($0, forKey: PreferenceKeys.enableInactiveBranchDetection) }
)
}

private var hideInactivePRs: Binding<Bool> {
Binding(
get: { defaults.bool(forKey: PreferenceKeys.hideInactivePRs) },
set: { defaults.set($0, forKey: PreferenceKeys.hideInactivePRs) }
get: { hideInactivePRsValue },
set: { hideInactivePRsValue = $0; defaults.set($0, forKey: PreferenceKeys.hideInactivePRs) }
)
}

private var inactiveBranchThresholdDays: Binding<Int> {
Binding(
get: { defaults.object(forKey: PreferenceKeys.inactiveBranchThresholdDays) as? Int ?? Constants.defaultInactiveBranchThreshold },
set: { defaults.set($0, forKey: PreferenceKeys.inactiveBranchThresholdDays) }
get: { inactiveBranchThresholdDaysValue },
set: { inactiveBranchThresholdDaysValue = $0; defaults.set($0, forKey: PreferenceKeys.inactiveBranchThresholdDays) }
)
}

Expand Down Expand Up @@ -198,15 +209,26 @@ struct SettingsView: View {

if enableVoice.wrappedValue {
VStack(alignment: .leading, spacing: 4) {
Text("Announcement text")
Text("Build announcement text")
.font(.caption)
.foregroundColor(.secondary)

TextField("", text: voiceAnnouncementText, prompt: Text("Build ready for Q A"))
TextField("", text: voiceAnnouncementTextBuildComplete, prompt: Text(Constants.defaultVoiceAnnouncementTextBuildComplete))
.textFieldStyle(.roundedBorder)
.help("The text that will be spoken when a watched build completes successfully")
}
.padding(.leading, 20)

VStack(alignment: .leading, spacing: 4) {
Text("PR update announcement text")
.font(.caption)
.foregroundColor(.secondary)

TextField("", text: voiceAnnouncementTextPRUpdated, prompt: Text(Constants.defaultVoiceAnnouncementTextPRUpdated))
.textFieldStyle(.roundedBorder)
.help("The text that will be spoken when a watched PR is updated")
}
.padding(.leading, 20)
}
}

Expand Down
Loading