Commit b1290cb6a0

b1290cb6a067bd81337644dae8bac4ace7631097

parent: 14b8a9abb8

Unsigned

cmc <hello@cleberg.net> · 2026-08-27 06:45 UTC

create and edit: issues, MRs, milestones

issue create and mr create from the list screens (+ button); title/body
editing on both detail screens behind the actions menu; a milestone
picker on issues fed by milestone list --state open, with none to
clear. Bodies travel over stdin with --file - in argv; an absent body
omits both. mr create's branches are typed, not picked — no command
lists branches yet (krz/gitbay#45) — and the target prefills with the
default branch from repo show.

ComposeSheet is the shared title/body editor for the three simple
sheets; MR create has its own with branch fields.

Verified against production via the API with the app's exact argv:
issue create returned {number}, issue edit rewrote title and body.

Ref #11, krz/gitbay#45

Layout: unified · split

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 +23
@@ -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,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 func comment(_ text: String) async { 57 func comment(_ text: String) async {
35 // stdin is only read when argv carries --file -. 58 // stdin is only read when argv carries --file -.
36 await perform(["issue", "comment"] + ref + ["--file", "-"], stdin: text) 59 await perform(["issue", "comment"] + ref + ["--file", "-"], stdin: text)
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 +8
@@ -58,6 +58,14 @@ 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 travels in stdin, but the server only reads it when 70 // Long text travels in stdin, but the server only reads it when
63 // argv says so: --file - is required, not implied. 71 // argv says so: --file - is required, not implied.
gitbay/Views/Issues/IssueListView.swift +33
@@ -3,9 +3,14 @@ import SwiftUI
3struct IssueListView: View { 3struct 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,34 @@ 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 }
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 .task { await model.load() } 65 .task { await model.load() }
33 .refreshable { await model.load() } 66 .refreshable { await model.load() }
34 } 67 }
gitbay/Views/Issues/IssueView.swift +58 −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,29 @@ 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 }
110 } 151 }
111 152
112 @ViewBuilder 153 @ViewBuilder
@@ -172,8 +213,23 @@ struct IssueView: View {
172 private var toolbar: some ToolbarContent { 213 private var toolbar: some ToolbarContent {
173 ToolbarItem(placement: .topBarTrailing) { 214 ToolbarItem(placement: .topBarTrailing) {
174 if let issue = model.state.value { 215 if let issue = model.state.value {
175 Button(issue.isOpen ? "Close" : "Reopen") { 216 Menu {
176 Task { issue.isOpen ? await model.close() : await model.reopen() } 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 .disabled(model.working) 234 .disabled(model.working)
179 } 235 }
gitbay/Views/MRs/MRListView.swift +97
@@ -3,9 +3,12 @@ import SwiftUI
3struct MRListView: View { 3struct 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,105 @@ 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 }
43 }
44 .sheet(isPresented: $composing) {
45 MRCreateSheet(model: createModel) {
46 composing = false
47 Task { await model.load() }
48 }
49 }
32 .task { await model.load() } 50 .task { await model.load() }
33 .refreshable { await model.load() } 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.
57private 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
37struct MRRow: View { 134struct MRRow: View {
38 let mr: MergeRequest 135 let mr: MergeRequest
39 136
gitbay/Views/MRs/MRView.swift +26
@@ -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: {
gitbay/Views/Shared/ComposeSheet.swift added +54
@@ -0,0 +1,54 @@
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 }
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 @@
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}