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
8 changes: 4 additions & 4 deletions MonitorLizard/Models/PullRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ enum PRType: String, Codable, Hashable {
}
}

struct PullRequest: Identifiable, Hashable {
struct PullRequest: Identifiable, Hashable, Codable {
let number: Int
let title: String
let repository: RepositoryInfo
Expand Down Expand Up @@ -75,16 +75,16 @@ struct PullRequest: Identifiable, Hashable {
!statusChecks.isEmpty
}

struct RepositoryInfo: Hashable {
struct RepositoryInfo: Hashable, Codable {
let name: String
let nameWithOwner: String
}

struct Author: Hashable {
struct Author: Hashable, Codable {
let login: String
}

struct Label: Hashable, Identifiable {
struct Label: Hashable, Identifiable, Codable {
let id: String
let name: String
let color: String
Expand Down
8 changes: 8 additions & 0 deletions MonitorLizard/MonitorLizard.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
objects = {

/* Begin PBXBuildFile section */
3235E3782FB25D2B00A8B4D4 /* PRCacheService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3235E3772FB25D2A00A8B4D4 /* PRCacheService.swift */; };
32364F9E2F1143E100493ED7 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32364F9D2F1143E100493ED7 /* Constants.swift */; };
324376112F5DB275009B7E2D /* UpdateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324376102F5DB275009B7E2D /* UpdateService.swift */; };
3273F7A72F6DD65A005F7B77 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 324376122F5DB2A0009B7E2D /* Sparkle */; };
Expand All @@ -29,6 +30,7 @@
32TESTS032F4A000000000001 /* WindowOcclusionObserverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32TESTS032F4A000000000000 /* WindowOcclusionObserverTests.swift */; };
32TESTS042F4A000000000001 /* GitHubServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32TESTS042F4A000000000000 /* GitHubServiceTests.swift */; };
32TESTS052F4A000000000001 /* ShellExecutorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32TESTS052F4A000000000000 /* ShellExecutorTests.swift */; };
32TESTS062F4A000000000001 /* PRCacheServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32TESTS062F4A000000000000 /* PRCacheServiceTests.swift */; };
A8C2D4E19F7B4A2D9E3B7C41 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7BF1FEEFD8924EFA85A1FE55 /* Assets.xcassets */; };
AA0001112F103E3200F0ABCD /* OtherPRsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0002222F103E3200F0ABCD /* OtherPRsService.swift */; };
AA0003332F103E3200F0ABCD /* AddPRView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0004442F103E3200F0ABCD /* AddPRView.swift */; };
Expand All @@ -46,6 +48,7 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
3235E3772FB25D2A00A8B4D4 /* PRCacheService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PRCacheService.swift; sourceTree = "<group>"; };
32364F9D2F1143E100493ED7 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = "<group>"; };
324376102F5DB275009B7E2D /* UpdateService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateService.swift; sourceTree = "<group>"; };
329D9EB32F103DF700F0E6EA /* MonitorLizard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MonitorLizard.app; sourceTree = BUILT_PRODUCTS_DIR; };
Expand All @@ -70,6 +73,7 @@
32TESTS032F4A000000000000 /* WindowOcclusionObserverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowOcclusionObserverTests.swift; sourceTree = "<group>"; };
32TESTS042F4A000000000000 /* GitHubServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubServiceTests.swift; sourceTree = "<group>"; };
32TESTS052F4A000000000000 /* ShellExecutorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellExecutorTests.swift; sourceTree = "<group>"; };
32TESTS062F4A000000000000 /* PRCacheServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PRCacheServiceTests.swift; sourceTree = "<group>"; };
7BF1FEEFD8924EFA85A1FE55 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
AA0002222F103E3200F0ABCD /* OtherPRsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtherPRsService.swift; sourceTree = "<group>"; };
AA0004442F103E3200F0ABCD /* AddPRView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPRView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -152,6 +156,7 @@
324376102F5DB275009B7E2D /* UpdateService.swift */,
AA0002222F103E3200F0ABCD /* OtherPRsService.swift */,
BB0002222F103E3200F0ABCD /* CustomNamesService.swift */,
3235E3772FB25D2A00A8B4D4 /* PRCacheService.swift */,
);
path = Services;
sourceTree = "<group>";
Expand Down Expand Up @@ -183,6 +188,7 @@
32TESTS032F4A000000000000 /* WindowOcclusionObserverTests.swift */,
32TESTS042F4A000000000000 /* GitHubServiceTests.swift */,
32TESTS052F4A000000000000 /* ShellExecutorTests.swift */,
32TESTS062F4A000000000000 /* PRCacheServiceTests.swift */,
);
name = MonitorLizardTests;
path = ../MonitorLizardTests;
Expand Down Expand Up @@ -302,6 +308,7 @@
32DEMO9E2F103E3200F0E6EB /* DemoData.swift in Sources */,
329D9EF02F103E3200F0E6EA /* MonitorLizardApp.swift in Sources */,
329D9EF12F103E3200F0E6EA /* GitHubService.swift in Sources */,
3235E3782FB25D2B00A8B4D4 /* PRCacheService.swift in Sources */,
329D9EF22F103E3200F0E6EA /* NotificationService.swift in Sources */,
329D9EF32F103E3200F0E6EA /* ShellExecutor.swift in Sources */,
329D9EF42F103E3200F0E6EA /* WatchlistService.swift in Sources */,
Expand All @@ -327,6 +334,7 @@
32TESTS032F4A000000000001 /* WindowOcclusionObserverTests.swift in Sources */,
32TESTS042F4A000000000001 /* GitHubServiceTests.swift in Sources */,
32TESTS052F4A000000000001 /* ShellExecutorTests.swift in Sources */,
32TESTS062F4A000000000001 /* PRCacheServiceTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
41 changes: 41 additions & 0 deletions MonitorLizard/Services/PRCacheService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Foundation

final class PRCacheService {
private let defaults: UserDefaults
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()

// In-session hash to skip redundant UserDefaults writes.
// Note: Data.hashValue seed resets per process, so these are not persisted.
private var lastMainHash: Int?
private var lastOtherHash: Int?

private enum Key {
static let mainPRs = "cachedMainPRs"
static let otherPRs = "cachedOtherPRs"
}

init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}

func save(mainPRs: [PullRequest], otherPRs: [PullRequest]) {
if let data = try? encoder.encode(mainPRs), data.hashValue != lastMainHash {
defaults.set(data, forKey: Key.mainPRs)
lastMainHash = data.hashValue
}
if let data = try? encoder.encode(otherPRs), data.hashValue != lastOtherHash {
defaults.set(data, forKey: Key.otherPRs)
lastOtherHash = data.hashValue
}
}

func loadMainPRs() -> [PullRequest] { load(forKey: Key.mainPRs) }
func loadOtherPRs() -> [PullRequest] { load(forKey: Key.otherPRs) }

private func load<T: Decodable>(forKey key: String) -> [T] {
guard let data = defaults.data(forKey: key),
let values = try? decoder.decode([T].self, from: data) else { return [] }
return values
}
}
21 changes: 20 additions & 1 deletion MonitorLizard/ViewModels/PRMonitorViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class PRMonitorViewModel: ObservableObject {
private let notificationService = NotificationService.shared
private let otherPRsService: OtherPRsService
private let customNamesService: CustomNamesService
private let cacheService: PRCacheService

private var refreshTimer: Timer?
private var sortSettingObserver: AnyCancellable?
Expand Down Expand Up @@ -84,12 +85,15 @@ class PRMonitorViewModel: ObservableObject {
init(isDemoMode: Bool = false,
watchlistService: WatchlistService? = nil,
otherPRsService: OtherPRsService? = nil,
customNamesService: CustomNamesService? = nil) {
customNamesService: CustomNamesService? = nil,
cacheService: PRCacheService? = nil) {
self.isDemoMode = isDemoMode
self.githubService = GitHubService(isDemoMode: isDemoMode)
self.watchlistService = watchlistService ?? .shared
self.otherPRsService = otherPRsService ?? OtherPRsService()
self.customNamesService = customNamesService ?? CustomNamesService()
self.cacheService = cacheService ?? PRCacheService()
restoreFromCache()
setupNotifications()
startPolling()
observeSortSetting()
Expand All @@ -104,6 +108,19 @@ class PRMonitorViewModel: ObservableObject {
}
}

private func restoreFromCache() {
let cached = cacheService.loadMainPRs()
if !cached.isEmpty {
unsortedPullRequests = cached.map {
var pr = $0; pr.isWatched = watchlistService.isWatched(pr); return pr
}
applySorting()
}
otherPullRequests = cacheService.loadOtherPRs().map {
var pr = $0; pr.isWatched = watchlistService.isWatched(pr); return pr
}
}

private func observeSortSetting() {
sortSettingObserver = UserDefaults.standard
.publisher(for: \.sortNonSuccessFirst)
Expand Down Expand Up @@ -216,6 +233,8 @@ class PRMonitorViewModel: ObservableObject {
selectedRepository = "All Repositories"
}

cacheService.save(mainPRs: unsortedPullRequests, otherPRs: otherPullRequests)

lastRefreshTime = Date()
isGHAvailable = true

Expand Down
123 changes: 123 additions & 0 deletions MonitorLizardTests/PRCacheServiceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import Testing
import Foundation
@testable import MonitorLizard

@MainActor
struct PRCacheServiceTests {

private func makeService() -> PRCacheService {
let suite = UserDefaults(suiteName: "test-\(UUID().uuidString)")!
return PRCacheService(defaults: suite)
}

private func makePR(number: Int, isWatched: Bool = false, type: PRType = .authored) -> PullRequest {
PullRequest(
number: number,
title: "Test PR #\(number)",
repository: PullRequest.RepositoryInfo(name: "repo", nameWithOwner: "owner/repo"),
url: "https://github.com/owner/repo/pull/\(number)",
author: PullRequest.Author(login: "testuser"),
headRefName: "feature/test",
updatedAt: Date(timeIntervalSince1970: 1_000_000),
buildStatus: .success,
isWatched: isWatched,
labels: [],
type: type,
isDraft: false,
statusChecks: [],
reviewDecision: nil,
host: "github.com",
customName: nil
)
}

// MARK: Empty state

@Test func emptyOnFirstLaunch() {
let service = makeService()
#expect(service.loadMainPRs().isEmpty)
#expect(service.loadOtherPRs().isEmpty)
}

// MARK: Round-trip

@Test func roundTripMainPRs() {
let service = makeService()
service.save(mainPRs: [makePR(number: 1), makePR(number: 2)], otherPRs: [])
let loaded = service.loadMainPRs()
#expect(loaded.map(\.number) == [1, 2])
}

@Test func roundTripOtherPRs() {
let service = makeService()
service.save(mainPRs: [], otherPRs: [makePR(number: 3, type: .other)])
let loaded = service.loadOtherPRs()
#expect(loaded.map(\.number) == [3])
}

@Test func preservesKeyFields() {
let service = makeService()
let pr = PullRequest(
number: 42,
title: "Important PR",
repository: PullRequest.RepositoryInfo(name: "myrepo", nameWithOwner: "org/myrepo"),
url: "https://github.com/org/myrepo/pull/42",
author: PullRequest.Author(login: "alice"),
headRefName: "feature/cool",
updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
buildStatus: .failure,
isWatched: true,
labels: [PullRequest.Label(id: "lb1", name: "bug", color: "d73a4a")],
type: .reviewing,
isDraft: true,
statusChecks: [],
reviewDecision: .approved,
host: "github.com",
customName: "My Custom Name"
)

service.save(mainPRs: [pr], otherPRs: [])
let loaded = service.loadMainPRs()[0]

#expect(loaded.number == 42)
#expect(loaded.title == "Important PR")
#expect(loaded.repository.nameWithOwner == "org/myrepo")
#expect(loaded.author.login == "alice")
#expect(loaded.buildStatus == .failure)
#expect(loaded.isWatched == true)
#expect(loaded.labels.count == 1)
#expect(loaded.labels[0].name == "bug")
#expect(loaded.type == .reviewing)
#expect(loaded.isDraft == true)
#expect(loaded.reviewDecision == .approved)
#expect(loaded.customName == "My Custom Name")
}

// MARK: Overwrite

@Test func subsequentSaveOverwritesPrevious() {
let service = makeService()
service.save(mainPRs: [makePR(number: 1)], otherPRs: [])
service.save(mainPRs: [makePR(number: 2), makePR(number: 3)], otherPRs: [])
let loaded = service.loadMainPRs()
#expect(loaded.map(\.number) == [2, 3])
}

@Test func savingEmptyListClearsPreviousData() {
let service = makeService()
service.save(mainPRs: [makePR(number: 1)], otherPRs: [makePR(number: 2)])
service.save(mainPRs: [], otherPRs: [])
#expect(service.loadMainPRs().isEmpty)
#expect(service.loadOtherPRs().isEmpty)
}

// MARK: Hash guard

@Test func hashGuardDoesNotCorruptDataOnRepeatedSave() {
let service = makeService()
let prs = [makePR(number: 1)]
service.save(mainPRs: prs, otherPRs: [])
service.save(mainPRs: prs, otherPRs: []) // same data — hash guard skips write
#expect(service.loadMainPRs().map(\.number) == [1])
}
}