Commit 4cb7a356bb
Verified · cmc
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 +23
| @@ -10,6 +10,8 @@ final class IssueDetailViewModel { | ||
| 10 | 10 | private(set) var state: LoadState<IssueDetail> = .loading |
| 11 | 11 | private(set) var actionError: String? |
| 12 | 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 | 16 | private let client: GitbayClient |
| 15 | 17 | let repoPath: String |
| @@ -31,6 +33,27 @@ 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 | 57 | func comment(_ text: String) async { |
| 35 | 58 | // stdin is only read when argv carries --file -. |
| 36 | 59 | await perform(["issue", "comment"] + ref + ["--file", "-"], stdin: text) |
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 +8
| @@ -58,6 +58,14 @@ final class MRDetailViewModel { | ||
| 58 | 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 | 69 | func comment(_ text: String) async { |
| 62 | 70 | // Long text travels in stdin, but the server only reads it when |
| 63 | 71 | // argv says so: --file - is required, not implied. |
gitbay/Views/Issues/IssueListView.swift +33
| @@ -3,9 +3,14 @@ import SwiftUI | ||
| 3 | 3 | struct IssueListView: View { |
| 4 | 4 | |
| 5 | 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 | 11 | init(client: GitbayClient, repo: String) { |
| 8 | 12 | _model = State(initialValue: IssueListViewModel(client: client, repoPath: repo)) |
| 13 | _createModel = State(initialValue: IssueCreateViewModel(client: client, repoPath: repo)) | |
| 9 | 14 | } |
| 10 | 15 | |
| 11 | 16 | var body: some View { |
| @@ -29,6 +34,34 @@ struct IssueListView: View { | ||
| 29 | 34 | .overlay { LoadStateOverlay(state: model.state) } |
| 30 | 35 | .navigationTitle("Issues") |
| 31 | 36 | .navigationBarTitleDisplayMode(.inline) |
| 37 | .toolbar { | |
| 38 | ToolbarItem(placement: .topBarTrailing) { | |
| 39 | Button { | |
| 40 | composing = true | |
| 41 | } label: { | |
| 42 | Image(systemName: "plus") | |
| 43 | } | |
| 44 | } | |
| 45 | } | |
| 46 | .sheet(isPresented: $composing) { | |
| 47 | ComposeSheet( | |
| 48 | heading: "New Issue", | |
| 49 | submitLabel: "Create", | |
| 50 | working: createModel.working, | |
| 51 | errorMessage: createModel.errorMessage, | |
| 52 | title: $draftTitle, | |
| 53 | bodyText: $draftBody | |
| 54 | ) { | |
| 55 | Task { | |
| 56 | if await createModel.create(title: draftTitle, body: draftBody) != nil { | |
| 57 | draftTitle = "" | |
| 58 | draftBody = "" | |
| 59 | composing = false | |
| 60 | await model.load() | |
| 61 | } | |
| 62 | } | |
| 63 | } | |
| 64 | } | |
| 32 | 65 | .task { await model.load() } |
| 33 | 66 | .refreshable { await model.load() } |
| 34 | 67 | } |
gitbay/Views/Issues/IssueView.swift +58 −2
| @@ -6,6 +6,9 @@ struct IssueView: View { | ||
| 6 | 6 | @State private var commentText = "" |
| 7 | 7 | @State private var editingLabel = "" |
| 8 | 8 | @State private var editingAssignee = "" |
| 9 | @State private var editing = false | |
| 10 | @State private var draftTitle = "" | |
| 11 | @State private var draftBody = "" | |
| 9 | 12 | |
| 10 | 13 | init(client: GitbayClient, repo: String, number: Int64) { |
| 11 | 14 | _model = State(initialValue: IssueDetailViewModel( |
| @@ -41,6 +44,21 @@ struct IssueView: View { | ||
| 41 | 44 | .navigationTitle("#\(model.number)") |
| 42 | 45 | .navigationBarTitleDisplayMode(.inline) |
| 43 | 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 | 62 | .task { await model.load() } |
| 45 | 63 | .refreshable { await model.load() } |
| 46 | 64 | } |
| @@ -107,6 +125,29 @@ struct IssueView: View { | ||
| 107 | 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 | } | |
| 110 | 151 | } |
| 111 | 152 | |
| 112 | 153 | @ViewBuilder |
| @@ -172,8 +213,23 @@ struct IssueView: View { | ||
| 172 | 213 | private var toolbar: some ToolbarContent { |
| 173 | 214 | ToolbarItem(placement: .topBarTrailing) { |
| 174 | 215 | if let issue = model.state.value { |
| 175 | Button(issue.isOpen ? "Close" : "Reopen") { | |
| 176 | Task { issue.isOpen ? await model.close() : await model.reopen() } | |
| 216 | Menu { | |
| 217 | Button { | |
| 218 | draftTitle = issue.title | |
| 219 | draftBody = issue.body ?? "" | |
| 220 | editing = true | |
| 221 | } label: { | |
| 222 | Label("Edit", systemImage: "pencil") | |
| 223 | } | |
| 224 | Button(issue.isOpen ? "Close" : "Reopen") { | |
| 225 | Task { issue.isOpen ? await model.close() : await model.reopen() } | |
| 226 | } | |
| 227 | } label: { | |
| 228 | if model.working { | |
| 229 | ProgressView() | |
| 230 | } else { | |
| 231 | Image(systemName: "ellipsis.circle") | |
| 232 | } | |
| 177 | 233 | } |
| 178 | 234 | .disabled(model.working) |
| 179 | 235 | } |
gitbay/Views/MRs/MRListView.swift +97
| @@ -3,9 +3,12 @@ import SwiftUI | ||
| 3 | 3 | struct MRListView: View { |
| 4 | 4 | |
| 5 | 5 | @State private var model: MRListViewModel |
| 6 | @State private var createModel: MRCreateViewModel | |
| 7 | @State private var composing = false | |
| 6 | 8 | |
| 7 | 9 | init(client: GitbayClient, repo: String) { |
| 8 | 10 | _model = State(initialValue: MRListViewModel(client: client, repoPath: repo)) |
| 11 | _createModel = State(initialValue: MRCreateViewModel(client: client, repoPath: repo)) | |
| 9 | 12 | } |
| 10 | 13 | |
| 11 | 14 | var body: some View { |
| @@ -29,11 +32,105 @@ struct MRListView: View { | ||
| 29 | 32 | .overlay { LoadStateOverlay(state: model.state) } |
| 30 | 33 | .navigationTitle("Merge Requests") |
| 31 | 34 | .navigationBarTitleDisplayMode(.inline) |
| 35 | .toolbar { | |
| 36 | ToolbarItem(placement: .topBarTrailing) { | |
| 37 | Button { | |
| 38 | composing = true | |
| 39 | } label: { | |
| 40 | Image(systemName: "plus") | |
| 41 | } | |
| 42 | } | |
| 43 | } | |
| 44 | .sheet(isPresented: $composing) { | |
| 45 | MRCreateSheet(model: createModel) { | |
| 46 | composing = false | |
| 47 | Task { await model.load() } | |
| 48 | } | |
| 49 | } | |
| 32 | 50 | .task { await model.load() } |
| 33 | 51 | .refreshable { await model.load() } |
| 34 | 52 | } |
| 35 | 53 | } |
| 36 | 54 | |
| 55 | /// Source and target are typed, not picked: no command lists branches | |
| 56 | /// yet (krz/gitbay#45). Target prefills with the default branch. | |
| 57 | private struct MRCreateSheet: View { | |
| 58 | ||
| 59 | let model: MRCreateViewModel | |
| 60 | let onCreated: () -> Void | |
| 61 | ||
| 62 | @Environment(\.dismiss) private var dismiss | |
| 63 | @State private var source = "" | |
| 64 | @State private var target = "" | |
| 65 | @State private var title = "" | |
| 66 | @State private var bodyText = "" | |
| 67 | ||
| 68 | var body: some View { | |
| 69 | NavigationStack { | |
| 70 | Form { | |
| 71 | Section("Branches") { | |
| 72 | TextField("Source branch", text: $source) | |
| 73 | .autocorrectionDisabled() | |
| 74 | .textInputAutocapitalization(.never) | |
| 75 | TextField("Target branch", text: $target) | |
| 76 | .autocorrectionDisabled() | |
| 77 | .textInputAutocapitalization(.never) | |
| 78 | } | |
| 79 | Section("Title") { | |
| 80 | TextField("Title", text: $title, axis: .vertical) | |
| 81 | .lineLimit(1...3) | |
| 82 | } | |
| 83 | Section("Body") { | |
| 84 | TextEditor(text: $bodyText) | |
| 85 | .frame(minHeight: 120) | |
| 86 | } | |
| 87 | if let error = model.errorMessage { | |
| 88 | Section { | |
| 89 | Label(error, systemImage: "exclamationmark.triangle") | |
| 90 | .foregroundStyle(.red) | |
| 91 | .font(.subheadline) | |
| 92 | } | |
| 93 | } | |
| 94 | } | |
| 95 | .navigationTitle("New Merge Request") | |
| 96 | .navigationBarTitleDisplayMode(.inline) | |
| 97 | .toolbar { | |
| 98 | ToolbarItem(placement: .cancellationAction) { | |
| 99 | Button("Cancel") { dismiss() } | |
| 100 | } | |
| 101 | ToolbarItem(placement: .confirmationAction) { | |
| 102 | if model.working { | |
| 103 | ProgressView() | |
| 104 | } else { | |
| 105 | Button("Create") { | |
| 106 | Task { | |
| 107 | if await model.create( | |
| 108 | source: source, target: target, | |
| 109 | title: title, body: bodyText | |
| 110 | ) != nil { | |
| 111 | onCreated() | |
| 112 | } | |
| 113 | } | |
| 114 | } | |
| 115 | .disabled( | |
| 116 | source.trimmingCharacters(in: .whitespaces).isEmpty | |
| 117 | || target.trimmingCharacters(in: .whitespaces).isEmpty | |
| 118 | || title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty | |
| 119 | ) | |
| 120 | } | |
| 121 | } | |
| 122 | } | |
| 123 | .interactiveDismissDisabled(model.working) | |
| 124 | .task { | |
| 125 | await model.loadDefaultBranch() | |
| 126 | if target.isEmpty, let branch = model.defaultBranch { | |
| 127 | target = branch | |
| 128 | } | |
| 129 | } | |
| 130 | } | |
| 131 | } | |
| 132 | } | |
| 133 | ||
| 37 | 134 | struct MRRow: View { |
| 38 | 135 | let mr: MergeRequest |
| 39 | 136 | |
gitbay/Views/MRs/MRView.swift +26
| @@ -6,6 +6,9 @@ struct MRView: View { | ||
| 6 | 6 | @State private var commentText = "" |
| 7 | 7 | @State private var confirmingMerge = false |
| 8 | 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 | 13 | init(client: GitbayClient, repo: String, number: Int64) { |
| 11 | 14 | _model = State(initialValue: MRDetailViewModel( |
| @@ -69,6 +72,21 @@ struct MRView: View { | ||
| 69 | 72 | Button("Close", role: .destructive) { Task { await model.close() } } |
| 70 | 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 | 92 | // MARK: - Sections |
| @@ -219,6 +237,14 @@ struct MRView: View { | ||
| 219 | 237 | ToolbarItem(placement: .topBarTrailing) { |
| 220 | 238 | if let mr = model.state.value, mr.isOpen { |
| 221 | 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 | 248 | Button { |
| 223 | 249 | Task { await model.review(.approve) } |
| 224 | 250 | } label: { |
gitbay/Views/Shared/ComposeSheet.swift added +54
| @@ -0,0 +1,54 @@ | ||
| 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 | } | |
| 23 | Section("Body") { | |
| 24 | TextEditor(text: $bodyText) | |
| 25 | .frame(minHeight: 160) | |
| 26 | .font(.body) | |
| 27 | } | |
| 28 | if let errorMessage { | |
| 29 | Section { | |
| 30 | Label(errorMessage, systemImage: "exclamationmark.triangle") | |
| 31 | .foregroundStyle(.red) | |
| 32 | .font(.subheadline) | |
| 33 | } | |
| 34 | } | |
| 35 | } | |
| 36 | .navigationTitle(heading) | |
| 37 | .navigationBarTitleDisplayMode(.inline) | |
| 38 | .toolbar { | |
| 39 | ToolbarItem(placement: .cancellationAction) { | |
| 40 | Button("Cancel") { dismiss() } | |
| 41 | } | |
| 42 | ToolbarItem(placement: .confirmationAction) { | |
| 43 | if working { | |
| 44 | ProgressView() | |
| 45 | } else { | |
| 46 | Button(submitLabel, action: onSubmit) | |
| 47 | .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) | |
| 48 | } | |
| 49 | } | |
| 50 | } | |
| 51 | .interactiveDismissDisabled(working) | |
| 52 | } | |
| 53 | } | |
| 54 | } | |
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 | } | |