docs/superpowers/plans/2026-09-06-mr10-profile-set.md
237 lines · 13008 bytes
8 symbols in this file
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:
--linkreplaces the whole set, it does not append.applyProfiledoesp.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.--link ""clears all links (:81-84).''clears any field. An empty--descriptionis "remove my description", which is different from omitting the flag entirely.- 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. - 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
MainActorisolation. Wire models arenonisolated structs. - Swift Testing only — never XCTest.
gitbayTestsis hermetic and offline; network goes throughStubProtocol.- New files under
gitbay/andgitbayTests/need noproject.pbxprojedit. - The label model is
IssueLabel; the notification model isInboxNotification. - Never mention Claude, LLMs or AI in commits, comments, or the merge request. No
Co-Authored-Bytrailer. - 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]; nestedLinkwithlabel: String,url: Stringinit(from profile: ProfileViewModel.Profile)— seeds the form from what the server reportedvar isEmpty: Bool— true when nothing would be sentfunc flags() -> [String]— everything except the about textvar aboutStdin: String?— the about text, when it is being set
Rules, each pinned by a test:
--linksends the entire set, one flag per link,label|url. A link with an empty label sends just the URL (no leading|).- 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. - 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. - The about text never appears in argv. It goes to stdin via
--file -. --about-formatis sent whenever the about text is, using the stored format unless the user changed it.- More than 5 links is refused locally before sending — the server caps at 5 and the form should not let it get there.
- 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:
saveProfileand 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 settoyesfor 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.