a native ios client for gitbay

client ios swift

https://gitbay.org

app: client, auth, browse, MRs, issues, builds, dashboard !1

merged cmc wants to merge krz/gitbay-ios:client-envelope into main

59 files changed, +6264 −0

gitbay.xcodeproj/project.pbxproj added +489
@@ -0,0 +1,489 @@
1// !$*UTF8*$!
2{
3 archiveVersion = 1;
4 classes = {
5 };
6 objectVersion = 77;
7 objects = {
8
9/* Begin PBXBuildFile section */
10 8B289850303FF914001BF44A /* Highlightr in Frameworks */ = {isa = PBXBuildFile; productRef = 8B2898B1303FF3E5001BF44A /* Highlightr */; };
11/* End PBXBuildFile section */
12
13/* Begin PBXContainerItemProxy section */
14 8B2898A7303FF3E5001BF44A /* PBXContainerItemProxy */ = {
15 isa = PBXContainerItemProxy;
16 containerPortal = 8B28980A303FF3E5001BF44A /* Project object */;
17 proxyType = 1;
18 remoteGlobalIDString = 8B289811303FF3E5001BF44A;
19 remoteInfo = gitbay;
20 };
21/* End PBXContainerItemProxy section */
22
23/* Begin PBXFileReference section */
24 8B289812303FF3E5001BF44A /* gitbay.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = gitbay.app; sourceTree = BUILT_PRODUCTS_DIR; };
25 8B2898A2303FF3E5001BF44A /* gitbayTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = gitbayTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
26/* End PBXFileReference section */
27
28/* Begin PBXFileSystemSynchronizedRootGroup section */
29 8B289814303FF3E5001BF44A /* gitbay */ = {
30 isa = PBXFileSystemSynchronizedRootGroup;
31 path = gitbay;
32 sourceTree = "<group>";
33 };
34 8B2898A0303FF3E5001BF44A /* gitbayTests */ = {
35 isa = PBXFileSystemSynchronizedRootGroup;
36 path = gitbayTests;
37 sourceTree = "<group>";
38 };
39/* End PBXFileSystemSynchronizedRootGroup section */
40
41/* Begin PBXFrameworksBuildPhase section */
42 8B28980F303FF3E5001BF44A /* Frameworks */ = {
43 isa = PBXFrameworksBuildPhase;
44 buildActionMask = 2147483647;
45 files = (
46 8B289850303FF914001BF44A /* Highlightr in Frameworks */,
47 );
48 runOnlyForDeploymentPostprocessing = 0;
49 };
50 8B2898A4303FF3E5001BF44A /* Frameworks */ = {
51 isa = PBXFrameworksBuildPhase;
52 buildActionMask = 2147483647;
53 files = (
54 );
55 runOnlyForDeploymentPostprocessing = 0;
56 };
57/* End PBXFrameworksBuildPhase section */
58
59/* Begin PBXGroup section */
60 8B289809303FF3E5001BF44A = {
61 isa = PBXGroup;
62 children = (
63 8B289814303FF3E5001BF44A /* gitbay */,
64 8B2898A0303FF3E5001BF44A /* gitbayTests */,
65 8B289813303FF3E5001BF44A /* Products */,
66 );
67 sourceTree = "<group>";
68 };
69 8B289813303FF3E5001BF44A /* Products */ = {
70 isa = PBXGroup;
71 children = (
72 8B289812303FF3E5001BF44A /* gitbay.app */,
73 8B2898A2303FF3E5001BF44A /* gitbayTests.xctest */,
74 );
75 name = Products;
76 sourceTree = "<group>";
77 };
78/* End PBXGroup section */
79
80/* Begin PBXNativeTarget section */
81 8B289811303FF3E5001BF44A /* gitbay */ = {
82 isa = PBXNativeTarget;
83 buildConfigurationList = 8B28981D303FF3E6001BF44A /* Build configuration list for PBXNativeTarget "gitbay" */;
84 buildPhases = (
85 8B28980E303FF3E5001BF44A /* Sources */,
86 8B28980F303FF3E5001BF44A /* Frameworks */,
87 8B289810303FF3E5001BF44A /* Resources */,
88 );
89 buildRules = (
90 );
91 dependencies = (
92 );
93 fileSystemSynchronizedGroups = (
94 8B289814303FF3E5001BF44A /* gitbay */,
95 );
96 name = gitbay;
97 packageProductDependencies = (
98 8B2898B1303FF3E5001BF44A /* Highlightr */,
99 );
100 productName = gitbay;
101 productReference = 8B289812303FF3E5001BF44A /* gitbay.app */;
102 productType = "com.apple.product-type.application";
103 };
104 8B2898A1303FF3E5001BF44A /* gitbayTests */ = {
105 isa = PBXNativeTarget;
106 buildConfigurationList = 8B2898AA303FF3E5001BF44A /* Build configuration list for PBXNativeTarget "gitbayTests" */;
107 buildPhases = (
108 8B2898A3303FF3E5001BF44A /* Sources */,
109 8B2898A4303FF3E5001BF44A /* Frameworks */,
110 8B2898A5303FF3E5001BF44A /* Resources */,
111 );
112 buildRules = (
113 );
114 dependencies = (
115 8B2898A6303FF3E5001BF44A /* PBXTargetDependency */,
116 );
117 fileSystemSynchronizedGroups = (
118 8B2898A0303FF3E5001BF44A /* gitbayTests */,
119 );
120 name = gitbayTests;
121 packageProductDependencies = (
122 );
123 productName = gitbayTests;
124 productReference = 8B2898A2303FF3E5001BF44A /* gitbayTests.xctest */;
125 productType = "com.apple.product-type.bundle.unit-test";
126 };
127/* End PBXNativeTarget section */
128
129/* Begin PBXProject section */
130 8B28980A303FF3E5001BF44A /* Project object */ = {
131 isa = PBXProject;
132 attributes = {
133 BuildIndependentTargetsInParallel = 1;
134 LastSwiftUpdateCheck = 2660;
135 LastUpgradeCheck = 2660;
136 TargetAttributes = {
137 8B289811303FF3E5001BF44A = {
138 CreatedOnToolsVersion = 26.6;
139 };
140 8B2898A1303FF3E5001BF44A = {
141 CreatedOnToolsVersion = 26.6;
142 TestTargetID = 8B289811303FF3E5001BF44A;
143 };
144 };
145 };
146 buildConfigurationList = 8B28980D303FF3E5001BF44A /* Build configuration list for PBXProject "gitbay" */;
147 developmentRegion = en;
148 hasScannedForEncodings = 0;
149 knownRegions = (
150 en,
151 Base,
152 );
153 mainGroup = 8B289809303FF3E5001BF44A;
154 minimizedProjectReferenceProxies = 1;
155 packageReferences = (
156 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */,
157 );
158 preferredProjectObjectVersion = 77;
159 productRefGroup = 8B289813303FF3E5001BF44A /* Products */;
160 projectDirPath = "";
161 projectRoot = "";
162 targets = (
163 8B289811303FF3E5001BF44A /* gitbay */,
164 8B2898A1303FF3E5001BF44A /* gitbayTests */,
165 );
166 };
167/* End PBXProject section */
168
169/* Begin PBXResourcesBuildPhase section */
170 8B289810303FF3E5001BF44A /* Resources */ = {
171 isa = PBXResourcesBuildPhase;
172 buildActionMask = 2147483647;
173 files = (
174 );
175 runOnlyForDeploymentPostprocessing = 0;
176 };
177 8B2898A5303FF3E5001BF44A /* Resources */ = {
178 isa = PBXResourcesBuildPhase;
179 buildActionMask = 2147483647;
180 files = (
181 );
182 runOnlyForDeploymentPostprocessing = 0;
183 };
184/* End PBXResourcesBuildPhase section */
185
186/* Begin PBXSourcesBuildPhase section */
187 8B28980E303FF3E5001BF44A /* Sources */ = {
188 isa = PBXSourcesBuildPhase;
189 buildActionMask = 2147483647;
190 files = (
191 );
192 runOnlyForDeploymentPostprocessing = 0;
193 };
194 8B2898A3303FF3E5001BF44A /* Sources */ = {
195 isa = PBXSourcesBuildPhase;
196 buildActionMask = 2147483647;
197 files = (
198 );
199 runOnlyForDeploymentPostprocessing = 0;
200 };
201/* End PBXSourcesBuildPhase section */
202
203/* Begin PBXTargetDependency section */
204 8B2898A6303FF3E5001BF44A /* PBXTargetDependency */ = {
205 isa = PBXTargetDependency;
206 target = 8B289811303FF3E5001BF44A /* gitbay */;
207 targetProxy = 8B2898A7303FF3E5001BF44A /* PBXContainerItemProxy */;
208 };
209/* End PBXTargetDependency section */
210
211/* Begin XCBuildConfiguration section */
212 8B28981B303FF3E6001BF44A /* Debug */ = {
213 isa = XCBuildConfiguration;
214 buildSettings = {
215 ALWAYS_SEARCH_USER_PATHS = NO;
216 ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
217 CLANG_ANALYZER_NONNULL = YES;
218 CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
219 CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
220 CLANG_ENABLE_MODULES = YES;
221 CLANG_ENABLE_OBJC_ARC = YES;
222 CLANG_ENABLE_OBJC_WEAK = YES;
223 CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
224 CLANG_WARN_BOOL_CONVERSION = YES;
225 CLANG_WARN_COMMA = YES;
226 CLANG_WARN_CONSTANT_CONVERSION = YES;
227 CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
228 CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
229 CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
230 CLANG_WARN_EMPTY_BODY = YES;
231 CLANG_WARN_ENUM_CONVERSION = YES;
232 CLANG_WARN_INFINITE_RECURSION = YES;
233 CLANG_WARN_INT_CONVERSION = YES;
234 CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
235 CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
236 CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
237 CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
238 CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
239 CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
240 CLANG_WARN_STRICT_PROTOTYPES = YES;
241 CLANG_WARN_SUSPICIOUS_MOVE = YES;
242 CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
243 CLANG_WARN_UNREACHABLE_CODE = YES;
244 CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
245 COPY_PHASE_STRIP = NO;
246 DEBUG_INFORMATION_FORMAT = dwarf;
247 DEVELOPMENT_TEAM = ZCNAX3VL9D;
248 ENABLE_STRICT_OBJC_MSGSEND = YES;
249 ENABLE_TESTABILITY = YES;
250 ENABLE_USER_SCRIPT_SANDBOXING = YES;
251 GCC_C_LANGUAGE_STANDARD = gnu17;
252 GCC_DYNAMIC_NO_PIC = NO;
253 GCC_NO_COMMON_BLOCKS = YES;
254 GCC_OPTIMIZATION_LEVEL = 0;
255 GCC_PREPROCESSOR_DEFINITIONS = (
256 "DEBUG=1",
257 "$(inherited)",
258 );
259 GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
260 GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
261 GCC_WARN_UNDECLARED_SELECTOR = YES;
262 GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
263 GCC_WARN_UNUSED_FUNCTION = YES;
264 GCC_WARN_UNUSED_VARIABLE = YES;
265 IPHONEOS_DEPLOYMENT_TARGET = 26.5;
266 LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
267 MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
268 MTL_FAST_MATH = YES;
269 ONLY_ACTIVE_ARCH = YES;
270 SDKROOT = iphoneos;
271 SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
272 SWIFT_OPTIMIZATION_LEVEL = "-Onone";
273 };
274 name = Debug;
275 };
276 8B28981C303FF3E6001BF44A /* Release */ = {
277 isa = XCBuildConfiguration;
278 buildSettings = {
279 ALWAYS_SEARCH_USER_PATHS = NO;
280 ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
281 CLANG_ANALYZER_NONNULL = YES;
282 CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
283 CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
284 CLANG_ENABLE_MODULES = YES;
285 CLANG_ENABLE_OBJC_ARC = YES;
286 CLANG_ENABLE_OBJC_WEAK = YES;
287 CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
288 CLANG_WARN_BOOL_CONVERSION = YES;
289 CLANG_WARN_COMMA = YES;
290 CLANG_WARN_CONSTANT_CONVERSION = YES;
291 CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
292 CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
293 CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
294 CLANG_WARN_EMPTY_BODY = YES;
295 CLANG_WARN_ENUM_CONVERSION = YES;
296 CLANG_WARN_INFINITE_RECURSION = YES;
297 CLANG_WARN_INT_CONVERSION = YES;
298 CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
299 CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
300 CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
301 CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
302 CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
303 CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
304 CLANG_WARN_STRICT_PROTOTYPES = YES;
305 CLANG_WARN_SUSPICIOUS_MOVE = YES;
306 CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
307 CLANG_WARN_UNREACHABLE_CODE = YES;
308 CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
309 COPY_PHASE_STRIP = NO;
310 DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
311 DEVELOPMENT_TEAM = ZCNAX3VL9D;
312 ENABLE_NS_ASSERTIONS = NO;
313 ENABLE_STRICT_OBJC_MSGSEND = YES;
314 ENABLE_USER_SCRIPT_SANDBOXING = YES;
315 GCC_C_LANGUAGE_STANDARD = gnu17;
316 GCC_NO_COMMON_BLOCKS = YES;
317 GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
318 GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
319 GCC_WARN_UNDECLARED_SELECTOR = YES;
320 GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
321 GCC_WARN_UNUSED_FUNCTION = YES;
322 GCC_WARN_UNUSED_VARIABLE = YES;
323 IPHONEOS_DEPLOYMENT_TARGET = 26.5;
324 LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
325 MTL_ENABLE_DEBUG_INFO = NO;
326 MTL_FAST_MATH = YES;
327 SDKROOT = iphoneos;
328 SWIFT_COMPILATION_MODE = wholemodule;
329 VALIDATE_PRODUCT = YES;
330 };
331 name = Release;
332 };
333 8B28981E303FF3E6001BF44A /* Debug */ = {
334 isa = XCBuildConfiguration;
335 buildSettings = {
336 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
337 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
338 CODE_SIGN_STYLE = Automatic;
339 CURRENT_PROJECT_VERSION = 1;
340 DEVELOPMENT_TEAM = ZCNAX3VL9D;
341 ENABLE_PREVIEWS = YES;
342 GENERATE_INFOPLIST_FILE = YES;
343 INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
344 INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
345 INFOPLIST_KEY_UILaunchScreen_Generation = YES;
346 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
347 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
348 LD_RUNPATH_SEARCH_PATHS = (
349 "$(inherited)",
350 "@executable_path/Frameworks",
351 );
352 MARKETING_VERSION = 1.0;
353 PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbay;
354 PRODUCT_NAME = "$(TARGET_NAME)";
355 STRING_CATALOG_GENERATE_SYMBOLS = YES;
356 SWIFT_APPROACHABLE_CONCURRENCY = YES;
357 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
358 SWIFT_EMIT_LOC_STRINGS = YES;
359 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
360 SWIFT_VERSION = 6.0;
361 TARGETED_DEVICE_FAMILY = "1,2";
362 };
363 name = Debug;
364 };
365 8B28981F303FF3E6001BF44A /* Release */ = {
366 isa = XCBuildConfiguration;
367 buildSettings = {
368 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
369 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
370 CODE_SIGN_STYLE = Automatic;
371 CURRENT_PROJECT_VERSION = 1;
372 DEVELOPMENT_TEAM = ZCNAX3VL9D;
373 ENABLE_PREVIEWS = YES;
374 GENERATE_INFOPLIST_FILE = YES;
375 INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
376 INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
377 INFOPLIST_KEY_UILaunchScreen_Generation = YES;
378 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
379 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
380 LD_RUNPATH_SEARCH_PATHS = (
381 "$(inherited)",
382 "@executable_path/Frameworks",
383 );
384 MARKETING_VERSION = 1.0;
385 PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbay;
386 PRODUCT_NAME = "$(TARGET_NAME)";
387 STRING_CATALOG_GENERATE_SYMBOLS = YES;
388 SWIFT_APPROACHABLE_CONCURRENCY = YES;
389 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
390 SWIFT_EMIT_LOC_STRINGS = YES;
391 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
392 SWIFT_VERSION = 6.0;
393 TARGETED_DEVICE_FAMILY = "1,2";
394 };
395 name = Release;
396 };
397 8B2898A8303FF3E5001BF44A /* Debug */ = {
398 isa = XCBuildConfiguration;
399 buildSettings = {
400 BUNDLE_LOADER = "$(TEST_HOST)";
401 CURRENT_PROJECT_VERSION = 1;
402 DEVELOPMENT_TEAM = ZCNAX3VL9D;
403 GENERATE_INFOPLIST_FILE = YES;
404 MARKETING_VERSION = 1.0;
405 PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbayTests;
406 PRODUCT_NAME = "$(TARGET_NAME)";
407 SWIFT_APPROACHABLE_CONCURRENCY = YES;
408 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
409 SWIFT_EMIT_LOC_STRINGS = NO;
410 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
411 SWIFT_VERSION = 6.0;
412 TARGETED_DEVICE_FAMILY = "1,2";
413 TEST_HOST = "$(BUILT_PRODUCTS_DIR)/gitbay.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/gitbay";
414 };
415 name = Debug;
416 };
417 8B2898A9303FF3E5001BF44A /* Release */ = {
418 isa = XCBuildConfiguration;
419 buildSettings = {
420 BUNDLE_LOADER = "$(TEST_HOST)";
421 CURRENT_PROJECT_VERSION = 1;
422 DEVELOPMENT_TEAM = ZCNAX3VL9D;
423 GENERATE_INFOPLIST_FILE = YES;
424 MARKETING_VERSION = 1.0;
425 PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbayTests;
426 PRODUCT_NAME = "$(TARGET_NAME)";
427 SWIFT_APPROACHABLE_CONCURRENCY = YES;
428 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
429 SWIFT_EMIT_LOC_STRINGS = NO;
430 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
431 SWIFT_VERSION = 6.0;
432 TARGETED_DEVICE_FAMILY = "1,2";
433 TEST_HOST = "$(BUILT_PRODUCTS_DIR)/gitbay.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/gitbay";
434 };
435 name = Release;
436 };
437/* End XCBuildConfiguration section */
438
439/* Begin XCConfigurationList section */
440 8B28980D303FF3E5001BF44A /* Build configuration list for PBXProject "gitbay" */ = {
441 isa = XCConfigurationList;
442 buildConfigurations = (
443 8B28981B303FF3E6001BF44A /* Debug */,
444 8B28981C303FF3E6001BF44A /* Release */,
445 );
446 defaultConfigurationIsVisible = 0;
447 defaultConfigurationName = Release;
448 };
449 8B28981D303FF3E6001BF44A /* Build configuration list for PBXNativeTarget "gitbay" */ = {
450 isa = XCConfigurationList;
451 buildConfigurations = (
452 8B28981E303FF3E6001BF44A /* Debug */,
453 8B28981F303FF3E6001BF44A /* Release */,
454 );
455 defaultConfigurationIsVisible = 0;
456 defaultConfigurationName = Release;
457 };
458 8B2898AA303FF3E5001BF44A /* Build configuration list for PBXNativeTarget "gitbayTests" */ = {
459 isa = XCConfigurationList;
460 buildConfigurations = (
461 8B2898A8303FF3E5001BF44A /* Debug */,
462 8B2898A9303FF3E5001BF44A /* Release */,
463 );
464 defaultConfigurationIsVisible = 0;
465 defaultConfigurationName = Release;
466 };
467/* End XCConfigurationList section */
468
469/* Begin XCRemoteSwiftPackageReference section */
470 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */ = {
471 isa = XCRemoteSwiftPackageReference;
472 repositoryURL = "https://github.com/raspu/Highlightr";
473 requirement = {
474 kind = upToNextMajorVersion;
475 minimumVersion = 2.1.0;
476 };
477 };
478/* End XCRemoteSwiftPackageReference section */
479
480/* Begin XCSwiftPackageProductDependency section */
481 8B2898B1303FF3E5001BF44A /* Highlightr */ = {
482 isa = XCSwiftPackageProductDependency;
483 package = 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */;
484 productName = Highlightr;
485 };
486/* End XCSwiftPackageProductDependency section */
487 };
488 rootObject = 8B28980A303FF3E5001BF44A /* Project object */;
489}
gitbay.xcodeproj/project.xcworkspace/contents.xcworkspacedata added +7
@@ -0,0 +1,7 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<Workspace
3 version = "1.0">
4 <FileRef
5 location = "self:">
6 </FileRef>
7</Workspace>
gitbay.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved added +15
@@ -0,0 +1,15 @@
1{
2 "originHash" : "0a8fce6d3cd251ad12ea6d2f48f55bfdc4fa43f93e39dce01f4b2baa78e6feb8",
3 "pins" : [
4 {
5 "identity" : "highlightr",
6 "kind" : "remoteSourceControl",
7 "location" : "https://github.com/raspu/Highlightr",
8 "state" : {
9 "revision" : "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef",
10 "version" : "2.3.0"
11 }
12 }
13 ],
14 "version" : 3
15}
gitbay/Assets.xcassets/AccentColor.colorset/Contents.json added +11
@@ -0,0 +1,11 @@
1{
2 "colors" : [
3 {
4 "idiom" : "universal"
5 }
6 ],
7 "info" : {
8 "author" : "xcode",
9 "version" : 1
10 }
11}
gitbay/Assets.xcassets/AppIcon.appiconset/Contents.json added +35
@@ -0,0 +1,35 @@
1{
2 "images" : [
3 {
4 "idiom" : "universal",
5 "platform" : "ios",
6 "size" : "1024x1024"
7 },
8 {
9 "appearances" : [
10 {
11 "appearance" : "luminosity",
12 "value" : "dark"
13 }
14 ],
15 "idiom" : "universal",
16 "platform" : "ios",
17 "size" : "1024x1024"
18 },
19 {
20 "appearances" : [
21 {
22 "appearance" : "luminosity",
23 "value" : "tinted"
24 }
25 ],
26 "idiom" : "universal",
27 "platform" : "ios",
28 "size" : "1024x1024"
29 }
30 ],
31 "info" : {
32 "author" : "xcode",
33 "version" : 1
34 }
35}
gitbay/Assets.xcassets/Contents.json added +6
@@ -0,0 +1,6 @@
1{
2 "info" : {
3 "author" : "xcode",
4 "version" : 1
5 }
6}
gitbay/Auth/Account.swift added +20
@@ -0,0 +1,20 @@
1import Foundation
2
3/// One signed-in identity: an account on an instance. gitbay is
4/// self-hosted, so the pair is the unit the same person on two
5/// instances is two accounts.
6nonisolated struct Account: Codable, Hashable, Sendable, Identifiable {
7 let instance: GitbayInstance
8 let username: String
9
10 /// Stable across renames of nothing usernames are the identity on an
11 /// instance, and tokens are keyed by this.
12 var id: String {
13 "\(instance.baseURL.absoluteString)#\(username)"
14 }
15
16 /// "cmc on gitbay.org"
17 var label: String {
18 "\(username) on \(instance.baseURL.host() ?? instance.baseURL.absoluteString)"
19 }
20}
gitbay/Auth/SessionStore.swift added +135
@@ -0,0 +1,135 @@
1import Foundation
2import Observation
3
4/// Which accounts exist, which one is active, and the client for it.
5///
6/// Signing in validates the pasted token with `whoami` before anything is
7/// stored, so a stored account is always one that worked at least once. A
8/// revoked token later comes back as 401 on some request; `expire` turns
9/// that into a clean sign-out with a plain sentence on the sign-in screen.
10@Observable
11@MainActor
12final class SessionStore {
13
14 private(set) var accounts: [Account]
15 private(set) var current: Account?
16 private(set) var client: GitbayClient?
17
18 /// Why the user is looking at the sign-in screen, when there is a
19 /// reason beyond never having signed in.
20 private(set) var signedOutMessage: String?
21
22 private let store: any TokenStore
23 private let makeClient: @Sendable (GitbayInstance, String) -> GitbayClient
24
25 /// The active account survives relaunch in UserDefaults. An account id
26 /// is not a secret the token it points at stays in the Keychain.
27 private let defaults: UserDefaults
28 private var currentAccountID: String {
29 get { defaults.string(forKey: "currentAccountID") ?? "" }
30 set { defaults.set(newValue, forKey: "currentAccountID") }
31 }
32
33 init(
34 store: any TokenStore = KeychainTokenStore(),
35 defaults: UserDefaults = .standard,
36 makeClient: @escaping @Sendable (GitbayInstance, String) -> GitbayClient = {
37 GitbayClient(instance: $0, token: $1)
38 }
39 ) {
40 self.store = store
41 self.makeClient = makeClient
42 self.defaults = defaults
43 self.accounts = store.loadAccounts()
44 restore()
45 }
46
47 private func restore() {
48 let candidate = accounts.first { $0.id == currentAccountID } ?? accounts.first
49 guard let candidate, let token = store.token(for: candidate.id) else {
50 current = nil
51 client = nil
52 return
53 }
54 activate(candidate, token: token)
55 }
56
57 /// Validate a pasted token against an instance and store the account.
58 /// Throws `GitbayError` with a user-facing message on every failure.
59 func signIn(instanceURL: String, token: String) async throws {
60 let instance = try GitbayInstance(url: instanceURL)
61 let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
62 guard !trimmed.isEmpty else {
63 throw GitbayError.unauthorized("empty token")
64 }
65 let candidate = makeClient(instance, trimmed)
66 let who = try await candidate.read(["whoami"], as: WhoamiResponse.self)
67
68 let account = Account(instance: instance, username: who.username)
69 try store.saveToken(trimmed, for: account.id)
70 if !accounts.contains(account) {
71 accounts.append(account)
72 try store.saveAccounts(accounts)
73 }
74 current = account
75 client = candidate
76 currentAccountID = account.id
77 signedOutMessage = nil
78 }
79
80 /// Switch to another stored account. Returns false if its token is
81 /// gone from the Keychain, in which case the account is dropped too.
82 @discardableResult
83 func activate(_ account: Account) -> Bool {
84 guard let token = store.token(for: account.id) else {
85 remove(account)
86 return false
87 }
88 activate(account, token: token)
89 return true
90 }
91
92 private func activate(_ account: Account, token: String) {
93 current = account
94 client = makeClient(account.instance, token)
95 currentAccountID = account.id
96 }
97
98 /// Forget an account: token out of the Keychain, account out of the
99 /// list, and if it was active, over to the next one or signed out.
100 func remove(_ account: Account) {
101 store.deleteToken(for: account.id)
102 accounts.removeAll { $0.id == account.id }
103 try? store.saveAccounts(accounts)
104 if current?.id == account.id {
105 current = nil
106 client = nil
107 currentAccountID = ""
108 restore()
109 }
110 }
111
112 /// The server said 401: the token is expired or revoked. Sign the
113 /// account out cleanly and say why never crash, never loop.
114 func expire(_ account: Account) {
115 remove(account)
116 if current == nil {
117 signedOutMessage =
118 "The token for \(account.label) is no longer valid. Mint a new one and sign in again."
119 }
120 }
121
122 /// Route an error from any screen: a 401 expires the current account;
123 /// everything else is the caller's to show.
124 func handle(_ error: any Error) {
125 guard let error = error as? GitbayError, error.requiresReauthentication,
126 let current else {
127 return
128 }
129 expire(current)
130 }
131
132 nonisolated private struct WhoamiResponse: Decodable, Sendable {
133 let username: String
134 }
135}
gitbay/Auth/TokenStore.swift added +98
@@ -0,0 +1,98 @@
1import Foundation
2@preconcurrency import Security
3
4/// Persistence for accounts and their tokens. The Keychain implementation
5/// is the real one; tests use an in-memory stand-in so no test touches the
6/// host Keychain.
7nonisolated protocol TokenStore: Sendable {
8 func saveToken(_ token: String, for accountID: String) throws
9 func token(for accountID: String) -> String?
10 func deleteToken(for accountID: String)
11 func saveAccounts(_ accounts: [Account]) throws
12 func loadAccounts() -> [Account]
13}
14
15enum TokenStoreError: LocalizedError, Sendable {
16 case keychain(OSStatus)
17
18 var errorDescription: String? {
19 switch self {
20 case .keychain(let status):
21 "Could not store the token securely (\(status))."
22 }
23 }
24}
25
26/// Tokens and the account list live in the Keychain and nowhere else
27/// nothing git tracks, nothing in UserDefaults, no iCloud sync
28/// (ThisDeviceOnly): a token names this phone, not the account.
29nonisolated struct KeychainTokenStore: TokenStore {
30
31 private static let service = "org.gitbay.gitbay"
32 private static let accountsKey = "accounts"
33
34 func saveToken(_ token: String, for accountID: String) throws {
35 try save(Data(token.utf8), account: "token." + accountID)
36 }
37
38 func token(for accountID: String) -> String? {
39 load(account: "token." + accountID).flatMap { String(data: $0, encoding: .utf8) }
40 }
41
42 func deleteToken(for accountID: String) {
43 delete(account: "token." + accountID)
44 }
45
46 func saveAccounts(_ accounts: [Account]) throws {
47 try save(JSONEncoder().encode(accounts), account: Self.accountsKey)
48 }
49
50 func loadAccounts() -> [Account] {
51 guard let data = load(account: Self.accountsKey),
52 let accounts = try? JSONDecoder().decode([Account].self, from: data) else {
53 return []
54 }
55 return accounts
56 }
57
58 // MARK: - SecItem
59
60 private func save(_ data: Data, account: String) throws {
61 delete(account: account)
62 let query: [String: Any] = [
63 kSecClass as String: kSecClassGenericPassword,
64 kSecAttrService as String: Self.service,
65 kSecAttrAccount as String: account,
66 kSecValueData as String: data,
67 kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
68 ]
69 let status = SecItemAdd(query as CFDictionary, nil)
70 guard status == errSecSuccess else {
71 throw TokenStoreError.keychain(status)
72 }
73 }
74
75 private func load(account: String) -> Data? {
76 let query: [String: Any] = [
77 kSecClass as String: kSecClassGenericPassword,
78 kSecAttrService as String: Self.service,
79 kSecAttrAccount as String: account,
80 kSecReturnData as String: true,
81 kSecMatchLimit as String: kSecMatchLimitOne,
82 ]
83 var result: AnyObject?
84 guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else {
85 return nil
86 }
87 return result as? Data
88 }
89
90 private func delete(account: String) {
91 let query: [String: Any] = [
92 kSecClass as String: kSecClassGenericPassword,
93 kSecAttrService as String: Self.service,
94 kSecAttrAccount as String: account,
95 ]
96 SecItemDelete(query as CFDictionary)
97 }
98}
gitbay/Builds/BuildListViewModel.swift added +29
@@ -0,0 +1,29 @@
1import Foundation
2import Observation
3
4/// `build list <repo>`.
5@Observable
6@MainActor
7final class BuildListViewModel {
8
9 private(set) var state: LoadState<[Build]> = .loading
10
11 private let client: GitbayClient
12 let repoPath: String
13
14 init(client: GitbayClient, repoPath: String) {
15 self.client = client
16 self.repoPath = repoPath
17 }
18
19 func load() async {
20 do {
21 let builds = try await client.readList(["build", "list", repoPath], of: Build.self)
22 state = builds.isEmpty
23 ? .empty("No builds. Builds run when a push touches a repo with a .gitbay/ job file.")
24 : .loaded(builds.sorted { $0.number > $1.number })
25 } catch {
26 state = .from(error)
27 }
28 }
29}
gitbay/Builds/BuildModels.swift added +21
@@ -0,0 +1,21 @@
1import Foundation
2
3/// One row of `build list`.
4nonisolated struct Build: Decodable, Sendable, Hashable, Identifiable {
5 let number: Int64
6 let job: String
7 let status: String
8 let sha: String
9 let ref: String
10 let createdAt: Date
11 let finishedAt: Date?
12
13 enum CodingKeys: String, CodingKey {
14 case number, job, status, sha, ref
15 case createdAt = "created_at"
16 case finishedAt = "finished_at"
17 }
18
19 var id: Int64 { number }
20 var shortSHA: String { String(sha.prefix(10)) }
21}
gitbay/ContentView.swift added +112
@@ -0,0 +1,112 @@
1import SwiftUI
2
3struct ContentView: View {
4
5 @Environment(SessionStore.self) private var session
6
7 var body: some View {
8 if let client = session.client, let account = session.current {
9 TabView {
10 Tab("Dashboard", systemImage: "square.grid.2x2") {
11 NavigationStack {
12 DashboardView(client: client, username: account.username)
13 .navigationDestinations(client: client)
14 }
15 }
16 Tab("Repositories", systemImage: "books.vertical") {
17 NavigationStack {
18 RepoListView(client: client)
19 .navigationDestinations(client: client)
20 }
21 }
22 }
23 .id(account.id) // fresh screens on account switch
24 } else {
25 NavigationStack {
26 SignInView()
27 }
28 }
29 }
30
31}
32
33/// Every value-routed screen, attachable to any stack root.
34private struct RouteDestinations: ViewModifier {
35
36 let client: GitbayClient
37
38 func body(content: Content) -> some View {
39 content
40 .navigationDestination(for: RepoRoute.self) { route in
41 destination(route, client: client)
42 }
43 .navigationDestination(for: MRRoute.self) { route in
44 destination(route, client: client)
45 }
46 .navigationDestination(for: IssueRoute.self) { route in
47 destination(route, client: client)
48 }
49 .navigationDestination(for: BuildRoute.self) { route in
50 destination(route, client: client)
51 }
52 }
53
54 @ViewBuilder
55 private func destination(_ route: RepoRoute, client: GitbayClient) -> some View {
56 switch route {
57 case .repo(let path):
58 RepoView(client: client, path: path)
59 case .tree(let repo, let directory, let ref):
60 TreeView(client: client, repo: repo, directory: directory, ref: ref)
61 case .file(let repo, let path, let ref):
62 FileView(client: client, repo: repo, path: path, ref: ref)
63 case .log(let repo):
64 LogView(client: client, repo: repo)
65 case .addAccount:
66 SignInView()
67 }
68 }
69
70 @ViewBuilder
71 private func destination(_ route: MRRoute, client: GitbayClient) -> some View {
72 switch route {
73 case .list(let repo):
74 MRListView(client: client, repo: repo)
75 case .mr(let repo, let number):
76 MRView(client: client, repo: repo, number: number)
77 case .diff(let repo, let number):
78 DiffView(client: client, repo: repo, number: number)
79 }
80 }
81
82 @ViewBuilder
83 private func destination(_ route: IssueRoute, client: GitbayClient) -> some View {
84 switch route {
85 case .list(let repo):
86 IssueListView(client: client, repo: repo)
87 case .issue(let repo, let number):
88 IssueView(client: client, repo: repo, number: number)
89 }
90 }
91
92 @ViewBuilder
93 private func destination(_ route: BuildRoute, client: GitbayClient) -> some View {
94 switch route {
95 case .list(let repo):
96 BuildListView(client: client, repo: repo)
97 case .log(let repo, let number):
98 BuildLogView(client: client, repo: repo, number: number)
99 }
100 }
101}
102
103extension View {
104 func navigationDestinations(client: GitbayClient) -> some View {
105 modifier(RouteDestinations(client: client))
106 }
107}
108
109#Preview {
110 ContentView()
111 .environment(SessionStore())
112}
gitbay/Dashboard/DashboardViewModel.swift added +164
@@ -0,0 +1,164 @@
1import Foundation
2import Observation
3
4/// "What needs me": open MRs, issues assigned to me, recent builds.
5///
6/// The API has no cross-repo aggregate yet (a recorded gap), so this
7/// fans out per repo with bounded concurrency and shows results as they
8/// arrive. ETag revalidation makes the refresh cheap in bytes, but it is
9/// still ~2N requests against a 120/min bucket the shape that argues
10/// for the one aggregate command; see the filed issue. A 429 stops the
11/// scan and shows what arrived.
12@Observable
13@MainActor
14final class DashboardViewModel {
15
16 nonisolated struct RepoMR: Sendable, Hashable, Identifiable {
17 let repo: String
18 let mr: MergeRequest
19 var id: String { "\(repo)!\(mr.number)" }
20 }
21
22 nonisolated struct RepoIssue: Sendable, Hashable, Identifiable {
23 let repo: String
24 let issue: Issue
25 var id: String { "\(repo)#\(issue.number)" }
26 }
27
28 nonisolated struct RepoBuild: Sendable, Hashable, Identifiable {
29 let repo: String
30 let build: Build
31 var id: String { "\(repo)#\(build.number)" }
32 }
33
34 private(set) var openMRs: [RepoMR] = []
35 private(set) var assignedIssues: [RepoIssue] = []
36 private(set) var recentBuilds: [RepoBuild] = []
37 private(set) var scanning = false
38 private(set) var scannedRepos = 0
39 private(set) var totalRepos = 0
40 /// Set when the scan was cut short (rate limit, error) partial
41 /// results are on screen and honesty about it beats silence.
42 private(set) var scanNote: String?
43
44 private let client: GitbayClient
45 private let username: String
46 /// In-flight cap. Low on purpose: the phone shares one 120/min bucket
47 /// with everything else the user does.
48 private let width = 4
49
50 init(client: GitbayClient, username: String) {
51 self.client = client
52 self.username = username
53 }
54
55 func load() async {
56 scanning = true
57 scanNote = nil
58 defer { scanning = false }
59
60 let repos: [RepoSummary]
61 do {
62 repos = try await client.readList(["repo", "list"], of: RepoSummary.self)
63 .filter { !$0.isArchived }
64 } catch {
65 scanNote = LoadState<Never>.from(error).failureMessage
66 return
67 }
68 totalRepos = repos.count
69 scannedRepos = 0
70
71 var mrs: [RepoMR] = []
72 var issues: [RepoIssue] = []
73 var builds: [RepoBuild] = []
74
75 // Fan out width-at-a-time; each repo costs up to three reads.
76 var iterator = repos.makeIterator()
77 var stop = false
78 while !stop {
79 var batch: [RepoSummary] = []
80 for _ in 0..<width {
81 if let next = iterator.next() { batch.append(next) }
82 }
83 if batch.isEmpty { break }
84
85 await withTaskGroup(of: RepoScan?.self) { group in
86 for repo in batch {
87 group.addTask { [client, username] in
88 await Self.scan(repo.path, client: client, username: username)
89 }
90 }
91 for await result in group {
92 scannedRepos += 1
93 guard let result else {
94 stop = true
95 continue
96 }
97 mrs.append(contentsOf: result.mrs.map { RepoMR(repo: result.repo, mr: $0) })
98 issues.append(contentsOf: result.issues.map { RepoIssue(repo: result.repo, issue: $0) })
99 builds.append(contentsOf: result.builds.map { RepoBuild(repo: result.repo, build: $0) })
100 }
101 }
102 publish(mrs: mrs, issues: issues, builds: builds)
103 }
104 if stop {
105 scanNote = "Rate limited part way — showing \(scannedRepos) of \(totalRepos) repositories."
106 }
107 }
108
109 private func publish(mrs: [RepoMR], issues: [RepoIssue], builds: [RepoBuild]) {
110 openMRs = mrs.sorted { $0.mr.createdAt > $1.mr.createdAt }
111 assignedIssues = issues.sorted { $0.issue.createdAt > $1.issue.createdAt }
112 recentBuilds = Array(builds.sorted { $0.build.createdAt > $1.build.createdAt }.prefix(10))
113 }
114
115 private nonisolated struct RepoScan: Sendable {
116 let repo: String
117 let mrs: [MergeRequest]
118 let issues: [Issue]
119 let builds: [Build]
120 }
121
122 /// One repo's slice of the dashboard. nil means "stop the scan"
123 /// the rate limiter said so.
124 private nonisolated static func scan(
125 _ repo: String,
126 client: GitbayClient,
127 username: String
128 ) async -> RepoScan? {
129 do {
130 let mrs = try await client.readList(
131 ["mr", "list", repo, "--state", "open"], of: MergeRequest.self
132 )
133 let issues = try await client.readList(
134 ["issue", "list", repo, "--state", "open"], of: Issue.self
135 ).filter { $0.assignees?.contains(username) == true }
136 // Builds only where something else is happening; a third call
137 // per silent repo is what the missing aggregate would spare.
138 var builds: [Build] = []
139 if !mrs.isEmpty || !issues.isEmpty {
140 builds = Array(try await client.readList(
141 ["build", "list", repo], of: Build.self
142 ).prefix(3))
143 }
144 return RepoScan(repo: repo, mrs: mrs, issues: issues, builds: builds)
145 } catch let error as GitbayError {
146 if case .rateLimited = error { return nil }
147 // One repo failing (permissions changed, whatever) should not
148 // hide the rest of the dashboard.
149 return RepoScan(repo: repo, mrs: [], issues: [], builds: [])
150 } catch {
151 return RepoScan(repo: repo, mrs: [], issues: [], builds: [])
152 }
153 }
154}
155
156extension LoadState {
157 /// The message of a `.failed`/`.empty`, for callers that only need text.
158 var failureMessage: String? {
159 switch self {
160 case .failed(let message), .empty(let message): message
161 default: nil
162 }
163 }
164}
gitbay/Issues/IssueDetailViewModel.swift added +75
@@ -0,0 +1,75 @@
1import Foundation
2import Observation
3
4/// One issue and everything triage needs: comment, close, reopen, labels,
5/// assignees. Writes reload; refusals surface verbatim.
6@Observable
7@MainActor
8final class IssueDetailViewModel {
9
10 private(set) var state: LoadState<IssueDetail> = .loading
11 private(set) var actionError: String?
12 private(set) var working = false
13
14 private let client: GitbayClient
15 let repoPath: String
16 let number: Int64
17
18 init(client: GitbayClient, repoPath: String, number: Int64) {
19 self.client = client
20 self.repoPath = repoPath
21 self.number = number
22 }
23
24 private var ref: [String] { [repoPath, String(number)] }
25
26 func load() async {
27 do {
28 state = .loaded(try await client.read(["issue", "show"] + ref, as: IssueDetail.self))
29 } catch {
30 state = .from(error)
31 }
32 }
33
34 func comment(_ text: String) async {
35 await perform(["issue", "comment"] + ref, stdin: text)
36 }
37
38 func close() async {
39 await perform(["issue", "close"] + ref)
40 }
41
42 func reopen() async {
43 await perform(["issue", "reopen"] + ref)
44 }
45
46 func addLabel(_ label: String) async {
47 await perform(["issue", "label"] + ref + ["--add", label])
48 }
49
50 func removeLabel(_ label: String) async {
51 await perform(["issue", "label"] + ref + ["--remove", label])
52 }
53
54 func addAssignee(_ user: String) async {
55 await perform(["issue", "assign"] + ref + ["--add", user])
56 }
57
58 func removeAssignee(_ user: String) async {
59 await perform(["issue", "assign"] + ref + ["--remove", user])
60 }
61
62 private func perform(_ argv: [String], stdin: String? = nil) async {
63 working = true
64 actionError = nil
65 defer { working = false }
66 do {
67 try await client.run(argv, stdin: stdin)
68 await load()
69 } catch let error as GitbayError {
70 actionError = error.userFacingMessage
71 } catch {
72 actionError = GitbayError.transport(error).userFacingMessage
73 }
74 }
75}
gitbay/Issues/IssueListViewModel.swift added +40
@@ -0,0 +1,40 @@
1import Foundation
2import Observation
3
4/// `issue list <repo> --state <s>`.
5@Observable
6@MainActor
7final class IssueListViewModel {
8
9 enum StateFilter: String, CaseIterable, Identifiable, Sendable {
10 case open, closed, all
11 var id: String { rawValue }
12 }
13
14 private(set) var state: LoadState<[Issue]> = .loading
15 var filter: StateFilter = .open {
16 didSet { if filter != oldValue { Task { await load() } } }
17 }
18
19 private let client: GitbayClient
20 let repoPath: String
21
22 init(client: GitbayClient, repoPath: String) {
23 self.client = client
24 self.repoPath = repoPath
25 }
26
27 func load() async {
28 state = .loading
29 do {
30 let issues = try await client.readList(
31 ["issue", "list", repoPath, "--state", filter.rawValue], of: Issue.self
32 )
33 state = issues.isEmpty
34 ? .empty("No \(filter == .all ? "" : filter.rawValue + " ")issues.")
35 : .loaded(issues.sorted { $0.number > $1.number })
36 } catch {
37 state = .from(error)
38 }
39 }
40}
gitbay/Issues/IssueModels.swift added +56
@@ -0,0 +1,56 @@
1import Foundation
2
3/// One issue; `issue list` rows and the header of `issue show`.
4nonisolated struct Issue: Decodable, Sendable, Hashable, Identifiable {
5 let number: Int64
6 let title: String
7 let state: String
8 let author: String
9 let milestone: String?
10 let labels: [String]?
11 let assignees: [String]?
12 let body: String?
13 let createdAt: Date
14
15 enum CodingKeys: String, CodingKey {
16 case number, title, state, author, milestone, labels, assignees, body
17 case createdAt = "created_at"
18 }
19
20 var id: Int64 { number }
21 var isOpen: Bool { state == "open" }
22}
23
24/// `issue show <owner/name> <n>` the issue plus its comments.
25nonisolated struct IssueDetail: Decodable, Sendable, Hashable {
26 let number: Int64
27 let title: String
28 let state: String
29 let author: String
30 let milestone: String?
31 let labels: [String]?
32 let assignees: [String]?
33 let body: String?
34 let createdAt: Date
35 let comments: [Comment]?
36
37 enum CodingKeys: String, CodingKey {
38 case number, title, state, author, milestone, labels, assignees, body, comments
39 case createdAt = "created_at"
40 }
41
42 var isOpen: Bool { state == "open" }
43
44 nonisolated struct Comment: Decodable, Sendable, Hashable, Identifiable {
45 let author: String
46 let body: String
47 let createdAt: Date
48
49 enum CodingKeys: String, CodingKey {
50 case author, body
51 case createdAt = "created_at"
52 }
53
54 var id: String { author + createdAt.timeIntervalSince1970.description + body }
55 }
56}
gitbay/MRs/MRDetailViewModel.swift added +103
@@ -0,0 +1,103 @@
1import Foundation
2import Observation
3
4/// One merge request: `mr show`, the parsed diff, the review threads, and
5/// every write the review cycle needs. Writes reload the server's state
6/// is the state.
7@Observable
8@MainActor
9final class MRDetailViewModel {
10
11 private(set) var state: LoadState<MRDetail> = .loading
12 private(set) var diff: UnifiedDiff?
13 private(set) var threads: [ReviewThread] = []
14 /// A write failed; the server's sentence, shown until the next action.
15 private(set) var actionError: String?
16 private(set) var working = false
17
18 private let client: GitbayClient
19 let repoPath: String
20 let number: Int64
21
22 init(client: GitbayClient, repoPath: String, number: Int64) {
23 self.client = client
24 self.repoPath = repoPath
25 self.number = number
26 }
27
28 private var ref: [String] { [repoPath, String(number)] }
29
30 func load() async {
31 do {
32 let detail = try await client.read(["mr", "show"] + ref, as: MRDetail.self)
33 state = .loaded(detail)
34 } catch {
35 state = .from(error)
36 return
37 }
38 // The diff and threads are secondary: their failure leaves the
39 // header usable rather than sinking the screen.
40 async let diffText = try? client.readText(["mr", "diff"] + ref)
41 async let threadList = try? client.readList(["mr", "threads"] + ref, of: ReviewThread.self)
42 diff = (await diffText).map(UnifiedDiff.parse)
43 threads = await threadList ?? []
44 }
45
46 var unresolvedCount: Int {
47 threads.count { !$0.isResolved }
48 }
49
50 // MARK: - Writes
51
52 enum Verdict: String, Sendable {
53 case approve = "--approve"
54 case requestChanges = "--request-changes"
55 }
56
57 func review(_ verdict: Verdict) async {
58 await perform(["mr", "review"] + ref + [verdict.rawValue])
59 }
60
61 func comment(_ text: String) async {
62 // Long text goes in stdin, the CLI's --file - discipline.
63 await perform(["mr", "comment"] + ref, stdin: text)
64 }
65
66 func merge(strategy: String? = nil) async {
67 var argv = ["mr", "merge"] + ref
68 if let strategy { argv.append(contentsOf: ["--strategy", strategy]) }
69 await perform(argv)
70 }
71
72 func close() async {
73 await perform(["mr", "close"] + ref)
74 }
75
76 func setResolved(_ thread: ReviewThread, _ resolved: Bool) async {
77 await perform(["mr", resolved ? "resolve" : "unresolve"] + ref + [String(thread.id)])
78 }
79
80 func reply(to thread: ReviewThread, _ text: String) async {
81 await perform(
82 ["mr", "diff-comment"] + ref + ["--reply", String(thread.id)],
83 stdin: text
84 )
85 }
86
87 private func perform(_ argv: [String], stdin: String? = nil) async {
88 working = true
89 actionError = nil
90 defer { working = false }
91 do {
92 try await client.run(argv, stdin: stdin)
93 await load()
94 } catch let error as GitbayError {
95 // A refusal explains the rule (approvals missing, threads
96 // unresolved, checks red). Surface it verbatim; never route
97 // around it.
98 actionError = error.userFacingMessage
99 } catch {
100 actionError = GitbayError.transport(error).userFacingMessage
101 }
102 }
103}
gitbay/MRs/MRListViewModel.swift added +40
@@ -0,0 +1,40 @@
1import Foundation
2import Observation
3
4/// `mr list <repo> --state <s>`.
5@Observable
6@MainActor
7final class MRListViewModel {
8
9 enum StateFilter: String, CaseIterable, Identifiable, Sendable {
10 case open, merged, closed, all
11 var id: String { rawValue }
12 }
13
14 private(set) var state: LoadState<[MergeRequest]> = .loading
15 var filter: StateFilter = .open {
16 didSet { if filter != oldValue { Task { await load() } } }
17 }
18
19 private let client: GitbayClient
20 let repoPath: String
21
22 init(client: GitbayClient, repoPath: String) {
23 self.client = client
24 self.repoPath = repoPath
25 }
26
27 func load() async {
28 state = .loading
29 do {
30 let mrs = try await client.readList(
31 ["mr", "list", repoPath, "--state", filter.rawValue], of: MergeRequest.self
32 )
33 state = mrs.isEmpty
34 ? .empty("No \(filter == .all ? "" : filter.rawValue + " ")merge requests.")
35 : .loaded(mrs.sorted { $0.number > $1.number })
36 } catch {
37 state = .from(error)
38 }
39 }
40}
gitbay/MRs/MRModels.swift added +121
@@ -0,0 +1,121 @@
1import Foundation
2
3/// One row of `mr list`, and the header half of `mr show`.
4nonisolated struct MergeRequest: Decodable, Sendable, Hashable, Identifiable {
5 let number: Int64
6 let title: String
7 let state: String
8 let author: String
9 /// "branch" for a same-repo MR, "owner/name:branch" cross-repo,
10 /// "" when the source is gone.
11 let source: String
12 let targetRef: String
13 let headSHA: String
14 let body: String?
15 let createdAt: Date
16
17 enum CodingKeys: String, CodingKey {
18 case number, title, state, author, source, body
19 case targetRef = "target_ref"
20 case headSHA = "head_sha"
21 case createdAt = "created_at"
22 }
23
24 var id: Int64 { number }
25 var isOpen: Bool { state == "open" }
26}
27
28/// The whole of `mr show`.
29nonisolated struct MRDetail: Decodable, Sendable, Hashable {
30 let number: Int64
31 let title: String
32 let state: String
33 let author: String
34 let source: String
35 let targetRef: String
36 let headSHA: String
37 let body: String?
38 let createdAt: Date
39 let checks: [Check]?
40 let checksCombined: String?
41 let unresolvedThreads: Int?
42 let commits: [MRCommit]?
43 let comments: [MRComment]?
44 let reviews: [Review]?
45
46 enum CodingKeys: String, CodingKey {
47 case number, title, state, author, source, body, checks, commits, comments, reviews
48 case targetRef = "target_ref"
49 case headSHA = "head_sha"
50 case createdAt = "created_at"
51 case checksCombined = "checks_combined"
52 case unresolvedThreads = "unresolved_threads"
53 }
54
55 var isOpen: Bool { state == "open" }
56
57 nonisolated struct Check: Decodable, Sendable, Hashable {
58 let context: String
59 let state: String
60 let url: String?
61 }
62
63 nonisolated struct MRCommit: Decodable, Sendable, Hashable, Identifiable {
64 let sha: String
65 let subject: String
66 var id: String { sha }
67 var shortSHA: String { String(sha.prefix(10)) }
68 }
69
70 nonisolated struct MRComment: Decodable, Sendable, Hashable, Identifiable {
71 let author: String
72 let body: String
73 let createdAt: Date
74
75 enum CodingKeys: String, CodingKey {
76 case author, body
77 case createdAt = "created_at"
78 }
79
80 var id: String { author + createdAt.timeIntervalSince1970.description + body }
81 }
82
83 nonisolated struct Review: Decodable, Sendable, Hashable, Identifiable {
84 let reviewer: String
85 let verdict: String
86 /// The review predates the current head it approved older code.
87 let stale: Bool
88 var id: String { reviewer }
89 }
90}
91
92/// One review thread of `mr threads`.
93nonisolated struct ReviewThread: Decodable, Sendable, Hashable, Identifiable {
94 let id: Int64
95 let path: String
96 let side: String // new | old
97 let line: Int64
98 /// Anchored to an older head; the diff has moved under it.
99 let stale: Bool
100 let resolvedBy: String?
101 let comments: [Comment]
102
103 enum CodingKeys: String, CodingKey {
104 case id, path, side, line, stale, comments
105 case resolvedBy = "resolved_by"
106 }
107
108 var isResolved: Bool { resolvedBy != nil }
109
110 nonisolated struct Comment: Decodable, Sendable, Hashable, Identifiable {
111 let id: Int64
112 let author: String
113 let body: String
114 let createdAt: Date
115
116 enum CodingKeys: String, CodingKey {
117 case id, author, body
118 case createdAt = "created_at"
119 }
120 }
121}
gitbay/MRs/UnifiedDiff.swift added +148
@@ -0,0 +1,148 @@
1import Foundation
2
3/// `mr diff` returns one unified diff as text (a recorded gap, #40-family:
4/// the web parses it server-side). This parses it client-side into files,
5/// hunks and lines for rendering.
6nonisolated struct UnifiedDiff: Sendable, Hashable {
7
8 let files: [File]
9
10 nonisolated struct File: Sendable, Hashable, Identifiable {
11 let oldPath: String
12 let newPath: String
13 let hunks: [Hunk]
14 let isBinary: Bool
15
16 var id: String { oldPath + "" + newPath }
17
18 /// The path to show: the new one, unless the file was deleted.
19 var displayPath: String {
20 newPath == "/dev/null" ? oldPath : newPath
21 }
22 var isNew: Bool { oldPath == "/dev/null" }
23 var isDeleted: Bool { newPath == "/dev/null" }
24
25 var additions: Int {
26 hunks.reduce(0) { $0 + $1.lines.count { $0.kind == .addition } }
27 }
28 var deletions: Int {
29 hunks.reduce(0) { $0 + $1.lines.count { $0.kind == .deletion } }
30 }
31 }
32
33 nonisolated struct Hunk: Sendable, Hashable, Identifiable {
34 let header: String // "@@ -l,c +l,c @@ context"
35 let lines: [Line]
36 var id: String { header }
37 }
38
39 nonisolated struct Line: Sendable, Hashable, Identifiable {
40 enum Kind: Sendable, Hashable {
41 case context, addition, deletion
42 }
43
44 let kind: Kind
45 /// Line numbers in the old and new file; nil on the side the line
46 /// does not exist on.
47 let oldNumber: Int?
48 let newNumber: Int?
49 let text: String
50
51 var id: String { "\(oldNumber ?? 0):\(newNumber ?? 0):\(text)" }
52 }
53
54 /// Total across files, for the summary row.
55 var additions: Int { files.reduce(0) { $0 + $1.additions } }
56 var deletions: Int { files.reduce(0) { $0 + $1.deletions } }
57
58 // MARK: - Parsing
59
60 static func parse(_ text: String) -> UnifiedDiff {
61 var files: [File] = []
62 var lines = text.components(separatedBy: "\n")[...]
63
64 while let line = lines.first {
65 guard line.hasPrefix("diff --git ") else {
66 lines = lines.dropFirst()
67 continue
68 }
69 lines = lines.dropFirst()
70
71 var oldPath = ""
72 var newPath = ""
73 var isBinary = false
74 var hunks: [Hunk] = []
75
76 // Header lines up to the first hunk or the next file.
77 while let header = lines.first, !header.hasPrefix("diff --git ") {
78 if header.hasPrefix("--- ") {
79 oldPath = Self.stripPrefix(String(header.dropFirst(4)))
80 } else if header.hasPrefix("+++ ") {
81 newPath = Self.stripPrefix(String(header.dropFirst(4)))
82 } else if header.hasPrefix("Binary files ") || header.hasPrefix("GIT binary patch") {
83 isBinary = true
84 } else if header.hasPrefix("@@") {
85 break
86 }
87 lines = lines.dropFirst()
88 }
89
90 // Hunks.
91 while let hunkHeader = lines.first, hunkHeader.hasPrefix("@@") {
92 lines = lines.dropFirst()
93 var (oldNumber, newNumber) = Self.startNumbers(hunkHeader)
94 var hunkLines: [Line] = []
95 loop: while let bodyLine = lines.first {
96 switch bodyLine.first {
97 case "+":
98 hunkLines.append(Line(kind: .addition, oldNumber: nil,
99 newNumber: newNumber, text: String(bodyLine.dropFirst())))
100 newNumber += 1
101 case "-":
102 hunkLines.append(Line(kind: .deletion, oldNumber: oldNumber,
103 newNumber: nil, text: String(bodyLine.dropFirst())))
104 oldNumber += 1
105 case " ":
106 hunkLines.append(Line(kind: .context, oldNumber: oldNumber,
107 newNumber: newNumber, text: String(bodyLine.dropFirst())))
108 oldNumber += 1
109 newNumber += 1
110 case "\\": // "\ No newline at end of file"
111 break
112 default:
113 break loop
114 }
115 lines = lines.dropFirst()
116 }
117 hunks.append(Hunk(header: hunkHeader, lines: hunkLines))
118 }
119
120 files.append(File(oldPath: oldPath, newPath: newPath, hunks: hunks, isBinary: isBinary))
121 }
122 return UnifiedDiff(files: files)
123 }
124
125 /// "a/path" "path"; "/dev/null" stays.
126 private static func stripPrefix(_ path: String) -> String {
127 if path == "/dev/null" { return path }
128 if path.hasPrefix("a/") || path.hasPrefix("b/") {
129 return String(path.dropFirst(2))
130 }
131 return path
132 }
133
134 /// "@@ -12,5 +14,6 @@ " (12, 14)
135 private static func startNumbers(_ header: String) -> (Int, Int) {
136 var old = 1
137 var new = 1
138 let parts = header.split(separator: " ")
139 for part in parts {
140 if part.hasPrefix("-"), let n = Int(part.dropFirst().split(separator: ",")[0]) {
141 old = n
142 } else if part.hasPrefix("+"), let n = Int(part.dropFirst().split(separator: ",")[0]) {
143 new = n
144 }
145 }
146 return (old, new)
147 }
148}
gitbay/Networking/ETagStore.swift added +63
@@ -0,0 +1,63 @@
1import Foundation
2import os
3
4/// The validator and body of one cached read.
5nonisolated struct ETagEntry: Sendable, Equatable {
6 let etag: String
7 let payload: Data
8}
9
10/// In-memory ETag cache for `GET /api/v1/read`.
11///
12/// The server salts each ETag with the account, so one caller's validator
13/// never matches another's; entries are keyed by account here too, so a
14/// 304 can never be answered out of someone else's body.
15nonisolated final class ETagStore: Sendable {
16
17 private struct Slot {
18 let entry: ETagEntry
19 var usedAt: UInt64
20 }
21
22 private let state: OSAllocatedUnfairLock<(slots: [String: Slot], clock: UInt64)>
23 private let capacity: Int
24
25 init(capacity: Int = 200) {
26 self.capacity = capacity
27 self.state = OSAllocatedUnfairLock(initialState: (slots: [:], clock: 0))
28 }
29
30 static func key(account: String, argv: [String]) -> String {
31 // NUL cannot appear in argv, so joining on it cannot collide.
32 ([account] + argv).joined(separator: "\u{0}")
33 }
34
35 func entry(for key: String) -> ETagEntry? {
36 state.withLock { state in
37 guard var slot = state.slots[key] else { return nil }
38 state.clock += 1
39 slot.usedAt = state.clock
40 state.slots[key] = slot
41 return slot.entry
42 }
43 }
44
45 func store(_ entry: ETagEntry, for key: String) {
46 state.withLock { state in
47 state.clock += 1
48 state.slots[key] = Slot(entry: entry, usedAt: state.clock)
49 guard state.slots.count > capacity else { return }
50 let oldest = state.slots.min { $0.value.usedAt < $1.value.usedAt }
51 if let oldest { state.slots.removeValue(forKey: oldest.key) }
52 }
53 }
54
55 /// Drop everything. Called on sign-out: the entries are account data.
56 func clear() {
57 state.withLock { $0.slots.removeAll() }
58 }
59
60 var count: Int {
61 state.withLock { $0.slots.count }
62 }
63}
gitbay/Networking/Envelope.swift added +48
@@ -0,0 +1,48 @@
1import Foundation
2
3/// Exit codes shared by the CLI, SSH and both HTTP surfaces.
4/// `internal/protocol/protocol.go` is the source of truth.
5nonisolated enum ExitCode: Int, Sendable {
6 case ok = 0
7 case failure = 1
8 case usage = 2
9 case notFound = 3
10 case denied = 4
11 case protocolError = 5
12}
13
14/// Every response from either API surface is the command's own JSON
15/// envelope with `exit_code` added.
16///
17/// `data` is absent when a command emits an empty result Go omits a nil
18/// slice so an empty list arrives as no key at all rather than `[]`.
19/// `output` carries commands that write raw text instead of JSON (`mr
20/// diff`, `help`); the server wraps those.
21nonisolated struct Envelope<Payload: Decodable & Sendable>: Decodable, Sendable {
22 let protocolVersion: Int
23 let data: Payload?
24 let error: String?
25 let stderr: String?
26 let output: String?
27 let exitCode: Int?
28
29 enum CodingKeys: String, CodingKey {
30 case protocolVersion = "protocol_version"
31 case data, error, stderr, output
32 case exitCode = "exit_code"
33 }
34
35 /// The message to put in front of a person. `error` and `stderr` were
36 /// both written to be read by one.
37 var message: String? {
38 let text = error ?? stderr
39 guard let text, !text.isEmpty else { return nil }
40 return text
41 }
42}
43
44/// Placeholder for commands whose `data` we do not decode. It accepts any
45/// JSON, so discarding a payload never fails on its shape.
46nonisolated struct NoPayload: Decodable, Sendable {
47 init(from _: any Decoder) throws {}
48}
gitbay/Networking/GitbayClient.swift added +333
@@ -0,0 +1,333 @@
1import CryptoKit
2import Foundation
3import os
4
5nonisolated private let logger = Logger(subsystem: "org.gitbay.gitbay", category: "GitbayClient")
6
7/// Speaks the two halves of the gitbay API: `GET /api/v1/read` for the
8/// commands the registry marks read-only, `POST /api/v1/cmd` for the rest.
9///
10/// Both dispatch the same command registry server-side, so this client adds
11/// no semantics of its own it carries argv there and an envelope back.
12///
13/// A client is bound to one account on one instance. Signing out means
14/// dropping the client, not mutating it.
15nonisolated final class GitbayClient: Sendable {
16
17 /// How long the client will wait out a 429 by itself. Beyond this the
18 /// wait belongs on screen, not inside a request that looks hung.
19 static let maximumAutomaticWait: TimeInterval = 3
20
21 let instance: GitbayInstance
22
23 private let token: String
24 private let session: URLSession
25 private let etags: ETagStore
26 private let redirectGuard: RedirectGuard
27 /// Cache keys are scoped by account without holding the token.
28 private let accountKey: String
29
30 init(
31 instance: GitbayInstance,
32 token: String,
33 session: URLSession = .shared,
34 etags: ETagStore = ETagStore()
35 ) {
36 self.instance = instance
37 self.token = token
38 self.session = session
39 self.etags = etags
40 self.redirectGuard = RedirectGuard(instance: instance)
41 let digest = SHA256.hash(data: Data(token.utf8))
42 self.accountKey = digest.prefix(8).map { String(format: "%02x", $0) }.joined()
43 }
44
45 // MARK: - Reads
46
47 /// Run a read-only command, decoding `data` as `Payload`.
48 ///
49 /// Revalidates with `If-None-Match` when a previous body is cached: a
50 /// 304 costs a round trip and no body, which is the point on a phone.
51 func read<Payload: Decodable & Sendable>(
52 _ argv: [String],
53 as _: Payload.Type
54 ) async throws -> Payload {
55 let envelope: Envelope<Payload> = try await readEnvelope(argv)
56 guard let data = envelope.data else {
57 throw GitbayError.decoding(MissingData(argv: argv))
58 }
59 return data
60 }
61
62 /// Run a read-only command that returns a list.
63 ///
64 /// An empty result arrives as no `data` key at all Go omits a nil
65 /// slice so absent means empty here rather than a decode failure.
66 func readList<Element: Decodable & Sendable>(
67 _ argv: [String],
68 of _: Element.Type
69 ) async throws -> [Element] {
70 let envelope: Envelope<[Element]> = try await readEnvelope(argv)
71 return envelope.data ?? []
72 }
73
74 /// Run a read-only command that emits raw text rather than JSON
75 /// (`mr diff`, `build log`). The server wraps those as `output`.
76 func readText(_ argv: [String]) async throws -> String {
77 let envelope: Envelope<NoPayload> = try await readEnvelope(argv)
78 return envelope.output ?? ""
79 }
80
81 private func readEnvelope<Payload: Decodable & Sendable>(
82 _ argv: [String],
83 revalidate: Bool = true
84 ) async throws -> Envelope<Payload> {
85 let url = instance.readURL(argv: argv)
86 let cacheKey = ETagStore.key(account: accountKey, argv: argv)
87 let cached = revalidate ? etags.entry(for: cacheKey) : nil
88
89 var request = URLRequest(url: url)
90 // URLSession's own cache would answer some of these transparently
91 // and the revalidation would never be visible here. The ETag layer
92 // in this file is the only one.
93 request.cachePolicy = .reloadIgnoringLocalCacheData
94 if let cached {
95 request.setValue(cached.etag, forHTTPHeaderField: "If-None-Match")
96 }
97
98 let (body, response) = try await perform(request, argv: argv)
99
100 if response.statusCode == 304 {
101 guard let cached else {
102 // Only reachable if the entry was evicted between the read
103 // above and the response. Ask again without a validator,
104 // which cannot come back 304.
105 return try await readEnvelope(argv, revalidate: false)
106 }
107 return try decode(cached.payload, status: 200, argv: argv, surface: .read)
108 }
109
110 if let etag = response.value(forHTTPHeaderField: "ETag"), response.statusCode == 200 {
111 etags.store(ETagEntry(etag: etag, payload: body), for: cacheKey)
112 }
113 return try decode(body, status: response.statusCode, argv: argv, surface: .read)
114 }
115
116 // MARK: - Writes
117
118 /// Run a command that changes state. Long text goes in `stdin`, never
119 /// in argv the same discipline the CLI uses for `--file -`.
120 @discardableResult
121 func run<Payload: Decodable & Sendable>(
122 _ argv: [String],
123 stdin: String? = nil,
124 as _: Payload.Type
125 ) async throws -> Payload? {
126 var request = URLRequest(url: instance.cmdURL)
127 request.httpMethod = "POST"
128 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
129 var payload: [String: Any] = ["argv": argv]
130 if let stdin { payload["stdin"] = stdin }
131 request.httpBody = try JSONSerialization.data(withJSONObject: payload)
132
133 let (body, response) = try await perform(request, argv: argv)
134 let envelope: Envelope<Payload> = try decode(
135 body, status: response.statusCode, argv: argv, surface: .command
136 )
137 return envelope.data
138 }
139
140 /// Run a command whose result carries nothing the caller needs.
141 func run(_ argv: [String], stdin: String? = nil) async throws {
142 _ = try await run(argv, stdin: stdin, as: NoPayload.self)
143 }
144
145 // MARK: - Transport
146
147 private func perform(
148 _ request: URLRequest,
149 argv: [String]
150 ) async throws -> (Data, HTTPURLResponse) {
151 guard let url = request.url, instance.isOwn(url) else {
152 throw GitbayError.unexpectedHost(request.url ?? instance.baseURL)
153 }
154 var request = request
155 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
156
157 // One retry, no more. A phone on a flaky network is exactly the
158 // caller that turns a retry loop into a rate-limit spiral.
159 for attempt in 0...1 {
160 let body: Data
161 let response: HTTPURLResponse
162 do {
163 let (data, urlResponse) = try await session.data(for: request, delegate: redirectGuard)
164 guard let http = urlResponse as? HTTPURLResponse else {
165 throw GitbayError.transport(URLError(.badServerResponse))
166 }
167 (body, response) = (data, http)
168 } catch let error as GitbayError {
169 throw error
170 } catch {
171 throw GitbayError.transport(error)
172 }
173
174 let isLastAttempt = attempt == 1
175
176 if response.statusCode == 429 {
177 let wait = retryAfter(response) ?? Self.maximumAutomaticWait
178 guard !isLastAttempt, wait <= Self.maximumAutomaticWait else {
179 throw GitbayError.rateLimited(retryAfter: wait)
180 }
181 try await Task.sleep(for: .seconds(wait))
182 continue
183 }
184
185 if (500...599).contains(response.statusCode), !isLastAttempt {
186 try await Task.sleep(for: .milliseconds(500))
187 continue
188 }
189 return (body, response)
190 }
191 // The loop returns or throws on every path; this satisfies the
192 // compiler rather than describing a reachable state.
193 throw GitbayError.failure("")
194 }
195
196 /// gitbay sends Retry-After as whole seconds.
197 private func retryAfter(_ response: HTTPURLResponse) -> TimeInterval? {
198 guard let value = response.value(forHTTPHeaderField: "Retry-After"),
199 let seconds = TimeInterval(value.trimmingCharacters(in: .whitespaces)),
200 seconds >= 0 else {
201 return nil
202 }
203 return seconds
204 }
205
206 // MARK: - Envelope
207
208 private enum Surface { case read, command }
209
210 /// Dates on the wire are RFC 3339, some with fractional seconds
211 /// (SQLite's `%Y-%m-%dT%H:%M:%fZ` default), some without (`repo log`).
212 private static func decoder() -> JSONDecoder {
213 let decoder = JSONDecoder()
214 decoder.dateDecodingStrategy = .custom { decoder in
215 let text = try decoder.singleValueContainer().decode(String.self)
216 if let date = try? Date(text, strategy: .iso8601) {
217 return date
218 }
219 if let date = try? Date(text, strategy: .iso8601.year().month().day()
220 .dateTimeSeparator(.standard).time(includingFractionalSeconds: true)) {
221 return date
222 }
223 throw DecodingError.dataCorrupted(.init(
224 codingPath: decoder.codingPath,
225 debugDescription: "unrecognized date: \(text)"
226 ))
227 }
228 return decoder
229 }
230
231 private func decode<Payload: Decodable & Sendable>(
232 _ body: Data,
233 status: Int,
234 argv: [String],
235 surface: Surface
236 ) throws -> Envelope<Payload> {
237 // Probe the envelope before touching the payload, so a failure or
238 // a foreign protocol version is recognised whatever shape `data`
239 // has an error envelope never matches the expected payload.
240 let probe: Envelope<NoPayload>
241 do {
242 probe = try Self.decoder().decode(Envelope<NoPayload>.self, from: body)
243 } catch {
244 throw GitbayError.decoding(error)
245 }
246 guard probe.protocolVersion == 1 else {
247 throw GitbayError.protocolMismatch(probe.protocolVersion)
248 }
249 if let error = failure(probe, status: status, argv: argv, surface: surface) {
250 throw error
251 }
252 do {
253 return try Self.decoder().decode(Envelope<Payload>.self, from: body)
254 } catch {
255 throw GitbayError.decoding(error)
256 }
257 }
258
259 private func failure(
260 _ envelope: Envelope<NoPayload>,
261 status: Int,
262 argv: [String],
263 surface: Surface
264 ) -> GitbayError? {
265 let message = envelope.message ?? ""
266
267 // The gate rejections bad token, unknown command, a write sent to
268 // the read surface never reach a command, so they carry no exit
269 // code. Those are read off the status.
270 guard let code = envelope.exitCode.flatMap(ExitCode.init(rawValue:)) else {
271 switch status {
272 case 200: return nil
273 case 401: return .unauthorized(message)
274 case 403: return .denied(message)
275 case 404: return .notFound(message)
276 case 429: return .rateLimited(retryAfter: 0)
277 case 400 where surface == .read:
278 logger.error("read refused: \(message, privacy: .public)")
279 return .notReadable(message)
280 case 400:
281 logger.error("usage error: \(argv, privacy: .private)\(message, privacy: .public)")
282 return .usage(message)
283 default: return .failure(message)
284 }
285 }
286
287 switch code {
288 case .ok:
289 return nil
290 case .usage:
291 // The app built argv wrong. Log it; never put argv on screen.
292 logger.error("usage error: \(argv, privacy: .private)\(message, privacy: .public)")
293 return .usage(message)
294 case .notFound:
295 return .notFound(message)
296 case .denied:
297 return .denied(message)
298 case .failure, .protocolError:
299 return .failure(message)
300 }
301 }
302
303 struct MissingData: LocalizedError, Sendable {
304 let argv: [String]
305 var errorDescription: String? { "The response carried no data." }
306 }
307}
308
309/// Refuses to follow a redirect off the instance. Without this a redirect
310/// would carry the bearer token to whatever host answered.
311private nonisolated final class RedirectGuard: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
312
313 private let instance: GitbayInstance
314
315 init(instance: GitbayInstance) {
316 self.instance = instance
317 }
318
319 func urlSession(
320 _ session: URLSession,
321 task: URLSessionTask,
322 willPerformHTTPRedirection response: HTTPURLResponse,
323 newRequest request: URLRequest,
324 completionHandler: @escaping (URLRequest?) -> Void
325 ) {
326 guard let url = request.url, instance.isOwn(url) else {
327 logger.error("refused redirect off \(self.instance.baseURL.absoluteString, privacy: .public)")
328 completionHandler(nil)
329 return
330 }
331 completionHandler(request)
332 }
333}
gitbay/Networking/GitbayError.swift added +65
@@ -0,0 +1,65 @@
1import Foundation
2
3/// A failure from either API surface, already mapped off the exit code so
4/// callers switch on meaning rather than on numbers.
5///
6/// exit http case
7/// 2 400 usage a bug in the app
8/// 3 404 notFound an empty state, not an error banner
9/// 4 403 denied the message explains the rule; show it verbatim
10/// 1, 5 500 failure retried once by the client, then surfaced
11/// 401 unauthorized the token is invalid, expired or revoked
12/// 429 rateLimited Retry-After was longer than the client will wait
13nonisolated enum GitbayError: LocalizedError, Sendable {
14 case usage(String)
15 case notFound(String)
16 case denied(String)
17 case failure(String)
18 case unauthorized(String)
19 case rateLimited(retryAfter: TimeInterval)
20 /// The command exists but is not read-only, so it cannot be reached
21 /// with GET. Also an app bug: it means we routed a write through `read`.
22 case notReadable(String)
23 case transport(any Error)
24 case decoding(any Error)
25 /// A major protocol version the app does not understand.
26 case protocolMismatch(Int)
27 /// The client refused to send the token somewhere other than the instance.
28 case unexpectedHost(URL)
29
30 /// Text for a person. `denied` and `notFound` come from the server
31 /// verbatim those messages exist to explain a rule, and rewording
32 /// them loses the explanation.
33 var userFacingMessage: String {
34 switch self {
35 case .denied(let message), .notFound(let message):
36 message
37 case .unauthorized:
38 "Your token is no longer valid. Sign in again."
39 case .rateLimited(let seconds):
40 "Too many requests. Try again in \(Int(seconds.rounded()))s."
41 case .failure(let message):
42 message.isEmpty ? "The server could not complete that." : message
43 case .transport:
44 "Check your connection and try again."
45 // A usage or protocol failure is ours, not the user's. argv never
46 // reaches the screen.
47 case .usage, .notReadable, .decoding, .protocolMismatch, .unexpectedHost:
48 "Something went wrong. Please try again."
49 }
50 }
51
52 var errorDescription: String? { userFacingMessage }
53
54 /// True when the right response is an empty screen rather than a banner.
55 var isEmptyState: Bool {
56 if case .notFound = self { return true }
57 return false
58 }
59
60 /// True when the token needs replacing and the app should sign out.
61 var requiresReauthentication: Bool {
62 if case .unauthorized = self { return true }
63 return false
64 }
65}
gitbay/Networking/GitbayInstance.swift added +71
@@ -0,0 +1,71 @@
1import Foundation
2
3/// A gitbay deployment. gitbay is self-hosted, so an account is only
4/// meaningful paired with the instance it lives on.
5nonisolated struct GitbayInstance: Sendable, Hashable, Codable {
6
7 /// Origin of the instance, e.g. `https://gitbay.org`. Path, query and
8 /// fragment are stripped on init so endpoint construction cannot be
9 /// steered by whatever the user pasted.
10 let baseURL: URL
11
12 enum InvalidURL: LocalizedError, Sendable {
13 case notAURL(String)
14 case insecureScheme(String)
15
16 var errorDescription: String? {
17 switch self {
18 case .notAURL(let text):
19 "\(text) is not a server address."
20 case .insecureScheme(let scheme):
21 "\(scheme):// would send your token in the clear. Use https."
22 }
23 }
24 }
25
26 init(url text: String) throws {
27 let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
28 let withScheme = trimmed.contains("://") ? trimmed : "https://\(trimmed)"
29 guard var components = URLComponents(string: withScheme),
30 let host = components.host, !host.isEmpty else {
31 throw InvalidURL.notAURL(trimmed)
32 }
33 let scheme = (components.scheme ?? "https").lowercased()
34 // A bearer token over plaintext is a token given away. localhost is
35 // exempt so a self-hoster can point the app at their own machine.
36 guard scheme == "https" || (scheme == "http" && Self.isLoopback(host)) else {
37 throw InvalidURL.insecureScheme(scheme)
38 }
39 components.scheme = scheme
40 components.path = ""
41 components.query = nil
42 components.fragment = nil
43 guard let url = components.url else { throw InvalidURL.notAURL(trimmed) }
44 self.baseURL = url
45 }
46
47 private static func isLoopback(_ host: String) -> Bool {
48 host == "localhost" || host == "127.0.0.1" || host == "::1"
49 }
50
51 /// `GET /api/v1/read` argv arrives as repeated query parameters.
52 func readURL(argv: [String]) -> URL {
53 var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
54 components.path = "/api/v1/read"
55 components.queryItems = argv.map { URLQueryItem(name: "argv", value: $0) }
56 return components.url!
57 }
58
59 /// `POST /api/v1/cmd`
60 var cmdURL: URL {
61 baseURL.appending(path: "/api/v1/cmd")
62 }
63
64 /// Whether a URL is on this instance. Checked before the Authorization
65 /// header goes on a request, and again on every redirect.
66 func isOwn(_ url: URL) -> Bool {
67 url.scheme?.lowercased() == baseURL.scheme
68 && url.host()?.lowercased() == baseURL.host()?.lowercased()
69 && url.port == baseURL.port
70 }
71}
gitbay/Repos/FileViewModel.swift added +37
@@ -0,0 +1,37 @@
1import Foundation
2import Observation
3
4/// One file via `repo cat`: text renders (highlighted when the language is
5/// known), binary says so, truncated says so never a guess.
6@Observable
7@MainActor
8final class FileViewModel {
9
10 private(set) var state: LoadState<FileContent> = .loading
11
12 private let client: GitbayClient
13 let repoPath: String
14 let filePath: String
15 let ref: String?
16
17 init(client: GitbayClient, repoPath: String, filePath: String, ref: String? = nil) {
18 self.client = client
19 self.repoPath = repoPath
20 self.filePath = filePath
21 self.ref = ref
22 }
23
24 var fileName: String {
25 String(filePath.split(separator: "/").last ?? "")
26 }
27
28 func load() async {
29 var argv = ["repo", "cat", repoPath, filePath]
30 if let ref { argv.append(contentsOf: ["--ref", ref]) }
31 do {
32 state = .loaded(try await client.read(argv, as: FileContent.self))
33 } catch {
34 state = .from(error)
35 }
36 }
37}
gitbay/Repos/LoadState.swift added +25
@@ -0,0 +1,25 @@
1import Foundation
2
3/// One screen's loading lifecycle. `empty` is a first-class state, not a
4/// failure exit 3 lands here, and so does a list with nothing in it.
5nonisolated enum LoadState<Value: Sendable>: Sendable {
6 case loading
7 case loaded(Value)
8 case empty(String)
9 case failed(String)
10
11 var value: Value? {
12 if case .loaded(let value) = self { return value }
13 return nil
14 }
15}
16
17extension LoadState {
18 /// Map any thrown error onto the state a screen should show.
19 static func from(_ error: any Error) -> LoadState {
20 guard let error = error as? GitbayError else {
21 return .failed(GitbayError.transport(error).userFacingMessage)
22 }
23 return error.isEmptyState ? .empty(error.userFacingMessage) : .failed(error.userFacingMessage)
24 }
25}
gitbay/Repos/LogViewModel.swift added +27
@@ -0,0 +1,27 @@
1import Foundation
2import Observation
3
4/// `repo log`: the default branch's history, signature verdict per commit.
5@Observable
6@MainActor
7final class LogViewModel {
8
9 private(set) var state: LoadState<[Commit]> = .loading
10
11 private let client: GitbayClient
12 let repoPath: String
13
14 init(client: GitbayClient, repoPath: String) {
15 self.client = client
16 self.repoPath = repoPath
17 }
18
19 func load() async {
20 do {
21 let commits = try await client.readList(["repo", "log", repoPath], of: Commit.self)
22 state = commits.isEmpty ? .empty("No commits yet.") : .loaded(commits)
23 } catch {
24 state = .from(error)
25 }
26 }
27}
gitbay/Repos/RepoDetailViewModel.swift added +48
@@ -0,0 +1,48 @@
1import Foundation
2import Observation
3
4/// The repo screen: header from `repo show`, README found in the root
5/// tree and fetched with `repo cat`.
6@Observable
7@MainActor
8final class RepoDetailViewModel {
9
10 private(set) var state: LoadState<RepoDetail> = .loading
11 /// README markdown, when the root tree has one. Absent is normal.
12 private(set) var readme: String?
13
14 private let client: GitbayClient
15 private let path: String
16
17 init(client: GitbayClient, path: String) {
18 self.client = client
19 self.path = path
20 }
21
22 func load() async {
23 do {
24 let detail = try await client.read(["repo", "show", path], as: RepoDetail.self)
25 state = .loaded(detail)
26 } catch {
27 state = .from(error)
28 return
29 }
30 await loadReadme()
31 }
32
33 private func loadReadme() async {
34 // The README is whatever the root tree calls one, matched the way
35 // the web UI matches: README, README.md, readme.org, and so on.
36 guard let listing = try? await client.read(
37 ["repo", "tree", path], as: TreeListing.self
38 ) else { return }
39 let candidate = listing.entries.first {
40 !$0.isDirectory && $0.name.lowercased().hasPrefix("readme")
41 }
42 guard let candidate else { return }
43 guard let file = try? await client.read(
44 ["repo", "cat", path, candidate.name], as: FileContent.self
45 ), !file.binary, let content = file.content else { return }
46 readme = content
47 }
48}
gitbay/Repos/RepoListViewModel.swift added +38
@@ -0,0 +1,38 @@
1import Foundation
2import Observation
3
4/// The `repo list` screen: everything you own or can reach, filterable.
5@Observable
6@MainActor
7final class RepoListViewModel {
8
9 private(set) var state: LoadState<[RepoSummary]> = .loading
10 var searchText = ""
11
12 private let client: GitbayClient
13
14 init(client: GitbayClient) {
15 self.client = client
16 }
17
18 var visibleRepos: [RepoSummary] {
19 guard let repos = state.value else { return [] }
20 let query = searchText.trimmingCharacters(in: .whitespaces).lowercased()
21 guard !query.isEmpty else { return repos }
22 return repos.filter {
23 $0.path.lowercased().contains(query)
24 || ($0.description ?? "").lowercased().contains(query)
25 }
26 }
27
28 func load() async {
29 do {
30 let repos = try await client.readList(["repo", "list"], of: RepoSummary.self)
31 state = repos.isEmpty
32 ? .empty("No repositories yet. Create one over SSH: gitbay repo create <name>")
33 : .loaded(repos.sorted { $0.path < $1.path })
34 } catch {
35 state = .from(error)
36 }
37 }
38}
gitbay/Repos/RepoModels.swift added +138
@@ -0,0 +1,138 @@
1import Foundation
2
3/// One row of `repo list`.
4nonisolated struct RepoSummary: Decodable, Sendable, Hashable, Identifiable {
5 let path: String
6 let visibility: String
7 let description: String?
8 let archived: Bool?
9
10 var id: String { path }
11 var isArchived: Bool { archived ?? false }
12 var isPrivate: Bool { visibility == "private" }
13
14 /// "krz/gitbay" ("krz", "gitbay")
15 var owner: String { String(path.split(separator: "/").first ?? "") }
16 var name: String { String(path.split(separator: "/").last ?? "") }
17}
18
19/// `repo show <owner/name>`.
20nonisolated struct RepoDetail: Decodable, Sendable, Hashable {
21 let path: String
22 let description: String?
23 let website: String?
24 let visibility: String
25 let defaultBranch: String
26 let protectedBranches: [String]?
27 let archived: Bool?
28 let topics: [String]?
29
30 enum CodingKeys: String, CodingKey {
31 case path, description, website, visibility, archived, topics
32 case defaultBranch = "default_branch"
33 case protectedBranches = "protected_branches"
34 }
35
36 var isArchived: Bool { archived ?? false }
37}
38
39/// `repo tree` one directory listing.
40nonisolated struct TreeListing: Decodable, Sendable, Hashable {
41 let path: String
42 let ref: String
43 let dir: String
44 let entries: [TreeEntry]
45}
46
47/// One entry of a tree. The sha is the git object id content at a sha
48/// never changes, which is what makes caching on it sound.
49nonisolated struct TreeEntry: Decodable, Sendable, Hashable, Identifiable {
50 let name: String
51 let type: String // blob | tree
52 let mode: String
53 let sha: String
54 let size: Int64?
55
56 var id: String { sha + name }
57 var isDirectory: Bool { type == "tree" }
58 var isSymlink: Bool { mode == "120000" }
59}
60
61/// `repo cat` one file. Exactly one of `content`/`base64` is set.
62nonisolated struct FileContent: Decodable, Sendable, Hashable {
63 let path: String
64 let ref: String
65 let file: String
66 let size: Int
67 let binary: Bool
68 let truncated: Bool?
69 let content: String?
70 let base64: String?
71
72 var isTruncated: Bool { truncated ?? false }
73
74 var data: Data? {
75 if let content { return Data(content.utf8) }
76 if let base64 { return Data(base64Encoded: base64) }
77 return nil
78 }
79}
80
81/// `repo log` one commit, with its signature verdict.
82nonisolated struct Commit: Decodable, Sendable, Hashable, Identifiable {
83 let sha: String
84 let subject: String
85 let authorName: String
86 let authorEmail: String
87 let committerEmail: String?
88 let date: Date
89 let signature: Signature
90
91 enum CodingKeys: String, CodingKey {
92 case sha, subject, date, signature
93 case authorName = "author_name"
94 case authorEmail = "author_email"
95 case committerEmail = "committer_email"
96 }
97
98 var id: String { sha }
99 var shortSHA: String { String(sha.prefix(10)) }
100
101 nonisolated struct Signature: Decodable, Sendable, Hashable {
102 let state: SignatureState
103 let signer: String?
104 let keyFingerprint: String?
105
106 enum CodingKeys: String, CodingKey {
107 case state, signer
108 case keyFingerprint = "key_fingerprint"
109 }
110 }
111}
112
113/// The server's verdict on a commit signature; `internal/sig/verify.go`.
114/// Unknown future states decode as `.unrecognized` rather than failing the
115/// whole log.
116nonisolated enum SignatureState: Sendable, Hashable, Decodable {
117 case verified
118 case signedUnknownKey
119 case signedEmailMismatch
120 case signedKeyExpired
121 case signedKeyRevoked
122 case badSignature
123 case unsigned
124 case unrecognized(String)
125
126 init(from decoder: any Decoder) throws {
127 switch try decoder.singleValueContainer().decode(String.self) {
128 case "verified": self = .verified
129 case "signed_unknown_key": self = .signedUnknownKey
130 case "signed_email_mismatch": self = .signedEmailMismatch
131 case "signed_key_expired": self = .signedKeyExpired
132 case "signed_key_revoked": self = .signedKeyRevoked
133 case "bad_signature": self = .badSignature
134 case "unsigned": self = .unsigned
135 case let other: self = .unrecognized(other)
136 }
137 }
138}
gitbay/Repos/TreeViewModel.swift added +48
@@ -0,0 +1,48 @@
1import Foundation
2import Observation
3
4/// One directory of `repo tree`. Navigation pushes a new instance per
5/// level, so back is free and each level revalidates independently.
6@Observable
7@MainActor
8final class TreeViewModel {
9
10 private(set) var state: LoadState<TreeListing> = .loading
11
12 private let client: GitbayClient
13 let repoPath: String
14 let directory: String
15 let ref: String?
16
17 init(client: GitbayClient, repoPath: String, directory: String = "", ref: String? = nil) {
18 self.client = client
19 self.repoPath = repoPath
20 self.directory = directory
21 self.ref = ref
22 }
23
24 /// Directories first, then files, both alphabetically the ordering
25 /// every forge uses.
26 var entries: [TreeEntry] {
27 guard let listing = state.value else { return [] }
28 return listing.entries.sorted {
29 if $0.isDirectory != $1.isDirectory { return $0.isDirectory }
30 return $0.name.localizedStandardCompare($1.name) == .orderedAscending
31 }
32 }
33
34 func load() async {
35 var argv = ["repo", "tree", repoPath]
36 if !directory.isEmpty { argv.append(directory) }
37 if let ref { argv.append(contentsOf: ["--ref", ref]) }
38 do {
39 state = .loaded(try await client.read(argv, as: TreeListing.self))
40 } catch {
41 state = .from(error)
42 }
43 }
44
45 func childDirectory(_ entry: TreeEntry) -> String {
46 directory.isEmpty ? entry.name : "\(directory)/\(entry.name)"
47 }
48}
gitbay/Views/Builds/BuildListView.swift added +81
@@ -0,0 +1,81 @@
1import SwiftUI
2
3struct BuildListView: View {
4
5 @State private var model: BuildListViewModel
6
7 init(client: GitbayClient, repo: String) {
8 _model = State(initialValue: BuildListViewModel(client: client, repoPath: repo))
9 }
10
11 var body: some View {
12 List {
13 ForEach(model.state.value ?? []) { build in
14 NavigationLink(value: BuildRoute.log(repo: model.repoPath, number: build.number)) {
15 BuildRow(build: build)
16 }
17 }
18 }
19 .overlay { LoadStateOverlay(state: model.state) }
20 .navigationTitle("Builds")
21 .navigationBarTitleDisplayMode(.inline)
22 .task { await model.load() }
23 .refreshable { await model.load() }
24 }
25}
26
27private struct BuildRow: View {
28 let build: Build
29
30 var body: some View {
31 HStack(spacing: 10) {
32 Image(systemName: icon)
33 .foregroundStyle(color)
34 VStack(alignment: .leading, spacing: 2) {
35 Text("#\(build.number) \(build.job)")
36 .font(.subheadline.weight(.medium))
37 HStack(spacing: 6) {
38 Text(build.ref)
39 Text(build.shortSHA)
40 .font(.caption.monospaced())
41 }
42 .font(.caption)
43 .foregroundStyle(.secondary)
44 }
45 Spacer()
46 VStack(alignment: .trailing, spacing: 2) {
47 Text(build.status)
48 .font(.caption.weight(.medium))
49 .foregroundStyle(color)
50 Text(build.createdAt, format: .relative(presentation: .named))
51 .font(.caption)
52 .foregroundStyle(.tertiary)
53 }
54 }
55 .padding(.vertical, 2)
56 }
57
58 private var icon: String {
59 switch build.status {
60 case "success": "checkmark.circle.fill"
61 case "failure", "error": "xmark.circle.fill"
62 case "running": "circle.dotted"
63 case "queued", "pending": "clock"
64 default: "questionmark.circle"
65 }
66 }
67
68 private var color: Color {
69 switch build.status {
70 case "success": .green
71 case "failure", "error": .red
72 case "running", "queued", "pending": .orange
73 default: .secondary
74 }
75 }
76}
77
78nonisolated enum BuildRoute: Hashable {
79 case list(repo: String)
80 case log(repo: String, number: Int64)
81}
gitbay/Views/Builds/BuildLogView.swift added +55
@@ -0,0 +1,55 @@
1import SwiftUI
2
3/// `build log` plain text, can be large. Monospaced, both-axis scroll.
4struct BuildLogView: View {
5
6 private let client: GitbayClient
7 private let repo: String
8 private let number: Int64
9 @State private var state: LoadState<String> = .loading
10
11 init(client: GitbayClient, repo: String, number: Int64) {
12 self.client = client
13 self.repo = repo
14 self.number = number
15 }
16
17 var body: some View {
18 ZStack {
19 Color.clear
20 if let log = state.value {
21 if log.isEmpty {
22 ContentUnavailableView {
23 Label("No log yet", systemImage: "doc.text")
24 }
25 } else {
26 ScrollView([.horizontal, .vertical]) {
27 Text(log)
28 .font(.caption2.monospaced())
29 .frame(maxWidth: .infinity, alignment: .leading)
30 .padding(12)
31 .textSelection(.enabled)
32 }
33 }
34 }
35 }
36 .overlay { LoadStateOverlay(state: state) }
37 .navigationTitle("Build #\(number)")
38 .navigationBarTitleDisplayMode(.inline)
39 .task {
40 do {
41 state = .loaded(try await client.readText(["build", "log", repo, String(number)]))
42 } catch {
43 state = .from(error)
44 }
45 }
46 .refreshable {
47 state = .loading
48 do {
49 state = .loaded(try await client.readText(["build", "log", repo, String(number)]))
50 } catch {
51 state = .from(error)
52 }
53 }
54 }
55}
gitbay/Views/Dashboard/DashboardView.swift added +114
@@ -0,0 +1,114 @@
1import SwiftUI
2
3/// What needs me, across every repo I can reach.
4struct DashboardView: View {
5
6 @State private var model: DashboardViewModel
7
8 init(client: GitbayClient, username: String) {
9 _model = State(initialValue: DashboardViewModel(client: client, username: username))
10 }
11
12 var body: some View {
13 List {
14 if let note = model.scanNote {
15 Section {
16 Label(note, systemImage: "exclamationmark.triangle")
17 .font(.caption)
18 .foregroundStyle(.orange)
19 }
20 }
21
22 Section("Needs review") {
23 if model.openMRs.isEmpty {
24 emptyRow(model.scanning ? "Scanning…" : "No open merge requests.")
25 } else {
26 ForEach(model.openMRs) { entry in
27 NavigationLink(value: MRRoute.mr(repo: entry.repo, number: entry.mr.number)) {
28 VStack(alignment: .leading, spacing: 2) {
29 Text(entry.repo)
30 .font(.caption)
31 .foregroundStyle(.secondary)
32 MRRow(mr: entry.mr)
33 }
34 }
35 }
36 }
37 }
38
39 Section("Assigned to me") {
40 if model.assignedIssues.isEmpty {
41 emptyRow(model.scanning ? "Scanning…" : "No assigned issues.")
42 } else {
43 ForEach(model.assignedIssues) { entry in
44 NavigationLink(value: IssueRoute.issue(repo: entry.repo, number: entry.issue.number)) {
45 VStack(alignment: .leading, spacing: 2) {
46 Text(entry.repo)
47 .font(.caption)
48 .foregroundStyle(.secondary)
49 HStack(spacing: 6) {
50 Text("#\(entry.issue.number)")
51 .font(.caption.monospaced())
52 .foregroundStyle(.secondary)
53 Text(entry.issue.title)
54 .font(.subheadline.weight(.medium))
55 .lineLimit(2)
56 }
57 }
58 }
59 }
60 }
61 }
62
63 Section("Recent builds") {
64 if model.recentBuilds.isEmpty {
65 emptyRow(model.scanning ? "Scanning…" : "No recent builds where something is open.")
66 } else {
67 ForEach(model.recentBuilds) { entry in
68 NavigationLink(value: BuildRoute.log(repo: entry.repo, number: entry.build.number)) {
69 HStack(spacing: 8) {
70 Image(systemName: entry.build.status == "success"
71 ? "checkmark.circle.fill"
72 : entry.build.status == "failure" ? "xmark.circle.fill" : "circle.dotted")
73 .foregroundStyle(entry.build.status == "success"
74 ? .green : entry.build.status == "failure" ? .red : .orange)
75 VStack(alignment: .leading, spacing: 2) {
76 Text(entry.repo)
77 .font(.caption)
78 .foregroundStyle(.secondary)
79 Text("#\(entry.build.number) \(entry.build.job)")
80 .font(.subheadline)
81 }
82 Spacer()
83 Text(entry.build.createdAt, format: .relative(presentation: .named))
84 .font(.caption)
85 .foregroundStyle(.tertiary)
86 }
87 }
88 }
89 }
90 }
91
92 if model.scanning {
93 Section {
94 HStack {
95 ProgressView()
96 Text("Scanning \(model.scannedRepos)/\(model.totalRepos) repositories…")
97 .font(.caption)
98 .foregroundStyle(.secondary)
99 }
100 }
101 }
102 }
103 .navigationTitle("Dashboard")
104 .toolbar { AccountMenu() }
105 .task { await model.load() }
106 .refreshable { await model.load() }
107 }
108
109 private func emptyRow(_ text: String) -> some View {
110 Text(text)
111 .font(.subheadline)
112 .foregroundStyle(.secondary)
113 }
114}
gitbay/Views/Issues/IssueListView.swift added +79
@@ -0,0 +1,79 @@
1import SwiftUI
2
3struct IssueListView: View {
4
5 @State private var model: IssueListViewModel
6
7 init(client: GitbayClient, repo: String) {
8 _model = State(initialValue: IssueListViewModel(client: client, repoPath: repo))
9 }
10
11 var body: some View {
12 List {
13 Picker("State", selection: Bindable(model).filter) {
14 ForEach(IssueListViewModel.StateFilter.allCases) { filter in
15 Text(filter.rawValue.capitalized).tag(filter)
16 }
17 }
18 .pickerStyle(.segmented)
19 .listRowBackground(Color.clear)
20 .listRowInsets(EdgeInsets())
21
22 ForEach(model.state.value ?? []) { issue in
23 NavigationLink(value: IssueRoute.issue(repo: model.repoPath, number: issue.number)) {
24 IssueRow(issue: issue)
25 }
26 }
27 }
28 .overlay { LoadStateOverlay(state: model.state) }
29 .navigationTitle("Issues")
30 .navigationBarTitleDisplayMode(.inline)
31 .task { await model.load() }
32 .refreshable { await model.load() }
33 }
34}
35
36private struct IssueRow: View {
37 let issue: Issue
38
39 var body: some View {
40 VStack(alignment: .leading, spacing: 4) {
41 HStack(alignment: .firstTextBaseline, spacing: 6) {
42 Text("#\(issue.number)")
43 .font(.caption.monospaced())
44 .foregroundStyle(.secondary)
45 Text(issue.title)
46 .font(.subheadline.weight(.medium))
47 .lineLimit(2)
48 }
49 HStack(spacing: 6) {
50 Image(systemName: issue.isOpen ? "circle" : "checkmark.circle.fill")
51 .font(.caption2)
52 .foregroundStyle(issue.isOpen ? .green : .purple)
53 ForEach(issue.labels ?? [], id: \.self) { label in
54 Text(label)
55 .font(.caption2)
56 .padding(.horizontal, 5)
57 .padding(.vertical, 1)
58 .background(.quaternary, in: Capsule())
59 }
60 Spacer()
61 if let assignees = issue.assignees, !assignees.isEmpty {
62 Text(assignees.joined(separator: ", "))
63 .font(.caption)
64 .foregroundStyle(.secondary)
65 .lineLimit(1)
66 }
67 Text(issue.createdAt, format: .relative(presentation: .named))
68 .font(.caption)
69 .foregroundStyle(.tertiary)
70 }
71 }
72 .padding(.vertical, 2)
73 }
74}
75
76nonisolated enum IssueRoute: Hashable {
77 case list(repo: String)
78 case issue(repo: String, number: Int64)
79}
gitbay/Views/Issues/IssueView.swift added +182
@@ -0,0 +1,182 @@
1import SwiftUI
2
3struct IssueView: View {
4
5 @State private var model: IssueDetailViewModel
6 @State private var commentText = ""
7 @State private var editingLabel = ""
8 @State private var editingAssignee = ""
9
10 init(client: GitbayClient, repo: String, number: Int64) {
11 _model = State(initialValue: IssueDetailViewModel(
12 client: client, repoPath: repo, number: number
13 ))
14 }
15
16 var body: some View {
17 List {
18 if let issue = model.state.value {
19 header(issue)
20
21 if let error = model.actionError {
22 Section {
23 Label(error, systemImage: "hand.raised")
24 .foregroundStyle(.orange)
25 .font(.subheadline)
26 }
27 }
28
29 if let body = issue.body, !body.isEmpty {
30 Section {
31 MarkdownView(markdown: body)
32 .padding(.vertical, 4)
33 }
34 }
35
36 triageSection(issue)
37 commentsSection(issue.comments ?? [])
38 }
39 }
40 .overlay { LoadStateOverlay(state: model.state) }
41 .navigationTitle("#\(model.number)")
42 .navigationBarTitleDisplayMode(.inline)
43 .toolbar { toolbar }
44 .task { await model.load() }
45 .refreshable { await model.load() }
46 }
47
48 @ViewBuilder
49 private func header(_ issue: IssueDetail) -> some View {
50 Section {
51 VStack(alignment: .leading, spacing: 6) {
52 Text(issue.title)
53 .font(.headline)
54 HStack(spacing: 6) {
55 Label(issue.state, systemImage: issue.isOpen ? "circle" : "checkmark.circle.fill")
56 .font(.caption.weight(.medium))
57 .foregroundStyle(issue.isOpen ? .green : .purple)
58 Text("by \(issue.author)")
59 Text(issue.createdAt, format: .relative(presentation: .named))
60 .foregroundStyle(.tertiary)
61 if let milestone = issue.milestone {
62 Label(milestone, systemImage: "flag")
63 }
64 }
65 .font(.caption)
66 .foregroundStyle(.secondary)
67 }
68 .padding(.vertical, 2)
69 }
70 }
71
72 @ViewBuilder
73 private func triageSection(_ issue: IssueDetail) -> some View {
74 Section("Labels") {
75 labelFlow(issue.labels ?? [], remove: { label in
76 Task { await model.removeLabel(label) }
77 })
78 HStack {
79 TextField("Add label", text: $editingLabel)
80 .autocorrectionDisabled()
81 .textInputAutocapitalization(.never)
82 Button {
83 let label = editingLabel.trimmingCharacters(in: .whitespaces)
84 editingLabel = ""
85 Task { await model.addLabel(label) }
86 } label: {
87 Image(systemName: "plus.circle.fill")
88 }
89 .disabled(editingLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
90 }
91 }
92 Section("Assignees") {
93 labelFlow(issue.assignees ?? [], remove: { user in
94 Task { await model.removeAssignee(user) }
95 })
96 HStack {
97 TextField("Assign user", text: $editingAssignee)
98 .autocorrectionDisabled()
99 .textInputAutocapitalization(.never)
100 Button {
101 let user = editingAssignee.trimmingCharacters(in: .whitespaces)
102 editingAssignee = ""
103 Task { await model.addAssignee(user) }
104 } label: {
105 Image(systemName: "plus.circle.fill")
106 }
107 .disabled(editingAssignee.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
108 }
109 }
110 }
111
112 @ViewBuilder
113 private func labelFlow(_ items: [String], remove: @escaping (String) -> Void) -> some View {
114 if !items.isEmpty {
115 ScrollView(.horizontal, showsIndicators: false) {
116 HStack(spacing: 6) {
117 ForEach(items, id: \.self) { item in
118 HStack(spacing: 3) {
119 Text(item)
120 Button {
121 remove(item)
122 } label: {
123 Image(systemName: "xmark.circle.fill")
124 .foregroundStyle(.tertiary)
125 }
126 .disabled(model.working)
127 }
128 .font(.caption)
129 .padding(.horizontal, 8)
130 .padding(.vertical, 3)
131 .background(.quaternary, in: Capsule())
132 }
133 }
134 }
135 }
136 }
137
138 private func commentsSection(_ comments: [IssueDetail.Comment]) -> some View {
139 Section("Comments") {
140 ForEach(comments) { comment in
141 VStack(alignment: .leading, spacing: 4) {
142 HStack {
143 Text(comment.author)
144 .font(.caption.weight(.semibold))
145 Text(comment.createdAt, format: .relative(presentation: .named))
146 .font(.caption)
147 .foregroundStyle(.tertiary)
148 }
149 MarkdownView(markdown: comment.body)
150 .font(.subheadline)
151 }
152 .padding(.vertical, 2)
153 }
154
155 HStack {
156 TextField("Comment", text: $commentText, axis: .vertical)
157 .lineLimit(1...5)
158 Button {
159 let text = commentText
160 commentText = ""
161 Task { await model.comment(text) }
162 } label: {
163 Image(systemName: "arrow.up.circle.fill")
164 }
165 .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
166 || model.working)
167 }
168 }
169 }
170
171 @ToolbarContentBuilder
172 private var toolbar: some ToolbarContent {
173 ToolbarItem(placement: .topBarTrailing) {
174 if let issue = model.state.value {
175 Button(issue.isOpen ? "Close" : "Reopen") {
176 Task { issue.isOpen ? await model.close() : await model.reopen() }
177 }
178 .disabled(model.working)
179 }
180 }
181 }
182}
gitbay/Views/MRs/DiffView.swift added +163
@@ -0,0 +1,163 @@
1import SwiftUI
2
3/// The parsed unified diff, file by file, hunk by hunk. Loads its own
4/// text so the screen is reachable by value from anywhere.
5struct DiffView: View {
6
7 private let client: GitbayClient
8 private let repo: String
9 private let number: Int64
10 @State private var state: LoadState<UnifiedDiff> = .loading
11
12 init(client: GitbayClient, repo: String, number: Int64) {
13 self.client = client
14 self.repo = repo
15 self.number = number
16 }
17
18 var body: some View {
19 ZStack {
20 Color.clear
21 if let diff = state.value, !diff.files.isEmpty {
22 List {
23 ForEach(diff.files) { file in
24 FileDiffSection(file: file)
25 }
26 }
27 .listStyle(.plain)
28 } else if case .loaded = state {
29 ContentUnavailableView {
30 Label("No changes", systemImage: "plus.forwardslash.minus")
31 }
32 }
33 }
34 .overlay { LoadStateOverlay(state: state) }
35 .navigationTitle("Diff")
36 .navigationBarTitleDisplayMode(.inline)
37 .task {
38 do {
39 let text = try await client.readText(["mr", "diff", repo, String(number)])
40 state = .loaded(UnifiedDiff.parse(text))
41 } catch {
42 state = .from(error)
43 }
44 }
45 }
46}
47
48private struct FileDiffSection: View {
49
50 let file: UnifiedDiff.File
51 @State private var collapsed = false
52
53 var body: some View {
54 Section {
55 if !collapsed {
56 if file.isBinary {
57 Text("Binary file")
58 .font(.caption)
59 .foregroundStyle(.secondary)
60 } else {
61 ForEach(file.hunks) { hunk in
62 HunkView(hunk: hunk)
63 }
64 }
65 }
66 } header: {
67 Button {
68 collapsed.toggle()
69 } label: {
70 HStack(spacing: 6) {
71 Image(systemName: collapsed ? "chevron.right" : "chevron.down")
72 .font(.caption2)
73 Text(file.displayPath)
74 .font(.caption.monospaced().weight(.semibold))
75 .lineLimit(1)
76 .truncationMode(.head)
77 if file.isNew {
78 Text("new").font(.caption2).foregroundStyle(.green)
79 }
80 if file.isDeleted {
81 Text("deleted").font(.caption2).foregroundStyle(.red)
82 }
83 Spacer()
84 Text("+\(file.additions)").foregroundStyle(.green).font(.caption2)
85 Text("\(file.deletions)").foregroundStyle(.red).font(.caption2)
86 }
87 }
88 .buttonStyle(.plain)
89 .textCase(nil)
90 }
91 }
92}
93
94private struct HunkView: View {
95
96 let hunk: UnifiedDiff.Hunk
97
98 var body: some View {
99 ScrollView(.horizontal) {
100 VStack(alignment: .leading, spacing: 0) {
101 Text(hunk.header)
102 .font(.caption2.monospaced())
103 .foregroundStyle(.blue)
104 .padding(.vertical, 2)
105 ForEach(hunk.lines) { line in
106 LineView(line: line)
107 }
108 }
109 }
110 .listRowInsets(EdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8))
111 }
112}
113
114private struct LineView: View {
115
116 let line: UnifiedDiff.Line
117
118 var body: some View {
119 HStack(spacing: 0) {
120 Text(line.oldNumber.map(String.init) ?? "")
121 .frame(width: 34, alignment: .trailing)
122 .foregroundStyle(.tertiary)
123 Text(line.newNumber.map(String.init) ?? "")
124 .frame(width: 34, alignment: .trailing)
125 .foregroundStyle(.tertiary)
126 Text(marker)
127 .frame(width: 14)
128 .foregroundStyle(markerColor)
129 Text(line.text.isEmpty ? " " : line.text)
130 .foregroundStyle(textColor)
131 }
132 .font(.caption2.monospaced())
133 .background(background)
134 }
135
136 private var marker: String {
137 switch line.kind {
138 case .addition: "+"
139 case .deletion: ""
140 case .context: " "
141 }
142 }
143
144 private var markerColor: Color {
145 switch line.kind {
146 case .addition: .green
147 case .deletion: .red
148 case .context: .clear
149 }
150 }
151
152 private var textColor: Color {
153 .primary
154 }
155
156 private var background: Color {
157 switch line.kind {
158 case .addition: .green.opacity(0.12)
159 case .deletion: .red.opacity(0.12)
160 case .context: .clear
161 }
162 }
163}
gitbay/Views/MRs/MRListView.swift added +94
@@ -0,0 +1,94 @@
1import SwiftUI
2
3struct MRListView: View {
4
5 @State private var model: MRListViewModel
6
7 init(client: GitbayClient, repo: String) {
8 _model = State(initialValue: MRListViewModel(client: client, repoPath: repo))
9 }
10
11 var body: some View {
12 List {
13 Picker("State", selection: Bindable(model).filter) {
14 ForEach(MRListViewModel.StateFilter.allCases) { filter in
15 Text(filter.rawValue.capitalized).tag(filter)
16 }
17 }
18 .pickerStyle(.segmented)
19 .listRowBackground(Color.clear)
20 .listRowInsets(EdgeInsets())
21
22 ForEach(model.state.value ?? []) { mr in
23 NavigationLink(value: MRRoute.mr(repo: model.repoPath, number: mr.number)) {
24 MRRow(mr: mr)
25 }
26 }
27 }
28 .overlay { LoadStateOverlay(state: model.state) }
29 .navigationTitle("Merge Requests")
30 .navigationBarTitleDisplayMode(.inline)
31 .task { await model.load() }
32 .refreshable { await model.load() }
33 }
34}
35
36struct MRRow: View {
37 let mr: MergeRequest
38
39 var body: some View {
40 VStack(alignment: .leading, spacing: 4) {
41 HStack(alignment: .firstTextBaseline, spacing: 6) {
42 Text("!\(mr.number)")
43 .font(.caption.monospaced())
44 .foregroundStyle(.secondary)
45 Text(mr.title)
46 .font(.subheadline.weight(.medium))
47 .lineLimit(2)
48 }
49 HStack(spacing: 6) {
50 MRStateBadge(state: mr.state)
51 Text(mr.source.isEmpty ? "(source gone)" : mr.source)
52 .lineLimit(1)
53 Image(systemName: "arrow.right")
54 .font(.caption2)
55 Text(mr.targetRef)
56 Spacer()
57 Text(mr.createdAt, format: .relative(presentation: .named))
58 .foregroundStyle(.tertiary)
59 }
60 .font(.caption)
61 .foregroundStyle(.secondary)
62 }
63 .padding(.vertical, 2)
64 }
65}
66
67struct MRStateBadge: View {
68 let state: String
69
70 var body: some View {
71 Text(state.replacingOccurrences(of: "_", with: " "))
72 .font(.caption2.weight(.medium))
73 .padding(.horizontal, 6)
74 .padding(.vertical, 1)
75 .background(color.opacity(0.15), in: Capsule())
76 .foregroundStyle(color)
77 }
78
79 private var color: Color {
80 switch state {
81 case "open": .green
82 case "merged": .purple
83 case "closed": .red
84 default: .secondary
85 }
86 }
87}
88
89/// MR navigation routes, separate from the repo stack's.
90nonisolated enum MRRoute: Hashable {
91 case list(repo: String)
92 case mr(repo: String, number: Int64)
93 case diff(repo: String, number: Int64)
94}
gitbay/Views/MRs/MRView.swift added +338
@@ -0,0 +1,338 @@
1import SwiftUI
2
3struct MRView: View {
4
5 @State private var model: MRDetailViewModel
6 @State private var commentText = ""
7 @State private var confirmingMerge = false
8 @State private var confirmingClose = false
9
10 init(client: GitbayClient, repo: String, number: Int64) {
11 _model = State(initialValue: MRDetailViewModel(
12 client: client, repoPath: repo, number: number
13 ))
14 }
15
16 var body: some View {
17 List {
18 if let mr = model.state.value {
19 header(mr)
20
21 if let error = model.actionError {
22 Section {
23 Label(error, systemImage: "hand.raised")
24 .foregroundStyle(.orange)
25 .font(.subheadline)
26 }
27 }
28
29 if let body = mr.body, !body.isEmpty {
30 Section {
31 MarkdownView(markdown: body)
32 .padding(.vertical, 4)
33 }
34 }
35
36 diffSection
37
38 if let commits = mr.commits, !commits.isEmpty {
39 commitsSection(commits)
40 }
41 if let checks = mr.checks, !checks.isEmpty {
42 checksSection(checks, combined: mr.checksCombined)
43 }
44 if let reviews = mr.reviews, !reviews.isEmpty {
45 reviewsSection(reviews)
46 }
47 if !model.threads.isEmpty {
48 threadsSection
49 }
50 commentsSection(mr.comments ?? [])
51 }
52 }
53 .overlay { LoadStateOverlay(state: model.state) }
54 .navigationTitle("!\(model.number)")
55 .navigationBarTitleDisplayMode(.inline)
56 .toolbar { toolbar }
57 .task { await model.load() }
58 .refreshable { await model.load() }
59 .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) {
60 Button("Merge") { Task { await model.merge() } }
61 Button("Squash") { Task { await model.merge(strategy: "squash") } }
62 Button("Fast-forward") { Task { await model.merge(strategy: "ff") } }
63 Button("Rebase") { Task { await model.merge(strategy: "rebase") } }
64 Button("Cancel", role: .cancel) {}
65 } message: {
66 Text("The server enforces approvals, threads and checks — a refusal will say why.")
67 }
68 .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) {
69 Button("Close", role: .destructive) { Task { await model.close() } }
70 Button("Cancel", role: .cancel) {}
71 }
72 }
73
74 // MARK: - Sections
75
76 @ViewBuilder
77 private func header(_ mr: MRDetail) -> some View {
78 Section {
79 VStack(alignment: .leading, spacing: 6) {
80 Text(mr.title)
81 .font(.headline)
82 HStack(spacing: 6) {
83 MRStateBadge(state: mr.state)
84 Text(mr.source.isEmpty ? "(source gone)" : mr.source)
85 .lineLimit(1)
86 Image(systemName: "arrow.right")
87 .font(.caption2)
88 Text(mr.targetRef)
89 }
90 .font(.caption)
91 .foregroundStyle(.secondary)
92 HStack(spacing: 6) {
93 Text("by \(mr.author)")
94 Text(mr.createdAt, format: .relative(presentation: .named))
95 .foregroundStyle(.tertiary)
96 }
97 .font(.caption)
98 .foregroundStyle(.secondary)
99 }
100 .padding(.vertical, 2)
101 }
102 }
103
104 private var diffSection: some View {
105 Section {
106 NavigationLink(value: MRRoute.diff(repo: model.repoPath, number: model.number)) {
107 HStack {
108 Label("Diff", systemImage: "plus.forwardslash.minus")
109 Spacer()
110 if let diff = model.diff {
111 Text("+\(diff.additions)")
112 .foregroundStyle(.green)
113 Text("\(diff.deletions)")
114 .foregroundStyle(.red)
115 }
116 }
117 .font(.subheadline)
118 }
119 }
120 }
121
122 private func commitsSection(_ commits: [MRDetail.MRCommit]) -> some View {
123 Section("Commits") {
124 ForEach(commits) { commit in
125 HStack(spacing: 8) {
126 Text(commit.shortSHA)
127 .font(.caption.monospaced())
128 .foregroundStyle(.secondary)
129 Text(commit.subject)
130 .font(.subheadline)
131 .lineLimit(1)
132 }
133 }
134 }
135 }
136
137 private func checksSection(_ checks: [MRDetail.Check], combined: String?) -> some View {
138 Section("Checks" + (combined.map { "\($0)" } ?? "")) {
139 ForEach(checks, id: \.context) { check in
140 HStack {
141 Image(systemName: checkIcon(check.state))
142 .foregroundStyle(checkColor(check.state))
143 Text(check.context)
144 .font(.subheadline)
145 Spacer()
146 Text(check.state)
147 .font(.caption)
148 .foregroundStyle(.secondary)
149 }
150 }
151 }
152 }
153
154 private func reviewsSection(_ reviews: [MRDetail.Review]) -> some View {
155 Section("Reviews") {
156 ForEach(reviews) { review in
157 HStack {
158 Image(systemName: review.verdict == "approve"
159 ? "checkmark.circle.fill" : "exclamationmark.circle.fill")
160 .foregroundStyle(review.verdict == "approve" ? .green : .orange)
161 Text(review.reviewer)
162 .font(.subheadline)
163 Spacer()
164 if review.stale {
165 Text("stale")
166 .font(.caption2)
167 .padding(.horizontal, 5)
168 .padding(.vertical, 1)
169 .background(.quaternary, in: Capsule())
170 }
171 }
172 }
173 }
174 }
175
176 private var threadsSection: some View {
177 Section("Threads — \(model.unresolvedCount) unresolved") {
178 ForEach(model.threads) { thread in
179 ThreadView(thread: thread, model: model)
180 }
181 }
182 }
183
184 private func commentsSection(_ comments: [MRDetail.MRComment]) -> some View {
185 Section("Comments") {
186 ForEach(comments) { comment in
187 VStack(alignment: .leading, spacing: 4) {
188 HStack {
189 Text(comment.author)
190 .font(.caption.weight(.semibold))
191 Text(comment.createdAt, format: .relative(presentation: .named))
192 .font(.caption)
193 .foregroundStyle(.tertiary)
194 }
195 MarkdownView(markdown: comment.body)
196 .font(.subheadline)
197 }
198 .padding(.vertical, 2)
199 }
200
201 HStack {
202 TextField("Comment", text: $commentText, axis: .vertical)
203 .lineLimit(1...5)
204 Button {
205 let text = commentText
206 commentText = ""
207 Task { await model.comment(text) }
208 } label: {
209 Image(systemName: "arrow.up.circle.fill")
210 }
211 .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
212 || model.working)
213 }
214 }
215 }
216
217 @ToolbarContentBuilder
218 private var toolbar: some ToolbarContent {
219 ToolbarItem(placement: .topBarTrailing) {
220 if let mr = model.state.value, mr.isOpen {
221 Menu {
222 Button {
223 Task { await model.review(.approve) }
224 } label: {
225 Label("Approve", systemImage: "checkmark.circle")
226 }
227 Button {
228 Task { await model.review(.requestChanges) }
229 } label: {
230 Label("Request Changes", systemImage: "exclamationmark.circle")
231 }
232 Divider()
233 Button {
234 confirmingMerge = true
235 } label: {
236 Label("Merge", systemImage: "arrow.triangle.merge")
237 }
238 Button(role: .destructive) {
239 confirmingClose = true
240 } label: {
241 Label("Close", systemImage: "xmark.circle")
242 }
243 } label: {
244 if model.working {
245 ProgressView()
246 } else {
247 Image(systemName: "ellipsis.circle")
248 }
249 }
250 .disabled(model.working)
251 }
252 }
253 }
254
255 private func checkIcon(_ state: String) -> String {
256 switch state {
257 case "success": "checkmark.circle.fill"
258 case "failure", "error": "xmark.circle.fill"
259 case "pending", "running": "circle.dotted"
260 default: "questionmark.circle"
261 }
262 }
263
264 private func checkColor(_ state: String) -> Color {
265 switch state {
266 case "success": .green
267 case "failure", "error": .red
268 case "pending", "running": .orange
269 default: .secondary
270 }
271 }
272}
273
274/// One review thread: anchor, comments, reply, resolve.
275private struct ThreadView: View {
276
277 let thread: ReviewThread
278 let model: MRDetailViewModel
279 @State private var replyText = ""
280
281 var body: some View {
282 VStack(alignment: .leading, spacing: 6) {
283 HStack(spacing: 6) {
284 Image(systemName: thread.isResolved
285 ? "checkmark.bubble" : "bubble.left.and.exclamationmark.bubble.right")
286 .font(.caption)
287 .foregroundStyle(thread.isResolved ? .green : .orange)
288 Text("\(thread.path):\(thread.line)")
289 .font(.caption.monospaced())
290 .lineLimit(1)
291 if thread.stale {
292 Text("stale")
293 .font(.caption2)
294 .padding(.horizontal, 5)
295 .padding(.vertical, 1)
296 .background(.quaternary, in: Capsule())
297 }
298 Spacer()
299 Button(thread.isResolved ? "Reopen" : "Resolve") {
300 Task { await model.setResolved(thread, !thread.isResolved) }
301 }
302 .font(.caption)
303 .buttonStyle(.bordered)
304 .disabled(model.working)
305 }
306 ForEach(thread.comments) { comment in
307 VStack(alignment: .leading, spacing: 2) {
308 HStack {
309 Text(comment.author)
310 .font(.caption.weight(.semibold))
311 Text(comment.createdAt, format: .relative(presentation: .named))
312 .font(.caption2)
313 .foregroundStyle(.tertiary)
314 }
315 Text(comment.body)
316 .font(.subheadline)
317 }
318 }
319 if !thread.isResolved {
320 HStack {
321 TextField("Reply", text: $replyText, axis: .vertical)
322 .font(.subheadline)
323 .lineLimit(1...4)
324 Button {
325 let text = replyText
326 replyText = ""
327 Task { await model.reply(to: thread, text) }
328 } label: {
329 Image(systemName: "arrow.up.circle.fill")
330 }
331 .disabled(replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
332 || model.working)
333 }
334 }
335 }
336 .padding(.vertical, 4)
337 }
338}
gitbay/Views/Repos/FileView.swift added +80
@@ -0,0 +1,80 @@
1import SwiftUI
2
3struct FileView: View {
4
5 @Environment(\.colorScheme) private var colorScheme
6 @State private var model: FileViewModel
7
8 init(client: GitbayClient, repo: String, path: String, ref: String?) {
9 _model = State(initialValue: FileViewModel(
10 client: client, repoPath: repo, filePath: path, ref: ref
11 ))
12 }
13
14 var body: some View {
15 Group {
16 if let file = model.state.value {
17 content(file)
18 } else {
19 Color.clear
20 }
21 }
22 .overlay { LoadStateOverlay(state: model.state) }
23 .navigationTitle(model.fileName)
24 .navigationBarTitleDisplayMode(.inline)
25 .task { await model.load() }
26 }
27
28 @ViewBuilder
29 private func content(_ file: FileContent) -> some View {
30 if file.binary {
31 ContentUnavailableView {
32 Label("Binary file", systemImage: "doc.zipper")
33 } description: {
34 Text("\(file.file) · \(Int64(file.size).formatted(.byteCount(style: .file)))")
35 }
36 } else if let text = file.content {
37 VStack(spacing: 0) {
38 if file.isTruncated {
39 Label(
40 "Truncated — showing the first \(Int64(file.size).formatted(.byteCount(style: .file))).",
41 systemImage: "scissors"
42 )
43 .font(.caption)
44 .frame(maxWidth: .infinity, alignment: .leading)
45 .padding(8)
46 .background(.yellow.opacity(0.15))
47 }
48 CodeScrollView(text: text, fileName: model.fileName, colorScheme: colorScheme)
49 }
50 }
51 }
52}
53
54/// Both-axis scrolling for code, highlighted when the language is known.
55private struct CodeScrollView: View {
56
57 let text: String
58 let fileName: String
59 let colorScheme: ColorScheme
60
61 var body: some View {
62 ScrollView([.horizontal, .vertical]) {
63 code
64 .padding(12)
65 .frame(maxWidth: .infinity, alignment: .leading)
66 }
67 .font(.caption.monospaced())
68 }
69
70 private var code: some View {
71 let font = UIFont.monospacedSystemFont(
72 ofSize: UIFont.preferredFont(forTextStyle: .caption1).pointSize, weight: .regular
73 )
74 let highlighter = SyntaxHighlighter(colorScheme: colorScheme)
75 if let highlighted = highlighter.highlight(text, fileName: fileName, font: font) {
76 return Text(AttributedString(highlighted))
77 }
78 return Text(text)
79 }
80}
gitbay/Views/Repos/LoadStateOverlay.swift added +33
@@ -0,0 +1,33 @@
1import SwiftUI
2
3/// The three non-content states of any screen, rendered consistently.
4struct LoadStateOverlay<Value: Sendable>: View {
5
6 let state: LoadState<Value>
7 /// Whether the underlying screen is showing nothing a filter can
8 /// empty a loaded list.
9 var isEmpty = false
10
11 var body: some View {
12 switch state {
13 case .loading:
14 ProgressView()
15 case .empty(let message):
16 ContentUnavailableView {
17 Label("Nothing here", systemImage: "tray")
18 } description: {
19 Text(message)
20 }
21 case .failed(let message):
22 ContentUnavailableView {
23 Label("Could not load", systemImage: "wifi.exclamationmark")
24 } description: {
25 Text(message)
26 }
27 case .loaded:
28 if isEmpty {
29 ContentUnavailableView.search
30 }
31 }
32 }
33}
gitbay/Views/Repos/LogView.swift added +92
@@ -0,0 +1,92 @@
1import SwiftUI
2
3struct LogView: View {
4
5 @State private var model: LogViewModel
6
7 init(client: GitbayClient, repo: String) {
8 _model = State(initialValue: LogViewModel(client: client, repoPath: repo))
9 }
10
11 var body: some View {
12 List {
13 ForEach(model.state.value ?? []) { commit in
14 CommitRow(commit: commit)
15 }
16 }
17 .overlay { LoadStateOverlay(state: model.state) }
18 .navigationTitle("History")
19 .navigationBarTitleDisplayMode(.inline)
20 .task { await model.load() }
21 .refreshable { await model.load() }
22 }
23}
24
25struct CommitRow: View {
26 let commit: Commit
27
28 var body: some View {
29 VStack(alignment: .leading, spacing: 4) {
30 Text(commit.subject)
31 .font(.subheadline.weight(.medium))
32 .lineLimit(2)
33 HStack(spacing: 6) {
34 Text(commit.shortSHA)
35 .font(.caption.monospaced())
36 .foregroundStyle(.secondary)
37 SignatureBadge(signature: commit.signature)
38 Spacer()
39 Text(commit.authorName)
40 .font(.caption)
41 .foregroundStyle(.secondary)
42 .lineLimit(1)
43 Text(commit.date, format: .relative(presentation: .named))
44 .font(.caption)
45 .foregroundStyle(.tertiary)
46 }
47 }
48 .padding(.vertical, 2)
49 }
50}
51
52/// The server's signature verdict, worn as a small badge. The states come
53/// from the registry; the app only chooses glyph and colour.
54struct SignatureBadge: View {
55 let signature: Commit.Signature
56
57 var body: some View {
58 if let (icon, color, text) = presentation {
59 Label {
60 Text(text)
61 } icon: {
62 Image(systemName: icon)
63 }
64 .font(.caption2)
65 .foregroundStyle(color)
66 .labelStyle(.titleAndIcon)
67 }
68 }
69
70 /// Unsigned commits are the norm no badge at all. Everything else
71 /// says what the server concluded.
72 private var presentation: (String, Color, String)? {
73 switch signature.state {
74 case .unsigned:
75 nil
76 case .verified:
77 ("checkmark.seal.fill", .green, signature.signer ?? "verified")
78 case .signedUnknownKey:
79 ("questionmark.diamond", .secondary, "unknown key")
80 case .signedEmailMismatch:
81 ("exclamationmark.triangle", .orange, "email mismatch")
82 case .signedKeyExpired:
83 ("clock.badge.exclamationmark", .orange, "key expired")
84 case .signedKeyRevoked:
85 ("xmark.seal", .red, "key revoked")
86 case .badSignature:
87 ("xmark.seal.fill", .red, "bad signature")
88 case .unrecognized(let state):
89 ("questionmark.circle", .secondary, state)
90 }
91 }
92}
gitbay/Views/Repos/MarkdownView.swift added +177
@@ -0,0 +1,177 @@
1import SwiftUI
2
3/// Block-level markdown for READMEs: headings, fenced code, lists, quotes,
4/// paragraphs. Inline styling comes from AttributedString's own markdown
5/// parsing; blocks are split here because it only does inline.
6struct MarkdownView: View {
7
8 let markdown: String
9
10 var body: some View {
11 VStack(alignment: .leading, spacing: 12) {
12 ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
13 blockView(block)
14 }
15 }
16 }
17
18 // MARK: - Block model
19
20 private enum Block {
21 case heading(level: Int, text: String)
22 case code(String)
23 case quote(String)
24 case bullet([String])
25 case ordered([String])
26 case rule
27 case paragraph(String)
28 }
29
30 private var blocks: [Block] {
31 var blocks: [Block] = []
32 var lines = markdown.components(separatedBy: "\n")[...]
33
34 while let line = lines.first {
35 let trimmed = line.trimmingCharacters(in: .whitespaces)
36
37 if trimmed.hasPrefix("```") {
38 lines = lines.dropFirst()
39 var code: [String] = []
40 while let next = lines.first,
41 !next.trimmingCharacters(in: .whitespaces).hasPrefix("```") {
42 code.append(next)
43 lines = lines.dropFirst()
44 }
45 lines = lines.dropFirst() // closing fence
46 blocks.append(.code(code.joined(separator: "\n")))
47 continue
48 }
49 if trimmed.hasPrefix("#") {
50 let level = trimmed.prefix(while: { $0 == "#" }).count
51 let text = trimmed.drop(while: { $0 == "#" }).trimmingCharacters(in: .whitespaces)
52 blocks.append(.heading(level: min(level, 4), text: text))
53 lines = lines.dropFirst()
54 continue
55 }
56 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
57 blocks.append(.rule)
58 lines = lines.dropFirst()
59 continue
60 }
61 if trimmed.hasPrefix(">") {
62 var quote: [String] = []
63 while let next = lines.first?.trimmingCharacters(in: .whitespaces), next.hasPrefix(">") {
64 quote.append(next.dropFirst().trimmingCharacters(in: .whitespaces))
65 lines = lines.dropFirst()
66 }
67 blocks.append(.quote(quote.joined(separator: " ")))
68 continue
69 }
70 if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") {
71 var items: [String] = []
72 while let next = lines.first?.trimmingCharacters(in: .whitespaces),
73 next.hasPrefix("- ") || next.hasPrefix("* ") || next.hasPrefix("+ ") {
74 items.append(String(next.dropFirst(2)))
75 lines = lines.dropFirst()
76 }
77 blocks.append(.bullet(items))
78 continue
79 }
80 if trimmed.range(of: #"^\d+\. "#, options: .regularExpression) != nil {
81 var items: [String] = []
82 while let next = lines.first?.trimmingCharacters(in: .whitespaces),
83 let range = next.range(of: #"^\d+\. "#, options: .regularExpression) {
84 items.append(String(next[range.upperBound...]))
85 lines = lines.dropFirst()
86 }
87 blocks.append(.ordered(items))
88 continue
89 }
90 if trimmed.isEmpty {
91 lines = lines.dropFirst()
92 continue
93 }
94 // Paragraph: consume until a blank line or another block start.
95 var paragraph: [String] = []
96 while let next = lines.first {
97 let t = next.trimmingCharacters(in: .whitespaces)
98 if t.isEmpty || t.hasPrefix("#") || t.hasPrefix("```") || t.hasPrefix(">")
99 || t.hasPrefix("- ") || t.hasPrefix("* ") {
100 break
101 }
102 paragraph.append(t)
103 lines = lines.dropFirst()
104 }
105 blocks.append(.paragraph(paragraph.joined(separator: " ")))
106 }
107 return blocks
108 }
109
110 // MARK: - Rendering
111
112 @ViewBuilder
113 private func blockView(_ block: Block) -> some View {
114 switch block {
115 case .heading(let level, let text):
116 inline(text)
117 .font(headingFont(level))
118 .padding(.top, level <= 2 ? 8 : 4)
119 case .code(let code):
120 ScrollView(.horizontal) {
121 Text(code)
122 .font(.caption.monospaced())
123 .padding(10)
124 }
125 .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
126 case .quote(let text):
127 HStack(spacing: 10) {
128 RoundedRectangle(cornerRadius: 2)
129 .fill(.tertiary)
130 .frame(width: 3)
131 inline(text).foregroundStyle(.secondary)
132 }
133 .fixedSize(horizontal: false, vertical: true)
134 case .bullet(let items):
135 VStack(alignment: .leading, spacing: 4) {
136 ForEach(Array(items.enumerated()), id: \.offset) { _, item in
137 HStack(alignment: .firstTextBaseline, spacing: 8) {
138 Text("")
139 inline(item)
140 }
141 }
142 }
143 case .ordered(let items):
144 VStack(alignment: .leading, spacing: 4) {
145 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
146 HStack(alignment: .firstTextBaseline, spacing: 8) {
147 Text("\(index + 1).").monospacedDigit()
148 inline(item)
149 }
150 }
151 }
152 case .rule:
153 Divider()
154 case .paragraph(let text):
155 inline(text)
156 }
157 }
158
159 private func inline(_ text: String) -> Text {
160 if let attributed = try? AttributedString(
161 markdown: text,
162 options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
163 ) {
164 return Text(attributed)
165 }
166 return Text(text)
167 }
168
169 private func headingFont(_ level: Int) -> Font {
170 switch level {
171 case 1: .title2.bold()
172 case 2: .title3.bold()
173 case 3: .headline
174 default: .subheadline.bold()
175 }
176 }
177}
gitbay/Views/Repos/RepoListView.swift added +88
@@ -0,0 +1,88 @@
1import SwiftUI
2
3struct RepoListView: View {
4
5 @Environment(SessionStore.self) private var session
6 @State private var model: RepoListViewModel
7
8 init(client: GitbayClient) {
9 _model = State(initialValue: RepoListViewModel(client: client))
10 }
11
12 var body: some View {
13 List {
14 ForEach(model.visibleRepos) { repo in
15 NavigationLink(value: RepoRoute.repo(repo.path)) {
16 RepoRow(repo: repo)
17 }
18 }
19 }
20 .overlay { LoadStateOverlay(state: model.state, isEmpty: model.visibleRepos.isEmpty) }
21 .searchable(text: Bindable(model).searchText, prompt: "Filter repositories")
22 .navigationTitle("Repositories")
23 .toolbar { AccountMenu() }
24 .task { await model.load() }
25 .refreshable { await model.load() }
26 }
27}
28
29private struct RepoRow: View {
30 let repo: RepoSummary
31
32 var body: some View {
33 VStack(alignment: .leading, spacing: 3) {
34 HStack(spacing: 6) {
35 Text(repo.path)
36 .font(.body.weight(.medium))
37 .lineLimit(1)
38 if repo.isPrivate {
39 Image(systemName: "lock.fill")
40 .font(.caption2)
41 .foregroundStyle(.secondary)
42 }
43 if repo.isArchived {
44 Text("archived")
45 .font(.caption2)
46 .padding(.horizontal, 5)
47 .padding(.vertical, 1)
48 .background(.quaternary, in: Capsule())
49 }
50 }
51 if let description = repo.description, !description.isEmpty {
52 Text(description)
53 .font(.subheadline)
54 .foregroundStyle(.secondary)
55 .lineLimit(2)
56 }
57 }
58 .padding(.vertical, 2)
59 }
60}
61
62/// The account switcher and sign-out, on every top-level screen.
63struct AccountMenu: ToolbarContent {
64
65 @Environment(SessionStore.self) private var session
66
67 var body: some ToolbarContent {
68 ToolbarItem(placement: .topBarTrailing) {
69 Menu {
70 if let current = session.current {
71 Section(current.label) {
72 Button("Sign Out", role: .destructive) {
73 session.remove(current)
74 }
75 }
76 }
77 ForEach(session.accounts.filter { $0.id != session.current?.id }) { account in
78 Button(account.label) {
79 session.activate(account)
80 }
81 }
82 NavigationLink("Add Account", value: RepoRoute.addAccount)
83 } label: {
84 Image(systemName: "person.crop.circle")
85 }
86 }
87 }
88}
gitbay/Views/Repos/RepoRoute.swift added +11
@@ -0,0 +1,11 @@
1import Foundation
2
3/// Navigation destinations for the repo stack. Values, so navigation is
4/// testable and deep-linkable later.
5nonisolated enum RepoRoute: Hashable {
6 case repo(String)
7 case tree(repo: String, directory: String, ref: String?)
8 case file(repo: String, path: String, ref: String?)
9 case log(repo: String)
10 case addAccount
11}
gitbay/Views/Repos/RepoView.swift added +94
@@ -0,0 +1,94 @@
1import SwiftUI
2
3struct RepoView: View {
4
5 @State private var model: RepoDetailViewModel
6 private let path: String
7
8 init(client: GitbayClient, path: String) {
9 self.path = path
10 _model = State(initialValue: RepoDetailViewModel(client: client, path: path))
11 }
12
13 var body: some View {
14 List {
15 if let detail = model.state.value {
16 header(detail)
17
18 Section {
19 NavigationLink(value: RepoRoute.tree(repo: path, directory: "", ref: nil)) {
20 Label("Files", systemImage: "folder")
21 }
22 NavigationLink(value: RepoRoute.log(repo: path)) {
23 Label("History", systemImage: "clock")
24 }
25 NavigationLink(value: MRRoute.list(repo: path)) {
26 Label("Merge Requests", systemImage: "arrow.triangle.merge")
27 }
28 NavigationLink(value: IssueRoute.list(repo: path)) {
29 Label("Issues", systemImage: "smallcircle.filled.circle")
30 }
31 NavigationLink(value: BuildRoute.list(repo: path)) {
32 Label("Builds", systemImage: "hammer")
33 }
34 }
35
36 if let readme = model.readme {
37 Section("README") {
38 MarkdownView(markdown: readme)
39 .padding(.vertical, 4)
40 }
41 }
42 }
43 }
44 .overlay { LoadStateOverlay(state: model.state) }
45 .navigationTitle(String(path.split(separator: "/").last ?? ""))
46 .navigationBarTitleDisplayMode(.inline)
47 .task { await model.load() }
48 .refreshable { await model.load() }
49 }
50
51 @ViewBuilder
52 private func header(_ detail: RepoDetail) -> some View {
53 Section {
54 VStack(alignment: .leading, spacing: 8) {
55 HStack(spacing: 6) {
56 Text(detail.path)
57 .font(.headline)
58 if detail.visibility == "private" {
59 Image(systemName: "lock.fill")
60 .font(.caption)
61 .foregroundStyle(.secondary)
62 }
63 if detail.isArchived {
64 Text("archived")
65 .font(.caption2)
66 .padding(.horizontal, 5)
67 .padding(.vertical, 1)
68 .background(.quaternary, in: Capsule())
69 }
70 }
71 if let description = detail.description, !description.isEmpty {
72 Text(description)
73 .font(.subheadline)
74 .foregroundStyle(.secondary)
75 }
76 if let website = detail.website, let url = URL(string: website) {
77 Link(website, destination: url)
78 .font(.caption)
79 .lineLimit(1)
80 }
81 HStack(spacing: 12) {
82 Label(detail.defaultBranch, systemImage: "arrow.triangle.branch")
83 if let topics = detail.topics, !topics.isEmpty {
84 Text(topics.joined(separator: " · "))
85 .lineLimit(1)
86 }
87 }
88 .font(.caption)
89 .foregroundStyle(.secondary)
90 }
91 .padding(.vertical, 4)
92 }
93 }
94}
gitbay/Views/Repos/SyntaxHighlighter.swift added +87
@@ -0,0 +1,87 @@
1import Foundation
2import Highlightr
3import SwiftUI
4import UIKit
5
6/// Adapted from hutch: Highlightr wraps a JavaScriptCore context and is
7/// not thread-safe, so an instance stays where it was made. A `nil` result
8/// means "show plain text", never an error.
9nonisolated final class SyntaxHighlighter {
10
11 private let highlightr: Highlightr?
12 private let supported: Set<String>
13
14 init(colorScheme: ColorScheme) {
15 let engine = Highlightr()
16 // Xcode-like in light, muted in dark, so code reads as native.
17 engine?.setTheme(to: colorScheme == .dark ? "atom-one-dark" : "xcode")
18 highlightr = engine
19 supported = Set(engine?.supportedLanguages() ?? [])
20 }
21
22 func highlight(_ code: String, fileName: String, font: UIFont) -> NSAttributedString? {
23 guard let highlightr, let language = Self.language(for: fileName),
24 supported.contains(language) else {
25 return nil
26 }
27 highlightr.theme.setCodeFont(font)
28 return highlightr.highlight(code, as: language, fastRender: true)
29 }
30
31 /// File extension highlight.js language name. Unknown is fine; the
32 /// caller falls back to plain text.
33 static func language(for fileName: String) -> String? {
34 let name = fileName.lowercased()
35 if name == "makefile" { return "makefile" }
36 if name == "dockerfile" { return "dockerfile" }
37 guard let ext = name.split(separator: ".").last, ext != name else { return nil }
38 switch ext {
39 case "swift": return "swift"
40 case "go": return "go"
41 case "rs": return "rust"
42 case "py": return "python"
43 case "rb": return "ruby"
44 case "js", "mjs", "cjs": return "javascript"
45 case "ts", "tsx": return "typescript"
46 case "jsx": return "javascript"
47 case "c", "h": return "c"
48 case "cc", "cpp", "cxx", "hpp": return "cpp"
49 case "m": return "objectivec"
50 case "cs": return "csharp"
51 case "java": return "java"
52 case "kt", "kts": return "kotlin"
53 case "sh", "bash", "zsh": return "bash"
54 case "fish": return "shell"
55 case "php": return "php"
56 case "pl": return "perl"
57 case "lua": return "lua"
58 case "html", "htm": return "xml"
59 case "xml", "plist", "storyboard", "xib": return "xml"
60 case "css": return "css"
61 case "scss", "sass": return "scss"
62 case "json": return "json"
63 case "yml", "yaml": return "yaml"
64 case "toml", "ini", "cfg": return "ini"
65 case "md", "markdown": return "markdown"
66 case "org": return "plaintext"
67 case "sql": return "sql"
68 case "diff", "patch": return "diff"
69 case "proto": return "protobuf"
70 case "cmake": return "cmake"
71 case "gradle": return "gradle"
72 case "ex", "exs": return "elixir"
73 case "erl": return "erlang"
74 case "hs": return "haskell"
75 case "ml", "mli": return "ocaml"
76 case "clj", "cljs": return "clojure"
77 case "scala": return "scala"
78 case "r": return "r"
79 case "dart": return "dart"
80 case "zig": return "plaintext"
81 case "vim": return "vim"
82 case "tex": return "latex"
83 case "txt", "text", "log": return nil
84 default: return nil
85 }
86 }
87}
gitbay/Views/Repos/TreeView.swift added +72
@@ -0,0 +1,72 @@
1import SwiftUI
2
3struct TreeView: View {
4
5 @State private var model: TreeViewModel
6
7 init(client: GitbayClient, repo: String, directory: String, ref: String?) {
8 _model = State(initialValue: TreeViewModel(
9 client: client, repoPath: repo, directory: directory, ref: ref
10 ))
11 }
12
13 var body: some View {
14 List {
15 ForEach(model.entries) { entry in
16 if entry.isDirectory {
17 NavigationLink(value: RepoRoute.tree(
18 repo: model.repoPath,
19 directory: model.childDirectory(entry),
20 ref: model.ref
21 )) {
22 EntryRow(entry: entry)
23 }
24 } else {
25 NavigationLink(value: RepoRoute.file(
26 repo: model.repoPath,
27 path: model.childDirectory(entry),
28 ref: model.ref
29 )) {
30 EntryRow(entry: entry)
31 }
32 }
33 }
34 }
35 .overlay { LoadStateOverlay(state: model.state) }
36 .navigationTitle(model.directory.isEmpty
37 ? String(model.repoPath.split(separator: "/").last ?? "")
38 : String(model.directory.split(separator: "/").last ?? ""))
39 .navigationBarTitleDisplayMode(.inline)
40 .task { await model.load() }
41 .refreshable { await model.load() }
42 }
43}
44
45private struct EntryRow: View {
46 let entry: TreeEntry
47
48 var body: some View {
49 HStack {
50 Label {
51 Text(entry.name)
52 .lineLimit(1)
53 } icon: {
54 Image(systemName: icon)
55 .foregroundStyle(entry.isDirectory ? Color.accentColor : .secondary)
56 }
57 Spacer()
58 if let size = entry.size, !entry.isDirectory {
59 Text(size.formatted(.byteCount(style: .file)))
60 .font(.caption)
61 .foregroundStyle(.tertiary)
62 .monospacedDigit()
63 }
64 }
65 }
66
67 private var icon: String {
68 if entry.isDirectory { return "folder.fill" }
69 if entry.isSymlink { return "arrow.turn.down.right" }
70 return "doc"
71 }
72}
gitbay/Views/SignInView.swift added +110
@@ -0,0 +1,110 @@
1import SwiftUI
2
3/// Paste a token, name the instance, done. There is no browser flow and no
4/// password anywhere in the system: tokens are minted over SSH on a
5/// machine that has it.
6struct SignInView: View {
7
8 @Environment(SessionStore.self) private var session
9
10 @State private var instanceURL = "gitbay.org"
11 @State private var token = ""
12 @State private var errorMessage: String?
13 @State private var signingIn = false
14 @FocusState private var tokenFieldFocused: Bool
15
16 var body: some View {
17 Form {
18 if let notice = session.signedOutMessage {
19 Section {
20 Label(notice, systemImage: "key.slash")
21 .foregroundStyle(.secondary)
22 }
23 }
24
25 Section("Instance") {
26 TextField("gitbay.org", text: $instanceURL)
27 .textContentType(.URL)
28 .keyboardType(.URL)
29 .autocorrectionDisabled()
30 .textInputAutocapitalization(.never)
31 }
32
33 Section {
34 SecureField("Paste a token", text: $token)
35 .autocorrectionDisabled()
36 .textInputAutocapitalization(.never)
37 .focused($tokenFieldFocused)
38 .submitLabel(.go)
39 .onSubmit { signIn() }
40 } header: {
41 Text("Token")
42 } footer: {
43 VStack(alignment: .leading, spacing: 8) {
44 Text("Mint one over SSH on a machine that has your key:")
45 Text(verbatim: "gitbay auth token create --name iphone --scope full --ttl 90d")
46 .font(.caption.monospaced())
47 .textSelection(.enabled)
48 Text("`--scope read` also works, but a read-only token cannot comment or merge.")
49 }
50 }
51
52 if let errorMessage {
53 Section {
54 Label(errorMessage, systemImage: "exclamationmark.triangle")
55 .foregroundStyle(.red)
56 }
57 }
58
59 Section {
60 Button {
61 signIn()
62 } label: {
63 if signingIn {
64 ProgressView()
65 .frame(maxWidth: .infinity)
66 } else {
67 Text("Sign In")
68 .frame(maxWidth: .infinity)
69 }
70 }
71 .disabled(signingIn || token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
72 }
73 }
74 .navigationTitle("Sign in to gitbay")
75 .onAppear { tokenFieldFocused = true }
76 }
77
78 private func signIn() {
79 signingIn = true
80 errorMessage = nil
81 Task {
82 defer { signingIn = false }
83 do {
84 try await session.signIn(instanceURL: instanceURL, token: token)
85 token = ""
86 } catch let error as GitbayError {
87 errorMessage = signInMessage(for: error)
88 } catch let error as GitbayInstance.InvalidURL {
89 errorMessage = error.errorDescription
90 } catch {
91 errorMessage = "Something went wrong. Please try again."
92 }
93 }
94 }
95
96 /// Sign-in is the one place a 401 means "that token", not "your session".
97 private func signInMessage(for error: GitbayError) -> String {
98 if error.requiresReauthentication {
99 return "That token was not accepted. Check it was pasted whole and has not expired."
100 }
101 return error.userFacingMessage
102 }
103}
104
105#Preview {
106 NavigationStack {
107 SignInView()
108 }
109 .environment(SessionStore())
110}
gitbay/gitbayApp.swift added +14
@@ -0,0 +1,14 @@
1import SwiftUI
2
3@main
4struct gitbayApp: App {
5
6 @State private var session = SessionStore()
7
8 var body: some Scene {
9 WindowGroup {
10 ContentView()
11 .environment(session)
12 }
13 }
14}
gitbayTests/DashboardViewModelTests.swift added +103
@@ -0,0 +1,103 @@
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 let twoRepoList = """
16 {"protocol_version":1,"data":[\
17 {"path":"krz/gitbay","visibility":"public"},\
18 {"path":"krz/dust","visibility":"public","archived":true},\
19 {"path":"krz/hutch","visibility":"public"}\
20 ],"exit_code":0}
21 """
22
23private let openMRJSON = """
24 {"protocol_version":1,"data":[\
25 {"number":7,"title":"fix","state":"open","author":"krz","source":"fix",\
26 "target_ref":"main","head_sha":"aa","created_at":"2026-08-22T10:00:00.000Z"}\
27 ],"exit_code":0}
28 """
29
30private let assignedIssueJSON = """
31 {"protocol_version":1,"data":[\
32 {"number":3,"title":"mine","state":"open","author":"krz","assignees":["cmc"],\
33 "created_at":"2026-08-23T10:00:00.000Z"},\
34 {"number":4,"title":"theirs","state":"open","author":"krz","assignees":["krz"],\
35 "created_at":"2026-08-23T11:00:00.000Z"}\
36 ],"exit_code":0}
37 """
38
39private let emptyJSON = #"{"protocol_version":1,"exit_code":0}"#
40
41private let buildJSON = """
42 {"protocol_version":1,"data":[\
43 {"number":9,"job":"ci","status":"success","sha":"aa00000000",\
44 "ref":"refs/heads/main","created_at":"2026-08-22T10:05:00.000Z"}\
45 ],"exit_code":0}
46 """
47
48@MainActor
49struct DashboardViewModelTests {
50
51 @Test func aggregatesAcrossReposSkippingArchivedOnes() async throws {
52 let (client, stub) = try makeClient()
53 stub.enqueue(.init(status: 200, json: twoRepoList, match: "argv=repo&argv=list"))
54 // krz/gitbay: one open MR, one assigned + one unassigned issue, a build.
55 stub.enqueue(.init(status: 200, json: openMRJSON, match: "argv=mr&argv=list&argv=krz/gitbay"))
56 stub.enqueue(.init(status: 200, json: assignedIssueJSON, match: "argv=issue&argv=list&argv=krz/gitbay"))
57 stub.enqueue(.init(status: 200, json: buildJSON, match: "argv=build&argv=list&argv=krz/gitbay"))
58 // krz/hutch: nothing open no build call should follow.
59 stub.enqueue(.init(status: 200, json: emptyJSON, match: "argv=mr&argv=list&argv=krz/hutch"))
60 stub.enqueue(.init(status: 200, json: emptyJSON, match: "argv=issue&argv=list&argv=krz/hutch"))
61 let model = DashboardViewModel(client: client, username: "cmc")
62
63 await model.load()
64
65 #expect(model.openMRs.map(\.id) == ["krz/gitbay!7"])
66 // Only issues assigned to me, and never from the archived repo.
67 #expect(model.assignedIssues.map(\.id) == ["krz/gitbay#3"])
68 #expect(model.recentBuilds.map(\.id) == ["krz/gitbay#9"])
69 #expect(model.scanNote == nil)
70 // The archived repo cost zero requests; the quiet repo cost two.
71 let scanned = stub.seen.map { $0.url.query() ?? "" }
72 #expect(!scanned.contains { $0.contains("krz/dust") })
73 #expect(scanned.count { $0.contains("krz/hutch") } == 2)
74 }
75
76 @Test func rateLimitStopsTheScanAndSaysSo() async throws {
77 let (client, stub) = try makeClient()
78 stub.enqueue(.init(status: 200, json: twoRepoList, match: "argv=repo&argv=list"))
79 stub.enqueue(.init(status: 200, json: openMRJSON, match: "argv=mr&argv=list&argv=krz/gitbay"))
80 stub.enqueue(.init(status: 200, json: assignedIssueJSON, match: "argv=issue&argv=list&argv=krz/gitbay"))
81 stub.enqueue(.init(status: 200, json: buildJSON, match: "argv=build&argv=list&argv=krz/gitbay"))
82 stub.enqueue(.init(
83 status: 429,
84 headers: ["Retry-After": "30"],
85 json: #"{"protocol_version":1,"error":"rate limited; retry in 30s"}"#,
86 match: "krz/hutch"
87 ))
88 stub.enqueue(.init(
89 status: 429,
90 headers: ["Retry-After": "30"],
91 json: #"{"protocol_version":1,"error":"rate limited; retry in 30s"}"#,
92 match: "krz/hutch"
93 ))
94 let model = DashboardViewModel(client: client, username: "cmc")
95
96 await model.load()
97
98 // What arrived before the limit is still on screen.
99 #expect(model.openMRs.count == 1)
100 let note = try #require(model.scanNote)
101 #expect(note.contains("Rate limited"))
102 }
103}
gitbayTests/GitbayClientTests.swift added +380
@@ -0,0 +1,380 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5/// Recorded envelopes, verbatim from gitbay.org v1.0.0.
6private enum Recorded {
7 static let whoami = """
8 {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0}
9 """
10 static let repoList = """
11 {"protocol_version":1,"data":[\
12 {"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"},\
13 {"path":"krz/secrets","visibility":"private","archived":true}\
14 ],"exit_code":0}
15 """
16 static let emptyList = """
17 {"protocol_version":1,"exit_code":0}
18 """
19 static let notFound = """
20 {"protocol_version":1,"error":"no such issue 99 in krz/gitbay","exit_code":3}
21 """
22 static let denied = """
23 {"protocol_version":1,"error":"krz/gitbay is archived and read-only","exit_code":4}
24 """
25 static let usage = """
26 {"protocol_version":1,"error":"usage: repo tree <owner/name> [<path>] [--ref <ref>]","exit_code":2}
27 """
28 static let writeOnReadSurface = """
29 {"protocol_version":1,"error":"repo create changes state; POST it to /api/v1/cmd"}
30 """
31 static let badToken = """
32 {"protocol_version":1,"error":"invalid or expired token"}
33 """
34 static let diff = """
35 {"protocol_version":1,"output":"diff --git a/main.go b/main.go\\n--- a/main.go\\n+++ b/main.go\\n","exit_code":0}
36 """
37}
38
39nonisolated private struct WhoamiPayload: Decodable, Sendable, Equatable {
40 let username: String
41 let admin: Bool
42}
43
44nonisolated private struct RepoPayload: Decodable, Sendable, Equatable {
45 let path: String
46 let visibility: String
47 let description: String?
48 let archived: Bool?
49}
50
51private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
52 let box = StubProtocol.box()
53 let client = GitbayClient(
54 instance: try GitbayInstance(url: "https://gitbay.org"),
55 token: "test-token",
56 session: box.session()
57 )
58 return (client, box)
59}
60
61struct GitbayClientReadTests {
62
63 @Test func readDecodesEnvelopeData() async throws {
64 let (client, stub) = try makeClient()
65 stub.enqueue(.init(status: 200, json: Recorded.whoami))
66
67 let who = try await client.read(["whoami"], as: WhoamiPayload.self)
68
69 #expect(who == WhoamiPayload(username: "cmc", admin: false))
70 let seen = try #require(stub.seen.first)
71 #expect(seen.method == "GET")
72 #expect(seen.url.path() == "/api/v1/read")
73 #expect(seen.url.query() == "argv=whoami")
74 #expect(seen.headers["Authorization"] == "Bearer test-token")
75 #expect(seen.headers["If-None-Match"] == nil)
76 }
77
78 @Test func argvArrivesAsRepeatedQueryParameters() async throws {
79 let (client, stub) = try makeClient()
80 stub.enqueue(.init(status: 200, json: Recorded.emptyList))
81
82 _ = try await client.readList(
83 ["repo", "tree", "krz/gitbay", "internal"], of: RepoPayload.self
84 )
85
86 let seen = try #require(stub.seen.first)
87 #expect(seen.url.query() == "argv=repo&argv=tree&argv=krz/gitbay&argv=internal")
88 }
89
90 @Test func secondReadRevalidatesAndA304ServesTheCachedBody() async throws {
91 let (client, stub) = try makeClient()
92 let etag = "\"12e3e2b8914d9abfceec233dac9a7454\""
93 stub.enqueue(.init(status: 200, headers: ["ETag": etag], json: Recorded.repoList))
94 stub.enqueue(.init(status: 304, headers: ["ETag": etag]))
95
96 let first = try await client.readList(["repo", "list"], of: RepoPayload.self)
97 let second = try await client.readList(["repo", "list"], of: RepoPayload.self)
98
99 #expect(first == second)
100 #expect(first.count == 2)
101 #expect(first[0].path == "krz/gitbay")
102 let requests = stub.seen
103 #expect(requests.count == 2)
104 #expect(requests[0].headers["If-None-Match"] == nil)
105 #expect(requests[1].headers["If-None-Match"] == etag)
106 }
107
108 @Test func changedBodyReplacesTheCachedValidator() async throws {
109 let (client, stub) = try makeClient()
110 stub.enqueue(.init(status: 200, headers: ["ETag": "\"aa\""], json: Recorded.repoList))
111 stub.enqueue(.init(status: 200, headers: ["ETag": "\"bb\""], json: Recorded.emptyList))
112 stub.enqueue(.init(status: 304))
113
114 _ = try await client.readList(["repo", "list"], of: RepoPayload.self)
115 let refreshed = try await client.readList(["repo", "list"], of: RepoPayload.self)
116 let revalidated = try await client.readList(["repo", "list"], of: RepoPayload.self)
117
118 #expect(refreshed.isEmpty)
119 #expect(revalidated.isEmpty)
120 #expect(stub.seen[2].headers["If-None-Match"] == "\"bb\"")
121 }
122
123 @Test func absentDataDecodesAsEmptyList() async throws {
124 let (client, stub) = try makeClient()
125 stub.enqueue(.init(status: 200, json: Recorded.emptyList))
126
127 let repos = try await client.readList(["repo", "list"], of: RepoPayload.self)
128
129 #expect(repos.isEmpty)
130 }
131
132 @Test func rawTextCommandsComeBackAsOutput() async throws {
133 let (client, stub) = try makeClient()
134 stub.enqueue(.init(status: 200, json: Recorded.diff))
135
136 let diff = try await client.readText(["mr", "diff", "krz/gitbay", "7"])
137
138 #expect(diff.hasPrefix("diff --git a/main.go"))
139 }
140}
141
142struct GitbayClientWriteTests {
143
144 @Test func writePostsArgvAndStdinAsJSON() async throws {
145 let (client, stub) = try makeClient()
146 stub.enqueue(.init(status: 200, json: Recorded.emptyList))
147
148 try await client.run(
149 ["issue", "comment", "krz/gitbay", "35"],
150 stdin: "looks right to me"
151 )
152
153 let seen = try #require(stub.seen.first)
154 #expect(seen.method == "POST")
155 #expect(seen.url.path() == "/api/v1/cmd")
156 let body = try #require(
157 try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]
158 )
159 #expect(body["argv"] as? [String] == ["issue", "comment", "krz/gitbay", "35"])
160 #expect(body["stdin"] as? String == "looks right to me")
161 }
162
163 @Test func stdinIsOmittedWhenAbsent() async throws {
164 let (client, stub) = try makeClient()
165 stub.enqueue(.init(status: 200, json: Recorded.emptyList))
166
167 try await client.run(["mr", "merge", "krz/gitbay", "7"])
168
169 let seen = try #require(stub.seen.first)
170 let body = try #require(
171 try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]
172 )
173 #expect(body["stdin"] == nil)
174 }
175}
176
177struct GitbayClientFailureTests {
178
179 @Test func exitThreeIsAnEmptyStateNotAnError() async throws {
180 let (client, stub) = try makeClient()
181 stub.enqueue(.init(status: 404, json: Recorded.notFound))
182
183 await #expect {
184 _ = try await client.read(["issue", "show", "krz/gitbay", "99"], as: WhoamiPayload.self)
185 } throws: { error in
186 guard let error = error as? GitbayError else { return false }
187 return error.isEmptyState
188 && error.userFacingMessage == "no such issue 99 in krz/gitbay"
189 }
190 }
191
192 @Test func exitFourSurfacesTheServersMessageVerbatim() async throws {
193 let (client, stub) = try makeClient()
194 stub.enqueue(.init(status: 403, json: Recorded.denied))
195
196 await #expect {
197 try await client.run(["issue", "comment", "krz/gitbay", "1"], stdin: "hi")
198 } throws: { error in
199 guard case .denied(let message)? = error as? GitbayError else { return false }
200 return message == "krz/gitbay is archived and read-only"
201 }
202 }
203
204 @Test func exitTwoNeverPutsArgvInTheUserFacingMessage() async throws {
205 let (client, stub) = try makeClient()
206 stub.enqueue(.init(status: 400, json: Recorded.usage))
207
208 await #expect {
209 _ = try await client.read(["repo", "tree"], as: WhoamiPayload.self)
210 } throws: { error in
211 guard case .usage? = error as? GitbayError else { return false }
212 let shown = (error as! GitbayError).userFacingMessage
213 return !shown.contains("repo tree") && !shown.contains("usage")
214 }
215 }
216
217 @Test func gateRejectionsWithoutExitCodesMapOffTheStatus() async throws {
218 let (client, stub) = try makeClient()
219 stub.enqueue(.init(status: 401, json: Recorded.badToken))
220
221 await #expect {
222 _ = try await client.read(["whoami"], as: WhoamiPayload.self)
223 } throws: { error in
224 (error as? GitbayError)?.requiresReauthentication == true
225 }
226 }
227
228 @Test func writeSentToTheReadSurfaceIsAnAppBug() async throws {
229 let (client, stub) = try makeClient()
230 stub.enqueue(.init(status: 400, json: Recorded.writeOnReadSurface))
231
232 await #expect {
233 _ = try await client.read(["repo", "create", "krz/new"], as: WhoamiPayload.self)
234 } throws: { error in
235 guard case .notReadable? = error as? GitbayError else { return false }
236 return true
237 }
238 }
239
240 @Test func serverFailureRetriesOnceThenSurfaces() async throws {
241 let (client, stub) = try makeClient()
242 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
243 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
244
245 await #expect {
246 _ = try await client.read(["repo", "list"], as: WhoamiPayload.self)
247 } throws: { error in
248 guard case .failure(let message)? = error as? GitbayError else { return false }
249 return message == "boom"
250 }
251 #expect(stub.seen.count == 2)
252 }
253
254 @Test func serverFailureRecoversWhenTheRetrySucceeds() async throws {
255 let (client, stub) = try makeClient()
256 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
257 stub.enqueue(.init(status: 200, json: Recorded.whoami))
258
259 let who = try await client.read(["whoami"], as: WhoamiPayload.self)
260
261 #expect(who.username == "cmc")
262 #expect(stub.seen.count == 2)
263 }
264
265 @Test func shortRetryAfterIsHonouredThenTheCallSucceeds() async throws {
266 let (client, stub) = try makeClient()
267 stub.enqueue(.init(
268 status: 429,
269 headers: ["Retry-After": "1"],
270 json: #"{"protocol_version":1,"error":"rate limited; retry in 1s"}"#
271 ))
272 stub.enqueue(.init(status: 200, json: Recorded.whoami))
273
274 let start = ContinuousClock.now
275 let who = try await client.read(["whoami"], as: WhoamiPayload.self)
276
277 #expect(who.username == "cmc")
278 #expect(ContinuousClock.now - start >= .seconds(1))
279 #expect(stub.seen.count == 2)
280 }
281
282 @Test func longRetryAfterSurfacesInsteadOfHanging() async throws {
283 let (client, stub) = try makeClient()
284 stub.enqueue(.init(
285 status: 429,
286 headers: ["Retry-After": "30"],
287 json: #"{"protocol_version":1,"error":"rate limited; retry in 30s"}"#
288 ))
289
290 await #expect {
291 _ = try await client.read(["whoami"], as: WhoamiPayload.self)
292 } throws: { error in
293 guard case .rateLimited(let wait)? = error as? GitbayError else { return false }
294 return wait == 30
295 }
296 #expect(stub.seen.count == 1)
297 }
298
299 @Test func unknownProtocolVersionIsRefused() async throws {
300 let (client, stub) = try makeClient()
301 stub.enqueue(.init(status: 200, json: #"{"protocol_version":2,"data":{},"exit_code":0}"#))
302
303 await #expect {
304 _ = try await client.read(["whoami"], as: WhoamiPayload.self)
305 } throws: { error in
306 guard case .protocolMismatch(2)? = error as? GitbayError else { return false }
307 return true
308 }
309 }
310}
311
312struct GitbayInstanceTests {
313
314 @Test func bareHostGetsHTTPS() throws {
315 let instance = try GitbayInstance(url: "gitbay.org")
316 #expect(instance.baseURL.absoluteString == "https://gitbay.org")
317 }
318
319 @Test func pastedPathAndQueryAreStripped() throws {
320 let instance = try GitbayInstance(url: "https://gitbay.org/krz/gitbay?tab=readme")
321 #expect(instance.baseURL.absoluteString == "https://gitbay.org")
322 }
323
324 @Test func plainHTTPIsRefusedExceptLoopback() throws {
325 #expect(throws: GitbayInstance.InvalidURL.self) {
326 _ = try GitbayInstance(url: "http://gitbay.org")
327 }
328 let local = try GitbayInstance(url: "http://localhost:3000")
329 #expect(local.baseURL.absoluteString == "http://localhost:3000")
330 }
331
332 @Test func readURLEncodesArgvInOrder() throws {
333 let instance = try GitbayInstance(url: "https://gitbay.org")
334 let url = instance.readURL(argv: ["repo", "cat", "krz/gitbay", "cmd/main.go"])
335 #expect(url.query() == "argv=repo&argv=cat&argv=krz/gitbay&argv=cmd/main.go")
336 }
337
338 @Test func ownHostCheckCoversSchemeHostAndPort() throws {
339 let instance = try GitbayInstance(url: "https://gitbay.org")
340 #expect(instance.isOwn(URL(string: "https://gitbay.org/api/v1/read")!))
341 #expect(instance.isOwn(URL(string: "https://GITBAY.ORG/api/v1/cmd")!))
342 #expect(!instance.isOwn(URL(string: "https://evil.example/api/v1/read")!))
343 #expect(!instance.isOwn(URL(string: "http://gitbay.org/api/v1/read")!))
344 #expect(!instance.isOwn(URL(string: "https://gitbay.org.evil.example/")!))
345 }
346}
347
348struct ETagStoreTests {
349
350 @Test func keysAreScopedByAccount() {
351 let a = ETagStore.key(account: "aaaa", argv: ["repo", "list"])
352 let b = ETagStore.key(account: "bbbb", argv: ["repo", "list"])
353 #expect(a != b)
354 }
355
356 @Test func joiningCannotCollideAcrossArgvBoundaries() {
357 let a = ETagStore.key(account: "x", argv: ["repo list"])
358 let b = ETagStore.key(account: "x", argv: ["repo", "list"])
359 #expect(a != b)
360 }
361
362 @Test func evictionDropsTheLeastRecentlyUsedEntry() {
363 let store = ETagStore(capacity: 2)
364 store.store(ETagEntry(etag: "a", payload: Data()), for: "a")
365 store.store(ETagEntry(etag: "b", payload: Data()), for: "b")
366 _ = store.entry(for: "a")
367 store.store(ETagEntry(etag: "c", payload: Data()), for: "c")
368
369 #expect(store.entry(for: "a") != nil)
370 #expect(store.entry(for: "b") == nil)
371 #expect(store.entry(for: "c") != nil)
372 }
373
374 @Test func clearEmptiesTheStore() {
375 let store = ETagStore()
376 store.store(ETagEntry(etag: "a", payload: Data()), for: "a")
377 store.clear()
378 #expect(store.count == 0)
379 }
380}
gitbayTests/IssueBuildViewModelTests.swift added +162
@@ -0,0 +1,162 @@
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 let issueListJSON = """
16 {"protocol_version":1,"data":[\
17 {"number":11,"title":"iOS app","state":"open","author":"krz",\
18 "labels":["app"],"assignees":["cmc"],"created_at":"2026-08-20T10:00:00.000Z"},\
19 {"number":40,"title":"pagination","state":"open","author":"krz",\
20 "created_at":"2026-08-21T10:00:00.000Z"}\
21 ],"exit_code":0}
22 """
23
24private let issueShowJSON = """
25 {"protocol_version":1,"data":{"number":11,"title":"iOS app","state":"open",\
26 "author":"krz","labels":["app"],"assignees":["cmc"],"body":"Build it.",\
27 "created_at":"2026-08-20T10:00:00.000Z",\
28 "comments":[{"author":"cmc","body":"started","created_at":"2026-08-22T09:00:00.000Z"}]},\
29 "exit_code":0}
30 """
31
32private let buildListJSON = """
33 {"protocol_version":1,"data":[\
34 {"number":3,"job":"ci","status":"success","sha":"65ba14e0000000000000",\
35 "ref":"refs/heads/main","created_at":"2026-08-20T10:00:00.000Z",\
36 "finished_at":"2026-08-20T10:05:00.000Z"},\
37 {"number":4,"job":"ci","status":"running","sha":"7953e780000000000000",\
38 "ref":"refs/heads/main","created_at":"2026-08-20T11:00:00.000Z"}\
39 ],"exit_code":0}
40 """
41
42@MainActor
43struct IssueListViewModelTests {
44
45 @Test func listsNewestFirstWithLabelsAndAssignees() async throws {
46 let (client, stub) = try makeClient()
47 stub.enqueue(.init(status: 200, json: issueListJSON))
48 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
49
50 await model.load()
51
52 let issues = try #require(model.state.value)
53 #expect(issues.map(\.number) == [40, 11])
54 #expect(issues[1].labels == ["app"])
55 #expect(issues[1].assignees == ["cmc"])
56 #expect(stub.seen.first?.url.query() ==
57 "argv=issue&argv=list&argv=krz/gitbay&argv=--state&argv=open")
58 }
59}
60
61@MainActor
62struct IssueDetailViewModelTests {
63
64 private func loadedModel() async throws -> (IssueDetailViewModel, StubProtocol.Box) {
65 let (client, stub) = try makeClient()
66 stub.enqueue(.init(status: 200, json: issueShowJSON))
67 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11)
68 await model.load()
69 return (model, stub)
70 }
71
72 @Test func loadsIssueWithComments() async throws {
73 let (model, _) = try await loadedModel()
74
75 let issue = try #require(model.state.value)
76 #expect(issue.title == "iOS app")
77 #expect(issue.comments?.count == 1)
78 }
79
80 @Test func commentTravelsInStdin() async throws {
81 let (model, stub) = try await loadedModel()
82 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
83 stub.enqueue(.init(status: 200, json: issueShowJSON))
84
85 await model.comment("triaged from the phone")
86
87 let write = stub.seen[1]
88 #expect(write.method == "POST")
89 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
90 #expect(body["argv"] as? [String] == ["issue", "comment", "krz/gitbay", "11"])
91 #expect(body["stdin"] as? String == "triaged from the phone")
92 }
93
94 @Test func labelAndAssigneeEditsUseAddRemoveFlags() async throws {
95 let (model, stub) = try await loadedModel()
96 for _ in 0..<4 {
97 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
98 stub.enqueue(.init(status: 200, json: issueShowJSON))
99 }
100
101 await model.addLabel("bug")
102 await model.removeLabel("app")
103 await model.addAssignee("krz")
104 await model.removeAssignee("cmc")
105
106 let writes = stub.seen.filter { $0.method == "POST" }
107 let argvs = try writes.map {
108 try #require(try JSONSerialization.jsonObject(with: $0.body) as? [String: Any])["argv"] as? [String]
109 }
110 #expect(argvs[0] == ["issue", "label", "krz/gitbay", "11", "--add", "bug"])
111 #expect(argvs[1] == ["issue", "label", "krz/gitbay", "11", "--remove", "app"])
112 #expect(argvs[2] == ["issue", "assign", "krz/gitbay", "11", "--add", "krz"])
113 #expect(argvs[3] == ["issue", "assign", "krz/gitbay", "11", "--remove", "cmc"])
114 }
115
116 @Test func closeAndReopenAreSeparateCommands() async throws {
117 let (model, stub) = try await loadedModel()
118 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
119 stub.enqueue(.init(status: 200, json: issueShowJSON))
120 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
121 stub.enqueue(.init(status: 200, json: issueShowJSON))
122
123 await model.close()
124 await model.reopen()
125
126 let writes = stub.seen.filter { $0.method == "POST" }
127 let argvs = try writes.map {
128 try #require(try JSONSerialization.jsonObject(with: $0.body) as? [String: Any])["argv"] as? [String]
129 }
130 #expect(argvs[0] == ["issue", "close", "krz/gitbay", "11"])
131 #expect(argvs[1] == ["issue", "reopen", "krz/gitbay", "11"])
132 }
133
134 @Test func aDeniedTriageActionSurfacesTheRule() async throws {
135 let (model, stub) = try await loadedModel()
136 stub.enqueue(.init(status: 403, json:
137 #"{"protocol_version":1,"error":"krz/gitbay is archived and read-only","exit_code":4}"#))
138
139 await model.close()
140
141 #expect(model.actionError == "krz/gitbay is archived and read-only")
142 #expect(model.state.value != nil)
143 }
144}
145
146@MainActor
147struct BuildListViewModelTests {
148
149 @Test func listsNewestFirst() async throws {
150 let (client, stub) = try makeClient()
151 stub.enqueue(.init(status: 200, json: buildListJSON))
152 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
153
154 await model.load()
155
156 let builds = try #require(model.state.value)
157 #expect(builds.map(\.number) == [4, 3])
158 #expect(builds[0].status == "running")
159 #expect(builds[0].finishedAt == nil)
160 #expect(builds[1].finishedAt != nil)
161 }
162}
gitbayTests/LiveInstanceTests.swift added +87
@@ -0,0 +1,87 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5/// Exercises a real instance. Skipped unless a token is provided:
6///
7/// GITBAY_TEST_TOKEN=$(gitbay auth token create --name ci --scope read --ttl 1h)
8///
9/// Optionally GITBAY_TEST_INSTANCE (defaults to gitbay.org). A read-scoped
10/// token is enough nothing here writes.
11struct LiveInstanceTests {
12
13 nonisolated private static var token: String? {
14 ProcessInfo.processInfo.environment["GITBAY_TEST_TOKEN"]
15 }
16
17 nonisolated private struct Whoami: Decodable, Sendable {
18 let username: String
19 }
20
21 @Test(.enabled(if: token != nil, "GITBAY_TEST_TOKEN is not set"))
22 func secondReadOfARealInstanceRevalidatesFor304() async throws {
23 let host = ProcessInfo.processInfo.environment["GITBAY_TEST_INSTANCE"] ?? "gitbay.org"
24 let instance = try GitbayInstance(url: host)
25
26 // A session that counts responses without touching their content,
27 // so the 304 is observed rather than inferred.
28 let client = GitbayClient(
29 instance: instance,
30 token: try #require(Self.token),
31 session: RecordingProtocol.session()
32 )
33 RecordingProtocol.reset()
34
35 let first = try await client.read(["whoami"], as: Whoami.self)
36 let second = try await client.read(["whoami"], as: Whoami.self)
37
38 #expect(first.username == second.username)
39 #expect(!first.username.isEmpty)
40 let statuses = RecordingProtocol.statuses
41 #expect(statuses == [200, 304])
42 }
43}
44
45/// Passes requests through to the network, recording each response status.
46nonisolated final class RecordingProtocol: URLProtocol, @unchecked Sendable {
47
48 private static let recorded = Mutex<[Int]>([])
49 private static let inner = URLSession(configuration: .ephemeral)
50
51 static var statuses: [Int] {
52 recorded.withLock { $0 }
53 }
54
55 static func reset() {
56 recorded.withLock { $0.removeAll() }
57 }
58
59 static func session() -> URLSession {
60 let configuration = URLSessionConfiguration.ephemeral
61 configuration.protocolClasses = [RecordingProtocol.self]
62 return URLSession(configuration: configuration)
63 }
64
65 override static func canInit(with _: URLRequest) -> Bool { true }
66
67 override static func canonicalRequest(for request: URLRequest) -> URLRequest { request }
68
69 override func startLoading() {
70 let request = self.request
71 Task {
72 do {
73 let (data, response) = try await Self.inner.data(for: request)
74 if let http = response as? HTTPURLResponse {
75 Self.recorded.withLock { $0.append(http.statusCode) }
76 }
77 self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
78 self.client?.urlProtocol(self, didLoad: data)
79 self.client?.urlProtocolDidFinishLoading(self)
80 } catch {
81 self.client?.urlProtocol(self, didFailWithError: error)
82 }
83 }
84 }
85
86 override func stopLoading() {}
87}
gitbayTests/MRViewModelTests.swift added +279
@@ -0,0 +1,279 @@
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 let mrListJSON = """
16 {"protocol_version":1,"data":[\
17 {"number":7,"title":"client: envelope decoding","state":"open","author":"cmc",\
18 "source":"client-envelope","target_ref":"main","head_sha":"aabbcc",\
19 "created_at":"2026-08-20T10:00:00.000Z"},\
20 {"number":9,"title":"auth: keychain","state":"open","author":"cmc",\
21 "source":"krz/fork:auth","target_ref":"main","head_sha":"ddeeff",\
22 "created_at":"2026-08-21T10:00:00.000Z"}\
23 ],"exit_code":0}
24 """
25
26private let mrShowJSON = """
27 {"protocol_version":1,"data":{"number":7,"title":"client: envelope decoding",\
28 "state":"open","author":"cmc","source":"client-envelope","target_ref":"main",\
29 "head_sha":"aabbcc","body":"Speaks both surfaces.","created_at":"2026-08-20T10:00:00.000Z",\
30 "checks":[{"context":"build","state":"success"}],"checks_combined":"success",\
31 "unresolved_threads":1,\
32 "commits":[{"sha":"aabbcc00000000000000","subject":"client: envelope"}],\
33 "comments":[{"author":"krz","body":"looks right","created_at":"2026-08-20T11:00:00.000Z"}],\
34 "reviews":[{"reviewer":"krz","verdict":"approve","stale":false}]},"exit_code":0}
35 """
36
37private let threadsJSON = """
38 {"protocol_version":1,"data":[\
39 {"id":3,"path":"gitbay/Client.swift","side":"new","line":42,"stale":false,\
40 "comments":[{"id":3,"author":"krz","body":"why retry twice?","created_at":"2026-08-20T10:30:00.000Z"}]},\
41 {"id":5,"path":"gitbay/Client.swift","side":"new","line":90,"stale":true,"resolved_by":"cmc",\
42 "comments":[{"id":5,"author":"krz","body":"naming","created_at":"2026-08-20T10:31:00.000Z"},\
43 {"id":6,"author":"cmc","body":"renamed","created_at":"2026-08-20T10:35:00.000Z"}]}\
44 ],"exit_code":0}
45 """
46
47private let diffText = """
48 diff --git a/main.go b/main.go
49 index 1234567..89abcde 100644
50 --- a/main.go
51 +++ b/main.go
52 @@ -1,4 +1,5 @@
53 package main
54 -import "fmt"
55 +import (
56 +\t"fmt"
57 +)
58
59 -func main() {}
60 @@ -10,2 +11,3 @@ func helper() {
61 \tx := 1
62 +\ty := 2
63 \t_ = x
64 diff --git a/new.txt b/new.txt
65 new file mode 100644
66 --- /dev/null
67 +++ b/new.txt
68 @@ -0,0 +1,2 @@
69 +hello
70 +world
71 """
72
73private func diffEnvelope() -> String {
74 let escaped = diffText
75 .replacingOccurrences(of: "\\", with: "\\\\")
76 .replacingOccurrences(of: "\"", with: "\\\"")
77 .replacingOccurrences(of: "\n", with: "\\n")
78 .replacingOccurrences(of: "\t", with: "\\t")
79 return "{\"protocol_version\":1,\"output\":\"\(escaped)\",\"exit_code\":0}"
80}
81
82struct UnifiedDiffParserTests {
83
84 @Test func parsesFilesHunksAndLineNumbers() {
85 let diff = UnifiedDiff.parse(diffText)
86
87 #expect(diff.files.count == 2)
88 let first = diff.files[0]
89 #expect(first.displayPath == "main.go")
90 #expect(first.hunks.count == 2)
91 #expect(first.additions == 4)
92 #expect(first.deletions == 2)
93
94 // Line numbering advances per side.
95 let lines = first.hunks[0].lines
96 #expect(lines[0].kind == .context)
97 #expect(lines[0].oldNumber == 1)
98 #expect(lines[0].newNumber == 1)
99 #expect(lines[1].kind == .deletion)
100 #expect(lines[1].oldNumber == 2)
101 #expect(lines[1].newNumber == nil)
102 #expect(lines[2].kind == .addition)
103 #expect(lines[2].oldNumber == nil)
104 #expect(lines[2].newNumber == 2)
105
106 // Second hunk restarts numbering from its header.
107 #expect(first.hunks[1].lines[0].oldNumber == 10)
108 #expect(first.hunks[1].lines[0].newNumber == 11)
109 }
110
111 @Test func newFilesAreMarked() {
112 let diff = UnifiedDiff.parse(diffText)
113 let added = diff.files[1]
114 #expect(added.isNew)
115 #expect(!added.isDeleted)
116 #expect(added.displayPath == "new.txt")
117 #expect(added.additions == 2)
118 }
119
120 @Test func totalsSumAcrossFiles() {
121 let diff = UnifiedDiff.parse(diffText)
122 #expect(diff.additions == 6)
123 #expect(diff.deletions == 2)
124 }
125
126 @Test func binaryFilesAreRecognised() {
127 let diff = UnifiedDiff.parse("""
128 diff --git a/logo.png b/logo.png
129 Binary files a/logo.png and b/logo.png differ
130 """)
131 #expect(diff.files.count == 1)
132 #expect(diff.files[0].isBinary)
133 }
134
135 @Test func emptyDiffParsesToNoFiles() {
136 #expect(UnifiedDiff.parse("").files.isEmpty)
137 }
138}
139
140@MainActor
141struct MRListViewModelTests {
142
143 @Test func listsNewestFirst() async throws {
144 let (client, stub) = try makeClient()
145 stub.enqueue(.init(status: 200, json: mrListJSON))
146 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
147
148 await model.load()
149
150 let mrs = try #require(model.state.value)
151 #expect(mrs.map(\.number) == [9, 7])
152 let seen = try #require(stub.seen.first)
153 #expect(seen.url.query() ==
154 "argv=mr&argv=list&argv=krz/gitbay&argv=--state&argv=open")
155 }
156
157 @Test func changingTheFilterReloadsWithThatState() async throws {
158 let (client, stub) = try makeClient()
159 stub.enqueue(.init(status: 200, json: mrListJSON))
160 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#))
161 let model = MRListViewModel(client: client, repoPath: "krz/gitbay")
162 await model.load()
163
164 model.filter = .merged
165 // The reload happens in a spawned task; give it a beat.
166 try await Task.sleep(for: .milliseconds(300))
167
168 #expect(stub.seen.count == 2)
169 #expect(stub.seen[1].url.query()?.contains("argv=merged") == true)
170 guard case .empty = model.state else {
171 Issue.record("expected .empty after filtering, got \(model.state)")
172 return
173 }
174 }
175}
176
177@MainActor
178struct MRDetailViewModelTests {
179
180 private func loadedModel() async throws -> (MRDetailViewModel, StubProtocol.Box) {
181 let (client, stub) = try makeClient()
182 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
183 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
184 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
185 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
186 await model.load()
187 return (model, stub)
188 }
189
190 @Test func loadsHeaderDiffAndThreads() async throws {
191 let (model, _) = try await loadedModel()
192
193 let mr = try #require(model.state.value)
194 #expect(mr.title == "client: envelope decoding")
195 #expect(mr.checksCombined == "success")
196 #expect(mr.reviews?.first?.verdict == "approve")
197 #expect(model.diff?.files.count == 2)
198 #expect(model.threads.count == 2)
199 #expect(model.unresolvedCount == 1)
200 }
201
202 @Test func approveSendsTheWriteThenReloads() async throws {
203 let (model, stub) = try await loadedModel()
204 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
205 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
206 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
207 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
208
209 await model.review(.approve)
210
211 let write = stub.seen[3]
212 #expect(write.method == "POST")
213 #expect(write.url.path() == "/api/v1/cmd")
214 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
215 #expect(body["argv"] as? [String] ==
216 ["mr", "review", "krz/gitbay", "7", "--approve"])
217 #expect(model.actionError == nil)
218 }
219
220 @Test func commentGoesThroughStdinNotArgv() async throws {
221 let (model, stub) = try await loadedModel()
222 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
223 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
224 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
225 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
226
227 await model.comment("long review text\nwith lines")
228
229 let write = stub.seen[3]
230 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
231 #expect(body["argv"] as? [String] == ["mr", "comment", "krz/gitbay", "7"])
232 #expect(body["stdin"] as? String == "long review text\nwith lines")
233 }
234
235 @Test func aMergeRefusalSurfacesTheServersRuleVerbatim() async throws {
236 let (model, stub) = try await loadedModel()
237 stub.enqueue(.init(status: 403, json:
238 #"{"protocol_version":1,"error":"merge blocked: 1 review thread unresolved","exit_code":4}"#))
239
240 await model.merge()
241
242 #expect(model.actionError == "merge blocked: 1 review thread unresolved")
243 // The refusal did not wipe the loaded screen.
244 #expect(model.state.value != nil)
245 #expect(stub.seen.count == 4)
246 }
247
248 @Test func resolveTargetsTheThreadID() async throws {
249 let (model, stub) = try await loadedModel()
250 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#))
251 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
252 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
253 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
254 let thread = try #require(model.threads.first { !$0.isResolved })
255
256 await model.setResolved(thread, true)
257
258 let write = stub.seen[3]
259 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
260 #expect(body["argv"] as? [String] == ["mr", "resolve", "krz/gitbay", "7", "3"])
261 }
262
263 @Test func replyUsesDiffCommentWithReplyFlag() async throws {
264 let (model, stub) = try await loadedModel()
265 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"id":9,"thread":3},"exit_code":0}"#))
266 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
267 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
268 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
269 let thread = try #require(model.threads.first { !$0.isResolved })
270
271 await model.reply(to: thread, "because 5xx is transient")
272
273 let write = stub.seen[3]
274 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
275 #expect(body["argv"] as? [String] ==
276 ["mr", "diff-comment", "krz/gitbay", "7", "--reply", "3"])
277 #expect(body["stdin"] as? String == "because 5xx is transient")
278 }
279}
gitbayTests/RepoViewModelTests.swift added +263
@@ -0,0 +1,263 @@
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 let repoListJSON = """
16 {"protocol_version":1,"data":[\
17 {"path":"krz/hutch","visibility":"public","description":"SourceHut iOS client"},\
18 {"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"},\
19 {"path":"krz/secrets","visibility":"private","archived":true}\
20 ],"exit_code":0}
21 """
22
23private let repoShowJSON = """
24 {"protocol_version":1,"data":{"path":"krz/gitbay","description":"a CLI-first git forge",\
25 "visibility":"public","default_branch":"main","topics":["git","forge"],\
26 "protected_branches":["main"]},"exit_code":0}
27 """
28
29private let rootTreeJSON = """
30 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","dir":"","entries":[\
31 {"name":"internal","type":"tree","mode":"040000","sha":"aaa1"},\
32 {"name":"README.md","type":"blob","mode":"100644","sha":"bbb2","size":1200},\
33 {"name":"main.go","type":"blob","mode":"100644","sha":"ccc3","size":300}\
34 ]},"exit_code":0}
35 """
36
37private let readmeJSON = """
38 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","file":"README.md",\
39 "size":18,"binary":false,"content":"# gitbay\\n\\na forge"},"exit_code":0}
40 """
41
42private let binaryFileJSON = """
43 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","file":"logo.png",\
44 "size":4,"binary":true,"base64":"iVBORw=="},"exit_code":0}
45 """
46
47private let logJSON = """
48 {"protocol_version":1,"data":[\
49 {"sha":"65ba14e0000000000000000000000000000000ab","subject":"httpd: GET /api/v1/read",\
50 "author_name":"krz","author_email":"krz@gitbay.org","date":"2026-08-20T10:00:00Z",\
51 "signature":{"state":"verified","signer":"krz","key_fingerprint":"SHA256:abc"}},\
52 {"sha":"7953e780000000000000000000000000000000cd","subject":"httpd: rate limit",\
53 "author_name":"krz","author_email":"krz@gitbay.org","date":"2026-08-19T10:00:00Z",\
54 "signature":{"state":"unsigned"}},\
55 {"sha":"40dab3e0000000000000000000000000000000ef","subject":"control: repo tree and cat",\
56 "author_name":"krz","author_email":"krz@gitbay.org","date":"2026-08-18T10:00:00Z",\
57 "signature":{"state":"one_day_a_new_state"}}\
58 ],"exit_code":0}
59 """
60
61@MainActor
62struct RepoListViewModelTests {
63
64 @Test func loadsAndSortsByPath() async throws {
65 let (client, stub) = try makeClient()
66 stub.enqueue(.init(status: 200, json: repoListJSON))
67 let model = RepoListViewModel(client: client)
68
69 await model.load()
70
71 #expect(model.visibleRepos.map(\.path) == ["krz/gitbay", "krz/hutch", "krz/secrets"])
72 #expect(model.visibleRepos[2].isPrivate)
73 #expect(model.visibleRepos[2].isArchived)
74 }
75
76 @Test func filterMatchesPathAndDescription() async throws {
77 let (client, stub) = try makeClient()
78 stub.enqueue(.init(status: 200, json: repoListJSON))
79 let model = RepoListViewModel(client: client)
80 await model.load()
81
82 model.searchText = "SourceHut"
83 #expect(model.visibleRepos.map(\.path) == ["krz/hutch"])
84
85 model.searchText = "gitb"
86 #expect(model.visibleRepos.map(\.path) == ["krz/gitbay"])
87 }
88
89 @Test func anEmptyListIsAnEmptyStateNotAnError() async throws {
90 let (client, stub) = try makeClient()
91 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#))
92 let model = RepoListViewModel(client: client)
93
94 await model.load()
95
96 guard case .empty = model.state else {
97 Issue.record("expected .empty, got \(model.state)")
98 return
99 }
100 }
101}
102
103@MainActor
104struct RepoDetailViewModelTests {
105
106 @Test func loadsHeaderThenFindsAndFetchesReadme() async throws {
107 let (client, stub) = try makeClient()
108 stub.enqueue(.init(status: 200, json: repoShowJSON))
109 stub.enqueue(.init(status: 200, json: rootTreeJSON))
110 stub.enqueue(.init(status: 200, json: readmeJSON))
111 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
112
113 await model.load()
114
115 let detail = try #require(model.state.value)
116 #expect(detail.defaultBranch == "main")
117 #expect(detail.topics == ["git", "forge"])
118 #expect(model.readme == "# gitbay\n\na forge")
119 // The README was fetched by name from the tree listing.
120 let catRequest = try #require(stub.seen.last)
121 #expect(catRequest.url.query()?.contains("argv=README.md") == true)
122 }
123
124 @Test func aRepoWithoutAReadmeIsFine() async throws {
125 let (client, stub) = try makeClient()
126 stub.enqueue(.init(status: 200, json: repoShowJSON))
127 stub.enqueue(.init(status: 200, json: """
128 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","dir":"",\
129 "entries":[{"name":"main.go","type":"blob","mode":"100644","sha":"ccc3","size":300}]},\
130 "exit_code":0}
131 """))
132 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
133
134 await model.load()
135
136 #expect(model.state.value != nil)
137 #expect(model.readme == nil)
138 #expect(stub.seen.count == 2) // no cat issued
139 }
140
141 @Test func aMissingRepoIsAnEmptyState() async throws {
142 let (client, stub) = try makeClient()
143 stub.enqueue(.init(status: 404, json:
144 #"{"protocol_version":1,"error":"no such repository krz/nope","exit_code":3}"#))
145 let model = RepoDetailViewModel(client: client, path: "krz/nope")
146
147 await model.load()
148
149 guard case .empty(let message) = model.state else {
150 Issue.record("expected .empty, got \(model.state)")
151 return
152 }
153 #expect(message == "no such repository krz/nope")
154 }
155}
156
157@MainActor
158struct TreeViewModelTests {
159
160 @Test func directoriesSortFirstThenAlphabetical() async throws {
161 let (client, stub) = try makeClient()
162 stub.enqueue(.init(status: 200, json: rootTreeJSON))
163 let model = TreeViewModel(client: client, repoPath: "krz/gitbay")
164
165 await model.load()
166
167 #expect(model.entries.map(\.name) == ["internal", "main.go", "README.md"])
168 }
169
170 @Test func childPathsNestCorrectly() async throws {
171 let (client, stub) = try makeClient()
172 stub.enqueue(.init(status: 200, json: rootTreeJSON))
173 let root = TreeViewModel(client: client, repoPath: "krz/gitbay")
174 await root.load()
175 let dir = try #require(root.entries.first { $0.isDirectory })
176
177 #expect(root.childDirectory(dir) == "internal")
178
179 let nested = TreeViewModel(client: client, repoPath: "krz/gitbay", directory: "internal")
180 #expect(nested.childDirectory(dir) == "internal/internal")
181 }
182
183 @Test func refIsForwardedWhenSet() async throws {
184 let (client, stub) = try makeClient()
185 stub.enqueue(.init(status: 200, json: rootTreeJSON))
186 let model = TreeViewModel(
187 client: client, repoPath: "krz/gitbay", directory: "internal", ref: "dev"
188 )
189
190 await model.load()
191
192 let seen = try #require(stub.seen.first)
193 #expect(seen.url.query() ==
194 "argv=repo&argv=tree&argv=krz/gitbay&argv=internal&argv=--ref&argv=dev")
195 }
196}
197
198@MainActor
199struct FileViewModelTests {
200
201 @Test func textFileLoads() async throws {
202 let (client, stub) = try makeClient()
203 stub.enqueue(.init(status: 200, json: readmeJSON))
204 let model = FileViewModel(client: client, repoPath: "krz/gitbay", filePath: "README.md")
205
206 await model.load()
207
208 let file = try #require(model.state.value)
209 #expect(file.content?.hasPrefix("# gitbay") == true)
210 #expect(!file.binary)
211 #expect(model.fileName == "README.md")
212 }
213
214 @Test func binaryFileCarriesBase64NotText() async throws {
215 let (client, stub) = try makeClient()
216 stub.enqueue(.init(status: 200, json: binaryFileJSON))
217 let model = FileViewModel(client: client, repoPath: "krz/gitbay", filePath: "logo.png")
218
219 await model.load()
220
221 let file = try #require(model.state.value)
222 #expect(file.binary)
223 #expect(file.content == nil)
224 #expect(file.data != nil)
225 }
226}
227
228@MainActor
229struct LogViewModelTests {
230
231 @Test func commitsDecodeWithSignatureStates() async throws {
232 let (client, stub) = try makeClient()
233 stub.enqueue(.init(status: 200, json: logJSON))
234 let model = LogViewModel(client: client, repoPath: "krz/gitbay")
235
236 await model.load()
237
238 let commits = try #require(model.state.value)
239 #expect(commits.count == 3)
240 #expect(commits[0].signature.state == .verified)
241 #expect(commits[0].signature.signer == "krz")
242 #expect(commits[1].signature.state == .unsigned)
243 // A state this build has never heard of must not sink the log.
244 #expect(commits[2].signature.state == .unrecognized("one_day_a_new_state"))
245 #expect(commits[0].shortSHA == "65ba14e000")
246 }
247}
248
249struct SyntaxHighlighterLanguageTests {
250
251 @Test func commonExtensionsResolve() {
252 #expect(SyntaxHighlighter.language(for: "GitbayClient.swift") == "swift")
253 #expect(SyntaxHighlighter.language(for: "read.go") == "go")
254 #expect(SyntaxHighlighter.language(for: "Makefile") == "makefile")
255 #expect(SyntaxHighlighter.language(for: "index.test.ts") == "typescript")
256 }
257
258 @Test func unknownExtensionsFallBackToPlain() {
259 #expect(SyntaxHighlighter.language(for: "notes.txt") == nil)
260 #expect(SyntaxHighlighter.language(for: "LICENSE") == nil)
261 #expect(SyntaxHighlighter.language(for: "weird.xyz123") == nil)
262 }
263}
gitbayTests/SessionStoreTests.swift added +197
@@ -0,0 +1,197 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5/// In-memory TokenStore so no test touches the host Keychain.
6nonisolated final class MemoryTokenStore: TokenStore, @unchecked Sendable {
7
8 private let state = Mutex<(tokens: [String: String], accounts: [Account])>((tokens: [:], accounts: []))
9
10 func saveToken(_ token: String, for accountID: String) throws {
11 state.withLock { $0.tokens[accountID] = token }
12 }
13
14 func token(for accountID: String) -> String? {
15 state.withLock { $0.tokens[accountID] }
16 }
17
18 func deleteToken(for accountID: String) {
19 state.withLock { _ = $0.tokens.removeValue(forKey: accountID) }
20 }
21
22 func saveAccounts(_ accounts: [Account]) throws {
23 state.withLock { $0.accounts = accounts }
24 }
25
26 func loadAccounts() -> [Account] {
27 state.withLock { $0.accounts }
28 }
29
30 var tokenCount: Int {
31 state.withLock { $0.tokens.count }
32 }
33}
34
35private let whoamiCMC = """
36 {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0}
37 """
38private let badToken = """
39 {"protocol_version":1,"error":"invalid or expired token"}
40 """
41
42@MainActor
43private func makeSession(
44 store: MemoryTokenStore = MemoryTokenStore()
45) -> (SessionStore, MemoryTokenStore, StubProtocol.Box) {
46 let box = StubProtocol.box()
47 let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")!
48 let session = SessionStore(store: store, defaults: defaults) { instance, token in
49 GitbayClient(instance: instance, token: token, session: box.session())
50 }
51 return (session, store, box)
52}
53
54@MainActor
55struct SessionStoreTests {
56
57 @Test func signInValidatesWithWhoamiThenStores() async throws {
58 let (session, store, stub) = makeSession()
59 stub.enqueue(.init(status: 200, json: whoamiCMC))
60
61 try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret")
62
63 #expect(session.current?.username == "cmc")
64 #expect(session.client != nil)
65 #expect(store.loadAccounts().count == 1)
66 let account = try #require(session.current)
67 #expect(store.token(for: account.id) == "gb_secret")
68 let seen = try #require(stub.seen.first)
69 #expect(seen.url.query() == "argv=whoami")
70 }
71
72 @Test func rejectedTokenStoresNothing() async throws {
73 let (session, store, stub) = makeSession()
74 stub.enqueue(.init(status: 401, json: badToken))
75
76 await #expect(throws: GitbayError.self) {
77 try await session.signIn(instanceURL: "gitbay.org", token: "gb_wrong")
78 }
79
80 #expect(session.current == nil)
81 #expect(store.loadAccounts().isEmpty)
82 #expect(store.tokenCount == 0)
83 }
84
85 @Test func whitespaceAroundThePastedTokenIsTrimmed() async throws {
86 let (session, store, stub) = makeSession()
87 stub.enqueue(.init(status: 200, json: whoamiCMC))
88
89 try await session.signIn(instanceURL: "gitbay.org", token: " gb_secret\n")
90
91 let account = try #require(session.current)
92 #expect(store.token(for: account.id) == "gb_secret")
93 let seen = try #require(stub.seen.first)
94 #expect(seen.headers["Authorization"] == "Bearer gb_secret")
95 }
96
97 @Test func signingInTwiceOnTheSameAccountDoesNotDuplicateIt() async throws {
98 let (session, store, stub) = makeSession()
99 stub.enqueue(.init(status: 200, json: whoamiCMC))
100 stub.enqueue(.init(status: 200, json: whoamiCMC))
101
102 try await session.signIn(instanceURL: "gitbay.org", token: "gb_old")
103 try await session.signIn(instanceURL: "gitbay.org", token: "gb_new")
104
105 #expect(store.loadAccounts().count == 1)
106 let account = try #require(session.current)
107 #expect(store.token(for: account.id) == "gb_new")
108 }
109
110 @Test func accountsOnTwoInstancesCoexist() async throws {
111 let (session, store, stub) = makeSession()
112 stub.enqueue(.init(status: 200, json: whoamiCMC))
113 stub.enqueue(.init(status: 200, json: whoamiCMC))
114
115 try await session.signIn(instanceURL: "gitbay.org", token: "gb_one")
116 try await session.signIn(instanceURL: "https://forge.example", token: "gb_two")
117
118 #expect(session.accounts.count == 2)
119 #expect(session.current?.instance.baseURL.host() == "forge.example")
120 let first = try #require(session.accounts.first)
121 #expect(session.activate(first))
122 #expect(session.current?.instance.baseURL.host() == "gitbay.org")
123 }
124
125 @Test func removingTheActiveAccountFallsBackToAnother() async throws {
126 let (session, _, stub) = makeSession()
127 stub.enqueue(.init(status: 200, json: whoamiCMC))
128 stub.enqueue(.init(status: 200, json: whoamiCMC))
129
130 try await session.signIn(instanceURL: "gitbay.org", token: "gb_one")
131 try await session.signIn(instanceURL: "https://forge.example", token: "gb_two")
132 let active = try #require(session.current)
133
134 session.remove(active)
135
136 #expect(session.current?.instance.baseURL.host() == "gitbay.org")
137 #expect(session.accounts.count == 1)
138 }
139
140 @Test func sessionRestoresFromTheStoreAcrossLaunches() async throws {
141 let store = MemoryTokenStore()
142 let (first, _, stub) = makeSession(store: store)
143 stub.enqueue(.init(status: 200, json: whoamiCMC))
144 try await first.signIn(instanceURL: "gitbay.org", token: "gb_secret")
145
146 // A second SessionStore over the same TokenStore is a relaunch.
147 let (second, _, _) = makeSession(store: store)
148
149 #expect(second.current?.username == "cmc")
150 #expect(second.client != nil)
151 }
152
153 @Test func aRevokedTokenBecomesACleanSignOutWithAMessage() async throws {
154 let (session, store, stub) = makeSession()
155 stub.enqueue(.init(status: 200, json: whoamiCMC))
156 try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret")
157 let account = try #require(session.current)
158
159 // Some later request comes back 401.
160 session.handle(GitbayError.unauthorized("invalid or expired token"))
161
162 #expect(session.current == nil)
163 #expect(session.client == nil)
164 #expect(store.tokenCount == 0)
165 #expect(store.loadAccounts().isEmpty)
166 let message = try #require(session.signedOutMessage)
167 #expect(message.contains(account.username))
168 #expect(message.contains("no longer valid"))
169 }
170
171 @Test func nonAuthErrorsDoNotSignAnyoneOut() async throws {
172 let (session, _, stub) = makeSession()
173 stub.enqueue(.init(status: 200, json: whoamiCMC))
174 try await session.signIn(instanceURL: "gitbay.org", token: "gb_secret")
175
176 session.handle(GitbayError.notFound("no such repo"))
177 session.handle(GitbayError.failure("boom"))
178 session.handle(URLError(.timedOut))
179
180 #expect(session.current != nil)
181 #expect(session.signedOutMessage == nil)
182 }
183
184 @Test func restoringWithAMissingTokenDropsTheAccount() async throws {
185 let store = MemoryTokenStore()
186 let (first, _, stub) = makeSession(store: store)
187 stub.enqueue(.init(status: 200, json: whoamiCMC))
188 try await first.signIn(instanceURL: "gitbay.org", token: "gb_secret")
189 let account = try #require(first.current)
190 store.deleteToken(for: account.id)
191
192 let (second, _, _) = makeSession(store: store)
193
194 #expect(second.current == nil)
195 #expect(second.client == nil)
196 }
197}
gitbayTests/StubProtocol.swift added +163
@@ -0,0 +1,163 @@
1import Foundation
2
3/// URLProtocol stub: tests enqueue responses, the client sees a real
4/// URLSession. Recorded envelopes go through here.
5///
6/// Swift Testing runs suites in parallel, so state is keyed per box: each
7/// `box()` gets its own queue and request log, matched up by a header the
8/// box's session adds to every request.
9nonisolated final class StubProtocol: URLProtocol, @unchecked Sendable {
10
11 struct Stub: Sendable {
12 let status: Int
13 let headers: [String: String]
14 let body: Data
15 /// When set, this stub only answers requests whose URL contains
16 /// it needed when the code under test issues requests
17 /// concurrently and arrival order is not deterministic.
18 let match: String?
19
20 init(status: Int, headers: [String: String] = [:], json: String, match: String? = nil) {
21 self.status = status
22 var headers = headers
23 headers["Content-Type"] = headers["Content-Type"] ?? "application/json"
24 self.headers = headers
25 self.body = Data(json.utf8)
26 self.match = match
27 }
28
29 init(status: Int, headers: [String: String] = [:], body: Data = Data(), match: String? = nil) {
30 self.status = status
31 self.headers = headers
32 self.body = body
33 self.match = match
34 }
35 }
36
37 /// A request as the stub saw it, for asserting on method, URL, headers
38 /// and body.
39 struct Seen: Sendable {
40 let url: URL
41 let method: String
42 let headers: [String: String]
43 let body: Data
44 }
45
46 /// One test's private stub queue and request log.
47 final class Box: Sendable {
48 fileprivate let id = UUID().uuidString
49 private let queue = Mutex<[Stub]>([])
50 private let log = Mutex<[Seen]>([])
51
52 func enqueue(_ stub: Stub) {
53 queue.withLock { $0.append(stub) }
54 }
55
56 var seen: [Seen] {
57 log.withLock { $0 }
58 }
59
60 /// The stubbed session for this box. The marker header is how the
61 /// shared protocol class finds its way back here.
62 func session() -> URLSession {
63 let configuration = URLSessionConfiguration.ephemeral
64 configuration.protocolClasses = [StubProtocol.self]
65 configuration.httpAdditionalHeaders = [StubProtocol.marker: id]
66 return URLSession(configuration: configuration)
67 }
68
69 fileprivate func next(for url: URL) -> Stub? {
70 queue.withLock { stubs in
71 let index = stubs.firstIndex {
72 $0.match.map { url.absoluteString.contains($0) } ?? true
73 }
74 guard let index else { return nil }
75 return stubs.remove(at: index)
76 }
77 }
78
79 fileprivate func record(_ request: Seen) {
80 log.withLock { $0.append(request) }
81 }
82 }
83
84 private static let marker = "X-Stub-Box"
85 private static let boxes = Mutex<[String: Box]>([:])
86
87 static func box() -> Box {
88 let box = Box()
89 boxes.withLock { $0[box.id] = box }
90 return box
91 }
92
93 // MARK: - URLProtocol
94
95 override static func canInit(with _: URLRequest) -> Bool { true }
96
97 override static func canonicalRequest(for request: URLRequest) -> URLRequest { request }
98
99 override func startLoading() {
100 guard let id = request.value(forHTTPHeaderField: Self.marker),
101 let box = Self.boxes.withLock({ $0[id] }) else {
102 client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
103 return
104 }
105
106 // httpBody is transformed into a stream by URLSession; read it back.
107 var body = Data()
108 if let stream = request.httpBodyStream {
109 stream.open()
110 defer { stream.close() }
111 let size = 4096
112 let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: size)
113 defer { buffer.deallocate() }
114 while stream.hasBytesAvailable {
115 let read = stream.read(buffer, maxLength: size)
116 guard read > 0 else { break }
117 body.append(buffer, count: read)
118 }
119 }
120 var headers = request.allHTTPHeaderFields ?? [:]
121 headers.removeValue(forKey: Self.marker)
122 box.record(Seen(
123 url: request.url!,
124 method: request.httpMethod ?? "GET",
125 headers: headers,
126 body: body
127 ))
128
129 guard let stub = box.next(for: request.url!) else {
130 client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
131 return
132 }
133 let response = HTTPURLResponse(
134 url: request.url!,
135 statusCode: stub.status,
136 httpVersion: "HTTP/1.1",
137 headerFields: stub.headers
138 )!
139 client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
140 if !stub.body.isEmpty {
141 client?.urlProtocol(self, didLoad: stub.body)
142 }
143 client?.urlProtocolDidFinishLoading(self)
144 }
145
146 override func stopLoading() {}
147}
148
149/// Minimal lock wrapper; os.OSAllocatedUnfairLock spelled test-side.
150nonisolated final class Mutex<Value>: @unchecked Sendable {
151 private var value: Value
152 private let lock = NSLock()
153
154 init(_ value: Value) {
155 self.value = value
156 }
157
158 func withLock<R>(_ body: (inout Value) -> R) -> R {
159 lock.lock()
160 defer { lock.unlock() }
161 return body(&value)
162 }
163}