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 {
88
99 private(set) var working = false
1010 private(set) var errorMessage: String?
11 var format: String?
1112
1213 private let client: GitbayClient
1314 let repoPath: String
@@ -33,6 +34,7 @@ final class IssueCreateViewModel {
3334 argv.append(contentsOf: ["--file", "-"])
3435 stdin = body
3536 }
37 if let format { argv.append(contentsOf: ["--format", format]) }
3638 let created = try await client.run(argv, stdin: stdin, as: Created.self)
3739 return created?.number
3840 } catch let error as GitbayError {
gitbay/Issues/IssueDetailViewModel.swift +10 −8
@@ -46,12 +46,12 @@ final class IssueDetailViewModel {
4646 }
4747
4848 /// Both fields are always sent: the title as given, the body over
49 /// stdin — an empty body clears it, matching the CLI.
50 func edit(title: String, body: String) async {
51 await perform(
52 ["issue", "edit"] + ref + ["--title", title, "--file", "-"],
53 stdin: body
54 )
49 /// stdin — an empty body clears it, matching the CLI. `format` is
50 /// appended only when given; omitting it lets the server default.
51 func edit(title: String, body: String, format: String? = nil) async {
52 var argv = ["issue", "edit"] + ref + ["--title", title, "--file", "-"]
53 if let format { argv.append(contentsOf: ["--format", format]) }
54 await perform(argv, stdin: body)
5555 }
5656
5757 /// nil clears the milestone; the command spells that "none".
@@ -66,9 +66,11 @@ final class IssueDetailViewModel {
6666 )) ?? []
6767 }
6868
69 func comment(_ text: String) async {
69 func comment(_ text: String, format: String? = nil) async {
7070 // 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)
7274 }
7375
7476 func close() async {
gitbay/Issues/IssueModels.swift +2
@@ -31,11 +31,13 @@ nonisolated struct IssueDetail: Decodable, Sendable, Hashable {
3131 let labels: [String]?
3232 let assignees: [String]?
3333 let body: String?
34 let bodyFormat: String?
3435 let createdAt: Date
3536 let comments: [Comment]?
3637
3738 enum CodingKeys: String, CodingKey {
3839 case number, title, state, author, milestone, labels, assignees, body, comments
40 case bodyFormat = "body_format"
3941 case createdAt = "created_at"
4042 }
4143
gitbay/MRs/MRCreateViewModel.swift +2
@@ -14,6 +14,7 @@ final class MRCreateViewModel {
1414 private(set) var errorMessage: String?
1515 private(set) var defaultBranch: String?
1616 var draft = false
17 var format: String?
1718
1819 private let client: GitbayClient
1920 let repoPath: String
@@ -46,6 +47,7 @@ final class MRCreateViewModel {
4647 argv.append(contentsOf: ["--file", "-"])
4748 stdin = body
4849 }
50 if let format { argv.append(contentsOf: ["--format", format]) }
4951 if draft { argv.append("--draft") }
5052 let created = try await client.run(argv, stdin: stdin, as: Created.self)
5153 return created?.number
gitbay/MRs/MRDetailViewModel.swift +10 −8
@@ -58,12 +58,12 @@ 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 )
61 /// Both fields are always sent; an empty body clears it. `format` is
62 /// appended only when given; omitting it lets the server default.
63 func edit(title: String, body: String, format: String? = nil) async {
64 var argv = ["mr", "edit"] + ref + ["--title", title, "--file", "-"]
65 if let format { argv.append(contentsOf: ["--format", format]) }
66 await perform(argv, stdin: body)
6767 }
6868
6969 /// Open milestones for the picker; fetched on first use.
@@ -81,10 +81,12 @@ final class MRDetailViewModel {
8181 await perform(["mr", "milestone"] + ref + [title ?? "none"])
8282 }
8383
84 func comment(_ text: String) async {
84 func comment(_ text: String, format: String? = nil) async {
8585 // Long text travels in stdin, but the server only reads it when
8686 // 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)
8890 }
8991
9092 func merge(strategy: String? = nil) async {
gitbay/MRs/MRModels.swift +3
@@ -62,6 +62,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
6262 let targetRef: String
6363 let headSHA: String
6464 let body: String?
65 let bodyFormat: String?
6566 let milestone: String?
6667 let createdAt: Date
6768 /// Set once the merge request is resolved. The actor is absent when
@@ -84,6 +85,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
8485
8586 enum CodingKeys: String, CodingKey {
8687 case number, title, state, author, source, body, milestone, checks, commits, comments, reviews, draft, stacked
88 case bodyFormat = "body_format"
8789 case targetRef = "target_ref"
8890 case headSHA = "head_sha"
8991 case createdAt = "created_at"
@@ -107,6 +109,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
107109 targetRef = try container.decode(String.self, forKey: .targetRef)
108110 headSHA = try container.decode(String.self, forKey: .headSHA)
109111 body = try container.decodeIfPresent(String.self, forKey: .body)
112 bodyFormat = try container.decodeIfPresent(String.self, forKey: .bodyFormat)
110113 milestone = try container.decodeIfPresent(String.self, forKey: .milestone)
111114 createdAt = try container.decode(Date.self, forKey: .createdAt)
112115 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}