Commit 7584e76f6d

7584e76f6dfe68b81ea4a5e962b5be4794b7350f

parent: a4c70fe5c1

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-06 23:24 UTC

Fork, watch, mute and bookmark a repository

Layout: unified · split

gitbay/Repos/RepoActionsViewModel.swift added +112
@@ -0,0 +1,112 @@
1import Foundation
2import Observation
3
4/// Fork, watch, mute and bookmark — the repository-level actions the
5/// server exposes but does not describe fully back:
6///
7/// - Watch/mute state is reported by no command at all (krz/gitbay#178),
8/// so `watch()`/`unwatch()` only send their command and report the
9/// result; they never track or infer a current state.
10/// - Bookmark state has to be derived by listing `repo bookmarks` and
11/// testing membership — there is no per-repo "is this bookmarked" call.
12/// - `fork_of` is only on `repo fork`'s own response, never on `repo
13/// show`, which is why `fork` hands its result back to the caller.
14@Observable
15@MainActor
16final class RepoActionsViewModel {
17
18 /// `repo fork`'s result — the only place `fork_of` is reported.
19 nonisolated struct ForkResult: Decodable, Sendable, Hashable {
20 let path: String
21 let forkOf: String
22
23 enum CodingKeys: String, CodingKey {
24 case path
25 case forkOf = "fork_of"
26 }
27 }
28
29 /// `nil` until known or after a failed load. Never `false` for "could
30 /// not check" — that would draw the wrong control.
31 private(set) var isBookmarked: Bool?
32 private(set) var actionError: String?
33 private(set) var notice: String?
34 private(set) var working = false
35
36 private let client: GitbayClient
37 let repoPath: String
38
39 init(client: GitbayClient, repoPath: String) {
40 self.client = client
41 self.repoPath = repoPath
42 }
43
44 // MARK: - Bookmarks
45
46 /// `repo bookmarks` — plural, no repository argument. Membership in
47 /// the listing is the only signal the server gives for this repo.
48 func loadBookmarkState() async {
49 do {
50 let bookmarks = try await client.readList(["repo", "bookmarks"], of: RepoSummary.self)
51 isBookmarked = bookmarks.contains { $0.path == repoPath }
52 } catch {
53 // "We don't know" is not "not bookmarked".
54 isBookmarked = nil
55 }
56 }
57
58 func setBookmarked(_ bookmarked: Bool) async {
59 await perform(["repo", bookmarked ? "bookmark" : "unbookmark", repoPath])
60 await loadBookmarkState()
61 }
62
63 // MARK: - Watch / mute
64
65 func watch() async {
66 await perform(["repo", "watch", repoPath])
67 if actionError == nil {
68 notice = "You are now watching this repository."
69 }
70 }
71
72 func unwatch() async {
73 await perform(["repo", "unwatch", repoPath])
74 if actionError == nil {
75 notice = "Notifications for this repository are muted."
76 }
77 }
78
79 // MARK: - Fork
80
81 func fork(named name: String?) async -> ForkResult? {
82 var argv = ["repo", "fork", repoPath]
83 if let name, !name.trimmingCharacters(in: .whitespaces).isEmpty {
84 argv += ["--name", name]
85 }
86 working = true
87 actionError = nil
88 defer { working = false }
89 do {
90 return try await client.run(argv, as: ForkResult.self)
91 } catch let error as GitbayError {
92 actionError = error.userFacingMessage
93 return nil
94 } catch {
95 actionError = GitbayError.transport(error).userFacingMessage
96 return nil
97 }
98 }
99
100 private func perform(_ argv: [String]) async {
101 working = true
102 actionError = nil
103 defer { working = false }
104 do {
105 try await client.run(argv)
106 } catch let error as GitbayError {
107 actionError = error.userFacingMessage
108 } catch {
109 actionError = GitbayError.transport(error).userFacingMessage
110 }
111 }
112}
gitbayTests/RepoActionTests.swift added +159
@@ -0,0 +1,159 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func argvFrom(_ url: URL) -> [String] {
16 URLComponents(url: url, resolvingAgainstBaseURL: false)?
17 .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
18}
19
20private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
21 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
22 return try #require(body["argv"] as? [String])
23}
24
25private let bookmarks = """
26 {"protocol_version":1,"data":[{"path":"krz/gitbay","visibility":"public"},\
27 {"path":"krz/solar","visibility":"public"}],"exit_code":0}
28 """
29private let ok = """
30 {"protocol_version":1,"exit_code":0}
31 """
32
33@MainActor
34struct RepoActionsTests {
35
36 /// `repo bookmarks` is plural and takes NO repository argument.
37 @Test func theBookmarkListingTakesNoArgument() async throws {
38 let (client, stub) = try makeClient()
39 stub.enqueue(.init(status: 200, json: bookmarks))
40 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
41 await model.loadBookmarkState()
42
43 #expect(argvFrom(try #require(stub.seen.last).url) == ["repo", "bookmarks"])
44 #expect(model.isBookmarked == true)
45 }
46
47 @Test func aRepoAbsentFromTheListingIsNotBookmarked() async throws {
48 let (client, stub) = try makeClient()
49 stub.enqueue(.init(status: 200, json: bookmarks))
50 let model = RepoActionsViewModel(client: client, repoPath: "krz/other")
51 await model.loadBookmarkState()
52
53 #expect(model.isBookmarked == false)
54 }
55
56 /// "We don't know" is not "not bookmarked". A failed load must leave
57 /// the state unknown rather than claiming the repository is not
58 /// bookmarked, which would draw the wrong control.
59 @Test func aFailedListingLeavesTheStateUnknown() async throws {
60 let (client, stub) = try makeClient()
61 stub.enqueue(.init(status: 200, json: """
62 {"protocol_version":1,"error":"denied","exit_code":4}
63 """))
64 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
65 await model.loadBookmarkState()
66
67 #expect(model.isBookmarked == nil)
68 }
69
70 @Test func bookmarkingAndUnbookmarkingAreDifferentCommands() async throws {
71 let (client, stub) = try makeClient()
72 stub.enqueue(.init(status: 200, json: bookmarks))
73 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
74 await model.loadBookmarkState()
75
76 stub.enqueue(.init(status: 200, json: ok))
77 stub.enqueue(.init(status: 200, json: bookmarks))
78 await model.setBookmarked(false)
79 var write = try #require(stub.seen.first { $0.method == "POST" })
80 #expect(try argvOf(write) == ["repo", "unbookmark", "krz/gitbay"])
81
82 let (client2, stub2) = try makeClient()
83 stub2.enqueue(.init(status: 200, json: bookmarks))
84 let model2 = RepoActionsViewModel(client: client2, repoPath: "krz/other")
85 await model2.loadBookmarkState()
86 stub2.enqueue(.init(status: 200, json: ok))
87 stub2.enqueue(.init(status: 200, json: bookmarks))
88 await model2.setBookmarked(true)
89 write = try #require(stub2.seen.first { $0.method == "POST" })
90 #expect(try argvOf(write) == ["repo", "bookmark", "krz/other"])
91 }
92
93 @Test func watchAndUnwatchSendTheirOwnCommands() async throws {
94 let (client, stub) = try makeClient()
95 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
96
97 stub.enqueue(.init(status: 200, json: ok))
98 await model.watch()
99 #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "watch", "krz/gitbay"])
100
101 stub.enqueue(.init(status: 200, json: ok))
102 await model.unwatch()
103 #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "unwatch", "krz/gitbay"])
104 }
105
106 @Test func forkingReturnsTheNewPath() async throws {
107 let (client, stub) = try makeClient()
108 stub.enqueue(.init(status: 200, json: """
109 {"protocol_version":1,"data":{"path":"cmc/gitbay","fork_of":"krz/gitbay"},\
110 "exit_code":0}
111 """))
112 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
113 let result = await model.fork(named: nil)
114
115 #expect(result?.path == "cmc/gitbay")
116 #expect(result?.forkOf == "krz/gitbay")
117 #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "fork", "krz/gitbay"])
118 }
119
120 @Test func forkingWithANameAppendsTheFlag() async throws {
121 let (client, stub) = try makeClient()
122 stub.enqueue(.init(status: 200, json: """
123 {"protocol_version":1,"data":{"path":"cmc/mine","fork_of":"krz/gitbay"},\
124 "exit_code":0}
125 """))
126 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
127 _ = await model.fork(named: "mine")
128
129 #expect(try argvOf(try #require(stub.seen.last))
130 == ["repo", "fork", "krz/gitbay", "--name", "mine"])
131 }
132
133 /// A blank name must not send an empty flag value.
134 @Test func forkingWithABlankNameOmitsTheFlag() async throws {
135 let (client, stub) = try makeClient()
136 stub.enqueue(.init(status: 200, json: """
137 {"protocol_version":1,"data":{"path":"cmc/gitbay","fork_of":"krz/gitbay"},\
138 "exit_code":0}
139 """))
140 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
141 _ = await model.fork(named: " ")
142
143 #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "fork", "krz/gitbay"])
144 }
145
146 @Test func aRefusedForkReturnsNilAndSurfaces() async throws {
147 let (client, stub) = try makeClient()
148 stub.enqueue(.init(status: 200, json: """
149 {"protocol_version":1,"error":"a repository named gitbay already exists",\
150 "exit_code":1}
151 """))
152 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
153 let result = await model.fork(named: nil)
154
155 #expect(result == nil)
156 #expect(model.actionError?.isEmpty == false)
157 #expect(model.working == false)
158 }
159}