a native ios client for gitbay

client ios swift

https://gitbay.org

create and edit: issues, MRs, milestones — and the stdin fix !4

merged cmc wants to merge krz/gitbay-ios:create-and-edit into main

15 files changed, +876 −9

gitbay.xcodeproj/project.pbxproj +110
@@ -18,11 +18,19 @@
1818 remoteGlobalIDString = 8B289811303FF3E5001BF44A;
1919 remoteInfo = gitbay;
2020 };
21 8B2898C7303FF3E5001BF44A /* PBXContainerItemProxy */ = {
22 isa = PBXContainerItemProxy;
23 containerPortal = 8B28980A303FF3E5001BF44A /* Project object */;
24 proxyType = 1;
25 remoteGlobalIDString = 8B289811303FF3E5001BF44A;
26 remoteInfo = gitbay;
27 };
2128/* End PBXContainerItemProxy section */
2229
2330/* Begin PBXFileReference section */
2431 8B289812303FF3E5001BF44A /* gitbay.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = gitbay.app; sourceTree = BUILT_PRODUCTS_DIR; };
2532 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; };
2634/* End PBXFileReference section */
2735
2836/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -36,6 +44,11 @@
3644 path = gitbayTests;
3745 sourceTree = "<group>";
3846 };
47 8B2898C0303FF3E5001BF44A /* gitbayUITests */ = {
48 isa = PBXFileSystemSynchronizedRootGroup;
49 path = gitbayUITests;
50 sourceTree = "<group>";
51 };
3952/* End PBXFileSystemSynchronizedRootGroup section */
4053
4154/* Begin PBXFrameworksBuildPhase section */
@@ -54,6 +67,13 @@
5467 );
5568 runOnlyForDeploymentPostprocessing = 0;
5669 };
70 8B2898C4303FF3E5001BF44A /* Frameworks */ = {
71 isa = PBXFrameworksBuildPhase;
72 buildActionMask = 2147483647;
73 files = (
74 );
75 runOnlyForDeploymentPostprocessing = 0;
76 };
5777/* End PBXFrameworksBuildPhase section */
5878
5979/* Begin PBXGroup section */
@@ -62,6 +82,7 @@
6282 children = (
6383 8B289814303FF3E5001BF44A /* gitbay */,
6484 8B2898A0303FF3E5001BF44A /* gitbayTests */,
85 8B2898C0303FF3E5001BF44A /* gitbayUITests */,
6586 8B289813303FF3E5001BF44A /* Products */,
6687 );
6788 sourceTree = "<group>";
@@ -71,6 +92,7 @@
7192 children = (
7293 8B289812303FF3E5001BF44A /* gitbay.app */,
7394 8B2898A2303FF3E5001BF44A /* gitbayTests.xctest */,
95 8B2898C2303FF3E5001BF44A /* gitbayUITests.xctest */,
7496 );
7597 name = Products;
7698 sourceTree = "<group>";
@@ -124,6 +146,29 @@
124146 productReference = 8B2898A2303FF3E5001BF44A /* gitbayTests.xctest */;
125147 productType = "com.apple.product-type.bundle.unit-test";
126148 };
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 };
127172/* End PBXNativeTarget section */
128173
129174/* Begin PBXProject section */
@@ -141,6 +186,10 @@
141186 CreatedOnToolsVersion = 26.6;
142187 TestTargetID = 8B289811303FF3E5001BF44A;
143188 };
189 8B2898C1303FF3E5001BF44A = {
190 CreatedOnToolsVersion = 26.6;
191 TestTargetID = 8B289811303FF3E5001BF44A;
192 };
144193 };
145194 };
146195 buildConfigurationList = 8B28980D303FF3E5001BF44A /* Build configuration list for PBXProject "gitbay" */;
@@ -162,6 +211,7 @@
162211 targets = (
163212 8B289811303FF3E5001BF44A /* gitbay */,
164213 8B2898A1303FF3E5001BF44A /* gitbayTests */,
214 8B2898C1303FF3E5001BF44A /* gitbayUITests */,
165215 );
166216 };
167217/* End PBXProject section */
@@ -181,6 +231,13 @@
181231 );
182232 runOnlyForDeploymentPostprocessing = 0;
183233 };
234 8B2898C5303FF3E5001BF44A /* Resources */ = {
235 isa = PBXResourcesBuildPhase;
236 buildActionMask = 2147483647;
237 files = (
238 );
239 runOnlyForDeploymentPostprocessing = 0;
240 };
184241/* End PBXResourcesBuildPhase section */
185242
186243/* Begin PBXSourcesBuildPhase section */
@@ -198,6 +255,13 @@
198255 );
199256 runOnlyForDeploymentPostprocessing = 0;
200257 };
258 8B2898C3303FF3E5001BF44A /* Sources */ = {
259 isa = PBXSourcesBuildPhase;
260 buildActionMask = 2147483647;
261 files = (
262 );
263 runOnlyForDeploymentPostprocessing = 0;
264 };
201265/* End PBXSourcesBuildPhase section */
202266
203267/* Begin PBXTargetDependency section */
@@ -206,6 +270,11 @@
206270 target = 8B289811303FF3E5001BF44A /* gitbay */;
207271 targetProxy = 8B2898A7303FF3E5001BF44A /* PBXContainerItemProxy */;
208272 };
273 8B2898C6303FF3E5001BF44A /* PBXTargetDependency */ = {
274 isa = PBXTargetDependency;
275 target = 8B289811303FF3E5001BF44A /* gitbay */;
276 targetProxy = 8B2898C7303FF3E5001BF44A /* PBXContainerItemProxy */;
277 };
209278/* End PBXTargetDependency section */
210279
211280/* Begin XCBuildConfiguration section */
@@ -434,6 +503,38 @@
434503 };
435504 name = Release;
436505 };
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 };
437538/* End XCBuildConfiguration section */
438539
439540/* Begin XCConfigurationList section */
@@ -464,6 +565,15 @@
464565 defaultConfigurationIsVisible = 0;
465566 defaultConfigurationName = Release;
466567 };
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 };
467577/* End XCConfigurationList section */
468578
469579/* Begin XCRemoteSwiftPackageReference section */
gitbay/Issues/IssueCreateViewModel.swift added +45
@@ -0,0 +1,45 @@
1import Foundation
2import Observation
3
4/// `issue create <repo> --title <t> --file -`.
5@Observable
6@MainActor
7final 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 {
1010 private(set) var state: LoadState<IssueDetail> = .loading
1111 private(set) var actionError: String?
1212 private(set) var working = false
13 /// Open milestones for the picker; fetched on first use.
14 private(set) var availableMilestones: [Milestone]?
1315
1416 private let client: GitbayClient
1517 let repoPath: String
@@ -31,8 +33,30 @@ final class IssueDetailViewModel {
3133 }
3234 }
3335
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
3457 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)
3660 }
3761
3862 func close() async {
gitbay/Issues/Milestone.swift added +13
@@ -0,0 +1,13 @@
1import Foundation
2
3/// One row of `milestone list`, with progress counts.
4nonisolated 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 @@
1import Foundation
2import 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
11final 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 {
5858 await perform(["mr", "review"] + ref + [verdict.rawValue])
5959 }
6060
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
6169 func comment(_ text: String) async {
62 // Long text goes in stdin, the CLI's --file - discipline.
63 await perform(["mr", "comment"] + ref, stdin: text)
70 // Long text travels in stdin, but the server only reads it when
71 // argv says so: --file - is required, not implied.
72 await perform(["mr", "comment"] + ref + ["--file", "-"], stdin: text)
6473 }
6574
6675 func merge(strategy: String? = nil) async {
@@ -79,7 +88,7 @@ final class MRDetailViewModel {
7988
8089 func reply(to thread: ReviewThread, _ text: String) async {
8190 await perform(
82 ["mr", "diff-comment"] + ref + ["--reply", String(thread.id)],
91 ["mr", "diff-comment"] + ref + ["--reply", String(thread.id), "--file", "-"],
8392 stdin: text
8493 )
8594 }
gitbay/Views/Issues/IssueListView.swift +34
@@ -3,9 +3,14 @@ import SwiftUI
33 struct IssueListView: View {
44
55 @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 = ""
610
711 init(client: GitbayClient, repo: String) {
812 _model = State(initialValue: IssueListViewModel(client: client, repoPath: repo))
13 _createModel = State(initialValue: IssueCreateViewModel(client: client, repoPath: repo))
914 }
1015
1116 var body: some View {
@@ -29,6 +34,35 @@ struct IssueListView: View {
2934 .overlay { LoadStateOverlay(state: model.state) }
3035 .navigationTitle("Issues")
3136 .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 }
3266 .task { await model.load() }
3367 .refreshable { await model.load() }
3468 }
gitbay/Views/Issues/IssueView.swift +60 −2
@@ -6,6 +6,9 @@ struct IssueView: View {
66 @State private var commentText = ""
77 @State private var editingLabel = ""
88 @State private var editingAssignee = ""
9 @State private var editing = false
10 @State private var draftTitle = ""
11 @State private var draftBody = ""
912
1013 init(client: GitbayClient, repo: String, number: Int64) {
1114 _model = State(initialValue: IssueDetailViewModel(
@@ -41,6 +44,21 @@ struct IssueView: View {
4144 .navigationTitle("#\(model.number)")
4245 .navigationBarTitleDisplayMode(.inline)
4346 .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 }
4462 .task { await model.load() }
4563 .refreshable { await model.load() }
4664 }
@@ -107,6 +125,30 @@ struct IssueView: View {
107125 .disabled(editingAssignee.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
108126 }
109127 }
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 }
110152 }
111153
112154 @ViewBuilder
@@ -172,10 +214,26 @@ struct IssueView: View {
172214 private var toolbar: some ToolbarContent {
173215 ToolbarItem(placement: .topBarTrailing) {
174216 if let issue = model.state.value {
175 Button(issue.isOpen ? "Close" : "Reopen") {
176 Task { issue.isOpen ? await model.close() : await model.reopen() }
217 Menu {
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 }
177234 }
178235 .disabled(model.working)
236 .accessibilityIdentifier("issue-actions-menu")
179237 }
180238 }
181239 }
gitbay/Views/MRs/MRListView.swift +105
@@ -3,9 +3,12 @@ import SwiftUI
33 struct MRListView: View {
44
55 @State private var model: MRListViewModel
6 @State private var createModel: MRCreateViewModel
7 @State private var composing = false
68
79 init(client: GitbayClient, repo: String) {
810 _model = State(initialValue: MRListViewModel(client: client, repoPath: repo))
11 _createModel = State(initialValue: MRCreateViewModel(client: client, repoPath: repo))
912 }
1013
1114 var body: some View {
@@ -29,11 +32,113 @@ struct MRListView: View {
2932 .overlay { LoadStateOverlay(state: model.state) }
3033 .navigationTitle("Merge Requests")
3134 .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 }
3251 .task { await model.load() }
3352 .refreshable { await model.load() }
3453 }
3554 }
3655
56/// Source and target are typed, not picked: no command lists branches
57/// yet (krz/gitbay#45). Target prefills with the default branch.
58private 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
37142 struct MRRow: View {
38143 let mr: MergeRequest
39144
gitbay/Views/MRs/MRView.swift +27
@@ -6,6 +6,9 @@ struct MRView: View {
66 @State private var commentText = ""
77 @State private var confirmingMerge = false
88 @State private var confirmingClose = false
9 @State private var editing = false
10 @State private var draftTitle = ""
11 @State private var draftBody = ""
912
1013 init(client: GitbayClient, repo: String, number: Int64) {
1114 _model = State(initialValue: MRDetailViewModel(
@@ -69,6 +72,21 @@ struct MRView: View {
6972 Button("Close", role: .destructive) { Task { await model.close() } }
7073 Button("Cancel", role: .cancel) {}
7174 }
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 }
7290 }
7391
7492 // MARK: - Sections
@@ -219,6 +237,14 @@ struct MRView: View {
219237 ToolbarItem(placement: .topBarTrailing) {
220238 if let mr = model.state.value, mr.isOpen {
221239 Menu {
240 Button {
241 draftTitle = mr.title
242 draftBody = mr.body ?? ""
243 editing = true
244 } label: {
245 Label("Edit", systemImage: "pencil")
246 }
247 Divider()
222248 Button {
223249 Task { await model.review(.approve) }
224250 } label: {
@@ -248,6 +274,7 @@ struct MRView: View {
248274 }
249275 }
250276 .disabled(model.working)
277 .accessibilityIdentifier("mr-actions-menu")
251278 }
252279 }
253280 }
gitbay/Views/Shared/ComposeSheet.swift added +61
@@ -0,0 +1,61 @@
1import SwiftUI
2
3/// Title-and-body editor shared by issue/MR create and edit sheets.
4struct 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 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private let createdJSON = #"{"protocol_version":1,"data":{"number":7},"exit_code":0}"#
16private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
17
18private 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
24struct 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
65struct 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
100struct 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 {
8787 let write = stub.seen[1]
8888 #expect(write.method == "POST")
8989 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", "-"])
9191 #expect(body["stdin"] as? String == "triaged from the phone")
9292 }
9393
gitbayTests/MRViewModelTests.swift +2 −2
@@ -228,7 +228,7 @@ struct MRDetailViewModelTests {
228228
229229 let write = stub.seen[3]
230230 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", "-"])
232232 #expect(body["stdin"] as? String == "long review text\nwith lines")
233233 }
234234
@@ -273,7 +273,7 @@ struct MRDetailViewModelTests {
273273 let write = stub.seen[3]
274274 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
275275 #expect(body["argv"] as? [String] ==
276 ["mr", "diff-comment", "krz/gitbay", "7", "--reply", "3"])
276 ["mr", "diff-comment", "krz/gitbay", "7", "--reply", "3", "--file", "-"])
277277 #expect(body["stdin"] as? String == "because 5xx is transient")
278278 }
279279 }
gitbayUITests/LiveSmokeUITests.swift added +142
@@ -0,0 +1,142 @@
1import 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.
15final 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}