Commit 7a9ec110e9
Unsigned
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 | ``` | |
| 25 | issue create <repo> --title <t> [--body <b> | --file -] [--format md|org] | |
| 26 | issue edit <repo> <n> [--title <t>] [--body <b> | --file -] [--format md|org] | |
| 27 | issue comment <repo> <n> [--message <m> | --file -] [--format md|org] | |
| 28 | mr create <target repo> --source … --target … --title <t> [--body | --file -] [--format md|org] [--draft] | |
| 29 | mr edit <repo> <n> [--title <t>] [--body <b> | --file -] [--format md|org] | |
| 30 | mr 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 | |
| 41 | RES=$(ls -td ~/Library/Developer/Xcode/DerivedData/gitbay-*/Logs/Test/*.xcresult | head -1) | |
| 42 | xcrun xcresulttool get test-results summary --path "$RES" | |
| 43 | ``` | |
| 44 | ||
| 45 | Baseline: **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 | ||
| 75 | Behaviour, each pinned by a test: | |
| 76 | 1. `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**. | |
| 77 | 2. `--format` is appended **only when a format is given**. Omitting it lets the server default; sending `--format ""` would be a usage error. | |
| 78 | 3. 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. | |
| 79 | 4. **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 | |
| 84 | import Foundation | |
| 85 | import Testing | |
| 86 | @testable import gitbay | |
| 87 | ||
| 88 | private 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 | ||
| 98 | private 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 | ||
| 103 | private 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 | ||
| 108 | struct 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 | |
| 172 | struct 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 | ||
| 234 | Add 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 | ||
| 249 | No 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 | ||
| 260 | Keep 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 | ||
| 274 | The 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 | ||
| 276 | Land 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 | 9 | private(set) var working = false |
| 10 | 10 | private(set) var errorMessage: String? |
| 11 | var format: String? | |
| 11 | 12 | |
| 12 | 13 | private let client: GitbayClient |
| 13 | 14 | let repoPath: String |
| @@ -33,6 +34,7 @@ final class IssueCreateViewModel { | ||
| 33 | 34 | argv.append(contentsOf: ["--file", "-"]) |
| 34 | 35 | stdin = body |
| 35 | 36 | } |
| 37 | if let format { argv.append(contentsOf: ["--format", format]) } | |
| 36 | 38 | let created = try await client.run(argv, stdin: stdin, as: Created.self) |
| 37 | 39 | return created?.number |
| 38 | 40 | } catch let error as GitbayError { |
gitbay/Issues/IssueDetailViewModel.swift +10 −8
| @@ -46,12 +46,12 @@ final class IssueDetailViewModel { | ||
| 46 | 46 | } |
| 47 | 47 | |
| 48 | 48 | /// 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) | |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | 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 | 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 | 76 | func close() async { |
gitbay/Issues/IssueModels.swift +4
| @@ -31,11 +31,13 @@ nonisolated struct IssueDetail: Decodable, Sendable, Hashable { | ||
| 31 | 31 | let labels: [String]? |
| 32 | 32 | let assignees: [String]? |
| 33 | 33 | let body: String? |
| 34 | let bodyFormat: String? | |
| 34 | 35 | let createdAt: Date |
| 35 | 36 | let comments: [Comment]? |
| 36 | 37 | |
| 37 | 38 | enum CodingKeys: String, CodingKey { |
| 38 | 39 | case number, title, state, author, milestone, labels, assignees, body, comments |
| 40 | case bodyFormat = "body_format" | |
| 39 | 41 | case createdAt = "created_at" |
| 40 | 42 | } |
| 41 | 43 | |
| @@ -44,10 +46,12 @@ nonisolated struct IssueDetail: Decodable, Sendable, Hashable { | ||
| 44 | 46 | nonisolated struct Comment: Decodable, Sendable, Hashable, Identifiable { |
| 45 | 47 | let author: String |
| 46 | 48 | let body: String |
| 49 | let bodyFormat: String? | |
| 47 | 50 | let createdAt: Date |
| 48 | 51 | |
| 49 | 52 | enum CodingKeys: String, CodingKey { |
| 50 | 53 | case author, body |
| 54 | case bodyFormat = "body_format" | |
| 51 | 55 | case createdAt = "created_at" |
| 52 | 56 | } |
| 53 | 57 | |
gitbay/MRs/MRCreateViewModel.swift +2
| @@ -14,6 +14,7 @@ final class MRCreateViewModel { | ||
| 14 | 14 | private(set) var errorMessage: String? |
| 15 | 15 | private(set) var defaultBranch: String? |
| 16 | 16 | var draft = false |
| 17 | var format: String? | |
| 17 | 18 | |
| 18 | 19 | private let client: GitbayClient |
| 19 | 20 | let repoPath: String |
| @@ -46,6 +47,7 @@ final class MRCreateViewModel { | ||
| 46 | 47 | argv.append(contentsOf: ["--file", "-"]) |
| 47 | 48 | stdin = body |
| 48 | 49 | } |
| 50 | if let format { argv.append(contentsOf: ["--format", format]) } | |
| 49 | 51 | if draft { argv.append("--draft") } |
| 50 | 52 | let created = try await client.run(argv, stdin: stdin, as: Created.self) |
| 51 | 53 | return created?.number |
gitbay/MRs/MRDetailViewModel.swift +10 −8
| @@ -58,12 +58,12 @@ final class MRDetailViewModel { | ||
| 58 | 58 | await perform(["mr", "review"] + ref + [verdict.rawValue]) |
| 59 | 59 | } |
| 60 | 60 | |
| 61 | /// Both fields are always sent; an empty body clears it. | |
| 62 | func edit(title: String, body: String) async { | |
| 63 | await perform( | |
| 64 | ["mr", "edit"] + ref + ["--title", title, "--file", "-"], | |
| 65 | stdin: body | |
| 66 | ) | |
| 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) | |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | 69 | /// Open milestones for the picker; fetched on first use. |
| @@ -81,10 +81,12 @@ final class MRDetailViewModel { | ||
| 81 | 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 | 85 | // Long text travels in stdin, but the server only reads it when |
| 86 | 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 | 92 | func merge(strategy: String? = nil) async { |
gitbay/MRs/MRModels.swift +5
| @@ -62,6 +62,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable { | ||
| 62 | 62 | let targetRef: String |
| 63 | 63 | let headSHA: String |
| 64 | 64 | let body: String? |
| 65 | let bodyFormat: String? | |
| 65 | 66 | let milestone: String? |
| 66 | 67 | let createdAt: Date |
| 67 | 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 | 86 | enum CodingKeys: String, CodingKey { |
| 86 | 87 | case number, title, state, author, source, body, milestone, checks, commits, comments, reviews, draft, stacked |
| 88 | case bodyFormat = "body_format" | |
| 87 | 89 | case targetRef = "target_ref" |
| 88 | 90 | case headSHA = "head_sha" |
| 89 | 91 | case createdAt = "created_at" |
| @@ -107,6 +109,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable { | ||
| 107 | 109 | targetRef = try container.decode(String.self, forKey: .targetRef) |
| 108 | 110 | headSHA = try container.decode(String.self, forKey: .headSHA) |
| 109 | 111 | body = try container.decodeIfPresent(String.self, forKey: .body) |
| 112 | bodyFormat = try container.decodeIfPresent(String.self, forKey: .bodyFormat) | |
| 110 | 113 | milestone = try container.decodeIfPresent(String.self, forKey: .milestone) |
| 111 | 114 | createdAt = try container.decode(Date.self, forKey: .createdAt) |
| 112 | 115 | mergedAt = try container.decodeIfPresent(Date.self, forKey: .mergedAt) |
| @@ -153,10 +156,12 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable { | ||
| 153 | 156 | nonisolated struct MRComment: Decodable, Sendable, Hashable, Identifiable { |
| 154 | 157 | let author: String |
| 155 | 158 | let body: String |
| 159 | let bodyFormat: String? | |
| 156 | 160 | let createdAt: Date |
| 157 | 161 | |
| 158 | 162 | enum CodingKeys: String, CodingKey { |
| 159 | 163 | case author, body |
| 164 | case bodyFormat = "body_format" | |
| 160 | 165 | case createdAt = "created_at" |
| 161 | 166 | } |
| 162 | 167 | |
gitbay/Views/Issues/IssueListView.swift +5 −1
| @@ -8,6 +8,7 @@ struct IssueListView: View { | ||
| 8 | 8 | @State private var filtering = false |
| 9 | 9 | @State private var draftTitle = "" |
| 10 | 10 | @State private var draftBody = "" |
| 11 | @State private var draftFormat = "md" | |
| 11 | 12 | |
| 12 | 13 | init(client: GitbayClient, repo: String) { |
| 13 | 14 | _model = State(initialValue: IssueListViewModel(client: client, repoPath: repo)) |
| @@ -83,12 +84,15 @@ struct IssueListView: View { | ||
| 83 | 84 | working: createModel.working, |
| 84 | 85 | errorMessage: createModel.errorMessage, |
| 85 | 86 | title: $draftTitle, |
| 86 | bodyText: $draftBody | |
| 87 | bodyText: $draftBody, | |
| 88 | format: $draftFormat | |
| 87 | 89 | ) { |
| 90 | createModel.format = draftFormat | |
| 88 | 91 | Task { |
| 89 | 92 | if await createModel.create(title: draftTitle, body: draftBody) != nil { |
| 90 | 93 | draftTitle = "" |
| 91 | 94 | draftBody = "" |
| 95 | draftFormat = "md" | |
| 92 | 96 | composing = false |
| 93 | 97 | await model.load() |
| 94 | 98 | } |
gitbay/Views/Issues/IssueView.swift +17 −5
| @@ -4,11 +4,13 @@ struct IssueView: View { | ||
| 4 | 4 | |
| 5 | 5 | @State private var model: IssueDetailViewModel |
| 6 | 6 | @State private var commentText = "" |
| 7 | @State private var commentFormat = "md" | |
| 7 | 8 | @State private var editingLabel = "" |
| 8 | 9 | @State private var editingAssignee = "" |
| 9 | 10 | @State private var editing = false |
| 10 | 11 | @State private var draftTitle = "" |
| 11 | 12 | @State private var draftBody = "" |
| 13 | @State private var draftFormat = "md" | |
| 12 | 14 | |
| 13 | 15 | init(client: GitbayClient, repo: String, number: Int64) { |
| 14 | 16 | _model = State(initialValue: IssueDetailViewModel( |
| @@ -29,7 +31,7 @@ struct IssueView: View { | ||
| 29 | 31 | |
| 30 | 32 | if let body = issue.body, !body.isEmpty { |
| 31 | 33 | Section { |
| 32 | MarkdownView(markdown: body) | |
| 34 | FormattedBodyView(text: body, format: issue.bodyFormat) | |
| 33 | 35 | .padding(.vertical, 4) |
| 34 | 36 | } |
| 35 | 37 | } |
| @@ -49,10 +51,11 @@ struct IssueView: View { | ||
| 49 | 51 | working: model.working, |
| 50 | 52 | errorMessage: model.actionError, |
| 51 | 53 | title: $draftTitle, |
| 52 | bodyText: $draftBody | |
| 54 | bodyText: $draftBody, | |
| 55 | format: $draftFormat | |
| 53 | 56 | ) { |
| 54 | 57 | Task { |
| 55 | await model.edit(title: draftTitle, body: draftBody) | |
| 58 | await model.edit(title: draftTitle, body: draftBody, format: draftFormat) | |
| 56 | 59 | if model.actionError == nil { editing = false } |
| 57 | 60 | } |
| 58 | 61 | } |
| @@ -191,7 +194,7 @@ struct IssueView: View { | ||
| 191 | 194 | .font(.gbSans(.caption)) |
| 192 | 195 | .foregroundStyle(.tertiary) |
| 193 | 196 | } |
| 194 | MarkdownView(markdown: comment.body) | |
| 197 | FormattedBodyView(text: comment.body, format: comment.bodyFormat) | |
| 195 | 198 | .font(.gbSans(.subheadline)) |
| 196 | 199 | } |
| 197 | 200 | .padding(.vertical, 2) |
| @@ -200,10 +203,18 @@ struct IssueView: View { | ||
| 200 | 203 | HStack { |
| 201 | 204 | TextField("Comment", text: $commentText, axis: .vertical) |
| 202 | 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 | 214 | Button { |
| 204 | 215 | let text = commentText |
| 205 | 216 | commentText = "" |
| 206 | Task { await model.comment(text) } | |
| 217 | Task { await model.comment(text, format: commentFormat) } | |
| 207 | 218 | } label: { |
| 208 | 219 | Image(systemName: "arrow.up.circle.fill") |
| 209 | 220 | } |
| @@ -221,6 +232,7 @@ struct IssueView: View { | ||
| 221 | 232 | Button { |
| 222 | 233 | draftTitle = issue.title |
| 223 | 234 | draftBody = issue.body ?? "" |
| 235 | draftFormat = issue.bodyFormat ?? "md" | |
| 224 | 236 | editing = true |
| 225 | 237 | } label: { |
| 226 | 238 | Label("Edit", systemImage: "pencil") |
gitbay/Views/MRs/MRListView.swift +8
| @@ -90,6 +90,7 @@ private struct MRCreateSheet: View { | ||
| 90 | 90 | @State private var target = "" |
| 91 | 91 | @State private var title = "" |
| 92 | 92 | @State private var bodyText = "" |
| 93 | @State private var format = "md" | |
| 93 | 94 | |
| 94 | 95 | var body: some View { |
| 95 | 96 | NavigationStack { |
| @@ -115,6 +116,12 @@ private struct MRCreateSheet: View { | ||
| 115 | 116 | .frame(minHeight: 120) |
| 116 | 117 | .autocorrectionDisabled() |
| 117 | 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 | 126 | Section { |
| 120 | 127 | Toggle("Draft", isOn: Bindable(model).draft) |
| @@ -139,6 +146,7 @@ private struct MRCreateSheet: View { | ||
| 139 | 146 | ProgressView() |
| 140 | 147 | } else { |
| 141 | 148 | Button("Create") { |
| 149 | model.format = format | |
| 142 | 150 | Task { |
| 143 | 151 | if await model.create( |
| 144 | 152 | source: source, target: target, |
gitbay/Views/MRs/MRView.swift +17 −5
| @@ -4,11 +4,13 @@ struct MRView: View { | ||
| 4 | 4 | |
| 5 | 5 | @State private var model: MRDetailViewModel |
| 6 | 6 | @State private var commentText = "" |
| 7 | @State private var commentFormat = "md" | |
| 7 | 8 | @State private var confirmingMerge = false |
| 8 | 9 | @State private var confirmingClose = false |
| 9 | 10 | @State private var editing = false |
| 10 | 11 | @State private var draftTitle = "" |
| 11 | 12 | @State private var draftBody = "" |
| 13 | @State private var draftFormat = "md" | |
| 12 | 14 | @State private var editingReviewer = "" |
| 13 | 15 | @State private var retargeting = false |
| 14 | 16 | @State private var pendingRetarget: String? |
| @@ -32,7 +34,7 @@ struct MRView: View { | ||
| 32 | 34 | |
| 33 | 35 | if let body = mr.body, !body.isEmpty { |
| 34 | 36 | Section { |
| 35 | MarkdownView(markdown: body) | |
| 37 | FormattedBodyView(text: body, format: mr.bodyFormat) | |
| 36 | 38 | .padding(.vertical, 4) |
| 37 | 39 | } |
| 38 | 40 | } |
| @@ -115,10 +117,11 @@ struct MRView: View { | ||
| 115 | 117 | working: model.working, |
| 116 | 118 | errorMessage: model.actionError, |
| 117 | 119 | title: $draftTitle, |
| 118 | bodyText: $draftBody | |
| 120 | bodyText: $draftBody, | |
| 121 | format: $draftFormat | |
| 119 | 122 | ) { |
| 120 | 123 | Task { |
| 121 | await model.edit(title: draftTitle, body: draftBody) | |
| 124 | await model.edit(title: draftTitle, body: draftBody, format: draftFormat) | |
| 122 | 125 | if model.actionError == nil { editing = false } |
| 123 | 126 | } |
| 124 | 127 | } |
| @@ -394,7 +397,7 @@ struct MRView: View { | ||
| 394 | 397 | .font(.gbSans(.caption)) |
| 395 | 398 | .foregroundStyle(.tertiary) |
| 396 | 399 | } |
| 397 | MarkdownView(markdown: comment.body) | |
| 400 | FormattedBodyView(text: comment.body, format: comment.bodyFormat) | |
| 398 | 401 | .font(.gbSans(.subheadline)) |
| 399 | 402 | } |
| 400 | 403 | .padding(.vertical, 2) |
| @@ -403,10 +406,18 @@ struct MRView: View { | ||
| 403 | 406 | HStack { |
| 404 | 407 | TextField("Comment", text: $commentText, axis: .vertical) |
| 405 | 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 | 417 | Button { |
| 407 | 418 | let text = commentText |
| 408 | 419 | commentText = "" |
| 409 | Task { await model.comment(text) } | |
| 420 | Task { await model.comment(text, format: commentFormat) } | |
| 410 | 421 | } label: { |
| 411 | 422 | Image(systemName: "arrow.up.circle.fill") |
| 412 | 423 | } |
| @@ -424,6 +435,7 @@ struct MRView: View { | ||
| 424 | 435 | Button { |
| 425 | 436 | draftTitle = mr.title |
| 426 | 437 | draftBody = mr.body ?? "" |
| 438 | draftFormat = mr.bodyFormat ?? "md" | |
| 427 | 439 | editing = true |
| 428 | 440 | } label: { |
| 429 | 441 | Label("Edit", systemImage: "pencil") |
gitbay/Views/Repos/FormattedBodyView.swift added +28
| @@ -0,0 +1,28 @@ | ||
| 1 | import OrgSwiftUI | |
| 2 | import 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. | |
| 8 | struct 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 | 9 | let errorMessage: String? |
| 10 | 10 | @Binding var title: String |
| 11 | 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 | 15 | let onSubmit: () -> Void |
| 13 | 16 | |
| 14 | 17 | @Environment(\.dismiss) private var dismiss |
| @@ -30,6 +33,14 @@ struct ComposeSheet: View { | ||
| 30 | 33 | .font(.gbSans(.body)) |
| 31 | 34 | .autocorrectionDisabled() |
| 32 | 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 | 45 | if let errorMessage { |
| 35 | 46 | Section { |
gitbayTests/BodyFormatTests.swift added +319
| @@ -0,0 +1,319 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |
| 6 | let box = StubProtocol.box() | |
| 7 | let client = GitbayClient( | |
| 8 | instance: try GitbayInstance(url: "https://gitbay.org"), | |
| 9 | token: "test-token", | |
| 10 | session: box.session() | |
| 11 | ) | |
| 12 | return (client, box) | |
| 13 | } | |
| 14 | ||
| 15 | private 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 | ||
| 20 | private 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 | ||
| 25 | struct 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 | |
| 128 | struct 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 | |
| 191 | struct 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 | |
| 264 | struct 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 | } | |