create and edit: issues, MRs, milestones — and the stdin fix !4
15 files changed, +876 −9
Layout: unified · split
gitbay.xcodeproj/project.pbxproj +110
| @@ -18,11 +18,19 @@ | |||
| 18 | remoteGlobalIDString = 8B289811303FF3E5001BF44A; | 18 | remoteGlobalIDString = 8B289811303FF3E5001BF44A; |
| 19 | remoteInfo = gitbay; | 19 | remoteInfo = gitbay; |
| 20 | }; | 20 | }; |
| 21 | 8B2898C7303FF3E5001BF44A /* PBXContainerItemProxy */ = { | ||
| 22 | isa = PBXContainerItemProxy; | ||
| 23 | containerPortal = 8B28980A303FF3E5001BF44A /* Project object */; | ||
| 24 | proxyType = 1; | ||
| 25 | remoteGlobalIDString = 8B289811303FF3E5001BF44A; | ||
| 26 | remoteInfo = gitbay; | ||
| 27 | }; | ||
| 21 | /* End PBXContainerItemProxy section */ | 28 | /* End PBXContainerItemProxy section */ |
| 22 | 29 | ||
| 23 | /* Begin PBXFileReference section */ | 30 | /* Begin PBXFileReference section */ |
| 24 | 8B289812303FF3E5001BF44A /* gitbay.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = gitbay.app; sourceTree = BUILT_PRODUCTS_DIR; }; | 31 | 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; }; | 32 | 8B2898A2303FF3E5001BF44A /* gitbayTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = gitbayTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; |
| 33 | 8B2898C2303FF3E5001BF44A /* gitbayUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = gitbayUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; | ||
| 26 | /* End PBXFileReference section */ | 34 | /* End PBXFileReference section */ |
| 27 | 35 | ||
| 28 | /* Begin PBXFileSystemSynchronizedRootGroup section */ | 36 | /* Begin PBXFileSystemSynchronizedRootGroup section */ |
| @@ -36,6 +44,11 @@ | |||
| 36 | path = gitbayTests; | 44 | path = gitbayTests; |
| 37 | sourceTree = "<group>"; | 45 | sourceTree = "<group>"; |
| 38 | }; | 46 | }; |
| 47 | 8B2898C0303FF3E5001BF44A /* gitbayUITests */ = { | ||
| 48 | isa = PBXFileSystemSynchronizedRootGroup; | ||
| 49 | path = gitbayUITests; | ||
| 50 | sourceTree = "<group>"; | ||
| 51 | }; | ||
| 39 | /* End PBXFileSystemSynchronizedRootGroup section */ | 52 | /* End PBXFileSystemSynchronizedRootGroup section */ |
| 40 | 53 | ||
| 41 | /* Begin PBXFrameworksBuildPhase section */ | 54 | /* Begin PBXFrameworksBuildPhase section */ |
| @@ -54,6 +67,13 @@ | |||
| 54 | ); | 67 | ); |
| 55 | runOnlyForDeploymentPostprocessing = 0; | 68 | runOnlyForDeploymentPostprocessing = 0; |
| 56 | }; | 69 | }; |
| 70 | 8B2898C4303FF3E5001BF44A /* Frameworks */ = { | ||
| 71 | isa = PBXFrameworksBuildPhase; | ||
| 72 | buildActionMask = 2147483647; | ||
| 73 | files = ( | ||
| 74 | ); | ||
| 75 | runOnlyForDeploymentPostprocessing = 0; | ||
| 76 | }; | ||
| 57 | /* End PBXFrameworksBuildPhase section */ | 77 | /* End PBXFrameworksBuildPhase section */ |
| 58 | 78 | ||
| 59 | /* Begin PBXGroup section */ | 79 | /* Begin PBXGroup section */ |
| @@ -62,6 +82,7 @@ | |||
| 62 | children = ( | 82 | children = ( |
| 63 | 8B289814303FF3E5001BF44A /* gitbay */, | 83 | 8B289814303FF3E5001BF44A /* gitbay */, |
| 64 | 8B2898A0303FF3E5001BF44A /* gitbayTests */, | 84 | 8B2898A0303FF3E5001BF44A /* gitbayTests */, |
| 85 | 8B2898C0303FF3E5001BF44A /* gitbayUITests */, | ||
| 65 | 8B289813303FF3E5001BF44A /* Products */, | 86 | 8B289813303FF3E5001BF44A /* Products */, |
| 66 | ); | 87 | ); |
| 67 | sourceTree = "<group>"; | 88 | sourceTree = "<group>"; |
| @@ -71,6 +92,7 @@ | |||
| 71 | children = ( | 92 | children = ( |
| 72 | 8B289812303FF3E5001BF44A /* gitbay.app */, | 93 | 8B289812303FF3E5001BF44A /* gitbay.app */, |
| 73 | 8B2898A2303FF3E5001BF44A /* gitbayTests.xctest */, | 94 | 8B2898A2303FF3E5001BF44A /* gitbayTests.xctest */, |
| 95 | 8B2898C2303FF3E5001BF44A /* gitbayUITests.xctest */, | ||
| 74 | ); | 96 | ); |
| 75 | name = Products; | 97 | name = Products; |
| 76 | sourceTree = "<group>"; | 98 | sourceTree = "<group>"; |
| @@ -124,6 +146,29 @@ | |||
| 124 | productReference = 8B2898A2303FF3E5001BF44A /* gitbayTests.xctest */; | 146 | productReference = 8B2898A2303FF3E5001BF44A /* gitbayTests.xctest */; |
| 125 | productType = "com.apple.product-type.bundle.unit-test"; | 147 | productType = "com.apple.product-type.bundle.unit-test"; |
| 126 | }; | 148 | }; |
| 149 | 8B2898C1303FF3E5001BF44A /* gitbayUITests */ = { | ||
| 150 | isa = PBXNativeTarget; | ||
| 151 | buildConfigurationList = 8B2898CA303FF3E5001BF44A /* Build configuration list for PBXNativeTarget "gitbayUITests" */; | ||
| 152 | buildPhases = ( | ||
| 153 | 8B2898C3303FF3E5001BF44A /* Sources */, | ||
| 154 | 8B2898C4303FF3E5001BF44A /* Frameworks */, | ||
| 155 | 8B2898C5303FF3E5001BF44A /* Resources */, | ||
| 156 | ); | ||
| 157 | buildRules = ( | ||
| 158 | ); | ||
| 159 | dependencies = ( | ||
| 160 | 8B2898C6303FF3E5001BF44A /* PBXTargetDependency */, | ||
| 161 | ); | ||
| 162 | fileSystemSynchronizedGroups = ( | ||
| 163 | 8B2898C0303FF3E5001BF44A /* gitbayUITests */, | ||
| 164 | ); | ||
| 165 | name = gitbayUITests; | ||
| 166 | packageProductDependencies = ( | ||
| 167 | ); | ||
| 168 | productName = gitbayUITests; | ||
| 169 | productReference = 8B2898C2303FF3E5001BF44A /* gitbayUITests.xctest */; | ||
| 170 | productType = "com.apple.product-type.bundle.ui-testing"; | ||
| 171 | }; | ||
| 127 | /* End PBXNativeTarget section */ | 172 | /* End PBXNativeTarget section */ |
| 128 | 173 | ||
| 129 | /* Begin PBXProject section */ | 174 | /* Begin PBXProject section */ |
| @@ -141,6 +186,10 @@ | |||
| 141 | CreatedOnToolsVersion = 26.6; | 186 | CreatedOnToolsVersion = 26.6; |
| 142 | TestTargetID = 8B289811303FF3E5001BF44A; | 187 | TestTargetID = 8B289811303FF3E5001BF44A; |
| 143 | }; | 188 | }; |
| 189 | 8B2898C1303FF3E5001BF44A = { | ||
| 190 | CreatedOnToolsVersion = 26.6; | ||
| 191 | TestTargetID = 8B289811303FF3E5001BF44A; | ||
| 192 | }; | ||
| 144 | }; | 193 | }; |
| 145 | }; | 194 | }; |
| 146 | buildConfigurationList = 8B28980D303FF3E5001BF44A /* Build configuration list for PBXProject "gitbay" */; | 195 | buildConfigurationList = 8B28980D303FF3E5001BF44A /* Build configuration list for PBXProject "gitbay" */; |
| @@ -162,6 +211,7 @@ | |||
| 162 | targets = ( | 211 | targets = ( |
| 163 | 8B289811303FF3E5001BF44A /* gitbay */, | 212 | 8B289811303FF3E5001BF44A /* gitbay */, |
| 164 | 8B2898A1303FF3E5001BF44A /* gitbayTests */, | 213 | 8B2898A1303FF3E5001BF44A /* gitbayTests */, |
| 214 | 8B2898C1303FF3E5001BF44A /* gitbayUITests */, | ||
| 165 | ); | 215 | ); |
| 166 | }; | 216 | }; |
| 167 | /* End PBXProject section */ | 217 | /* End PBXProject section */ |
| @@ -181,6 +231,13 @@ | |||
| 181 | ); | 231 | ); |
| 182 | runOnlyForDeploymentPostprocessing = 0; | 232 | runOnlyForDeploymentPostprocessing = 0; |
| 183 | }; | 233 | }; |
| 234 | 8B2898C5303FF3E5001BF44A /* Resources */ = { | ||
| 235 | isa = PBXResourcesBuildPhase; | ||
| 236 | buildActionMask = 2147483647; | ||
| 237 | files = ( | ||
| 238 | ); | ||
| 239 | runOnlyForDeploymentPostprocessing = 0; | ||
| 240 | }; | ||
| 184 | /* End PBXResourcesBuildPhase section */ | 241 | /* End PBXResourcesBuildPhase section */ |
| 185 | 242 | ||
| 186 | /* Begin PBXSourcesBuildPhase section */ | 243 | /* Begin PBXSourcesBuildPhase section */ |
| @@ -198,6 +255,13 @@ | |||
| 198 | ); | 255 | ); |
| 199 | runOnlyForDeploymentPostprocessing = 0; | 256 | runOnlyForDeploymentPostprocessing = 0; |
| 200 | }; | 257 | }; |
| 258 | 8B2898C3303FF3E5001BF44A /* Sources */ = { | ||
| 259 | isa = PBXSourcesBuildPhase; | ||
| 260 | buildActionMask = 2147483647; | ||
| 261 | files = ( | ||
| 262 | ); | ||
| 263 | runOnlyForDeploymentPostprocessing = 0; | ||
| 264 | }; | ||
| 201 | /* End PBXSourcesBuildPhase section */ | 265 | /* End PBXSourcesBuildPhase section */ |
| 202 | 266 | ||
| 203 | /* Begin PBXTargetDependency section */ | 267 | /* Begin PBXTargetDependency section */ |
| @@ -206,6 +270,11 @@ | |||
| 206 | target = 8B289811303FF3E5001BF44A /* gitbay */; | 270 | target = 8B289811303FF3E5001BF44A /* gitbay */; |
| 207 | targetProxy = 8B2898A7303FF3E5001BF44A /* PBXContainerItemProxy */; | 271 | targetProxy = 8B2898A7303FF3E5001BF44A /* PBXContainerItemProxy */; |
| 208 | }; | 272 | }; |
| 273 | 8B2898C6303FF3E5001BF44A /* PBXTargetDependency */ = { | ||
| 274 | isa = PBXTargetDependency; | ||
| 275 | target = 8B289811303FF3E5001BF44A /* gitbay */; | ||
| 276 | targetProxy = 8B2898C7303FF3E5001BF44A /* PBXContainerItemProxy */; | ||
| 277 | }; | ||
| 209 | /* End PBXTargetDependency section */ | 278 | /* End PBXTargetDependency section */ |
| 210 | 279 | ||
| 211 | /* Begin XCBuildConfiguration section */ | 280 | /* Begin XCBuildConfiguration section */ |
| @@ -434,6 +503,38 @@ | |||
| 434 | }; | 503 | }; |
| 435 | name = Release; | 504 | name = Release; |
| 436 | }; | 505 | }; |
| 506 | 8B2898C8303FF3E5001BF44A /* Debug */ = { | ||
| 507 | isa = XCBuildConfiguration; | ||
| 508 | buildSettings = { | ||
| 509 | CURRENT_PROJECT_VERSION = 1; | ||
| 510 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | ||
| 511 | GENERATE_INFOPLIST_FILE = YES; | ||
| 512 | MARKETING_VERSION = 1.0; | ||
| 513 | PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbayUITests; | ||
| 514 | PRODUCT_NAME = "$(TARGET_NAME)"; | ||
| 515 | SWIFT_EMIT_LOC_STRINGS = NO; | ||
| 516 | SWIFT_VERSION = 6.0; | ||
| 517 | TARGETED_DEVICE_FAMILY = "1,2"; | ||
| 518 | TEST_TARGET_NAME = gitbay; | ||
| 519 | }; | ||
| 520 | name = Debug; | ||
| 521 | }; | ||
| 522 | 8B2898C9303FF3E5001BF44A /* Release */ = { | ||
| 523 | isa = XCBuildConfiguration; | ||
| 524 | buildSettings = { | ||
| 525 | CURRENT_PROJECT_VERSION = 1; | ||
| 526 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | ||
| 527 | GENERATE_INFOPLIST_FILE = YES; | ||
| 528 | MARKETING_VERSION = 1.0; | ||
| 529 | PRODUCT_BUNDLE_IDENTIFIER = org.gitbay.gitbayUITests; | ||
| 530 | PRODUCT_NAME = "$(TARGET_NAME)"; | ||
| 531 | SWIFT_EMIT_LOC_STRINGS = NO; | ||
| 532 | SWIFT_VERSION = 6.0; | ||
| 533 | TARGETED_DEVICE_FAMILY = "1,2"; | ||
| 534 | TEST_TARGET_NAME = gitbay; | ||
| 535 | }; | ||
| 536 | name = Release; | ||
| 537 | }; | ||
| 437 | /* End XCBuildConfiguration section */ | 538 | /* End XCBuildConfiguration section */ |
| 438 | 539 | ||
| 439 | /* Begin XCConfigurationList section */ | 540 | /* Begin XCConfigurationList section */ |
| @@ -464,6 +565,15 @@ | |||
| 464 | defaultConfigurationIsVisible = 0; | 565 | defaultConfigurationIsVisible = 0; |
| 465 | defaultConfigurationName = Release; | 566 | defaultConfigurationName = Release; |
| 466 | }; | 567 | }; |
| 568 | 8B2898CA303FF3E5001BF44A /* Build configuration list for PBXNativeTarget "gitbayUITests" */ = { | ||
| 569 | isa = XCConfigurationList; | ||
| 570 | buildConfigurations = ( | ||
| 571 | 8B2898C8303FF3E5001BF44A /* Debug */, | ||
| 572 | 8B2898C9303FF3E5001BF44A /* Release */, | ||
| 573 | ); | ||
| 574 | defaultConfigurationIsVisible = 0; | ||
| 575 | defaultConfigurationName = Release; | ||
| 576 | }; | ||
| 467 | /* End XCConfigurationList section */ | 577 | /* End XCConfigurationList section */ |
| 468 | 578 | ||
| 469 | /* Begin XCRemoteSwiftPackageReference section */ | 579 | /* Begin XCRemoteSwiftPackageReference section */ |
gitbay/Issues/IssueCreateViewModel.swift added +45
| @@ -0,0 +1,45 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | |||
| 4 | /// `issue create <repo> --title <t> --file -`. | ||
| 5 | @Observable | ||
| 6 | @MainActor | ||
| 7 | final class IssueCreateViewModel { | ||
| 8 | |||
| 9 | private(set) var working = false | ||
| 10 | private(set) var errorMessage: String? | ||
| 11 | |||
| 12 | private let client: GitbayClient | ||
| 13 | let repoPath: String | ||
| 14 | |||
| 15 | init(client: GitbayClient, repoPath: String) { | ||
| 16 | self.client = client | ||
| 17 | self.repoPath = repoPath | ||
| 18 | } | ||
| 19 | |||
| 20 | nonisolated private struct Created: Decodable, Sendable { | ||
| 21 | let number: Int64 | ||
| 22 | } | ||
| 23 | |||
| 24 | /// Returns the new issue's number, or nil with `errorMessage` set. | ||
| 25 | func create(title: String, body: String) async -> Int64? { | ||
| 26 | working = true | ||
| 27 | errorMessage = nil | ||
| 28 | defer { working = false } | ||
| 29 | do { | ||
| 30 | var argv = ["issue", "create", repoPath, "--title", title] | ||
| 31 | var stdin: String? | ||
| 32 | if !body.isEmpty { | ||
| 33 | argv.append(contentsOf: ["--file", "-"]) | ||
| 34 | stdin = body | ||
| 35 | } | ||
| 36 | let created = try await client.run(argv, stdin: stdin, as: Created.self) | ||
| 37 | return created?.number | ||
| 38 | } catch let error as GitbayError { | ||
| 39 | errorMessage = error.userFacingMessage | ||
| 40 | } catch { | ||
| 41 | errorMessage = GitbayError.transport(error).userFacingMessage | ||
| 42 | } | ||
| 43 | return nil | ||
| 44 | } | ||
| 45 | } | ||
gitbay/Issues/IssueDetailViewModel.swift +25 −1
| @@ -10,6 +10,8 @@ final class IssueDetailViewModel { | |||
| 10 | private(set) var state: LoadState<IssueDetail> = .loading | 10 | private(set) var state: LoadState<IssueDetail> = .loading |
| 11 | private(set) var actionError: String? | 11 | private(set) var actionError: String? |
| 12 | private(set) var working = false | 12 | private(set) var working = false |
| 13 | /// Open milestones for the picker; fetched on first use. | ||
| 14 | private(set) var availableMilestones: [Milestone]? | ||
| 13 | 15 | ||
| 14 | private let client: GitbayClient | 16 | private let client: GitbayClient |
| 15 | let repoPath: String | 17 | let repoPath: String |
| @@ -31,8 +33,30 @@ final class IssueDetailViewModel { | |||
| 31 | } | 33 | } |
| 32 | } | 34 | } |
| 33 | 35 | ||
| 36 | /// Both fields are always sent: the title as given, the body over | ||
| 37 | /// stdin — an empty body clears it, matching the CLI. | ||
| 38 | func edit(title: String, body: String) async { | ||
| 39 | await perform( | ||
| 40 | ["issue", "edit"] + ref + ["--title", title, "--file", "-"], | ||
| 41 | stdin: body | ||
| 42 | ) | ||
| 43 | } | ||
| 44 | |||
| 45 | /// nil clears the milestone; the command spells that "none". | ||
| 46 | func setMilestone(_ title: String?) async { | ||
| 47 | await perform(["issue", "milestone"] + ref + [title ?? "none"]) | ||
| 48 | } | ||
| 49 | |||
| 50 | func loadMilestones() async { | ||
| 51 | guard availableMilestones == nil else { return } | ||
| 52 | availableMilestones = (try? await client.readList( | ||
| 53 | ["milestone", "list", repoPath, "--state", "open"], of: Milestone.self | ||
| 54 | )) ?? [] | ||
| 55 | } | ||
| 56 | |||
| 34 | func comment(_ text: String) async { | 57 | func comment(_ text: String) async { |
| 35 | await perform(["issue", "comment"] + ref, stdin: text) | 58 | // stdin is only read when argv carries --file -. |
| 59 | await perform(["issue", "comment"] + ref + ["--file", "-"], stdin: text) | ||
| 36 | } | 60 | } |
| 37 | 61 | ||
| 38 | func close() async { | 62 | func close() async { |
gitbay/Issues/Milestone.swift added +13
| @@ -0,0 +1,13 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// One row of `milestone list`, with progress counts. | ||
| 4 | nonisolated struct Milestone: Decodable, Sendable, Hashable, Identifiable { | ||
| 5 | let title: String | ||
| 6 | let description: String? | ||
| 7 | let due: String? | ||
| 8 | let state: String | ||
| 9 | let open: Int | ||
| 10 | let closed: Int | ||
| 11 | |||
| 12 | var id: String { title } | ||
| 13 | } | ||
gitbay/MRs/MRCreateViewModel.swift added +57
| @@ -0,0 +1,57 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | |||
| 4 | /// `mr create <repo> --source <b> --target <b> --title <t> --file -`. | ||
| 5 | /// | ||
| 6 | /// Branch names are typed, not picked: no command lists branches yet | ||
| 7 | /// (krz/gitbay#45). The target prefills with the default branch from | ||
| 8 | /// `repo show`. | ||
| 9 | @Observable | ||
| 10 | @MainActor | ||
| 11 | final class MRCreateViewModel { | ||
| 12 | |||
| 13 | private(set) var working = false | ||
| 14 | private(set) var errorMessage: String? | ||
| 15 | private(set) var defaultBranch: String? | ||
| 16 | |||
| 17 | private let client: GitbayClient | ||
| 18 | let repoPath: String | ||
| 19 | |||
| 20 | init(client: GitbayClient, repoPath: String) { | ||
| 21 | self.client = client | ||
| 22 | self.repoPath = repoPath | ||
| 23 | } | ||
| 24 | |||
| 25 | func loadDefaultBranch() async { | ||
| 26 | guard defaultBranch == nil else { return } | ||
| 27 | defaultBranch = (try? await client.read( | ||
| 28 | ["repo", "show", repoPath], as: RepoDetail.self | ||
| 29 | ))?.defaultBranch | ||
| 30 | } | ||
| 31 | |||
| 32 | nonisolated private struct Created: Decodable, Sendable { | ||
| 33 | let number: Int64 | ||
| 34 | } | ||
| 35 | |||
| 36 | func create(source: String, target: String, title: String, body: String) async -> Int64? { | ||
| 37 | working = true | ||
| 38 | errorMessage = nil | ||
| 39 | defer { working = false } | ||
| 40 | do { | ||
| 41 | var argv = ["mr", "create", repoPath, | ||
| 42 | "--source", source, "--target", target, "--title", title] | ||
| 43 | var stdin: String? | ||
| 44 | if !body.isEmpty { | ||
| 45 | argv.append(contentsOf: ["--file", "-"]) | ||
| 46 | stdin = body | ||
| 47 | } | ||
| 48 | let created = try await client.run(argv, stdin: stdin, as: Created.self) | ||
| 49 | return created?.number | ||
| 50 | } catch let error as GitbayError { | ||
| 51 | errorMessage = error.userFacingMessage | ||
| 52 | } catch { | ||
| 53 | errorMessage = GitbayError.transport(error).userFacingMessage | ||
| 54 | } | ||
| 55 | return nil | ||
| 56 | } | ||
| 57 | } | ||
gitbay/MRs/MRDetailViewModel.swift +12 −3
| @@ -58,9 +58,18 @@ final class MRDetailViewModel { | |||
| 58 | await perform(["mr", "review"] + ref + [verdict.rawValue]) | 58 | await perform(["mr", "review"] + ref + [verdict.rawValue]) |
| 59 | } | 59 | } |
| 60 | 60 | ||
| 61 | /// Both fields are always sent; an empty body clears it. | ||
| 62 | func edit(title: String, body: String) async { | ||
| 63 | await perform( | ||
| 64 | ["mr", "edit"] + ref + ["--title", title, "--file", "-"], | ||
| 65 | stdin: body | ||
| 66 | ) | ||
| 67 | } | ||
| 68 | |||
| 61 | func comment(_ text: String) async { | 69 | func comment(_ text: String) async { |
| 62 | // Long text goes in stdin, the CLI's --file - discipline. | 70 | // Long text travels in stdin, but the server only reads it when |
| 63 | await perform(["mr", "comment"] + ref, stdin: text) | 71 | // argv says so: --file - is required, not implied. |
| 72 | await perform(["mr", "comment"] + ref + ["--file", "-"], stdin: text) | ||
| 64 | } | 73 | } |
| 65 | 74 | ||
| 66 | func merge(strategy: String? = nil) async { | 75 | func merge(strategy: String? = nil) async { |
| @@ -79,7 +88,7 @@ final class MRDetailViewModel { | |||
| 79 | 88 | ||
| 80 | func reply(to thread: ReviewThread, _ text: String) async { | 89 | func reply(to thread: ReviewThread, _ text: String) async { |
| 81 | await perform( | 90 | await perform( |
| 82 | ["mr", "diff-comment"] + ref + ["--reply", String(thread.id)], | 91 | ["mr", "diff-comment"] + ref + ["--reply", String(thread.id), "--file", "-"], |
| 83 | stdin: text | 92 | stdin: text |
| 84 | ) | 93 | ) |
| 85 | } | 94 | } |
gitbay/Views/Issues/IssueListView.swift +34
| @@ -3,9 +3,14 @@ import SwiftUI | |||
| 3 | struct IssueListView: View { | 3 | struct IssueListView: View { |
| 4 | 4 | ||
| 5 | @State private var model: IssueListViewModel | 5 | @State private var model: IssueListViewModel |
| 6 | @State private var createModel: IssueCreateViewModel | ||
| 7 | @State private var composing = false | ||
| 8 | @State private var draftTitle = "" | ||
| 9 | @State private var draftBody = "" | ||
| 6 | 10 | ||
| 7 | init(client: GitbayClient, repo: String) { | 11 | init(client: GitbayClient, repo: String) { |
| 8 | _model = State(initialValue: IssueListViewModel(client: client, repoPath: repo)) | 12 | _model = State(initialValue: IssueListViewModel(client: client, repoPath: repo)) |
| 13 | _createModel = State(initialValue: IssueCreateViewModel(client: client, repoPath: repo)) | ||
| 9 | } | 14 | } |
| 10 | 15 | ||
| 11 | var body: some View { | 16 | var body: some View { |
| @@ -29,6 +34,35 @@ struct IssueListView: View { | |||
| 29 | .overlay { LoadStateOverlay(state: model.state) } | 34 | .overlay { LoadStateOverlay(state: model.state) } |
| 30 | .navigationTitle("Issues") | 35 | .navigationTitle("Issues") |
| 31 | .navigationBarTitleDisplayMode(.inline) | 36 | .navigationBarTitleDisplayMode(.inline) |
| 37 | .toolbar { | ||
| 38 | ToolbarItem(placement: .topBarTrailing) { | ||
| 39 | Button { | ||
| 40 | composing = true | ||
| 41 | } label: { | ||
| 42 | Image(systemName: "plus") | ||
| 43 | } | ||
| 44 | .accessibilityIdentifier("issue-create-button") | ||
| 45 | } | ||
| 46 | } | ||
| 47 | .sheet(isPresented: $composing) { | ||
| 48 | ComposeSheet( | ||
| 49 | heading: "New Issue", | ||
| 50 | submitLabel: "Create", | ||
| 51 | working: createModel.working, | ||
| 52 | errorMessage: createModel.errorMessage, | ||
| 53 | title: $draftTitle, | ||
| 54 | bodyText: $draftBody | ||
| 55 | ) { | ||
| 56 | Task { | ||
| 57 | if await createModel.create(title: draftTitle, body: draftBody) != nil { | ||
| 58 | draftTitle = "" | ||
| 59 | draftBody = "" | ||
| 60 | composing = false | ||
| 61 | await model.load() | ||
| 62 | } | ||
| 63 | } | ||
| 64 | } | ||
| 65 | } | ||
| 32 | .task { await model.load() } | 66 | .task { await model.load() } |
| 33 | .refreshable { await model.load() } | 67 | .refreshable { await model.load() } |
| 34 | } | 68 | } |
gitbay/Views/Issues/IssueView.swift +60 −2
| @@ -6,6 +6,9 @@ struct IssueView: View { | |||
| 6 | @State private var commentText = "" | 6 | @State private var commentText = "" |
| 7 | @State private var editingLabel = "" | 7 | @State private var editingLabel = "" |
| 8 | @State private var editingAssignee = "" | 8 | @State private var editingAssignee = "" |
| 9 | @State private var editing = false | ||
| 10 | @State private var draftTitle = "" | ||
| 11 | @State private var draftBody = "" | ||
| 9 | 12 | ||
| 10 | init(client: GitbayClient, repo: String, number: Int64) { | 13 | init(client: GitbayClient, repo: String, number: Int64) { |
| 11 | _model = State(initialValue: IssueDetailViewModel( | 14 | _model = State(initialValue: IssueDetailViewModel( |
| @@ -41,6 +44,21 @@ struct IssueView: View { | |||
| 41 | .navigationTitle("#\(model.number)") | 44 | .navigationTitle("#\(model.number)") |
| 42 | .navigationBarTitleDisplayMode(.inline) | 45 | .navigationBarTitleDisplayMode(.inline) |
| 43 | .toolbar { toolbar } | 46 | .toolbar { toolbar } |
| 47 | .sheet(isPresented: $editing) { | ||
| 48 | ComposeSheet( | ||
| 49 | heading: "Edit #\(model.number)", | ||
| 50 | submitLabel: "Save", | ||
| 51 | working: model.working, | ||
| 52 | errorMessage: model.actionError, | ||
| 53 | title: $draftTitle, | ||
| 54 | bodyText: $draftBody | ||
| 55 | ) { | ||
| 56 | Task { | ||
| 57 | await model.edit(title: draftTitle, body: draftBody) | ||
| 58 | if model.actionError == nil { editing = false } | ||
| 59 | } | ||
| 60 | } | ||
| 61 | } | ||
| 44 | .task { await model.load() } | 62 | .task { await model.load() } |
| 45 | .refreshable { await model.load() } | 63 | .refreshable { await model.load() } |
| 46 | } | 64 | } |
| @@ -107,6 +125,30 @@ struct IssueView: View { | |||
| 107 | .disabled(editingAssignee.trimmingCharacters(in: .whitespaces).isEmpty || model.working) | 125 | .disabled(editingAssignee.trimmingCharacters(in: .whitespaces).isEmpty || model.working) |
| 108 | } | 126 | } |
| 109 | } | 127 | } |
| 128 | Section("Milestone") { | ||
| 129 | Menu { | ||
| 130 | Button("None") { | ||
| 131 | Task { await model.setMilestone(nil) } | ||
| 132 | } | ||
| 133 | ForEach(model.availableMilestones ?? []) { milestone in | ||
| 134 | Button("\(milestone.title) (\(milestone.closed)/\(milestone.open + milestone.closed))") { | ||
| 135 | Task { await model.setMilestone(milestone.title) } | ||
| 136 | } | ||
| 137 | } | ||
| 138 | } label: { | ||
| 139 | HStack { | ||
| 140 | Label(issue.milestone ?? "None", systemImage: "flag") | ||
| 141 | .font(.subheadline) | ||
| 142 | Spacer() | ||
| 143 | Image(systemName: "chevron.up.chevron.down") | ||
| 144 | .font(.caption2) | ||
| 145 | .foregroundStyle(.secondary) | ||
| 146 | } | ||
| 147 | } | ||
| 148 | .disabled(model.working) | ||
| 149 | .task { await model.loadMilestones() } | ||
| 150 | .accessibilityIdentifier("milestone-menu") | ||
| 151 | } | ||
| 110 | } | 152 | } |
| 111 | 153 | ||
| 112 | @ViewBuilder | 154 | @ViewBuilder |
| @@ -172,10 +214,26 @@ struct IssueView: View { | |||
| 172 | private var toolbar: some ToolbarContent { | 214 | private var toolbar: some ToolbarContent { |
| 173 | ToolbarItem(placement: .topBarTrailing) { | 215 | ToolbarItem(placement: .topBarTrailing) { |
| 174 | if let issue = model.state.value { | 216 | if let issue = model.state.value { |
| 175 | Button(issue.isOpen ? "Close" : "Reopen") { | 217 | Menu { |
| 176 | Task { issue.isOpen ? await model.close() : await model.reopen() } | 218 | Button { |
| 219 | draftTitle = issue.title | ||
| 220 | draftBody = issue.body ?? "" | ||
| 221 | editing = true | ||
| 222 | } label: { | ||
| 223 | Label("Edit", systemImage: "pencil") | ||
| 224 | } | ||
| 225 | Button(issue.isOpen ? "Close" : "Reopen") { | ||
| 226 | Task { issue.isOpen ? await model.close() : await model.reopen() } | ||
| 227 | } | ||
| 228 | } label: { | ||
| 229 | if model.working { | ||
| 230 | ProgressView() | ||
| 231 | } else { | ||
| 232 | Image(systemName: "ellipsis.circle") | ||
| 233 | } | ||
| 177 | } | 234 | } |
| 178 | .disabled(model.working) | 235 | .disabled(model.working) |
| 236 | .accessibilityIdentifier("issue-actions-menu") | ||
| 179 | } | 237 | } |
| 180 | } | 238 | } |
| 181 | } | 239 | } |
gitbay/Views/MRs/MRListView.swift +105
| @@ -3,9 +3,12 @@ import SwiftUI | |||
| 3 | struct MRListView: View { | 3 | struct MRListView: View { |
| 4 | 4 | ||
| 5 | @State private var model: MRListViewModel | 5 | @State private var model: MRListViewModel |
| 6 | @State private var createModel: MRCreateViewModel | ||
| 7 | @State private var composing = false | ||
| 6 | 8 | ||
| 7 | init(client: GitbayClient, repo: String) { | 9 | init(client: GitbayClient, repo: String) { |
| 8 | _model = State(initialValue: MRListViewModel(client: client, repoPath: repo)) | 10 | _model = State(initialValue: MRListViewModel(client: client, repoPath: repo)) |
| 11 | _createModel = State(initialValue: MRCreateViewModel(client: client, repoPath: repo)) | ||
| 9 | } | 12 | } |
| 10 | 13 | ||
| 11 | var body: some View { | 14 | var body: some View { |
| @@ -29,11 +32,113 @@ struct MRListView: View { | |||
| 29 | .overlay { LoadStateOverlay(state: model.state) } | 32 | .overlay { LoadStateOverlay(state: model.state) } |
| 30 | .navigationTitle("Merge Requests") | 33 | .navigationTitle("Merge Requests") |
| 31 | .navigationBarTitleDisplayMode(.inline) | 34 | .navigationBarTitleDisplayMode(.inline) |
| 35 | .toolbar { | ||
| 36 | ToolbarItem(placement: .topBarTrailing) { | ||
| 37 | Button { | ||
| 38 | composing = true | ||
| 39 | } label: { | ||
| 40 | Image(systemName: "plus") | ||
| 41 | } | ||
| 42 | .accessibilityIdentifier("mr-create-button") | ||
| 43 | } | ||
| 44 | } | ||
| 45 | .sheet(isPresented: $composing) { | ||
| 46 | MRCreateSheet(model: createModel) { | ||
| 47 | composing = false | ||
| 48 | Task { await model.load() } | ||
| 49 | } | ||
| 50 | } | ||
| 32 | .task { await model.load() } | 51 | .task { await model.load() } |
| 33 | .refreshable { await model.load() } | 52 | .refreshable { await model.load() } |
| 34 | } | 53 | } |
| 35 | } | 54 | } |
| 36 | 55 | ||
| 56 | /// Source and target are typed, not picked: no command lists branches | ||
| 57 | /// yet (krz/gitbay#45). Target prefills with the default branch. | ||
| 58 | private struct MRCreateSheet: View { | ||
| 59 | |||
| 60 | let model: MRCreateViewModel | ||
| 61 | let onCreated: () -> Void | ||
| 62 | |||
| 63 | @Environment(\.dismiss) private var dismiss | ||
| 64 | @State private var source = "" | ||
| 65 | @State private var target = "" | ||
| 66 | @State private var title = "" | ||
| 67 | @State private var bodyText = "" | ||
| 68 | |||
| 69 | var body: some View { | ||
| 70 | NavigationStack { | ||
| 71 | Form { | ||
| 72 | Section("Branches") { | ||
| 73 | TextField("Source branch", text: $source) | ||
| 74 | .autocorrectionDisabled() | ||
| 75 | .textInputAutocapitalization(.never) | ||
| 76 | .accessibilityIdentifier("mr-source") | ||
| 77 | TextField("Target branch", text: $target) | ||
| 78 | .autocorrectionDisabled() | ||
| 79 | .textInputAutocapitalization(.never) | ||
| 80 | .accessibilityIdentifier("mr-target") | ||
| 81 | } | ||
| 82 | Section("Title") { | ||
| 83 | TextField("Title", text: $title, axis: .vertical) | ||
| 84 | .lineLimit(1...3) | ||
| 85 | .autocorrectionDisabled() | ||
| 86 | .accessibilityIdentifier("mr-title") | ||
| 87 | } | ||
| 88 | Section("Body") { | ||
| 89 | TextEditor(text: $bodyText) | ||
| 90 | .frame(minHeight: 120) | ||
| 91 | .autocorrectionDisabled() | ||
| 92 | .accessibilityIdentifier("mr-body") | ||
| 93 | } | ||
| 94 | if let error = model.errorMessage { | ||
| 95 | Section { | ||
| 96 | Label(error, systemImage: "exclamationmark.triangle") | ||
| 97 | .foregroundStyle(.red) | ||
| 98 | .font(.subheadline) | ||
| 99 | } | ||
| 100 | } | ||
| 101 | } | ||
| 102 | .navigationTitle("New Merge Request") | ||
| 103 | .navigationBarTitleDisplayMode(.inline) | ||
| 104 | .toolbar { | ||
| 105 | ToolbarItem(placement: .cancellationAction) { | ||
| 106 | Button("Cancel") { dismiss() } | ||
| 107 | } | ||
| 108 | ToolbarItem(placement: .confirmationAction) { | ||
| 109 | if model.working { | ||
| 110 | ProgressView() | ||
| 111 | } else { | ||
| 112 | Button("Create") { | ||
| 113 | Task { | ||
| 114 | if await model.create( | ||
| 115 | source: source, target: target, | ||
| 116 | title: title, body: bodyText | ||
| 117 | ) != nil { | ||
| 118 | onCreated() | ||
| 119 | } | ||
| 120 | } | ||
| 121 | } | ||
| 122 | .disabled( | ||
| 123 | source.trimmingCharacters(in: .whitespaces).isEmpty | ||
| 124 | || target.trimmingCharacters(in: .whitespaces).isEmpty | ||
| 125 | || title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty | ||
| 126 | ) | ||
| 127 | .accessibilityIdentifier("mr-submit") | ||
| 128 | } | ||
| 129 | } | ||
| 130 | } | ||
| 131 | .interactiveDismissDisabled(model.working) | ||
| 132 | .task { | ||
| 133 | await model.loadDefaultBranch() | ||
| 134 | if target.isEmpty, let branch = model.defaultBranch { | ||
| 135 | target = branch | ||
| 136 | } | ||
| 137 | } | ||
| 138 | } | ||
| 139 | } | ||
| 140 | } | ||
| 141 | |||
| 37 | struct MRRow: View { | 142 | struct MRRow: View { |
| 38 | let mr: MergeRequest | 143 | let mr: MergeRequest |
| 39 | 144 | ||
gitbay/Views/MRs/MRView.swift +27
| @@ -6,6 +6,9 @@ struct MRView: View { | |||
| 6 | @State private var commentText = "" | 6 | @State private var commentText = "" |
| 7 | @State private var confirmingMerge = false | 7 | @State private var confirmingMerge = false |
| 8 | @State private var confirmingClose = false | 8 | @State private var confirmingClose = false |
| 9 | @State private var editing = false | ||
| 10 | @State private var draftTitle = "" | ||
| 11 | @State private var draftBody = "" | ||
| 9 | 12 | ||
| 10 | init(client: GitbayClient, repo: String, number: Int64) { | 13 | init(client: GitbayClient, repo: String, number: Int64) { |
| 11 | _model = State(initialValue: MRDetailViewModel( | 14 | _model = State(initialValue: MRDetailViewModel( |
| @@ -69,6 +72,21 @@ struct MRView: View { | |||
| 69 | Button("Close", role: .destructive) { Task { await model.close() } } | 72 | Button("Close", role: .destructive) { Task { await model.close() } } |
| 70 | Button("Cancel", role: .cancel) {} | 73 | Button("Cancel", role: .cancel) {} |
| 71 | } | 74 | } |
| 75 | .sheet(isPresented: $editing) { | ||
| 76 | ComposeSheet( | ||
| 77 | heading: "Edit !\(model.number)", | ||
| 78 | submitLabel: "Save", | ||
| 79 | working: model.working, | ||
| 80 | errorMessage: model.actionError, | ||
| 81 | title: $draftTitle, | ||
| 82 | bodyText: $draftBody | ||
| 83 | ) { | ||
| 84 | Task { | ||
| 85 | await model.edit(title: draftTitle, body: draftBody) | ||
| 86 | if model.actionError == nil { editing = false } | ||
| 87 | } | ||
| 88 | } | ||
| 89 | } | ||
| 72 | } | 90 | } |
| 73 | 91 | ||
| 74 | // MARK: - Sections | 92 | // MARK: - Sections |
| @@ -219,6 +237,14 @@ struct MRView: View { | |||
| 219 | ToolbarItem(placement: .topBarTrailing) { | 237 | ToolbarItem(placement: .topBarTrailing) { |
| 220 | if let mr = model.state.value, mr.isOpen { | 238 | if let mr = model.state.value, mr.isOpen { |
| 221 | Menu { | 239 | Menu { |
| 240 | Button { | ||
| 241 | draftTitle = mr.title | ||
| 242 | draftBody = mr.body ?? "" | ||
| 243 | editing = true | ||
| 244 | } label: { | ||
| 245 | Label("Edit", systemImage: "pencil") | ||
| 246 | } | ||
| 247 | Divider() | ||
| 222 | Button { | 248 | Button { |
| 223 | Task { await model.review(.approve) } | 249 | Task { await model.review(.approve) } |
| 224 | } label: { | 250 | } label: { |
| @@ -248,6 +274,7 @@ struct MRView: View { | |||
| 248 | } | 274 | } |
| 249 | } | 275 | } |
| 250 | .disabled(model.working) | 276 | .disabled(model.working) |
| 277 | .accessibilityIdentifier("mr-actions-menu") | ||
| 251 | } | 278 | } |
| 252 | } | 279 | } |
| 253 | } | 280 | } |
gitbay/Views/Shared/ComposeSheet.swift added +61
| @@ -0,0 +1,61 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// Title-and-body editor shared by issue/MR create and edit sheets. | ||
| 4 | struct ComposeSheet: View { | ||
| 5 | |||
| 6 | let heading: String | ||
| 7 | let submitLabel: String | ||
| 8 | let working: Bool | ||
| 9 | let errorMessage: String? | ||
| 10 | @Binding var title: String | ||
| 11 | @Binding var bodyText: String | ||
| 12 | let onSubmit: () -> Void | ||
| 13 | |||
| 14 | @Environment(\.dismiss) private var dismiss | ||
| 15 | |||
| 16 | var body: some View { | ||
| 17 | NavigationStack { | ||
| 18 | Form { | ||
| 19 | Section("Title") { | ||
| 20 | TextField("Title", text: $title, axis: .vertical) | ||
| 21 | .lineLimit(1...3) | ||
| 22 | // Titles name code: paths, commands, branch names. | ||
| 23 | // Autocorrection rewrites them. | ||
| 24 | .autocorrectionDisabled() | ||
| 25 | .accessibilityIdentifier("compose-title") | ||
| 26 | } | ||
| 27 | Section("Body") { | ||
| 28 | TextEditor(text: $bodyText) | ||
| 29 | .frame(minHeight: 160) | ||
| 30 | .font(.body) | ||
| 31 | .autocorrectionDisabled() | ||
| 32 | .accessibilityIdentifier("compose-body") | ||
| 33 | } | ||
| 34 | if let errorMessage { | ||
| 35 | Section { | ||
| 36 | Label(errorMessage, systemImage: "exclamationmark.triangle") | ||
| 37 | .foregroundStyle(.red) | ||
| 38 | .font(.subheadline) | ||
| 39 | } | ||
| 40 | } | ||
| 41 | } | ||
| 42 | .navigationTitle(heading) | ||
| 43 | .navigationBarTitleDisplayMode(.inline) | ||
| 44 | .toolbar { | ||
| 45 | ToolbarItem(placement: .cancellationAction) { | ||
| 46 | Button("Cancel") { dismiss() } | ||
| 47 | } | ||
| 48 | ToolbarItem(placement: .confirmationAction) { | ||
| 49 | if working { | ||
| 50 | ProgressView() | ||
| 51 | } else { | ||
| 52 | Button(submitLabel, action: onSubmit) | ||
| 53 | .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) | ||
| 54 | .accessibilityIdentifier("compose-submit") | ||
| 55 | } | ||
| 56 | } | ||
| 57 | } | ||
| 58 | .interactiveDismissDisabled(working) | ||
| 59 | } | ||
| 60 | } | ||
| 61 | } | ||
gitbayTests/ComposeViewModelTests.swift added +182
| @@ -0,0 +1,182 @@ | |||
| 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 createdJSON = #"{"protocol_version":1,"data":{"number":7},"exit_code":0}"# | ||
| 16 | private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"# | ||
| 17 | |||
| 18 | private func argvOf(_ seen: StubProtocol.Seen) throws -> ([String], String?) { | ||
| 19 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | ||
| 20 | return (try #require(body["argv"] as? [String]), body["stdin"] as? String) | ||
| 21 | } | ||
| 22 | |||
| 23 | @MainActor | ||
| 24 | struct IssueCreateViewModelTests { | ||
| 25 | |||
| 26 | @Test func createSendsTitleInArgvAndBodyOverStdin() async throws { | ||
| 27 | let (client, stub) = try makeClient() | ||
| 28 | stub.enqueue(.init(status: 200, json: createdJSON)) | ||
| 29 | let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay") | ||
| 30 | |||
| 31 | let number = await model.create(title: "org READMEs", body: "render them properly") | ||
| 32 | |||
| 33 | #expect(number == 7) | ||
| 34 | let (argv, stdin) = try argvOf(try #require(stub.seen.first)) | ||
| 35 | #expect(argv == ["issue", "create", "krz/gitbay", "--title", "org READMEs", "--file", "-"]) | ||
| 36 | #expect(stdin == "render them properly") | ||
| 37 | } | ||
| 38 | |||
| 39 | @Test func anEmptyBodyOmitsStdinAndTheFileFlag() async throws { | ||
| 40 | let (client, stub) = try makeClient() | ||
| 41 | stub.enqueue(.init(status: 200, json: createdJSON)) | ||
| 42 | let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay") | ||
| 43 | |||
| 44 | _ = await model.create(title: "just a title", body: "") | ||
| 45 | |||
| 46 | let (argv, stdin) = try argvOf(try #require(stub.seen.first)) | ||
| 47 | #expect(argv == ["issue", "create", "krz/gitbay", "--title", "just a title"]) | ||
| 48 | #expect(stdin == nil) | ||
| 49 | } | ||
| 50 | |||
| 51 | @Test func aRefusalSurfacesAndReturnsNil() async throws { | ||
| 52 | let (client, stub) = try makeClient() | ||
| 53 | stub.enqueue(.init(status: 403, json: | ||
| 54 | #"{"protocol_version":1,"error":"krz/gitbay is archived and read-only","exit_code":4}"#)) | ||
| 55 | let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay") | ||
| 56 | |||
| 57 | let number = await model.create(title: "t", body: "b") | ||
| 58 | |||
| 59 | #expect(number == nil) | ||
| 60 | #expect(model.errorMessage == "krz/gitbay is archived and read-only") | ||
| 61 | } | ||
| 62 | } | ||
| 63 | |||
| 64 | @MainActor | ||
| 65 | struct MRCreateViewModelTests { | ||
| 66 | |||
| 67 | @Test func targetPrefillsFromRepoShow() async throws { | ||
| 68 | let (client, stub) = try makeClient() | ||
| 69 | stub.enqueue(.init(status: 200, json: """ | ||
| 70 | {"protocol_version":1,"data":{"path":"krz/gitbay","visibility":"public",\ | ||
| 71 | "default_branch":"main"},"exit_code":0} | ||
| 72 | """)) | ||
| 73 | let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay") | ||
| 74 | |||
| 75 | await model.loadDefaultBranch() | ||
| 76 | |||
| 77 | #expect(model.defaultBranch == "main") | ||
| 78 | } | ||
| 79 | |||
| 80 | @Test func createSendsBranchesTitleAndBody() async throws { | ||
| 81 | let (client, stub) = try makeClient() | ||
| 82 | stub.enqueue(.init(status: 200, json: | ||
| 83 | #"{"protocol_version":1,"data":{"number":4,"head_sha":"aa"},"exit_code":0}"#)) | ||
| 84 | let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay") | ||
| 85 | |||
| 86 | let number = await model.create( | ||
| 87 | source: "fix-thing", target: "main", title: "fix the thing", body: "details" | ||
| 88 | ) | ||
| 89 | |||
| 90 | #expect(number == 4) | ||
| 91 | let (argv, stdin) = try argvOf(try #require(stub.seen.first)) | ||
| 92 | #expect(argv == ["mr", "create", "krz/gitbay", | ||
| 93 | "--source", "fix-thing", "--target", "main", | ||
| 94 | "--title", "fix the thing", "--file", "-"]) | ||
| 95 | #expect(stdin == "details") | ||
| 96 | } | ||
| 97 | } | ||
| 98 | |||
| 99 | @MainActor | ||
| 100 | struct EditAndMilestoneTests { | ||
| 101 | |||
| 102 | private let issueShow = """ | ||
| 103 | {"protocol_version":1,"data":{"number":11,"title":"iOS app","state":"open",\ | ||
| 104 | "author":"krz","body":"Build it.","created_at":"2026-08-20T10:00:00.000Z"},"exit_code":0} | ||
| 105 | """ | ||
| 106 | |||
| 107 | @Test func issueEditSendsTitleAndBodyAlways() async throws { | ||
| 108 | let (client, stub) = try makeClient() | ||
| 109 | stub.enqueue(.init(status: 200, json: issueShow, match: "argv=show")) | ||
| 110 | let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11) | ||
| 111 | await model.load() | ||
| 112 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | ||
| 113 | stub.enqueue(.init(status: 200, json: issueShow, match: "argv=show")) | ||
| 114 | |||
| 115 | await model.edit(title: "iOS app (v1)", body: "") | ||
| 116 | |||
| 117 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 118 | let (argv, stdin) = try argvOf(write) | ||
| 119 | #expect(argv == ["issue", "edit", "krz/gitbay", "11", "--title", "iOS app (v1)", "--file", "-"]) | ||
| 120 | #expect(stdin == "") // empty body clears, matching the CLI | ||
| 121 | } | ||
| 122 | |||
| 123 | @Test func milestoneSetAndClearSpellNoneCorrectly() async throws { | ||
| 124 | let (client, stub) = try makeClient() | ||
| 125 | stub.enqueue(.init(status: 200, json: issueShow, match: "argv=show")) | ||
| 126 | let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11) | ||
| 127 | await model.load() | ||
| 128 | for _ in 0..<2 { | ||
| 129 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | ||
| 130 | stub.enqueue(.init(status: 200, json: issueShow, match: "argv=show")) | ||
| 131 | } | ||
| 132 | |||
| 133 | await model.setMilestone("v1.0.0") | ||
| 134 | await model.setMilestone(nil) | ||
| 135 | |||
| 136 | let writes = try stub.seen.filter { $0.method == "POST" }.map { try argvOf($0).0 } | ||
| 137 | #expect(writes[0] == ["issue", "milestone", "krz/gitbay", "11", "v1.0.0"]) | ||
| 138 | #expect(writes[1] == ["issue", "milestone", "krz/gitbay", "11", "none"]) | ||
| 139 | } | ||
| 140 | |||
| 141 | @Test func milestonesLoadOnceForThePicker() async throws { | ||
| 142 | let (client, stub) = try makeClient() | ||
| 143 | stub.enqueue(.init(status: 200, json: issueShow, match: "argv=show")) | ||
| 144 | let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11) | ||
| 145 | await model.load() | ||
| 146 | stub.enqueue(.init(status: 200, json: """ | ||
| 147 | {"protocol_version":1,"data":[{"title":"v1.0.0","state":"open","open":3,"closed":5}],\ | ||
| 148 | "exit_code":0} | ||
| 149 | """, match: "argv=milestone")) | ||
| 150 | |||
| 151 | await model.loadMilestones() | ||
| 152 | await model.loadMilestones() // second call must not refetch | ||
| 153 | |||
| 154 | #expect(model.availableMilestones?.first?.title == "v1.0.0") | ||
| 155 | #expect(stub.seen.count { ($0.url.query() ?? "").contains("argv=milestone") } == 1) | ||
| 156 | } | ||
| 157 | |||
| 158 | @Test func mrEditSendsTitleAndBody() async throws { | ||
| 159 | let (client, stub) = try makeClient() | ||
| 160 | let mrShow = """ | ||
| 161 | {"protocol_version":1,"data":{"number":7,"title":"old","state":"open","author":"cmc",\ | ||
| 162 | "source":"b","target_ref":"main","head_sha":"aa","created_at":"2026-08-20T10:00:00.000Z"},\ | ||
| 163 | "exit_code":0} | ||
| 164 | """ | ||
| 165 | stub.enqueue(.init(status: 200, json: mrShow, match: "argv=show")) | ||
| 166 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"output":"","exit_code":0}"#, match: "argv=diff")) | ||
| 167 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "argv=threads")) | ||
| 168 | let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) | ||
| 169 | await model.load() | ||
| 170 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | ||
| 171 | stub.enqueue(.init(status: 200, json: mrShow, match: "argv=show")) | ||
| 172 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"output":"","exit_code":0}"#, match: "argv=diff")) | ||
| 173 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "argv=threads")) | ||
| 174 | |||
| 175 | await model.edit(title: "new title", body: "new body") | ||
| 176 | |||
| 177 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 178 | let (argv, stdin) = try argvOf(write) | ||
| 179 | #expect(argv == ["mr", "edit", "krz/gitbay", "7", "--title", "new title", "--file", "-"]) | ||
| 180 | #expect(stdin == "new body") | ||
| 181 | } | ||
| 182 | } | ||
gitbayTests/IssueBuildViewModelTests.swift +1 −1
| @@ -87,7 +87,7 @@ struct IssueDetailViewModelTests { | |||
| 87 | let write = stub.seen[1] | 87 | let write = stub.seen[1] |
| 88 | #expect(write.method == "POST") | 88 | #expect(write.method == "POST") |
| 89 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) | 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"]) | 90 | #expect(body["argv"] as? [String] == ["issue", "comment", "krz/gitbay", "11", "--file", "-"]) |
| 91 | #expect(body["stdin"] as? String == "triaged from the phone") | 91 | #expect(body["stdin"] as? String == "triaged from the phone") |
| 92 | } | 92 | } |
| 93 | 93 | ||
gitbayTests/MRViewModelTests.swift +2 −2
| @@ -228,7 +228,7 @@ struct MRDetailViewModelTests { | |||
| 228 | 228 | ||
| 229 | let write = stub.seen[3] | 229 | let write = stub.seen[3] |
| 230 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) | 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"]) | 231 | #expect(body["argv"] as? [String] == ["mr", "comment", "krz/gitbay", "7", "--file", "-"]) |
| 232 | #expect(body["stdin"] as? String == "long review text\nwith lines") | 232 | #expect(body["stdin"] as? String == "long review text\nwith lines") |
| 233 | } | 233 | } |
| 234 | 234 | ||
| @@ -273,7 +273,7 @@ struct MRDetailViewModelTests { | |||
| 273 | let write = stub.seen[3] | 273 | let write = stub.seen[3] |
| 274 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) | 274 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) |
| 275 | #expect(body["argv"] as? [String] == | 275 | #expect(body["argv"] as? [String] == |
| 276 | ["mr", "diff-comment", "krz/gitbay", "7", "--reply", "3"]) | 276 | ["mr", "diff-comment", "krz/gitbay", "7", "--reply", "3", "--file", "-"]) |
| 277 | #expect(body["stdin"] as? String == "because 5xx is transient") | 277 | #expect(body["stdin"] as? String == "because 5xx is transient") |
| 278 | } | 278 | } |
| 279 | } | 279 | } |
gitbayUITests/LiveSmokeUITests.swift added +142
| @@ -0,0 +1,142 @@ | |||
| 1 | import XCTest | ||
| 2 | |||
| 3 | /// Live smoke flows against gitbay.org, driven by accessibility — the | ||
| 4 | /// coordinate-free verification the simulator's input pipeline cannot be | ||
| 5 | /// trusted to do (krz/gitbay-ios#5). | ||
| 6 | /// | ||
| 7 | /// These WRITE to the live instance (an MR is opened and closed, a | ||
| 8 | /// milestone assigned), so they only run when explicitly asked: | ||
| 9 | /// | ||
| 10 | /// TEST_RUNNER_GITBAY_UITEST_LIVE=1 xcodebuild test \ | ||
| 11 | /// -only-testing:gitbayUITests ... | ||
| 12 | /// | ||
| 13 | /// They assume the simulator is already signed in as an account that can | ||
| 14 | /// write to krz/gitbay-ios, and that branch `ui-smoke` exists. | ||
| 15 | final class LiveSmokeUITests: XCTestCase { | ||
| 16 | |||
| 17 | private var app: XCUIApplication! | ||
| 18 | |||
| 19 | override func setUpWithError() throws { | ||
| 20 | try XCTSkipUnless( | ||
| 21 | ProcessInfo.processInfo.environment["GITBAY_UITEST_LIVE"] == "1", | ||
| 22 | "live UI smoke is opt-in; set TEST_RUNNER_GITBAY_UITEST_LIVE=1" | ||
| 23 | ) | ||
| 24 | continueAfterFailure = false | ||
| 25 | app = XCUIApplication() | ||
| 26 | app.launch() | ||
| 27 | } | ||
| 28 | |||
| 29 | /// Everything in one ordered pass: milestone assign, edit-save, | ||
| 30 | /// MR create, MR close. One test so the flows share navigation and | ||
| 31 | /// the writes happen exactly once. | ||
| 32 | func testCreationAndEditingFlows() throws { | ||
| 33 | openRepo("krz/gitbay-ios") | ||
| 34 | |||
| 35 | // --- Issue #4: milestone picker assigns v1.0.0 --- | ||
| 36 | app.staticTexts["Issues"].firstMatch.tap() | ||
| 37 | let issueRow = app.staticTexts | ||
| 38 | .containing(NSPredicate(format: "label CONTAINS 'README.org'")).firstMatch | ||
| 39 | XCTAssertTrue(issueRow.waitForExistence(timeout: 10), "issue #4 not in the list") | ||
| 40 | issueRow.tap() | ||
| 41 | |||
| 42 | let milestoneMenu = app.descendants(matching: .any) | ||
| 43 | .matching(identifier: "milestone-menu").firstMatch | ||
| 44 | XCTAssertTrue(milestoneMenu.waitForExistence(timeout: 10)) | ||
| 45 | milestoneMenu.tap() | ||
| 46 | let milestoneChoice = app.buttons | ||
| 47 | .containing(NSPredicate(format: "label BEGINSWITH 'v1.0.0'")).firstMatch | ||
| 48 | XCTAssertTrue(milestoneChoice.waitForExistence(timeout: 5), "milestone menu did not open") | ||
| 49 | milestoneChoice.tap() | ||
| 50 | // The reload renders the assigned milestone in the header row. | ||
| 51 | XCTAssertTrue(app.staticTexts["v1.0.0"].firstMatch | ||
| 52 | .waitForExistence(timeout: 10), "milestone not shown after assign") | ||
| 53 | |||
| 54 | // --- Issue #4: edit sheet saves (content unchanged) --- | ||
| 55 | app.descendants(matching: .any).matching(identifier: "issue-actions-menu") | ||
| 56 | .firstMatch.tap() | ||
| 57 | let edit = app.buttons["Edit"].firstMatch | ||
| 58 | XCTAssertTrue(edit.waitForExistence(timeout: 5)) | ||
| 59 | edit.tap() | ||
| 60 | let title = app.descendants(matching: .any) | ||
| 61 | .matching(identifier: "compose-title").firstMatch | ||
| 62 | XCTAssertTrue(title.waitForExistence(timeout: 5), "edit sheet did not open") | ||
| 63 | XCTAssertTrue((title.value as? String)?.contains("README.org") == true, | ||
| 64 | "edit sheet did not prefill") | ||
| 65 | app.descendants(matching: .any).matching(identifier: "compose-submit") | ||
| 66 | .firstMatch.tap() | ||
| 67 | // Sheet dismissal proves the save round-tripped without error. | ||
| 68 | XCTAssertTrue(waitForDisappearance(title, timeout: 15), "edit save did not dismiss") | ||
| 69 | |||
| 70 | back() // to issues list | ||
| 71 | back() // to repo screen | ||
| 72 | |||
| 73 | // --- MR: create from ui-smoke, then close it --- | ||
| 74 | app.staticTexts["Merge Requests"].firstMatch.tap() | ||
| 75 | app.descendants(matching: .any).matching(identifier: "mr-create-button") | ||
| 76 | .firstMatch.tap() | ||
| 77 | |||
| 78 | let source = app.descendants(matching: .any) | ||
| 79 | .matching(identifier: "mr-source").firstMatch | ||
| 80 | XCTAssertTrue(source.waitForExistence(timeout: 5), "MR create sheet did not open") | ||
| 81 | source.tap() | ||
| 82 | source.typeText("ui-smoke") | ||
| 83 | |||
| 84 | let target = app.descendants(matching: .any) | ||
| 85 | .matching(identifier: "mr-target").firstMatch | ||
| 86 | // The default branch prefilled while the sheet loaded. | ||
| 87 | XCTAssertEqual(target.value as? String, "main", "target did not prefill") | ||
| 88 | |||
| 89 | let mrTitle = app.descendants(matching: .any) | ||
| 90 | .matching(identifier: "mr-title").firstMatch | ||
| 91 | mrTitle.tap() | ||
| 92 | mrTitle.typeText("UI smoke: mr create from the app") | ||
| 93 | |||
| 94 | app.descendants(matching: .any).matching(identifier: "mr-submit") | ||
| 95 | .firstMatch.tap() | ||
| 96 | |||
| 97 | let createdRow = app.staticTexts | ||
| 98 | .containing(NSPredicate(format: "label CONTAINS 'UI smoke'")).firstMatch | ||
| 99 | XCTAssertTrue(createdRow.waitForExistence(timeout: 15), "created MR not in the list") | ||
| 100 | createdRow.tap() | ||
| 101 | |||
| 102 | app.descendants(matching: .any).matching(identifier: "mr-actions-menu") | ||
| 103 | .firstMatch.tap() | ||
| 104 | let close = app.buttons["Close"].firstMatch | ||
| 105 | XCTAssertTrue(close.waitForExistence(timeout: 5)) | ||
| 106 | close.tap() | ||
| 107 | // The confirmation dialog's destructive Close. | ||
| 108 | let confirm = app.buttons["Close"].firstMatch | ||
| 109 | XCTAssertTrue(confirm.waitForExistence(timeout: 5), "close confirmation missing") | ||
| 110 | confirm.tap() | ||
| 111 | |||
| 112 | XCTAssertTrue(app.staticTexts["closed"].firstMatch | ||
| 113 | .waitForExistence(timeout: 15), "MR did not show closed after close") | ||
| 114 | } | ||
| 115 | |||
| 116 | // MARK: - Helpers | ||
| 117 | |||
| 118 | private func openRepo(_ path: String) { | ||
| 119 | let tab = app.buttons["Repositories"].firstMatch | ||
| 120 | XCTAssertTrue(tab.waitForExistence(timeout: 10)) | ||
| 121 | tab.tap() | ||
| 122 | let search = app.searchFields.firstMatch | ||
| 123 | XCTAssertTrue(search.waitForExistence(timeout: 10)) | ||
| 124 | search.tap() | ||
| 125 | search.typeText(path) | ||
| 126 | let row = app.staticTexts[path].firstMatch | ||
| 127 | XCTAssertTrue(row.waitForExistence(timeout: 15), "\(path) not in the repo list") | ||
| 128 | row.tap() | ||
| 129 | // Repo screen is loaded once its links render. | ||
| 130 | XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10)) | ||
| 131 | } | ||
| 132 | |||
| 133 | private func back() { | ||
| 134 | app.navigationBars.buttons.firstMatch.tap() | ||
| 135 | } | ||
| 136 | |||
| 137 | private func waitForDisappearance(_ element: XCUIElement, timeout: TimeInterval) -> Bool { | ||
| 138 | let predicate = NSPredicate(format: "exists == false") | ||
| 139 | let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) | ||
| 140 | return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed | ||
| 141 | } | ||
| 142 | } | ||