Commit 91fe479290

91fe47929082e834915a2868ca432aca58dec936

parent: 3c99511a5e

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-07 02:34 UTC

Send the body markup format on issues and merge requests

Layout: unified · split

gitbay/Issues/IssueCreateViewModel.swift +2
@@ -8,6 +8,7 @@ final class IssueCreateViewModel {
8 8
9 private(set) var working = false 9 private(set) var working = false
10 private(set) var errorMessage: String? 10 private(set) var errorMessage: String?
11 var format: String?
11 12
12 private let client: GitbayClient 13 private let client: GitbayClient
13 let repoPath: String 14 let repoPath: String
@@ -33,6 +34,7 @@ final class IssueCreateViewModel {
33 argv.append(contentsOf: ["--file", "-"]) 34 argv.append(contentsOf: ["--file", "-"])
34 stdin = body 35 stdin = body
35 } 36 }
37 if let format { argv.append(contentsOf: ["--format", format]) }
36 let created = try await client.run(argv, stdin: stdin, as: Created.self) 38 let created = try await client.run(argv, stdin: stdin, as: Created.self)
37 return created?.number 39 return created?.number
38 } catch let error as GitbayError { 40 } catch let error as GitbayError {
gitbay/Issues/IssueDetailViewModel.swift +10 −8
@@ -46,12 +46,12 @@ final class IssueDetailViewModel {
46 } 46 }
47 47
48 /// Both fields are always sent: the title as given, the body over 48 /// Both fields are always sent: the title as given, the body over
49 /// stdin — an empty body clears it, matching the CLI. 49 /// stdin — an empty body clears it, matching the CLI. `format` is
50 func edit(title: String, body: String) async { 50 /// appended only when given; omitting it lets the server default.
51 await perform( 51 func edit(title: String, body: String, format: String? = nil) async {
52 ["issue", "edit"] + ref + ["--title", title, "--file", "-"], 52 var argv = ["issue", "edit"] + ref + ["--title", title, "--file", "-"]
53 stdin: body 53 if let format { argv.append(contentsOf: ["--format", format]) }
54 ) 54 await perform(argv, stdin: body)
55 } 55 }
56 56
57 /// nil clears the milestone; the command spells that "none". 57 /// nil clears the milestone; the command spells that "none".
@@ -66,9 +66,11 @@ final class IssueDetailViewModel {
66 )) ?? [] 66 )) ?? []
67 } 67 }
68 68
69 func comment(_ text: String) async { 69 func comment(_ text: String, format: String? = nil) async {
70 // stdin is only read when argv carries --file -. 70 // stdin is only read when argv carries --file -.
71 await perform(["issue", "comment"] + ref + ["--file", "-"], stdin: text) 71 var argv = ["issue", "comment"] + ref + ["--file", "-"]
72 if let format { argv.append(contentsOf: ["--format", format]) }
73 await perform(argv, stdin: text)
72 } 74 }
73 75
74 func close() async { 76 func close() async {
gitbay/Issues/IssueModels.swift +2
@@ -31,11 +31,13 @@ nonisolated struct IssueDetail: Decodable, Sendable, Hashable {
31 let labels: [String]? 31 let labels: [String]?
32 let assignees: [String]? 32 let assignees: [String]?
33 let body: String? 33 let body: String?
34 let bodyFormat: String?
34 let createdAt: Date 35 let createdAt: Date
35 let comments: [Comment]? 36 let comments: [Comment]?
36 37
37 enum CodingKeys: String, CodingKey { 38 enum CodingKeys: String, CodingKey {
38 case number, title, state, author, milestone, labels, assignees, body, comments 39 case number, title, state, author, milestone, labels, assignees, body, comments
40 case bodyFormat = "body_format"
39 case createdAt = "created_at" 41 case createdAt = "created_at"
40 } 42 }
41 43
gitbay/MRs/MRCreateViewModel.swift +2
@@ -14,6 +14,7 @@ final class MRCreateViewModel {
14 private(set) var errorMessage: String? 14 private(set) var errorMessage: String?
15 private(set) var defaultBranch: String? 15 private(set) var defaultBranch: String?
16 var draft = false 16 var draft = false
17 var format: String?
17 18
18 private let client: GitbayClient 19 private let client: GitbayClient
19 let repoPath: String 20 let repoPath: String
@@ -46,6 +47,7 @@ final class MRCreateViewModel {
46 argv.append(contentsOf: ["--file", "-"]) 47 argv.append(contentsOf: ["--file", "-"])
47 stdin = body 48 stdin = body
48 } 49 }
50 if let format { argv.append(contentsOf: ["--format", format]) }
49 if draft { argv.append("--draft") } 51 if draft { argv.append("--draft") }
50 let created = try await client.run(argv, stdin: stdin, as: Created.self) 52 let created = try await client.run(argv, stdin: stdin, as: Created.self)
51 return created?.number 53 return created?.number
gitbay/MRs/MRDetailViewModel.swift +10 −8
@@ -58,12 +58,12 @@ 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. 61 /// Both fields are always sent; an empty body clears it. `format` is
62 func edit(title: String, body: String) async { 62 /// appended only when given; omitting it lets the server default.
63 await perform( 63 func edit(title: String, body: String, format: String? = nil) async {
64 ["mr", "edit"] + ref + ["--title", title, "--file", "-"], 64 var argv = ["mr", "edit"] + ref + ["--title", title, "--file", "-"]
65 stdin: body 65 if let format { argv.append(contentsOf: ["--format", format]) }
66 ) 66 await perform(argv, stdin: body)
67 } 67 }
68 68
69 /// Open milestones for the picker; fetched on first use. 69 /// Open milestones for the picker; fetched on first use.
@@ -81,10 +81,12 @@ final class MRDetailViewModel {
81 await perform(["mr", "milestone"] + ref + [title ?? "none"]) 81 await perform(["mr", "milestone"] + ref + [title ?? "none"])
82 } 82 }
83 83
84 func comment(_ text: String) async { 84 func comment(_ text: String, format: String? = nil) async {
85 // Long text travels in stdin, but the server only reads it when 85 // Long text travels in stdin, but the server only reads it when
86 // argv says so: --file - is required, not implied. 86 // argv says so: --file - is required, not implied.
87 await perform(["mr", "comment"] + ref + ["--file", "-"], stdin: text) 87 var argv = ["mr", "comment"] + ref + ["--file", "-"]
88 if let format { argv.append(contentsOf: ["--format", format]) }
89 await perform(argv, stdin: text)
88 } 90 }
89 91
90 func merge(strategy: String? = nil) async { 92 func merge(strategy: String? = nil) async {
gitbay/MRs/MRModels.swift +3
@@ -62,6 +62,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
62 let targetRef: String 62 let targetRef: String
63 let headSHA: String 63 let headSHA: String
64 let body: String? 64 let body: String?
65 let bodyFormat: String?
65 let milestone: String? 66 let milestone: String?
66 let createdAt: Date 67 let createdAt: Date
67 /// Set once the merge request is resolved. The actor is absent when 68 /// Set once the merge request is resolved. The actor is absent when
@@ -84,6 +85,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
84 85
85 enum CodingKeys: String, CodingKey { 86 enum CodingKeys: String, CodingKey {
86 case number, title, state, author, source, body, milestone, checks, commits, comments, reviews, draft, stacked 87 case number, title, state, author, source, body, milestone, checks, commits, comments, reviews, draft, stacked
88 case bodyFormat = "body_format"
87 case targetRef = "target_ref" 89 case targetRef = "target_ref"
88 case headSHA = "head_sha" 90 case headSHA = "head_sha"
89 case createdAt = "created_at" 91 case createdAt = "created_at"
@@ -107,6 +109,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
107 targetRef = try container.decode(String.self, forKey: .targetRef) 109 targetRef = try container.decode(String.self, forKey: .targetRef)
108 headSHA = try container.decode(String.self, forKey: .headSHA) 110 headSHA = try container.decode(String.self, forKey: .headSHA)
109 body = try container.decodeIfPresent(String.self, forKey: .body) 111 body = try container.decodeIfPresent(String.self, forKey: .body)
112 bodyFormat = try container.decodeIfPresent(String.self, forKey: .bodyFormat)
110 milestone = try container.decodeIfPresent(String.self, forKey: .milestone) 113 milestone = try container.decodeIfPresent(String.self, forKey: .milestone)
111 createdAt = try container.decode(Date.self, forKey: .createdAt) 114 createdAt = try container.decode(Date.self, forKey: .createdAt)
112 mergedAt = try container.decodeIfPresent(Date.self, forKey: .mergedAt) 115 mergedAt = try container.decodeIfPresent(Date.self, forKey: .mergedAt)
gitbayTests/BodyFormatTests.swift added +279
@@ -0,0 +1,279 @@
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 func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
16 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
17 return try #require(body["argv"] as? [String])
18}
19
20private func stdinOf(_ seen: StubProtocol.Seen) throws -> String? {
21 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
22 return body["stdin"] as? String
23}
24
25struct BodyFormatDecodingTests {
26
27 private func decodeIssue(_ json: String) throws -> IssueDetail {
28 let decoder = JSONDecoder()
29 decoder.dateDecodingStrategy = .iso8601
30 return try decoder.decode(IssueDetail.self, from: Data(json.utf8))
31 }
32
33 private func decodeMR(_ json: String) throws -> MRDetail {
34 let decoder = JSONDecoder()
35 decoder.dateDecodingStrategy = .iso8601
36 return try decoder.decode(MRDetail.self, from: Data(json.utf8))
37 }
38
39 /// body_format is omitempty; an ordinary body carries no key.
40 @Test func anIssueWithoutAStoredFormatDecodes() throws {
41 let issue = try decodeIssue("""
42 {"number":7,"title":"t","state":"open","author":"cmc",\
43 "body":"x","created_at":"2026-09-01T00:00:00Z"}
44 """)
45 #expect(issue.bodyFormat == nil)
46 }
47
48 @Test func anIssueStoredAsOrgSaysSo() throws {
49 let issue = try decodeIssue("""
50 {"number":7,"title":"t","state":"open","author":"cmc",\
51 "body":"x","body_format":"org","created_at":"2026-09-01T00:00:00Z"}
52 """)
53 #expect(issue.bodyFormat == "org")
54 }
55
56 @Test func aMergeRequestWithoutAStoredFormatDecodes() throws {
57 let mr = try decodeMR("""
58 {"number":7,"title":"t","state":"open","author":"cmc","source":"f",\
59 "target_ref":"main","head_sha":"a","created_at":"2026-09-01T00:00:00Z"}
60 """)
61 #expect(mr.bodyFormat == nil)
62 }
63
64 /// MRDetail's hand-written initialiser must still decode everything
65 /// it did before the new field was added.
66 @Test func addingTheFormatDidNotDropAnyMergeRequestField() throws {
67 let mr = try decodeMR("""
68 {"number":7,"title":"t","state":"open","draft":true,"author":"cmc","source":"f",\
69 "target_ref":"main","head_sha":"abc","body":"b","body_format":"org",\
70 "milestone":"v1","review_requests":["rae"],\
71 "stacked_on":{"number":6,"title":"below"},\
72 "stacked":[{"number":8,"title":"above"}],\
73 "created_at":"2026-09-01T00:00:00Z","merged_at":"2026-09-02T00:00:00Z",\
74 "merged_by":"cmc","checks_combined":"success","unresolved_threads":2}
75 """)
76 #expect(mr.bodyFormat == "org")
77 #expect(mr.draft)
78 #expect(mr.milestone == "v1")
79 #expect(mr.reviewRequests == ["rae"])
80 #expect(mr.stackedOn?.number == 6)
81 #expect(mr.stacked.map(\.number) == [8])
82 #expect(mr.mergedBy == "cmc")
83 #expect(mr.checksCombined == "success")
84 #expect(mr.unresolvedThreads == 2)
85 }
86}
87
88@MainActor
89struct BodyFormatWriteTests {
90
91 private let issueJSON = """
92 {"protocol_version":1,"data":{"number":7,"title":"t","state":"open","author":"cmc",\
93 "body":"x","body_format":"org","created_at":"2026-09-01T00:00:00Z"},"exit_code":0}
94 """
95
96 @Test func anIssueCommentCarriesItsFormat() async throws {
97 let (client, stub) = try makeClient()
98 stub.enqueue(.init(status: 200, json: issueJSON))
99 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
100 await model.load()
101
102 stub.enqueue(.init(status: 200, json: """
103 {"protocol_version":1,"exit_code":0}
104 """))
105 stub.enqueue(.init(status: 200, json: issueJSON))
106 await model.comment("hello", format: "org")
107
108 let write = try #require(stub.seen.first { $0.method == "POST" })
109 #expect(try argvOf(write)
110 == ["issue", "comment", "krz/gitbay", "7", "--file", "-", "--format", "org"])
111 #expect(try stdinOf(write) == "hello")
112 }
113
114 /// No format given means no flag — the server then defaults. Sending
115 /// `--format ""` would be a usage error.
116 @Test func noFormatMeansNoFlag() async throws {
117 let (client, stub) = try makeClient()
118 stub.enqueue(.init(status: 200, json: issueJSON))
119 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
120 await model.load()
121
122 stub.enqueue(.init(status: 200, json: """
123 {"protocol_version":1,"exit_code":0}
124 """))
125 stub.enqueue(.init(status: 200, json: issueJSON))
126 await model.comment("hello", format: nil)
127
128 let write = try #require(stub.seen.first { $0.method == "POST" })
129 #expect(try argvOf(write).contains("--format") == false)
130 }
131
132 @Test func anIssueEditCarriesItsFormat() async throws {
133 let (client, stub) = try makeClient()
134 stub.enqueue(.init(status: 200, json: issueJSON))
135 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
136 await model.load()
137
138 stub.enqueue(.init(status: 200, json: """
139 {"protocol_version":1,"exit_code":0}
140 """))
141 stub.enqueue(.init(status: 200, json: issueJSON))
142 await model.edit(title: "t2", body: "b2", format: "md")
143
144 let argv = try argvOf(try #require(stub.seen.first { $0.method == "POST" }))
145 #expect(argv.contains("--format"))
146 #expect(argv.contains("md"))
147 }
148}
149
150@MainActor
151struct BodyFormatMRWriteTests {
152
153 private let plainDetail = """
154 {"number":7,"title":"a change","state":"open","author":"cmc",\
155 "source":"feat","target_ref":"main","head_sha":"abc123",\
156 "created_at":"2026-09-01T00:00:00Z"}
157 """
158
159 private func loaded() throws -> (MRDetailViewModel, GitbayClient, StubProtocol.Box) {
160 let (client, box) = try makeClient()
161 box.enqueue(.init(status: 200, json: """
162 {"protocol_version":1,"data":\(plainDetail),"exit_code":0}
163 """))
164 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
165 return (model, client, box)
166 }
167
168 /// `mr show` load also fires `mr diff` and `mr threads`; leave both
169 /// unstubbed so they fail harmlessly, same as `MRLifecycleTests`.
170 @Test func aMergeRequestCommentCarriesItsFormat() async throws {
171 let (model, _, box) = try loaded()
172 await model.load()
173
174 box.enqueue(.init(status: 200, json: """
175 {"protocol_version":1,"exit_code":0}
176 """))
177 box.enqueue(.init(status: 200, json: """
178 {"protocol_version":1,"data":\(plainDetail),"exit_code":0}
179 """))
180 await model.comment("hello", format: "org")
181
182 let write = try #require(box.seen.first { $0.method == "POST" })
183 #expect(try argvOf(write)
184 == ["mr", "comment", "krz/gitbay", "7", "--file", "-", "--format", "org"])
185 #expect(try stdinOf(write) == "hello")
186 }
187
188 @Test func aMergeRequestCommentWithNoFormatOmitsTheFlag() async throws {
189 let (model, _, box) = try loaded()
190 await model.load()
191
192 box.enqueue(.init(status: 200, json: """
193 {"protocol_version":1,"exit_code":0}
194 """))
195 box.enqueue(.init(status: 200, json: """
196 {"protocol_version":1,"data":\(plainDetail),"exit_code":0}
197 """))
198 await model.comment("hello", format: nil)
199
200 let write = try #require(box.seen.first { $0.method == "POST" })
201 #expect(try argvOf(write).contains("--format") == false)
202 }
203
204 @Test func aMergeRequestEditCarriesItsFormat() async throws {
205 let (model, _, box) = try loaded()
206 await model.load()
207
208 box.enqueue(.init(status: 200, json: """
209 {"protocol_version":1,"exit_code":0}
210 """))
211 box.enqueue(.init(status: 200, json: """
212 {"protocol_version":1,"data":\(plainDetail),"exit_code":0}
213 """))
214 await model.edit(title: "t2", body: "b2", format: "md")
215
216 let write = try #require(box.seen.first { $0.method == "POST" })
217 #expect(try argvOf(write)
218 == ["mr", "edit", "krz/gitbay", "7", "--title", "t2", "--file", "-", "--format", "md"])
219 #expect(try stdinOf(write) == "b2")
220 }
221}
222
223@MainActor
224struct BodyFormatCreateTests {
225
226 @Test func creatingAnIssueWithAFormatAppendsTheFlag() async throws {
227 let (client, box) = try makeClient()
228 box.enqueue(.init(status: 200, json: """
229 {"protocol_version":1,"data":{"number":7},"exit_code":0}
230 """))
231 let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay")
232 model.format = "org"
233 _ = await model.create(title: "a title", body: "a body")
234
235 let write = try #require(box.seen.first { $0.method == "POST" })
236 #expect(try argvOf(write)
237 == ["issue", "create", "krz/gitbay", "--title", "a title",
238 "--file", "-", "--format", "org"])
239 }
240
241 @Test func creatingAnIssueWithoutAFormatOmitsTheFlag() async throws {
242 let (client, box) = try makeClient()
243 box.enqueue(.init(status: 200, json: """
244 {"protocol_version":1,"data":{"number":7},"exit_code":0}
245 """))
246 let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay")
247 _ = await model.create(title: "a title", body: "a body")
248
249 let write = try #require(box.seen.first { $0.method == "POST" })
250 #expect(try argvOf(write).contains("--format") == false)
251 }
252
253 @Test func creatingAMergeRequestWithAFormatAppendsTheFlag() async throws {
254 let (client, box) = try makeClient()
255 box.enqueue(.init(status: 200, json: """
256 {"protocol_version":1,"data":{"number":7},"exit_code":0}
257 """))
258 let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay")
259 model.format = "org"
260 _ = await model.create(source: "feat", target: "main", title: "a change", body: "a body")
261
262 let write = try #require(box.seen.first { $0.method == "POST" })
263 #expect(try argvOf(write)
264 == ["mr", "create", "krz/gitbay", "--source", "feat", "--target", "main",
265 "--title", "a change", "--file", "-", "--format", "org"])
266 }
267
268 @Test func creatingAMergeRequestWithoutAFormatOmitsTheFlag() async throws {
269 let (client, box) = try makeClient()
270 box.enqueue(.init(status: 200, json: """
271 {"protocol_version":1,"data":{"number":7},"exit_code":0}
272 """))
273 let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay")
274 _ = await model.create(source: "feat", target: "main", title: "a change", body: "a body")
275
276 let write = try #require(box.seen.first { $0.method == "POST" })
277 #expect(try argvOf(write).contains("--format") == false)
278 }
279}