docs/superpowers/plans/2026-09-06-mr11-body-markup.md

v1.6.2
gitbay-ios/docs/superpowers/plans/2026-09-06-mr11-body-markup.md rendered · source · history · blame · raw

291 lines · 15173 bytes

MR 11: Choose body markup Implementation Plan

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.

Goal: Close the last two buildable parity rows — choose body markup for merge requests and for issues.

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.

Spec: docs/superpowers/specs/2026-09-06-ios-parity-design.md

Global Constraints

  • Swift 6 language mode, default MainActor isolation. Wire models are nonisolated structs.
  • Swift Testing only — never XCTest.
  • gitbayTests is hermetic and offline; network goes through StubProtocol.
  • New files under gitbay/ and gitbayTests/ need no project.pbxproj edit.
  • The label model is IssueLabel; the notification model is InboxNotification.
  • Never mention Claude, LLMs or AI in commits, comments, or the merge request. No Co-Authored-By trailer.
  • Never commit to main.
  • 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.

The six commands in scope:

issue create  <repo> --title <t> [--body <b> | --file -] [--format md|org]
issue edit    <repo> <n> [--title <t>] [--body <b> | --file -] [--format md|org]
issue comment <repo> <n> [--message <m> | --file -] [--format md|org]
mr create     <target repo> --source … --target … --title <t> [--body | --file -] [--format md|org] [--draft]
mr edit       <repo> <n> [--title <t>] [--body <b> | --file -] [--format md|org]
mr comment    <repo> <n> [--message <m> | --file -] [--format md|org]

Two things are deliberately out of scope:

  • 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.
  • mr diff-comment has no format column and is always markdown. It gets no picker.

Counting tests — xcresulttool, not grep:

RES=$(ls -td ~/Library/Developer/Xcode/DerivedData/gitbay-*/Logs/Test/*.xcresult | head -1)
xcrun xcresulttool get test-results summary --path "$RES"

Baseline: 347 total, 346 passed, 1 skipped, 0 failed.


File Structure

File Responsibility
gitbay/Issues/IssueModels.swift (modify) Decode body_format on IssueDetail
gitbay/MRs/MRModels.swift (modify) Decode body_format on MRDetail
gitbay/Issues/IssueDetailViewModel.swift (modify) --format on edit and comment
gitbay/MRs/MRDetailViewModel.swift (modify) --format on edit and comment
gitbay/Issues/IssueCreateViewModel.swift (modify) --format on create
gitbay/MRs/MRCreateViewModel.swift (modify) --format on create
gitbayTests/BodyFormatTests.swift (create) Every test in this plan
gitbay/Views/Shared/ComposeSheet.swift (modify) Optional format control
gitbay/Views/Issues/IssueListView.swift, IssueView.swift, gitbay/Views/MRs/MRListView.swift, MRView.swift (modify) Pass the format through; picker on the comment fields

Task 1: Decode the stored format, and send it

Files: the two model files, the four view models; test in gitbayTests/BodyFormatTests.swift

Interfaces produced:

  • IssueDetail.bodyFormat: String? and MRDetail.bodyFormat: String?
  • IssueDetailViewModel.edit(title:body:format:), .comment(_:format:)
  • MRDetailViewModel.edit(title:body:format:), .comment(_:format:)
  • IssueCreateViewModel.format and MRCreateViewModel.format (settable properties, like the draft property MR 4 added — not new parameters, which would change every call site)

Behaviour, each pinned by a test:

  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.
  2. --format is appended only when a format is given. Omitting it lets the server default; sending --format "" would be a usage error.
  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.
  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.
  • Step 1: Write the failing tests
import Foundation
import Testing
@testable import gitbay

private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
    let box = StubProtocol.box()
    let client = GitbayClient(
        instance: try GitbayInstance(url: "https://gitbay.org"),
        token: "test-token",
        session: box.session()
    )
    return (client, box)
}

private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
    let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
    return try #require(body["argv"] as? [String])
}

private func stdinOf(_ seen: StubProtocol.Seen) throws -> String? {
    let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
    return body["stdin"] as? String
}

struct BodyFormatDecodingTests {

    private func decodeIssue(_ json: String) throws -> IssueDetail {
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        return try decoder.decode(IssueDetail.self, from: Data(json.utf8))
    }

    private func decodeMR(_ json: String) throws -> MRDetail {
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        return try decoder.decode(MRDetail.self, from: Data(json.utf8))
    }

    /// body_format is omitempty; an ordinary body carries no key.
    @Test func anIssueWithoutAStoredFormatDecodes() throws {
        let issue = try decodeIssue("""
            {"number":7,"title":"t","state":"open","author":"cmc",\
            "body":"x","created_at":"2026-09-01T00:00:00Z"}
            """)
        #expect(issue.bodyFormat == nil)
    }

    @Test func anIssueStoredAsOrgSaysSo() throws {
        let issue = try decodeIssue("""
            {"number":7,"title":"t","state":"open","author":"cmc",\
            "body":"x","body_format":"org","created_at":"2026-09-01T00:00:00Z"}
            """)
        #expect(issue.bodyFormat == "org")
    }

    @Test func aMergeRequestWithoutAStoredFormatDecodes() throws {
        let mr = try decodeMR("""
            {"number":7,"title":"t","state":"open","author":"cmc","source":"f",\
            "target_ref":"main","head_sha":"a","created_at":"2026-09-01T00:00:00Z"}
            """)
        #expect(mr.bodyFormat == nil)
    }

    /// MRDetail's hand-written initialiser must still decode everything
    /// it did before the new field was added.
    @Test func addingTheFormatDidNotDropAnyMergeRequestField() throws {
        let mr = try decodeMR("""
            {"number":7,"title":"t","state":"open","draft":true,"author":"cmc","source":"f",\
            "target_ref":"main","head_sha":"abc","body":"b","body_format":"org",\
            "milestone":"v1","review_requests":["rae"],\
            "stacked_on":{"number":6,"title":"below"},\
            "stacked":[{"number":8,"title":"above"}],\
            "created_at":"2026-09-01T00:00:00Z","merged_at":"2026-09-02T00:00:00Z",\
            "merged_by":"cmc","checks_combined":"success","unresolved_threads":2}
            """)
        #expect(mr.bodyFormat == "org")
        #expect(mr.draft)
        #expect(mr.milestone == "v1")
        #expect(mr.reviewRequests == ["rae"])
        #expect(mr.stackedOn?.number == 6)
        #expect(mr.stacked.map(\.number) == [8])
        #expect(mr.mergedBy == "cmc")
        #expect(mr.checksCombined == "success")
        #expect(mr.unresolvedThreads == 2)
    }
}

@MainActor
struct BodyFormatWriteTests {

    private let issueJSON = """
        {"protocol_version":1,"data":{"number":7,"title":"t","state":"open","author":"cmc",\
        "body":"x","body_format":"org","created_at":"2026-09-01T00:00:00Z"},"exit_code":0}
        """

    @Test func anIssueCommentCarriesItsFormat() async throws {
        let (client, stub) = try makeClient()
        stub.enqueue(.init(status: 200, json: issueJSON))
        let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
        await model.load()

        stub.enqueue(.init(status: 200, json: """
            {"protocol_version":1,"exit_code":0}
            """))
        stub.enqueue(.init(status: 200, json: issueJSON))
        await model.comment("hello", format: "org")

        let write = try #require(stub.seen.first { $0.method == "POST" })
        #expect(try argvOf(write)
            == ["issue", "comment", "krz/gitbay", "7", "--file", "-", "--format", "org"])
        #expect(try stdinOf(write) == "hello")
    }

    /// No format given means no flag — the server then defaults. Sending
    /// `--format ""` would be a usage error.
    @Test func noFormatMeansNoFlag() async throws {
        let (client, stub) = try makeClient()
        stub.enqueue(.init(status: 200, json: issueJSON))
        let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
        await model.load()

        stub.enqueue(.init(status: 200, json: """
            {"protocol_version":1,"exit_code":0}
            """))
        stub.enqueue(.init(status: 200, json: issueJSON))
        await model.comment("hello", format: nil)

        let write = try #require(stub.seen.first { $0.method == "POST" })
        #expect(try argvOf(write).contains("--format") == false)
    }

    @Test func anIssueEditCarriesItsFormat() async throws {
        let (client, stub) = try makeClient()
        stub.enqueue(.init(status: 200, json: issueJSON))
        let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
        await model.load()

        stub.enqueue(.init(status: 200, json: """
            {"protocol_version":1,"exit_code":0}
            """))
        stub.enqueue(.init(status: 200, json: issueJSON))
        await model.edit(title: "t2", body: "b2", format: "md")

        let argv = try argvOf(try #require(stub.seen.first { $0.method == "POST" }))
        #expect(argv.contains("--format"))
        #expect(argv.contains("md"))
    }
}

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.

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.

  • Step 2: Run to verify failure.
  • Step 3: Implement. Re-audit MRDetail.init(from:) field by field.
  • Step 4: Run the tests.
  • Step 5: Commit — git commit -m "Send the body markup format on issues and merge requests"

Task 2: The picker

Files: ComposeSheet.swift and the four view files

No unit tests — UI.

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.

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.

The default is the rule that matters:

  • 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.
  • A new body defaults to markdown, as today.
  • A new comment defaults to markdown. Do not inherit the parent body's format: the parent's author and the commenter are different people.

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.

  • Step 1: Optional format binding on ComposeSheet, release call site untouched
  • Step 2: Create and edit sheets pass the format, edits seeded from bodyFormat
  • Step 3: Pickers on the two comment fields, defaulting to markdown
  • Step 4: Build and run the full suite. No drop from 346 passed.
  • Step 5: Commit — git commit -m "Pick the body markup when writing"

Task 3: Flip the last two rows and open the merge request

  • Step 1: Set choose body markup to yes for iOS in both the Merge requests and the Issues tables.

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.

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.

  • Step 2: Run the full suite via xcresulttool; record the real numbers.
  • Step 3: Open the merge request.

Notes for whoever executes this

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.

mr diff-comment gets no picker. It has no format column server-side and is always markdown.

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.

A new comment defaults to markdown, not the parent's format — different author, different choice.