Commit 0a519cfa8f

0a519cfa8fe7e55bb7b4c4721e8d13c1751fea65

parent: c091e39943

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-22 17:56 UTC

profile: set description, website and links only

gitbay v1.31.0 moved the about text into <owner>/.gitbay, and profile
set and org profile refuse --about-format and --file as unknown flags,
so every save failed with exit 2.

Layout: unified · split

gitbay/Discovery/ProfileEdit.swift +4 −17
@@ -6,6 +6,9 @@ import Foundation
6/// the current profile, so every other field is also sent every time, 6/// the current profile, so every other field is also sent every time,
7/// including empty ones: `--description ""` clears, omitting the flag 7/// including empty ones: `--description ""` clears, omitting the flag
8/// would leave the old value alone. 8/// would leave the old value alone.
9///
10/// The about text is not here. It is a file in `<owner>/.gitbay`, written
11/// through the file editor like any other file.
9nonisolated struct ProfileEdit: Equatable, Sendable { 12nonisolated struct ProfileEdit: Equatable, Sendable {
10 13
11 nonisolated struct Link: Equatable, Sendable, Identifiable { 14 nonisolated struct Link: Equatable, Sendable, Identifiable {
@@ -16,27 +19,17 @@ nonisolated struct ProfileEdit: Equatable, Sendable {
16 19
17 var description: String 20 var description: String
18 var website: String 21 var website: String
19 var about: String
20 var aboutFormat: String
21 var links: [Link] 22 var links: [Link]
22 23
23 init( 24 init(description: String = "", website: String = "", links: [Link] = []) {
24 description: String = "", website: String = "",
25 about: String = "", aboutFormat: String = "md",
26 links: [Link] = []
27 ) {
28 self.description = description 25 self.description = description
29 self.website = website 26 self.website = website
30 self.about = about
31 self.aboutFormat = aboutFormat
32 self.links = links 27 self.links = links
33 } 28 }
34 29
35 init(from profile: ProfileViewModel.Profile) { 30 init(from profile: ProfileViewModel.Profile) {
36 description = profile.description ?? "" 31 description = profile.description ?? ""
37 website = profile.website ?? "" 32 website = profile.website ?? ""
38 about = profile.about ?? ""
39 aboutFormat = profile.aboutFormat ?? "md"
40 links = (profile.links ?? []).map { Link(label: $0.label ?? "", url: $0.url) } 33 links = (profile.links ?? []).map { Link(label: $0.label ?? "", url: $0.url) }
41 } 34 }
42 35
@@ -56,7 +49,6 @@ nonisolated struct ProfileEdit: Equatable, Sendable {
56 return nil 49 return nil
57 } 50 }
58 51
59 /// Everything except the about text, which travels over stdin.
60 func flags() -> [String] { 52 func flags() -> [String] {
61 var flags = ["--description", description, "--website", website] 53 var flags = ["--description", description, "--website", website]
62 if links.isEmpty { 54 if links.isEmpty {
@@ -67,11 +59,6 @@ nonisolated struct ProfileEdit: Equatable, Sendable {
67 flags += ["--link", label.isEmpty ? link.url : "\(label)|\(link.url)"] 59 flags += ["--link", label.isEmpty ? link.url : "\(label)|\(link.url)"]
68 } 60 }
69 } 61 }
70 flags += ["--about-format", aboutFormat, "--file", "-"]
71 return flags 62 return flags
72 } 63 }
73
74 /// The about text, sent over stdin — never in argv, since it is
75 /// long-form and may hold newlines.
76 var aboutStdin: String { about }
77} 64}
gitbay/Discovery/ProfileViewModel.swift +2 −3
@@ -134,12 +134,11 @@ final class ProfileViewModel {
134 } 134 }
135 135
136 /// `profile set` for the caller's own profile, `org profile <org>` for 136 /// `profile set` for the caller's own profile, `org profile <org>` for
137 /// an organization; both take the same flags. The about text travels 137 /// an organization; both take the same flags.
138 /// over stdin, never argv, since it is long-form and may carry newlines.
139 func saveProfile(_ edit: ProfileEdit) async -> Bool { 138 func saveProfile(_ edit: ProfileEdit) async -> Bool {
140 guard edit.validationError == nil else { return false } 139 guard edit.validationError == nil else { return false }
141 let command = state.value?.isOrg == true ? ["org", "profile", name] : ["profile", "set"] 140 let command = state.value?.isOrg == true ? ["org", "profile", name] : ["profile", "set"]
142 await perform(command + edit.flags(), stdin: edit.aboutStdin) 141 await perform(command + edit.flags())
143 return actionError == nil 142 return actionError == nil
144 } 143 }
145 144
gitbay/Views/Discovery/ProfileEditSheet.swift +4 −22
@@ -1,10 +1,9 @@
1import SwiftUI 1import SwiftUI
2 2
3/// `profile set` — always the caller's own. Description and website are 3/// `profile set`, or `org profile` for an organization. Description and
4/// single lines; about is long-form prose in whichever format the 4/// website are single lines; links are capped at five, matching the
5/// profile already carries, since switching formats reinterprets text 5/// server. The about text is a file and is edited from the profile
6/// that was already written; links are capped at five, matching the 6/// screen, not here.
7/// server.
8struct ProfileEditSheet: View { 7struct ProfileEditSheet: View {
9 8
10 let model: ProfileViewModel 9 let model: ProfileViewModel
@@ -35,23 +34,6 @@ struct ProfileEditSheet: View {
35 .textInputAutocapitalization(.never) 34 .textInputAutocapitalization(.never)
36 .accessibilityIdentifier("profile-edit-website") 35 .accessibilityIdentifier("profile-edit-website")
37 } 36 }
38 Section {
39 Picker("Format", selection: $edit.aboutFormat) {
40 Text("Markdown").tag("md")
41 Text("Org").tag("org")
42 }
43 .pickerStyle(.segmented)
44 .accessibilityIdentifier("profile-edit-format")
45 TextEditor(text: $edit.about)
46 .font(.gbMono(.caption))
47 .frame(minHeight: 160)
48 .autocorrectionDisabled()
49 .accessibilityIdentifier("profile-edit-about")
50 } header: {
51 Text("About")
52 } footer: {
53 Text("Changing the format reinterprets text that is already written.")
54 }
55 linksSection 37 linksSection
56 if let error = localError ?? model.actionError { 38 if let error = localError ?? model.actionError {
57 Section { 39 Section {
gitbayTests/ProfileEditTests.swift +12 −41
@@ -6,11 +6,9 @@ struct ProfileEditFlagTests {
6 6
7 private func edit( 7 private func edit(
8 description: String = "", website: String = "", 8 description: String = "", website: String = "",
9 about: String = "", aboutFormat: String = "md",
10 links: [ProfileEdit.Link] = [] 9 links: [ProfileEdit.Link] = []
11 ) -> ProfileEdit { 10 ) -> ProfileEdit {
12 ProfileEdit(description: description, website: website, 11 ProfileEdit(description: description, website: website, links: links)
13 about: about, aboutFormat: aboutFormat, links: links)
14 } 12 }
15 13
16 @Test func everyFieldSendsItsFlag() { 14 @Test func everyFieldSendsItsFlag() {
@@ -59,19 +57,14 @@ struct ProfileEditFlagTests {
59 #expect(flags.contains("|https://a.test") == false) 57 #expect(flags.contains("|https://a.test") == false)
60 } 58 }
61 59
62 /// Long-form text with newlines belongs in stdin, not argv. 60 /// Since gitbay v1.31.0 the about text is a file in <owner>/.gitbay,
63 @Test func theAboutTextGoesToStdinNotArgv() { 61 /// and `profile set` refuses any of these as an unknown flag (exit 2).
64 let e = edit(about: "line one\n\nline two", aboutFormat: "org") 62 @Test func noAboutFlagIsSent() {
65 #expect(e.flags().contains { $0.contains("line one") } == false) 63 let flags = edit(description: "hi", website: "https://x.test",
66 #expect(e.flags().contains("--file")) 64 links: [.init(label: "a", url: "https://a.test")]).flags()
67 #expect(e.flags().contains("-")) 65 #expect(!flags.contains("--about"))
68 #expect(e.aboutStdin == "line one\n\nline two") 66 #expect(!flags.contains("--about-format"))
69 } 67 #expect(!flags.contains("--file"))
70
71 @Test func theAboutFormatAccompaniesTheText() {
72 let flags = edit(about: "x", aboutFormat: "org").flags()
73 let i = try! #require(flags.firstIndex(of: "--about-format"))
74 #expect(flags[i + 1] == "org")
75 } 68 }
76 69
77 @Test func moreThanFiveLinksIsInvalid() { 70 @Test func moreThanFiveLinksIsInvalid() {
@@ -103,36 +96,17 @@ struct ProfileEditFromProfileTests {
103 "repos":[],"activity_total":0} 96 "repos":[],"activity_total":0}
104 """ 97 """
105 98
106 /// The real profile on gitbay.org is org, with tables and links that 99 @Test func seedsFromTheProfile() throws {
107 /// render as flat text under markdown — the stored format must round
108 /// trip, not default away from what the server holds.
109 @Test func seedsFromAnOrgProfile() throws {
110 let data = try #require(orgProfileJSON.data(using: .utf8)) 100 let data = try #require(orgProfileJSON.data(using: .utf8))
111 let profile = try JSONDecoder().decode(ProfileViewModel.Profile.self, from: data) 101 let profile = try JSONDecoder().decode(ProfileViewModel.Profile.self, from: data)
112 102
113 let edit = ProfileEdit(from: profile) 103 let edit = ProfileEdit(from: profile)
114 104
115 #expect(edit.aboutFormat == "org")
116 #expect(edit.description == "about me") 105 #expect(edit.description == "about me")
117 #expect(edit.website == "https://cleberg.net") 106 #expect(edit.website == "https://cleberg.net")
118 #expect(edit.about == "* heading\n\n| a | b |")
119 #expect(edit.links.map(\.url) == ["https://a.test", "https://b.test"]) 107 #expect(edit.links.map(\.url) == ["https://a.test", "https://b.test"])
120 #expect(edit.links.map(\.label) == ["", "blog"]) 108 #expect(edit.links.map(\.label) == ["", "blog"])
121 } 109 }
122
123 /// Absent `about_format`, seeding falls back to markdown.
124 @Test func seedingFallsBackToMarkdownWhenFormatIsAbsent() throws {
125 let json = """
126 {"name":"cmc","kind":"user",\
127 "description":"hi","repos":[],"activity_total":0}
128 """
129 let data = try #require(json.data(using: .utf8))
130 let profile = try JSONDecoder().decode(ProfileViewModel.Profile.self, from: data)
131
132 let edit = ProfileEdit(from: profile)
133
134 #expect(edit.aboutFormat == "md")
135 }
136} 110}
137 111
138private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { 112private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
@@ -170,14 +144,13 @@ struct ProfileSaveTests {
170 /// The write, then the reload `profile set` triggers — one POST, one 144 /// The write, then the reload `profile set` triggers — one POST, one
171 /// GET. Both need a stub or the flow ends up looking like a refusal 145 /// GET. Both need a stub or the flow ends up looking like a refusal
172 /// even when the write itself succeeded. 146 /// even when the write itself succeeded.
173 @Test func savesTheFullArgvWithAboutOnlyInStdin() async throws { 147 @Test func savesScalarsAndLinksWithNoStdin() async throws {
174 let (client, stub) = try makeClient() 148 let (client, stub) = try makeClient()
175 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) 149 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
176 stub.enqueue(.init(status: 200, json: profileJSON, match: "argv=show")) 150 stub.enqueue(.init(status: 200, json: profileJSON, match: "argv=show"))
177 let model = ProfileViewModel(client: client, name: "cmc") 151 let model = ProfileViewModel(client: client, name: "cmc")
178 let edit = ProfileEdit( 152 let edit = ProfileEdit(
179 description: "hi", website: "https://cleberg.net", 153 description: "hi", website: "https://cleberg.net",
180 about: "long form\n\nprose", aboutFormat: "org",
181 links: [.init(label: "blog", url: "https://b.test")] 154 links: [.init(label: "blog", url: "https://b.test")]
182 ) 155 )
183 156
@@ -190,10 +163,8 @@ struct ProfileSaveTests {
190 "profile", "set", 163 "profile", "set",
191 "--description", "hi", "--website", "https://cleberg.net", 164 "--description", "hi", "--website", "https://cleberg.net",
192 "--link", "blog|https://b.test", 165 "--link", "blog|https://b.test",
193 "--about-format", "org", "--file", "-",
194 ]) 166 ])
195 #expect(stdin == "long form\n\nprose") 167 #expect(stdin == nil)
196 #expect(argv.contains { $0.contains("long form") } == false)
197 } 168 }
198 169
199 /// An org's profile is written by `org profile <org>`, with the same 170 /// An org's profile is written by `org profile <org>`, with the same