Commit 7a9ec110e9

7a9ec110e9bb86107032cebae20e76528ffd04cf

parent: cc8a97bf86

Unsigned

cmc <hello@cleberg.net> · 2026-09-07 03:27 UTC

Choose the body markup, and render what was chosen (!58)

`--format md|org` on issue and merge request create, edit and comment.
The body still travels in stdin; the flag is sent only when a format was
chosen, so omitting it leaves the server's own default alone.

Editing an existing body starts on the format that body was stored in.
That is the point of the row: an org body opened under a markdown picker
and saved unchanged would silently become markdown, and its tables and
links would flatten. New bodies and new comments start on markdown, and a
comment does not inherit the parent's format — different author,
different choice.

Rendering follows. Bodies and comments now render through the format they
were stored with rather than through markdown unconditionally, so
choosing org produces org on the screen that offered the choice. Comments
carry their own stored format, which the issue and merge request comment
models had not been decoding. Diff-line comments are untouched: the
server has no format column for them and they are always markdown.

Releases are out of scope — `release create` and `release edit` take the
same flag, but there is no parity row for release markup, so the shared
compose sheet takes the format control as optional and that screen passes
none.

Closes the last two parity rows: choose body markup, on merge requests
and on issues.

Layout: unified · split

docs/superpowers/plans/2026-09-06-mr11-body-markup.md added +291
@@ -0,0 +1,291 @@
1# MR 11: Choose body markup Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Close the last two buildable parity rows — `choose body markup` for merge requests and for issues.
6
7**Architecture:** `--format md|org` on six commands, a picker in two composing surfaces, and one new decoded field. The whole difficulty is that the stored format is what every surface renders from, so an edit must default to the format the text was written in.
8
9**Spec:** `docs/superpowers/specs/2026-09-06-ios-parity-design.md`
10
11## Global Constraints
12
13- Swift 6 language mode, default `MainActor` isolation. Wire models are `nonisolated struct`s.
14- Swift Testing only — never XCTest.
15- `gitbayTests` is hermetic and offline; network goes through `StubProtocol`.
16- New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` edit.
17- The label model is `IssueLabel`; the notification model is `InboxNotification`.
18- Never mention Claude, LLMs or AI in commits, comments, or the merge request. No `Co-Authored-By` trailer.
19- Never commit to `main`.
20- **Waiting on an async request in a test: use the `until(_:_:)` helper** in `gitbayTests/StubProtocol.swift`, not a fixed sleep, and assert on the request identified by its argv rather than `stub.seen.last`. Do not index `stub.seen` directly — go through `#require`. The merge request immediately before this one fixed fifteen sites of exactly that.
21
22**The six commands in scope:**
23
24```
25issue create <repo> --title <t> [--body <b> | --file -] [--format md|org]
26issue edit <repo> <n> [--title <t>] [--body <b> | --file -] [--format md|org]
27issue comment <repo> <n> [--message <m> | --file -] [--format md|org]
28mr create <target repo> --source … --target … --title <t> [--body | --file -] [--format md|org] [--draft]
29mr edit <repo> <n> [--title <t>] [--body <b> | --file -] [--format md|org]
30mr comment <repo> <n> [--message <m> | --file -] [--format md|org]
31```
32
33**Two things are deliberately out of scope:**
34
35- **`release create` and `release edit` also take `--format`**, but the wiki has **no parity row for release markup**. `ReleaseView` shares `ComposeSheet` with the issue and merge request screens, so the format control must be **optional** and the release call site must keep passing nothing. Do not widen this merge request into releases.
36- **`mr diff-comment` has no format column and is always markdown.** It gets no picker.
37
38**Counting tests — `xcresulttool`, not grep:**
39
40```bash
41RES=$(ls -td ~/Library/Developer/Xcode/DerivedData/gitbay-*/Logs/Test/*.xcresult | head -1)
42xcrun xcresulttool get test-results summary --path "$RES"
43```
44
45Baseline: **347 total, 346 passed, 1 skipped, 0 failed.**
46
47---
48
49## File Structure
50
51| File | Responsibility |
52|------|----------------|
53| `gitbay/Issues/IssueModels.swift` (modify) | Decode `body_format` on `IssueDetail` |
54| `gitbay/MRs/MRModels.swift` (modify) | Decode `body_format` on `MRDetail` |
55| `gitbay/Issues/IssueDetailViewModel.swift` (modify) | `--format` on edit and comment |
56| `gitbay/MRs/MRDetailViewModel.swift` (modify) | `--format` on edit and comment |
57| `gitbay/Issues/IssueCreateViewModel.swift` (modify) | `--format` on create |
58| `gitbay/MRs/MRCreateViewModel.swift` (modify) | `--format` on create |
59| `gitbayTests/BodyFormatTests.swift` (create) | Every test in this plan |
60| `gitbay/Views/Shared/ComposeSheet.swift` (modify) | Optional format control |
61| `gitbay/Views/Issues/IssueListView.swift`, `IssueView.swift`, `gitbay/Views/MRs/MRListView.swift`, `MRView.swift` (modify) | Pass the format through; picker on the comment fields |
62
63---
64
65### Task 1: Decode the stored format, and send it
66
67**Files:** the two model files, the four view models; test in `gitbayTests/BodyFormatTests.swift`
68
69**Interfaces produced:**
70- `IssueDetail.bodyFormat: String?` and `MRDetail.bodyFormat: String?`
71- `IssueDetailViewModel.edit(title:body:format:)`, `.comment(_:format:)`
72- `MRDetailViewModel.edit(title:body:format:)`, `.comment(_:format:)`
73- `IssueCreateViewModel.format` and `MRCreateViewModel.format` (settable properties, like the `draft` property MR 4 added — **not** new parameters, which would change every call site)
74
75Behaviour, each pinned by a test:
761. `body_format` is **`omitempty`** — an ordinary body may carry no format key at all. Decoding must not require it. `MRDetail` has a hand-written `init(from:)` covering 24 properties, audited in MR 4; adding one means **re-auditing that nothing was dropped**.
772. `--format` is appended **only when a format is given**. Omitting it lets the server default; sending `--format ""` would be a usage error.
783. All six argv shapes are exact. `--format` goes with the body flags, and the body itself still travels in `stdin` via `--file -` as it does today.
794. **A format of `md` is still sent explicitly when the user chose it.** "The user picked markdown" and "the user expressed no preference" are the same on the wire, but the picker always has a value once shown, so an edit always sends one.
80
81- [ ] **Step 1: Write the failing tests**
82
83```swift
84import Foundation
85import Testing
86@testable import gitbay
87
88private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
89 let box = StubProtocol.box()
90 let client = GitbayClient(
91 instance: try GitbayInstance(url: "https://gitbay.org"),
92 token: "test-token",
93 session: box.session()
94 )
95 return (client, box)
96}
97
98private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
99 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
100 return try #require(body["argv"] as? [String])
101}
102
103private func stdinOf(_ seen: StubProtocol.Seen) throws -> String? {
104 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
105 return body["stdin"] as? String
106}
107
108struct BodyFormatDecodingTests {
109
110 private func decodeIssue(_ json: String) throws -> IssueDetail {
111 let decoder = JSONDecoder()
112 decoder.dateDecodingStrategy = .iso8601
113 return try decoder.decode(IssueDetail.self, from: Data(json.utf8))
114 }
115
116 private func decodeMR(_ json: String) throws -> MRDetail {
117 let decoder = JSONDecoder()
118 decoder.dateDecodingStrategy = .iso8601
119 return try decoder.decode(MRDetail.self, from: Data(json.utf8))
120 }
121
122 /// body_format is omitempty; an ordinary body carries no key.
123 @Test func anIssueWithoutAStoredFormatDecodes() throws {
124 let issue = try decodeIssue("""
125 {"number":7,"title":"t","state":"open","author":"cmc",\
126 "body":"x","created_at":"2026-09-01T00:00:00Z"}
127 """)
128 #expect(issue.bodyFormat == nil)
129 }
130
131 @Test func anIssueStoredAsOrgSaysSo() throws {
132 let issue = try decodeIssue("""
133 {"number":7,"title":"t","state":"open","author":"cmc",\
134 "body":"x","body_format":"org","created_at":"2026-09-01T00:00:00Z"}
135 """)
136 #expect(issue.bodyFormat == "org")
137 }
138
139 @Test func aMergeRequestWithoutAStoredFormatDecodes() throws {
140 let mr = try decodeMR("""
141 {"number":7,"title":"t","state":"open","author":"cmc","source":"f",\
142 "target_ref":"main","head_sha":"a","created_at":"2026-09-01T00:00:00Z"}
143 """)
144 #expect(mr.bodyFormat == nil)
145 }
146
147 /// MRDetail's hand-written initialiser must still decode everything
148 /// it did before the new field was added.
149 @Test func addingTheFormatDidNotDropAnyMergeRequestField() throws {
150 let mr = try decodeMR("""
151 {"number":7,"title":"t","state":"open","draft":true,"author":"cmc","source":"f",\
152 "target_ref":"main","head_sha":"abc","body":"b","body_format":"org",\
153 "milestone":"v1","review_requests":["rae"],\
154 "stacked_on":{"number":6,"title":"below"},\
155 "stacked":[{"number":8,"title":"above"}],\
156 "created_at":"2026-09-01T00:00:00Z","merged_at":"2026-09-02T00:00:00Z",\
157 "merged_by":"cmc","checks_combined":"success","unresolved_threads":2}
158 """)
159 #expect(mr.bodyFormat == "org")
160 #expect(mr.draft)
161 #expect(mr.milestone == "v1")
162 #expect(mr.reviewRequests == ["rae"])
163 #expect(mr.stackedOn?.number == 6)
164 #expect(mr.stacked.map(\.number) == [8])
165 #expect(mr.mergedBy == "cmc")
166 #expect(mr.checksCombined == "success")
167 #expect(mr.unresolvedThreads == 2)
168 }
169}
170
171@MainActor
172struct BodyFormatWriteTests {
173
174 private let issueJSON = """
175 {"protocol_version":1,"data":{"number":7,"title":"t","state":"open","author":"cmc",\
176 "body":"x","body_format":"org","created_at":"2026-09-01T00:00:00Z"},"exit_code":0}
177 """
178
179 @Test func anIssueCommentCarriesItsFormat() async throws {
180 let (client, stub) = try makeClient()
181 stub.enqueue(.init(status: 200, json: issueJSON))
182 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
183 await model.load()
184
185 stub.enqueue(.init(status: 200, json: """
186 {"protocol_version":1,"exit_code":0}
187 """))
188 stub.enqueue(.init(status: 200, json: issueJSON))
189 await model.comment("hello", format: "org")
190
191 let write = try #require(stub.seen.first { $0.method == "POST" })
192 #expect(try argvOf(write)
193 == ["issue", "comment", "krz/gitbay", "7", "--file", "-", "--format", "org"])
194 #expect(try stdinOf(write) == "hello")
195 }
196
197 /// No format given means no flag — the server then defaults. Sending
198 /// `--format ""` would be a usage error.
199 @Test func noFormatMeansNoFlag() async throws {
200 let (client, stub) = try makeClient()
201 stub.enqueue(.init(status: 200, json: issueJSON))
202 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
203 await model.load()
204
205 stub.enqueue(.init(status: 200, json: """
206 {"protocol_version":1,"exit_code":0}
207 """))
208 stub.enqueue(.init(status: 200, json: issueJSON))
209 await model.comment("hello", format: nil)
210
211 let write = try #require(stub.seen.first { $0.method == "POST" })
212 #expect(try argvOf(write).contains("--format") == false)
213 }
214
215 @Test func anIssueEditCarriesItsFormat() async throws {
216 let (client, stub) = try makeClient()
217 stub.enqueue(.init(status: 200, json: issueJSON))
218 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
219 await model.load()
220
221 stub.enqueue(.init(status: 200, json: """
222 {"protocol_version":1,"exit_code":0}
223 """))
224 stub.enqueue(.init(status: 200, json: issueJSON))
225 await model.edit(title: "t2", body: "b2", format: "md")
226
227 let argv = try argvOf(try #require(stub.seen.first { $0.method == "POST" }))
228 #expect(argv.contains("--format"))
229 #expect(argv.contains("md"))
230 }
231}
232```
233
234Add equivalent write tests for `MRDetailViewModel.comment`/`edit` and for both create view models — the create ones assert that setting `format` appends the flag and leaving it nil omits it, in the same shape MR 4 used for `--draft`.
235
236**Provision a stub for every request each flow makes.** `comment` and `edit` write *and* reload. Five tests in this repo have shipped under-provisioned; do not add a sixth.
237
238- [ ] **Step 2: Run to verify failure.**
239- [ ] **Step 3: Implement.** Re-audit `MRDetail.init(from:)` field by field.
240- [ ] **Step 4: Run the tests.**
241- [ ] **Step 5: Commit** — `git commit -m "Send the body markup format on issues and merge requests"`
242
243---
244
245### Task 2: The picker
246
247**Files:** `ComposeSheet.swift` and the four view files
248
249No unit tests — UI.
250
251**`ComposeSheet` gains an optional format binding.** It is used by four screens; the release screen must keep working unchanged, because release markup has no parity row. An `Binding<String>?` defaulting to nil, with the control rendered only when non-nil, keeps that call site untouched.
252
253**The comment fields compose separately**, in `IssueView` and `MRView` — they do not go through `ComposeSheet`. Each needs its own small picker beside its send button.
254
255**The default is the rule that matters:**
256- **Editing an existing body defaults to the format it was stored with**, from `bodyFormat`. Changing it reinterprets prose that already exists, so the picker must start where the text actually is. An org body opened under a markdown picker, saved unchanged, silently becomes markdown — and its tables and links flatten.
257- **A new body defaults to markdown**, as today.
258- **A new comment defaults to markdown.** Do not inherit the parent body's format: the parent's author and the commenter are different people.
259
260Keep the control small — a segmented `md`/`org` picker, or a menu. This sits under a text editor that is the point of the screen; it should not compete with it.
261
262- [ ] **Step 1: Optional format binding on `ComposeSheet`, release call site untouched**
263- [ ] **Step 2: Create and edit sheets pass the format, edits seeded from `bodyFormat`**
264- [ ] **Step 3: Pickers on the two comment fields, defaulting to markdown**
265- [ ] **Step 4: Build and run the full suite.** No drop from 346 passed.
266- [ ] **Step 5: Commit** — `git commit -m "Pick the body markup when writing"`
267
268---
269
270### Task 3: Flip the last two rows and open the merge request
271
272- [ ] **Step 1:** Set `choose body markup` to `yes` for iOS in **both** the Merge requests and the Issues tables.
273
274The prose after the Issues table currently reads that the `no` is *"the absence of a picker on the web form and in the iOS composer, both of which write markdown"*. **Update it** — the web has since gained one and iOS now has one too, so that sentence is wrong in both halves. Say that both surfaces offer a picker, and keep the note that diff-line comments have no format column and are always markdown.
275
276Land it via a worktree off `origin/main` in `krz/gitbay`, merged `--strategy ff`. That repo requires signed commits, so squash and rebase merges are refused, and its working tree usually holds unrelated work — never switch its branch. **Read the merge request number back from `mr create`'s JSON.**
277
278- [ ] **Step 2:** Run the full suite via `xcresulttool`; record the real numbers.
279- [ ] **Step 3:** Open the merge request.
280
281---
282
283## Notes for whoever executes this
284
285**Releases are out of scope.** `release create`/`edit` take `--format` too, but there is no parity row for it and `ReleaseView` shares `ComposeSheet`. Make the binding optional and leave that call site alone.
286
287**`mr diff-comment` gets no picker.** It has no format column server-side and is always markdown.
288
289**An edit defaults to the stored format.** This is the whole point of the row. Defaulting to markdown would silently convert org prose the first time someone edits a title.
290
291**A new comment defaults to markdown**, not the parent's format — different author, different choice.
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 +4
@@ -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
@@ -44,10 +46,12 @@ nonisolated struct IssueDetail: Decodable, Sendable, Hashable {
44 nonisolated struct Comment: Decodable, Sendable, Hashable, Identifiable { 46 nonisolated struct Comment: Decodable, Sendable, Hashable, Identifiable {
45 let author: String 47 let author: String
46 let body: String 48 let body: String
49 let bodyFormat: String?
47 let createdAt: Date 50 let createdAt: Date
48 51
49 enum CodingKeys: String, CodingKey { 52 enum CodingKeys: String, CodingKey {
50 case author, body 53 case author, body
54 case bodyFormat = "body_format"
51 case createdAt = "created_at" 55 case createdAt = "created_at"
52 } 56 }
53 57
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 +5
@@ -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)
@@ -153,10 +156,12 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
153 nonisolated struct MRComment: Decodable, Sendable, Hashable, Identifiable { 156 nonisolated struct MRComment: Decodable, Sendable, Hashable, Identifiable {
154 let author: String 157 let author: String
155 let body: String 158 let body: String
159 let bodyFormat: String?
156 let createdAt: Date 160 let createdAt: Date
157 161
158 enum CodingKeys: String, CodingKey { 162 enum CodingKeys: String, CodingKey {
159 case author, body 163 case author, body
164 case bodyFormat = "body_format"
160 case createdAt = "created_at" 165 case createdAt = "created_at"
161 } 166 }
162 167
gitbay/Views/Issues/IssueListView.swift +5 −1
@@ -8,6 +8,7 @@ struct IssueListView: View {
8 @State private var filtering = false 8 @State private var filtering = false
9 @State private var draftTitle = "" 9 @State private var draftTitle = ""
10 @State private var draftBody = "" 10 @State private var draftBody = ""
11 @State private var draftFormat = "md"
11 12
12 init(client: GitbayClient, repo: String) { 13 init(client: GitbayClient, repo: String) {
13 _model = State(initialValue: IssueListViewModel(client: client, repoPath: repo)) 14 _model = State(initialValue: IssueListViewModel(client: client, repoPath: repo))
@@ -83,12 +84,15 @@ struct IssueListView: View {
83 working: createModel.working, 84 working: createModel.working,
84 errorMessage: createModel.errorMessage, 85 errorMessage: createModel.errorMessage,
85 title: $draftTitle, 86 title: $draftTitle,
86 bodyText: $draftBody 87 bodyText: $draftBody,
88 format: $draftFormat
87 ) { 89 ) {
90 createModel.format = draftFormat
88 Task { 91 Task {
89 if await createModel.create(title: draftTitle, body: draftBody) != nil { 92 if await createModel.create(title: draftTitle, body: draftBody) != nil {
90 draftTitle = "" 93 draftTitle = ""
91 draftBody = "" 94 draftBody = ""
95 draftFormat = "md"
92 composing = false 96 composing = false
93 await model.load() 97 await model.load()
94 } 98 }
gitbay/Views/Issues/IssueView.swift +17 −5
@@ -4,11 +4,13 @@ struct IssueView: View {
4 4
5 @State private var model: IssueDetailViewModel 5 @State private var model: IssueDetailViewModel
6 @State private var commentText = "" 6 @State private var commentText = ""
7 @State private var commentFormat = "md"
7 @State private var editingLabel = "" 8 @State private var editingLabel = ""
8 @State private var editingAssignee = "" 9 @State private var editingAssignee = ""
9 @State private var editing = false 10 @State private var editing = false
10 @State private var draftTitle = "" 11 @State private var draftTitle = ""
11 @State private var draftBody = "" 12 @State private var draftBody = ""
13 @State private var draftFormat = "md"
12 14
13 init(client: GitbayClient, repo: String, number: Int64) { 15 init(client: GitbayClient, repo: String, number: Int64) {
14 _model = State(initialValue: IssueDetailViewModel( 16 _model = State(initialValue: IssueDetailViewModel(
@@ -29,7 +31,7 @@ struct IssueView: View {
29 31
30 if let body = issue.body, !body.isEmpty { 32 if let body = issue.body, !body.isEmpty {
31 Section { 33 Section {
32 MarkdownView(markdown: body) 34 FormattedBodyView(text: body, format: issue.bodyFormat)
33 .padding(.vertical, 4) 35 .padding(.vertical, 4)
34 } 36 }
35 } 37 }
@@ -49,10 +51,11 @@ struct IssueView: View {
49 working: model.working, 51 working: model.working,
50 errorMessage: model.actionError, 52 errorMessage: model.actionError,
51 title: $draftTitle, 53 title: $draftTitle,
52 bodyText: $draftBody 54 bodyText: $draftBody,
55 format: $draftFormat
53 ) { 56 ) {
54 Task { 57 Task {
55 await model.edit(title: draftTitle, body: draftBody) 58 await model.edit(title: draftTitle, body: draftBody, format: draftFormat)
56 if model.actionError == nil { editing = false } 59 if model.actionError == nil { editing = false }
57 } 60 }
58 } 61 }
@@ -191,7 +194,7 @@ struct IssueView: View {
191 .font(.gbSans(.caption)) 194 .font(.gbSans(.caption))
192 .foregroundStyle(.tertiary) 195 .foregroundStyle(.tertiary)
193 } 196 }
194 MarkdownView(markdown: comment.body) 197 FormattedBodyView(text: comment.body, format: comment.bodyFormat)
195 .font(.gbSans(.subheadline)) 198 .font(.gbSans(.subheadline))
196 } 199 }
197 .padding(.vertical, 2) 200 .padding(.vertical, 2)
@@ -200,10 +203,18 @@ struct IssueView: View {
200 HStack { 203 HStack {
201 TextField("Comment", text: $commentText, axis: .vertical) 204 TextField("Comment", text: $commentText, axis: .vertical)
202 .lineLimit(1...5) 205 .lineLimit(1...5)
206 Picker("Format", selection: $commentFormat) {
207 Text("Markdown").tag("md")
208 Text("Org").tag("org")
209 }
210 .pickerStyle(.segmented)
211 .labelsHidden()
212 .fixedSize()
213 .accessibilityIdentifier("issue-comment-format")
203 Button { 214 Button {
204 let text = commentText 215 let text = commentText
205 commentText = "" 216 commentText = ""
206 Task { await model.comment(text) } 217 Task { await model.comment(text, format: commentFormat) }
207 } label: { 218 } label: {
208 Image(systemName: "arrow.up.circle.fill") 219 Image(systemName: "arrow.up.circle.fill")
209 } 220 }
@@ -221,6 +232,7 @@ struct IssueView: View {
221 Button { 232 Button {
222 draftTitle = issue.title 233 draftTitle = issue.title
223 draftBody = issue.body ?? "" 234 draftBody = issue.body ?? ""
235 draftFormat = issue.bodyFormat ?? "md"
224 editing = true 236 editing = true
225 } label: { 237 } label: {
226 Label("Edit", systemImage: "pencil") 238 Label("Edit", systemImage: "pencil")
gitbay/Views/MRs/MRListView.swift +8
@@ -90,6 +90,7 @@ private struct MRCreateSheet: View {
90 @State private var target = "" 90 @State private var target = ""
91 @State private var title = "" 91 @State private var title = ""
92 @State private var bodyText = "" 92 @State private var bodyText = ""
93 @State private var format = "md"
93 94
94 var body: some View { 95 var body: some View {
95 NavigationStack { 96 NavigationStack {
@@ -115,6 +116,12 @@ private struct MRCreateSheet: View {
115 .frame(minHeight: 120) 116 .frame(minHeight: 120)
116 .autocorrectionDisabled() 117 .autocorrectionDisabled()
117 .accessibilityIdentifier("mr-body") 118 .accessibilityIdentifier("mr-body")
119 Picker("Format", selection: $format) {
120 Text("Markdown").tag("md")
121 Text("Org").tag("org")
122 }
123 .pickerStyle(.segmented)
124 .accessibilityIdentifier("mr-format")
118 } 125 }
119 Section { 126 Section {
120 Toggle("Draft", isOn: Bindable(model).draft) 127 Toggle("Draft", isOn: Bindable(model).draft)
@@ -139,6 +146,7 @@ private struct MRCreateSheet: View {
139 ProgressView() 146 ProgressView()
140 } else { 147 } else {
141 Button("Create") { 148 Button("Create") {
149 model.format = format
142 Task { 150 Task {
143 if await model.create( 151 if await model.create(
144 source: source, target: target, 152 source: source, target: target,
gitbay/Views/MRs/MRView.swift +17 −5
@@ -4,11 +4,13 @@ struct MRView: View {
4 4
5 @State private var model: MRDetailViewModel 5 @State private var model: MRDetailViewModel
6 @State private var commentText = "" 6 @State private var commentText = ""
7 @State private var commentFormat = "md"
7 @State private var confirmingMerge = false 8 @State private var confirmingMerge = false
8 @State private var confirmingClose = false 9 @State private var confirmingClose = false
9 @State private var editing = false 10 @State private var editing = false
10 @State private var draftTitle = "" 11 @State private var draftTitle = ""
11 @State private var draftBody = "" 12 @State private var draftBody = ""
13 @State private var draftFormat = "md"
12 @State private var editingReviewer = "" 14 @State private var editingReviewer = ""
13 @State private var retargeting = false 15 @State private var retargeting = false
14 @State private var pendingRetarget: String? 16 @State private var pendingRetarget: String?
@@ -32,7 +34,7 @@ struct MRView: View {
32 34
33 if let body = mr.body, !body.isEmpty { 35 if let body = mr.body, !body.isEmpty {
34 Section { 36 Section {
35 MarkdownView(markdown: body) 37 FormattedBodyView(text: body, format: mr.bodyFormat)
36 .padding(.vertical, 4) 38 .padding(.vertical, 4)
37 } 39 }
38 } 40 }
@@ -115,10 +117,11 @@ struct MRView: View {
115 working: model.working, 117 working: model.working,
116 errorMessage: model.actionError, 118 errorMessage: model.actionError,
117 title: $draftTitle, 119 title: $draftTitle,
118 bodyText: $draftBody 120 bodyText: $draftBody,
121 format: $draftFormat
119 ) { 122 ) {
120 Task { 123 Task {
121 await model.edit(title: draftTitle, body: draftBody) 124 await model.edit(title: draftTitle, body: draftBody, format: draftFormat)
122 if model.actionError == nil { editing = false } 125 if model.actionError == nil { editing = false }
123 } 126 }
124 } 127 }
@@ -394,7 +397,7 @@ struct MRView: View {
394 .font(.gbSans(.caption)) 397 .font(.gbSans(.caption))
395 .foregroundStyle(.tertiary) 398 .foregroundStyle(.tertiary)
396 } 399 }
397 MarkdownView(markdown: comment.body) 400 FormattedBodyView(text: comment.body, format: comment.bodyFormat)
398 .font(.gbSans(.subheadline)) 401 .font(.gbSans(.subheadline))
399 } 402 }
400 .padding(.vertical, 2) 403 .padding(.vertical, 2)
@@ -403,10 +406,18 @@ struct MRView: View {
403 HStack { 406 HStack {
404 TextField("Comment", text: $commentText, axis: .vertical) 407 TextField("Comment", text: $commentText, axis: .vertical)
405 .lineLimit(1...5) 408 .lineLimit(1...5)
409 Picker("Format", selection: $commentFormat) {
410 Text("Markdown").tag("md")
411 Text("Org").tag("org")
412 }
413 .pickerStyle(.segmented)
414 .labelsHidden()
415 .fixedSize()
416 .accessibilityIdentifier("mr-comment-format")
406 Button { 417 Button {
407 let text = commentText 418 let text = commentText
408 commentText = "" 419 commentText = ""
409 Task { await model.comment(text) } 420 Task { await model.comment(text, format: commentFormat) }
410 } label: { 421 } label: {
411 Image(systemName: "arrow.up.circle.fill") 422 Image(systemName: "arrow.up.circle.fill")
412 } 423 }
@@ -424,6 +435,7 @@ struct MRView: View {
424 Button { 435 Button {
425 draftTitle = mr.title 436 draftTitle = mr.title
426 draftBody = mr.body ?? "" 437 draftBody = mr.body ?? ""
438 draftFormat = mr.bodyFormat ?? "md"
427 editing = true 439 editing = true
428 } label: { 440 } label: {
429 Label("Edit", systemImage: "pencil") 441 Label("Edit", systemImage: "pencil")
gitbay/Views/Repos/FormattedBodyView.swift added +28
@@ -0,0 +1,28 @@
1import OrgSwiftUI
2import SwiftUI
3
4/// Renders an issue/MR body or comment in whichever format it was stored as.
5/// Mirrors `ReadmeView`'s dispatch, keyed on the stored `body_format` rather
6/// than a file name. A missing format renders as markdown, matching every
7/// body written before org support existed.
8struct FormattedBodyView: View {
9 let text: String
10 var format: String?
11
12 @Environment(\.colorScheme) private var colorScheme
13
14 var isOrg: Bool { format == "org" }
15
16 var body: some View {
17 if isOrg {
18 // Qualified: `OrgView` here is org-mode, not the organization view.
19 OrgSwiftUI.OrgView(
20 text,
21 styler: OrgSyntaxStyler(colorScheme: colorScheme),
22 keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone)
23 )
24 } else {
25 MarkdownView(markdown: text)
26 }
27 }
28}
gitbay/Views/Shared/ComposeSheet.swift +11
@@ -9,6 +9,9 @@ struct ComposeSheet: View {
9 let errorMessage: String? 9 let errorMessage: String?
10 @Binding var title: String 10 @Binding var title: String
11 @Binding var bodyText: String 11 @Binding var bodyText: String
12 /// nil hides the picker entirely — the release editor has no parity
13 /// row and passes nothing, leaving that call site untouched.
14 var format: Binding<String>? = nil
12 let onSubmit: () -> Void 15 let onSubmit: () -> Void
13 16
14 @Environment(\.dismiss) private var dismiss 17 @Environment(\.dismiss) private var dismiss
@@ -30,6 +33,14 @@ struct ComposeSheet: View {
30 .font(.gbSans(.body)) 33 .font(.gbSans(.body))
31 .autocorrectionDisabled() 34 .autocorrectionDisabled()
32 .accessibilityIdentifier("compose-body") 35 .accessibilityIdentifier("compose-body")
36 if let format {
37 Picker("Format", selection: format) {
38 Text("Markdown").tag("md")
39 Text("Org").tag("org")
40 }
41 .pickerStyle(.segmented)
42 .accessibilityIdentifier("compose-format")
43 }
33 } 44 }
34 if let errorMessage { 45 if let errorMessage {
35 Section { 46 Section {
gitbayTests/BodyFormatTests.swift added +319
@@ -0,0 +1,319 @@
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 /// body_format is omitempty on a comment too; an ordinary one carries no key.
88 @Test func anIssueCommentWithoutAStoredFormatDecodesNil() throws {
89 let issue = try decodeIssue("""
90 {"number":7,"title":"t","state":"open","author":"cmc",\
91 "created_at":"2026-09-01T00:00:00Z","comments":[{"author":"rae",\
92 "body":"hi","created_at":"2026-09-01T00:00:00Z"}]}
93 """)
94 #expect(issue.comments?.first?.bodyFormat == nil)
95 }
96
97 @Test func anIssueCommentStoredAsOrgSaysSo() throws {
98 let issue = try decodeIssue("""
99 {"number":7,"title":"t","state":"open","author":"cmc",\
100 "created_at":"2026-09-01T00:00:00Z","comments":[{"author":"rae",\
101 "body":"hi","body_format":"org","created_at":"2026-09-01T00:00:00Z"}]}
102 """)
103 #expect(issue.comments?.first?.bodyFormat == "org")
104 }
105
106 @Test func aMergeRequestCommentWithoutAStoredFormatDecodesNil() throws {
107 let mr = try decodeMR("""
108 {"number":7,"title":"t","state":"open","author":"cmc","source":"f",\
109 "target_ref":"main","head_sha":"a","created_at":"2026-09-01T00:00:00Z",\
110 "comments":[{"author":"rae","body":"hi",\
111 "created_at":"2026-09-01T00:00:00Z"}]}
112 """)
113 #expect(mr.comments?.first?.bodyFormat == nil)
114 }
115
116 @Test func aMergeRequestCommentStoredAsOrgSaysSo() throws {
117 let mr = try decodeMR("""
118 {"number":7,"title":"t","state":"open","author":"cmc","source":"f",\
119 "target_ref":"main","head_sha":"a","created_at":"2026-09-01T00:00:00Z",\
120 "comments":[{"author":"rae","body":"hi","body_format":"org",\
121 "created_at":"2026-09-01T00:00:00Z"}]}
122 """)
123 #expect(mr.comments?.first?.bodyFormat == "org")
124 }
125}
126
127@MainActor
128struct BodyFormatWriteTests {
129
130 private let issueJSON = """
131 {"protocol_version":1,"data":{"number":7,"title":"t","state":"open","author":"cmc",\
132 "body":"x","body_format":"org","created_at":"2026-09-01T00:00:00Z"},"exit_code":0}
133 """
134
135 @Test func anIssueCommentCarriesItsFormat() async throws {
136 let (client, stub) = try makeClient()
137 stub.enqueue(.init(status: 200, json: issueJSON))
138 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
139 await model.load()
140
141 stub.enqueue(.init(status: 200, json: """
142 {"protocol_version":1,"exit_code":0}
143 """))
144 stub.enqueue(.init(status: 200, json: issueJSON))
145 await model.comment("hello", format: "org")
146
147 let write = try #require(stub.seen.first { $0.method == "POST" })
148 #expect(try argvOf(write)
149 == ["issue", "comment", "krz/gitbay", "7", "--file", "-", "--format", "org"])
150 #expect(try stdinOf(write) == "hello")
151 }
152
153 /// No format given means no flag — the server then defaults. Sending
154 /// `--format ""` would be a usage error.
155 @Test func noFormatMeansNoFlag() async throws {
156 let (client, stub) = try makeClient()
157 stub.enqueue(.init(status: 200, json: issueJSON))
158 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
159 await model.load()
160
161 stub.enqueue(.init(status: 200, json: """
162 {"protocol_version":1,"exit_code":0}
163 """))
164 stub.enqueue(.init(status: 200, json: issueJSON))
165 await model.comment("hello", format: nil)
166
167 let write = try #require(stub.seen.first { $0.method == "POST" })
168 #expect(try argvOf(write).contains("--format") == false)
169 }
170
171 @Test func anIssueEditCarriesItsFormat() async throws {
172 let (client, stub) = try makeClient()
173 stub.enqueue(.init(status: 200, json: issueJSON))
174 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
175 await model.load()
176
177 stub.enqueue(.init(status: 200, json: """
178 {"protocol_version":1,"exit_code":0}
179 """))
180 stub.enqueue(.init(status: 200, json: issueJSON))
181 await model.edit(title: "t2", body: "b2", format: "md")
182
183 let write = try #require(stub.seen.first { $0.method == "POST" })
184 #expect(try argvOf(write)
185 == ["issue", "edit", "krz/gitbay", "7", "--title", "t2", "--file", "-", "--format", "md"])
186 #expect(try stdinOf(write) == "b2")
187 }
188}
189
190@MainActor
191struct BodyFormatMRWriteTests {
192
193 private let plainDetail = """
194 {"number":7,"title":"a change","state":"open","author":"cmc",\
195 "source":"feat","target_ref":"main","head_sha":"abc123",\
196 "created_at":"2026-09-01T00:00:00Z"}
197 """
198
199 private func loaded() throws -> (MRDetailViewModel, GitbayClient, StubProtocol.Box) {
200 let (client, box) = try makeClient()
201 box.enqueue(.init(status: 200, json: """
202 {"protocol_version":1,"data":\(plainDetail),"exit_code":0}
203 """))
204 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
205 return (model, client, box)
206 }
207
208 /// `mr show` load also fires `mr diff` and `mr threads`; leave both
209 /// unstubbed so they fail harmlessly, same as `MRLifecycleTests`.
210 @Test func aMergeRequestCommentCarriesItsFormat() async throws {
211 let (model, _, box) = try loaded()
212 await model.load()
213
214 box.enqueue(.init(status: 200, json: """
215 {"protocol_version":1,"exit_code":0}
216 """))
217 box.enqueue(.init(status: 200, json: """
218 {"protocol_version":1,"data":\(plainDetail),"exit_code":0}
219 """))
220 await model.comment("hello", format: "org")
221
222 let write = try #require(box.seen.first { $0.method == "POST" })
223 #expect(try argvOf(write)
224 == ["mr", "comment", "krz/gitbay", "7", "--file", "-", "--format", "org"])
225 #expect(try stdinOf(write) == "hello")
226 }
227
228 @Test func aMergeRequestCommentWithNoFormatOmitsTheFlag() async throws {
229 let (model, _, box) = try loaded()
230 await model.load()
231
232 box.enqueue(.init(status: 200, json: """
233 {"protocol_version":1,"exit_code":0}
234 """))
235 box.enqueue(.init(status: 200, json: """
236 {"protocol_version":1,"data":\(plainDetail),"exit_code":0}
237 """))
238 await model.comment("hello", format: nil)
239
240 let write = try #require(box.seen.first { $0.method == "POST" })
241 #expect(try argvOf(write).contains("--format") == false)
242 }
243
244 @Test func aMergeRequestEditCarriesItsFormat() async throws {
245 let (model, _, box) = try loaded()
246 await model.load()
247
248 box.enqueue(.init(status: 200, json: """
249 {"protocol_version":1,"exit_code":0}
250 """))
251 box.enqueue(.init(status: 200, json: """
252 {"protocol_version":1,"data":\(plainDetail),"exit_code":0}
253 """))
254 await model.edit(title: "t2", body: "b2", format: "md")
255
256 let write = try #require(box.seen.first { $0.method == "POST" })
257 #expect(try argvOf(write)
258 == ["mr", "edit", "krz/gitbay", "7", "--title", "t2", "--file", "-", "--format", "md"])
259 #expect(try stdinOf(write) == "b2")
260 }
261}
262
263@MainActor
264struct BodyFormatCreateTests {
265
266 @Test func creatingAnIssueWithAFormatAppendsTheFlag() async throws {
267 let (client, box) = try makeClient()
268 box.enqueue(.init(status: 200, json: """
269 {"protocol_version":1,"data":{"number":7},"exit_code":0}
270 """))
271 let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay")
272 model.format = "org"
273 _ = await model.create(title: "a title", body: "a body")
274
275 let write = try #require(box.seen.first { $0.method == "POST" })
276 #expect(try argvOf(write)
277 == ["issue", "create", "krz/gitbay", "--title", "a title",
278 "--file", "-", "--format", "org"])
279 }
280
281 @Test func creatingAnIssueWithoutAFormatOmitsTheFlag() async throws {
282 let (client, box) = try makeClient()
283 box.enqueue(.init(status: 200, json: """
284 {"protocol_version":1,"data":{"number":7},"exit_code":0}
285 """))
286 let model = IssueCreateViewModel(client: client, repoPath: "krz/gitbay")
287 _ = await model.create(title: "a title", body: "a body")
288
289 let write = try #require(box.seen.first { $0.method == "POST" })
290 #expect(try argvOf(write).contains("--format") == false)
291 }
292
293 @Test func creatingAMergeRequestWithAFormatAppendsTheFlag() async throws {
294 let (client, box) = try makeClient()
295 box.enqueue(.init(status: 200, json: """
296 {"protocol_version":1,"data":{"number":7},"exit_code":0}
297 """))
298 let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay")
299 model.format = "org"
300 _ = await model.create(source: "feat", target: "main", title: "a change", body: "a body")
301
302 let write = try #require(box.seen.first { $0.method == "POST" })
303 #expect(try argvOf(write)
304 == ["mr", "create", "krz/gitbay", "--source", "feat", "--target", "main",
305 "--title", "a change", "--file", "-", "--format", "org"])
306 }
307
308 @Test func creatingAMergeRequestWithoutAFormatOmitsTheFlag() async throws {
309 let (client, box) = try makeClient()
310 box.enqueue(.init(status: 200, json: """
311 {"protocol_version":1,"data":{"number":7},"exit_code":0}
312 """))
313 let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay")
314 _ = await model.create(source: "feat", target: "main", title: "a change", body: "a body")
315
316 let write = try #require(box.seen.first { $0.method == "POST" })
317 #expect(try argvOf(write).contains("--format") == false)
318 }
319}