docs/superpowers/plans/2026-09-06-mr10-profile-set.md

v1.7.0
gitbay-ios/docs/superpowers/plans/2026-09-06-mr10-profile-set.md rendered · source · history · blame · raw

237 lines · 13008 bytes

MR 10: Edit your profile 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 profile set parity row — edit your description, website, about text and links from the app.

Architecture: One write command behind an edit sheet on your own profile screen. The complication is entirely in profile set's flag semantics, which are unlike anything else this app sends.

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

The command, and why it needs care

profile set [--description <d>] [--website <url>] [--about <text>|--file -]
            [--about-format md|org] [--link <label|url>]...    ('' clears)

Five things, all verified against internal/control/profile.go:

  1. --link replaces the whole set, it does not append. applyProfile does p.Links = *e.Links (:159-161). So the editor sends every link it wants to keep on every save — dropping one from the form and saving is how you delete it.
  2. --link "" clears all links (:81-84).
  3. '' clears any field. An empty --description is "remove my description", which is different from omitting the flag entirely.
  4. At least one flag is required. With none, the command fails: "nothing to set: pass --description, --website, --about and/or --link" (:356-358). A save with no changes must not be sent.
  5. A link is label|url, split on the first |. Without a separator the whole value is the URL (:106-113). Labels are trimmed and truncated at 32 characters; a link with no URL is an error. At most 5 links (maxProfileLinks = 5, :31,100-102).

About text goes over stdin. It is long-form and contains newlines — the live profile on gitbay.org is org-mode with tables and links. Use --file - with the text in stdin, the same discipline the compose sheet already uses for issue and merge request bodies. Do not put it in argv.

--about-format md|org is stored alongside the text, so changing it reinterprets prose that already exists. The editor must default to the format the profile was stored with, read from about_format on profile show.

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.

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: 331 total, 330 passed, 1 skipped, 0 failed.


File Structure

File Responsibility
gitbay/Discovery/ProfileEdit.swift (create) ProfileEdit — the form's value type and its argv rendering
gitbay/Discovery/ProfileViewModel.swift (modify) saveProfile(_:) async -> Bool
gitbayTests/ProfileEditTests.swift (create) Every test in this plan
gitbay/Views/Discovery/ProfileEditSheet.swift (create) The edit form
gitbay/Views/Discovery/ProfileView.swift (modify) Edit button on your own profile

Task 1: The value type and its argv

Files: Create gitbay/Discovery/ProfileEdit.swift; test in gitbayTests/ProfileEditTests.swift

This task is pure argv construction — the same shape as MR 2's IssueFilter, and the same reason: a flag this command does not accept is a usage error the app cannot catch at compile time.

Interfaces produced:

  • nonisolated struct ProfileEdit: Equatable, Sendable — description: String, website: String, about: String, aboutFormat: String, links: [Link]; nested Link with label: String, url: String
  • init(from profile: ProfileViewModel.Profile) — seeds the form from what the server reported
  • var isEmpty: Bool — true when nothing would be sent
  • func flags() -> [String] — everything except the about text
  • var aboutStdin: String? — the about text, when it is being set

Rules, each pinned by a test:

  1. --link sends the entire set, one flag per link, label|url. A link with an empty label sends just the URL (no leading |).
  2. An empty link list sends --link "" — that is how the set is cleared. It must not simply omit the flag, which would leave the existing links untouched.
  3. A field cleared to empty sends --flag "", not omission. Empty means "remove this"; omitted means "leave it alone". Since the form is always seeded from the current profile, every field it holds is being set.
  4. The about text never appears in argv. It goes to stdin via --file -.
  5. --about-format is sent whenever the about text is, using the stored format unless the user changed it.
  6. More than 5 links is refused locally before sending — the server caps at 5 and the form should not let it get there.
  7. A link with no URL is refused locally.
  • Step 1: Write the failing tests
import Foundation
import Testing
@testable import gitbay

struct ProfileEditFlagTests {

    private func edit(
        description: String = "", website: String = "",
        about: String = "", aboutFormat: String = "md",
        links: [ProfileEdit.Link] = []
    ) -> ProfileEdit {
        ProfileEdit(description: description, website: website,
                    about: about, aboutFormat: aboutFormat, links: links)
    }

    @Test func everyFieldSendsItsFlag() {
        let e = edit(description: "hi", website: "https://x.test",
                     links: [.init(label: "blog", url: "https://b.test")])
        let flags = e.flags()
        #expect(flags.contains("--description"))
        #expect(flags.contains("hi"))
        #expect(flags.contains("--website"))
        #expect(flags.contains("https://x.test"))
        #expect(flags.contains("blog|https://b.test"))
    }

    /// An emptied field CLEARS it. Omitting the flag would leave the old
    /// value in place, which is a different outcome.
    @Test func anEmptiedFieldSendsAnEmptyValueNotNothing() {
        let flags = edit(description: "", website: "https://x.test").flags()
        let i = try! #require(flags.firstIndex(of: "--description"))
        #expect(flags[i + 1] == "")
    }

    /// `--link ""` is how the whole set is cleared. Omitting it would
    /// leave the existing links untouched.
    @Test func noLinksSendsAnEmptyLinkFlag() {
        let flags = edit(description: "hi").flags()
        let i = try! #require(flags.firstIndex(of: "--link"))
        #expect(flags[i + 1] == "")
    }

    /// The set replaces wholesale, so every kept link is sent every time.
    @Test func everyLinkIsSentSoTheSetReplaces() {
        let flags = edit(links: [
            .init(label: "a", url: "https://a.test"),
            .init(label: "b", url: "https://b.test"),
        ]).flags()
        #expect(flags.filter { $0 == "--link" }.count == 2)
        #expect(flags.contains("a|https://a.test"))
        #expect(flags.contains("b|https://b.test"))
    }

    /// Without a separator the whole value is the URL, so a label-less
    /// link must not send a leading pipe.
    @Test func aLabellessLinkSendsJustTheUrl() {
        let flags = edit(links: [.init(label: "", url: "https://a.test")]).flags()
        #expect(flags.contains("https://a.test"))
        #expect(flags.contains("|https://a.test") == false)
    }

    /// Long-form text with newlines belongs in stdin, not argv.
    @Test func theAboutTextGoesToStdinNotArgv() {
        let e = edit(about: "line one\n\nline two", aboutFormat: "org")
        #expect(e.flags().contains { $0.contains("line one") } == false)
        #expect(e.flags().contains("--file"))
        #expect(e.flags().contains("-"))
        #expect(e.aboutStdin == "line one\n\nline two")
    }

    @Test func theAboutFormatAccompaniesTheText() {
        let flags = edit(about: "x", aboutFormat: "org").flags()
        let i = try! #require(flags.firstIndex(of: "--about-format"))
        #expect(flags[i + 1] == "org")
    }

    @Test func moreThanFiveLinksIsInvalid() {
        let six = (1...6).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") }
        #expect(edit(links: six).validationError != nil)
        let five = (1...5).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") }
        #expect(edit(links: five).validationError == nil)
    }

    @Test func aLinkWithoutAUrlIsInvalid() {
        #expect(edit(links: [.init(label: "blog", url: "  ")]).validationError != nil)
    }
}

Add var validationError: String? to the interface list — the tests above use it.

  • Step 2: Run to verify failure.
  • Step 3: Implement.
  • Step 4: Run the tests.
  • Step 5: Commit — git commit -m "Profile edit value type and its flags"

Task 2: Saving, and the edit sheet

Files: Modify gitbay/Discovery/ProfileViewModel.swift; create gitbay/Views/Discovery/ProfileEditSheet.swift; modify gitbay/Views/Discovery/ProfileView.swift

Interfaces produced: ProfileViewModel.saveProfile(_ edit: ProfileEdit) async -> Bool

saveProfile sends ["profile", "set"] + edit.flags() with edit.aboutStdin as stdin, then reloads so the screen shows what the server stored. It must refuse to send an edit whose validationError is non-nil, and refuse to send when nothing would change.

The sheet: description and website as single-line fields; about as a TextEditor with a md/org picker; links as an editable list capped at five, each with a label and a URL.

The format picker defaults to the profile's stored about_format. Changing it reinterprets prose that already exists — the same rule that governs issue and merge request bodies. Do not default to markdown when the stored value is org; the live profile on gitbay.org is org, with tables and links that would render as flat text under the wrong renderer.

The edit button appears only on your own profile — profile set always writes the caller's own. The screen already computes this: MR 8 added an org-create button gated on !profile.isOrg && session.current?.username == profile.name. Reuse that condition rather than inventing a second one.

Add tests for saveProfile in gitbayTests/ProfileEditTests.swift:

  • The full argv reaches the command, with the about text in stdin and not in argv.
  • A save reloads, so the screen shows what the server stored rather than what was typed.
  • A refusal surfaces and returns false.
  • An invalid edit (six links) sends nothing at all.

Provision stubs for every request each flow makes — saveProfile writes and reloads. Three tests in this repo have already shipped under-provisioned, silently passing against bugs.

  • Step 1: saveProfile and its tests
  • Step 2: The edit sheet
  • Step 3: The edit button on your own profile
  • Step 4: Build and run the full suite.
  • Step 5: Commit — git commit -m "Edit your profile from the app"

Task 3: Flip the parity row and open the merge request

  • Step 1: Set profile set to yes for iOS in the Accounts table. Touch no other row.

The wiki note for that row currently says no surface has ever offered a form, and that the no is missing UI rather than a rule — update that prose too, since it stops being true. The web still has no form, so say that iOS has one and the web does not.

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

--link replaces the set. Every link you want to keep goes on every save, and --link "" clears them. This is the one flag semantic in the whole app that works this way, and getting it wrong silently loses or duplicates a user's links.

Empty is not omitted. --description "" removes the description; omitting --description leaves it. The form always sends what it holds.

About text goes in stdin. It is long-form with newlines. --file -, never argv.

Default the format to what was stored. The live profile is org-mode; defaulting to markdown would reinterpret prose that already exists.