app: client, auth, browse, MRs, issues, builds, dashboard !1
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 @@ | ||
| 1 | import 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. | |
| 6 | nonisolated 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 @@ | ||
| 1 | import Foundation | |
| 2 | import 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 | |
| 12 | final 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 @@ | ||
| 1 | import 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. | |
| 7 | nonisolated 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 | ||
| 15 | enum 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. | |
| 29 | nonisolated 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// `build list <repo>`. | |
| 5 | @Observable | |
| 6 | @MainActor | |
| 7 | final 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 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// One row of `build list`. | |
| 4 | nonisolated 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 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct 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. | |
| 34 | private 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 | ||
| 103 | extension 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 @@ | ||
| 1 | import Foundation | |
| 2 | import 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 | |
| 14 | final 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 | ||
| 156 | extension 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// One issue and everything triage needs: comment, close, reopen, labels, | |
| 5 | /// assignees. Writes reload; refusals surface verbatim. | |
| 6 | @Observable | |
| 7 | @MainActor | |
| 8 | final 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// `issue list <repo> --state <s>`. | |
| 5 | @Observable | |
| 6 | @MainActor | |
| 7 | final 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 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// One issue; `issue list` rows and the header of `issue show`. | |
| 4 | nonisolated 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. | |
| 25 | nonisolated 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 @@ | ||
| 1 | import Foundation | |
| 2 | import 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 | |
| 9 | final 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// `mr list <repo> --state <s>`. | |
| 5 | @Observable | |
| 6 | @MainActor | |
| 7 | final 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 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// One row of `mr list`, and the header half of `mr show`. | |
| 4 | nonisolated 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`. | |
| 29 | nonisolated 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`. | |
| 93 | nonisolated 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 @@ | ||
| 1 | import 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. | |
| 6 | nonisolated 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 @@ | ||
| 1 | import Foundation | |
| 2 | import os | |
| 3 | ||
| 4 | /// The validator and body of one cached read. | |
| 5 | nonisolated 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. | |
| 15 | nonisolated 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 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// Exit codes shared by the CLI, SSH and both HTTP surfaces. | |
| 4 | /// `internal/protocol/protocol.go` is the source of truth. | |
| 5 | nonisolated 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. | |
| 21 | nonisolated 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. | |
| 46 | nonisolated struct NoPayload: Decodable, Sendable { | |
| 47 | init(from _: any Decoder) throws {} | |
| 48 | } | |
gitbay/Networking/GitbayClient.swift added +333
| @@ -0,0 +1,333 @@ | ||
| 1 | import CryptoKit | |
| 2 | import Foundation | |
| 3 | import os | |
| 4 | ||
| 5 | nonisolated 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. | |
| 15 | nonisolated 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. | |
| 311 | private 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 @@ | ||
| 1 | import 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 | |
| 13 | nonisolated 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 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// A gitbay deployment. gitbay is self-hosted, so an account is only | |
| 4 | /// meaningful paired with the instance it lives on. | |
| 5 | nonisolated 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 @@ | ||
| 1 | import Foundation | |
| 2 | import 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 | |
| 8 | final 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 @@ | ||
| 1 | import 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. | |
| 5 | nonisolated 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 | ||
| 17 | extension 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// `repo log`: the default branch's history, signature verdict per commit. | |
| 5 | @Observable | |
| 6 | @MainActor | |
| 7 | final 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 @@ | ||
| 1 | import Foundation | |
| 2 | import 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 | |
| 8 | final 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// The `repo list` screen: everything you own or can reach, filterable. | |
| 5 | @Observable | |
| 6 | @MainActor | |
| 7 | final 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 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// One row of `repo list`. | |
| 4 | nonisolated 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>`. | |
| 20 | nonisolated 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. | |
| 40 | nonisolated 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. | |
| 49 | nonisolated 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. | |
| 62 | nonisolated 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. | |
| 82 | nonisolated 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. | |
| 116 | nonisolated 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 @@ | ||
| 1 | import Foundation | |
| 2 | import 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 | |
| 8 | final 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 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct 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 | ||
| 27 | private 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 | ||
| 78 | nonisolated 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 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// `build log` — plain text, can be large. Monospaced, both-axis scroll. | |
| 4 | struct 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 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// What needs me, across every repo I can reach. | |
| 4 | struct 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 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct 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 | ||
| 36 | private 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 | ||
| 76 | nonisolated 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 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct 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 @@ | ||
| 1 | import 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. | |
| 5 | struct 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 | ||
| 48 | private 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 | ||
| 94 | private 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 | ||
| 114 | private 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 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct 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 | ||
| 36 | struct 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 | ||
| 67 | struct 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. | |
| 90 | nonisolated 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 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct 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. | |
| 275 | private 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 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct 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. | |
| 55 | private 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 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// The three non-content states of any screen, rendered consistently. | |
| 4 | struct 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 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct 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 | ||
| 25 | struct 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. | |
| 54 | struct 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 @@ | ||
| 1 | import 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. | |
| 6 | struct 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 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct 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 | ||
| 29 | private 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. | |
| 63 | struct 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 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// Navigation destinations for the repo stack. Values, so navigation is | |
| 4 | /// testable and deep-linkable later. | |
| 5 | nonisolated 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 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Highlightr | |
| 3 | import SwiftUI | |
| 4 | import 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. | |
| 9 | nonisolated 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 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct 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 | ||
| 45 | private 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 @@ | ||
| 1 | import 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. | |
| 6 | struct 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 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | @main | |
| 4 | struct 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private 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 | ||
| 15 | private 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 | ||
| 23 | private 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 | ||
| 30 | private 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 | ||
| 39 | private let emptyJSON = #"{"protocol_version":1,"exit_code":0}"# | |
| 40 | ||
| 41 | private 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 | |
| 49 | struct 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | /// Recorded envelopes, verbatim from gitbay.org v1.0.0. | |
| 6 | private 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 | ||
| 39 | nonisolated private struct WhoamiPayload: Decodable, Sendable, Equatable { | |
| 40 | let username: String | |
| 41 | let admin: Bool | |
| 42 | } | |
| 43 | ||
| 44 | nonisolated private struct RepoPayload: Decodable, Sendable, Equatable { | |
| 45 | let path: String | |
| 46 | let visibility: String | |
| 47 | let description: String? | |
| 48 | let archived: Bool? | |
| 49 | } | |
| 50 | ||
| 51 | private 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 | ||
| 61 | struct 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 | ||
| 142 | struct 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 | ||
| 177 | struct 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 | ||
| 312 | struct 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 | ||
| 348 | struct 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private 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 | ||
| 15 | private 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 | ||
| 24 | private 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 | ||
| 32 | private 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 | |
| 43 | struct 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 | |
| 62 | struct 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 | |
| 147 | struct 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 @@ | ||
| 1 | import Foundation | |
| 2 | import 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. | |
| 11 | struct 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. | |
| 46 | nonisolated 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private 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 | ||
| 15 | private 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 | ||
| 26 | private 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 | ||
| 37 | private 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 | ||
| 47 | private 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 | ||
| 73 | private 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 | ||
| 82 | struct 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 | |
| 141 | struct 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 | |
| 178 | struct 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private 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 | ||
| 15 | private 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 | ||
| 23 | private 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 | ||
| 29 | private 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 | ||
| 37 | private 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 | ||
| 42 | private 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 | ||
| 47 | private 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 | |
| 62 | struct 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 | |
| 104 | struct 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 | |
| 158 | struct 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 | |
| 199 | struct 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 | |
| 229 | struct 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 | ||
| 249 | struct 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | /// In-memory TokenStore so no test touches the host Keychain. | |
| 6 | nonisolated 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 | ||
| 35 | private let whoamiCMC = """ | |
| 36 | {"protocol_version":1,"data":{"username":"cmc","admin":false,"key_scope":"full"},"exit_code":0} | |
| 37 | """ | |
| 38 | private let badToken = """ | |
| 39 | {"protocol_version":1,"error":"invalid or expired token"} | |
| 40 | """ | |
| 41 | ||
| 42 | @MainActor | |
| 43 | private 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 | |
| 55 | struct 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 @@ | ||
| 1 | import 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. | |
| 9 | nonisolated 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. | |
| 150 | nonisolated 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 | } | |