Profile save: drop the about flags, edit the about file !121

merged merged by cmc on 2026-09-22 20:41 UTC · krz/gitbay-ios:profile-about into main

7 files changed, +272 −99

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 +57 −12
@@ -16,7 +16,9 @@ final class ProfileViewModel {
16 /// Long-form profile text, rendered between the header and the 16 /// Long-form profile text, rendered between the header and the
17 /// activity graph the way the web renders it. 17 /// activity graph the way the web renders it.
18 let about: String? 18 let about: String?
19 let aboutFormat: String? 19 /// The file in `<name>/.gitbay` the text was read from, e.g.
20 /// `profile/README.org`. Its extension picks the renderer.
21 let aboutPath: String?
20 let links: [Link]? 22 let links: [Link]?
21 let orgs: [Member]? 23 let orgs: [Member]?
22 let members: [Member]? 24 let members: [Member]?
@@ -26,7 +28,7 @@ final class ProfileViewModel {
26 28
27 enum CodingKeys: String, CodingKey { 29 enum CodingKeys: String, CodingKey {
28 case name, kind, description, website, about, links, orgs, members, repos, activity 30 case name, kind, description, website, about, links, orgs, members, repos, activity
29 case aboutFormat = "about_format" 31 case aboutPath = "about_path"
30 case activityTotal = "activity_total" 32 case activityTotal = "activity_total"
31 } 33 }
32 34
@@ -42,11 +44,6 @@ final class ProfileViewModel {
42 return name == username 44 return name == username
43 } 45 }
44 46
45 /// The filename the about text is rendered under. The server has
46 /// none to dispatch on either, so the stored format picks the
47 /// extension and anything but org is markdown.
48 var aboutFile: String { aboutFormat == "org" ? "about.org" : "about.md" }
49
50 /// One of the free-form links a profile carries beside its 47 /// One of the free-form links a profile carries beside its
51 /// website. The server caps the list at five. 48 /// website. The server caps the list at five.
52 nonisolated struct Link: Decodable, Sendable, Hashable { 49 nonisolated struct Link: Decodable, Sendable, Hashable {
@@ -134,21 +131,69 @@ final class ProfileViewModel {
134 } 131 }
135 132
136 /// `profile set` for the caller's own profile, `org profile <org>` for 133 /// `profile set` for the caller's own profile, `org profile <org>` for
137 /// an organization; both take the same flags. The about text travels 134 /// 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 { 135 func saveProfile(_ edit: ProfileEdit) async -> Bool {
140 guard edit.validationError == nil else { return false } 136 guard edit.validationError == nil else { return false }
141 let command = state.value?.isOrg == true ? ["org", "profile", name] : ["profile", "set"] 137 let command = state.value?.isOrg == true ? ["org", "profile", name] : ["profile", "set"]
142 await perform(command + edit.flags(), stdin: edit.aboutStdin) 138 await perform(command + edit.flags())
143 return actionError == nil 139 return actionError == nil
144 } 140 }
145 141
146 private func perform(_ argv: [String], stdin: String? = nil) async { 142 /// The repository that holds this profile's about text.
143 var aboutRepo: String { "\(name)/.gitbay" }
144
145 /// Creates `<name>/.gitbay` when it is missing and commits a starter
146 /// `profile/README.md`, the same starter the web's settings page
147 /// writes, so the file editor has something to open. The repository
148 /// may already exist without the file, and creating it again would
149 /// fail, so it is looked up first — and when it does exist, the
150 /// commit targets its default branch: the about file is only read
151 /// from there, and an existing repository's default is not
152 /// necessarily `main` (the first branch ever pushed becomes the
153 /// default). A repository this call creates is fresh, so its default
154 /// is `main`.
155 func createAboutFile() async -> Bool {
156 working = true
157 actionError = nil
158 defer { working = false }
159 do {
160 var ref = "main"
161 do {
162 let existing = try await client.read(["repo", "show", aboutRepo], as: Exists.self)
163 ref = existing.defaultBranch ?? "main"
164 } catch let error as GitbayError where error.isEmptyState {
165 try await client.run(["repo", "create", aboutRepo])
166 }
167 try await client.run(
168 ["repo", "commit-file", aboutRepo, "profile/README.md", "--ref", ref,
169 "--message", "add profile about", "--file", "-"],
170 stdin: "# \(name)\n\nThis is the about text on your profile.\n")
171 await load()
172 return true
173 } catch let error as GitbayError {
174 actionError = error.userFacingMessage
175 } catch {
176 actionError = GitbayError.transport(error).userFacingMessage
177 }
178 return false
179 }
180
181 /// `repo show` is read only to learn whether the repository exists,
182 /// and which branch is its default.
183 nonisolated private struct Exists: Decodable, Sendable {
184 let defaultBranch: String?
185
186 enum CodingKeys: String, CodingKey {
187 case defaultBranch = "default_branch"
188 }
189 }
190
191 private func perform(_ argv: [String]) async {
147 working = true 192 working = true
148 actionError = nil 193 actionError = nil
149 defer { working = false } 194 defer { working = false }
150 do { 195 do {
151 try await client.run(argv, stdin: stdin) 196 try await client.run(argv)
152 await load() 197 await load()
153 } catch let error as GitbayError { 198 } catch let error as GitbayError {
154 actionError = error.userFacingMessage 199 actionError = error.userFacingMessage
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 {
gitbay/Views/Discovery/ProfileView.swift +25 −2
@@ -137,13 +137,36 @@ struct ProfileView: View {
137 } 137 }
138 } 138 }
139 139
140 /// The long-form about text, in whichever format it was written. 140 /// The long-form about text, rendered under the file it came from. A
141 /// viewer who may write the profile gets the file itself to edit, or,
142 /// when there is none yet, the offer to create it.
141 @ViewBuilder 143 @ViewBuilder
142 private func aboutSection(_ profile: ProfileViewModel.Profile) -> some View { 144 private func aboutSection(_ profile: ProfileViewModel.Profile) -> some View {
145 let canEdit = profile.canEdit(by: session.current?.username)
143 if let about = profile.about, !about.isEmpty { 146 if let about = profile.about, !about.isEmpty {
144 Section("About") { 147 Section("About") {
145 ReadmeView(name: profile.aboutFile, content: about) 148 ReadmeView(name: profile.aboutPath ?? "", content: about)
146 .padding(.vertical, 4) 149 .padding(.vertical, 4)
150 if canEdit, let path = profile.aboutPath {
151 NavigationLink(value: RepoRoute.file(repo: model.aboutRepo, path: path, ref: nil)) {
152 Label("Edit About", systemImage: "pencil")
153 }
154 .accessibilityIdentifier("profile-about-edit")
155 }
156 }
157 } else if canEdit {
158 Section {
159 Button {
160 Task { await model.createAboutFile() }
161 } label: {
162 Label("Create About File", systemImage: "doc.badge.plus")
163 }
164 .disabled(model.working)
165 .accessibilityIdentifier("profile-about-create")
166 } header: {
167 Text("About")
168 } footer: {
169 Text("Adds profile/README.md to \(model.aboutRepo), creating the repository if needed.")
147 } 170 }
148 } 171 }
149 } 172 }
gitbayTests/ProfileCommitTests.swift +149 −5
@@ -15,7 +15,7 @@ private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
15private let userProfileJSON = """ 15private let userProfileJSON = """
16 {"protocol_version":1,"data":{"name":"cmc","kind":"user",\ 16 {"protocol_version":1,"data":{"name":"cmc","kind":"user",\
17 "description":"Org-Mode · Self-Hosting · Privacy","website":"https://cleberg.net",\ 17 "description":"Org-Mode · Self-Hosting · Privacy","website":"https://cleberg.net",\
18 "about":"* Christian\\n\\nOrg-Mode.","about_format":"org",\ 18 "about":"* Christian\\n\\nOrg-Mode.","about_format":"org","about_path":"profile/README.org",\
19 "links":[{"label":"Tor","url":"http://example.onion/"},{"url":"https://krz.sh"}],\ 19 "links":[{"label":"Tor","url":"http://example.onion/"},{"url":"https://krz.sh"}],\
20 "orgs":[{"name":"krz","role":"admin"},{"name":"audit-labs","role":"admin"}],\ 20 "orgs":[{"name":"krz","role":"admin"},{"name":"audit-labs","role":"admin"}],\
21 "repos":[{"path":"cmc/notes","visibility":"private","description":"notes",\ 21 "repos":[{"path":"cmc/notes","visibility":"private","description":"notes",\
@@ -53,8 +53,8 @@ struct ProfileAggregateTests {
53 #expect(profile.repos[1].topics == nil) 53 #expect(profile.repos[1].topics == nil)
54 #expect(profile.repos[1].meta.isEmpty) 54 #expect(profile.repos[1].meta.isEmpty)
55 #expect(profile.about == "* Christian\n\nOrg-Mode.") 55 #expect(profile.about == "* Christian\n\nOrg-Mode.")
56 // The format picks the filename the about text renders under. 56 // The file's own path picks the renderer, as it does on the web.
57 #expect(profile.aboutFile == "about.org") 57 #expect(profile.aboutPath == "profile/README.org")
58 #expect(profile.links?.map(\.title) == ["Tor", "https://krz.sh"]) 58 #expect(profile.links?.map(\.title) == ["Tor", "https://krz.sh"])
59 #expect(profile.activityTotal == 15) 59 #expect(profile.activityTotal == 15)
60 // One read builds the whole page. 60 // One read builds the whole page.
@@ -78,8 +78,8 @@ struct ProfileAggregateTests {
78 // A profile with no about text and no links renders neither. 78 // A profile with no about text and no links renders neither.
79 #expect(profile.about == nil) 79 #expect(profile.about == nil)
80 #expect(profile.links == nil) 80 #expect(profile.links == nil)
81 // Markdown is the default for anything but org. 81 // No about file, no path.
82 #expect(profile.aboutFile == "about.md") 82 #expect(profile.aboutPath == nil)
83 } 83 }
84 84
85 @Test func anUnknownNameIsAnEmptyState() async throws { 85 @Test func anUnknownNameIsAnEmptyState() async throws {
@@ -148,3 +148,147 @@ struct CommitDetailTests {
148 } 148 }
149 } 149 }
150} 150}
151
152private func postArgv(_ stub: StubProtocol.Box) throws -> [([String], String?)] {
153 try stub.seen.filter { $0.method == "POST" }.map { seen in
154 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
155 return (try #require(body["argv"] as? [String]), body["stdin"] as? String)
156 }
157}
158
159private let okCmdJSON = #"{"protocol_version":1,"exit_code":0}"#
160
161@MainActor
162struct AboutFileTests {
163
164 /// The web's "profile-repo" form: create <owner>/.gitbay, commit a
165 /// starter README, show the result.
166 @Test func createsTheRepositoryAndAStarterFile() async throws {
167 let (client, stub) = try makeClient()
168 stub.enqueue(.init(status: 404, json:
169 #"{"protocol_version":1,"error":"no repository cmc/.gitbay","exit_code":3}"#,
170 match: "argv=repo&argv=show"))
171 stub.enqueue(.init(status: 200, json: okCmdJSON, match: "cmd"))
172 stub.enqueue(.init(status: 200, json: okCmdJSON, match: "cmd"))
173 stub.enqueue(.init(status: 200, json: userProfileJSON, match: "argv=profile&argv=show"))
174 let model = ProfileViewModel(client: client, name: "cmc")
175
176 let created = await model.createAboutFile()
177
178 #expect(created)
179 #expect(model.actionError == nil)
180 let writes = try postArgv(stub)
181 #expect(writes.map(\.0) == [
182 ["repo", "create", "cmc/.gitbay"],
183 ["repo", "commit-file", "cmc/.gitbay", "profile/README.md", "--ref", "main",
184 "--message", "add profile about", "--file", "-"],
185 ])
186 #expect(writes[1].1 == "# cmc\n\nThis is the about text on your profile.\n")
187 #expect(model.state.value?.aboutPath == "profile/README.org")
188 }
189
190 /// A .gitbay repository can exist without an about file in it — it
191 /// holds other per-owner configuration too. Creating it again would
192 /// fail, so only the file is written.
193 @Test func skipsCreateWhenTheRepositoryExists() async throws {
194 let (client, stub) = try makeClient()
195 stub.enqueue(.init(status: 200, json:
196 #"{"protocol_version":1,"data":{"path":"krz/.gitbay"},"exit_code":0}"#,
197 match: "argv=repo&argv=show"))
198 stub.enqueue(.init(status: 200, json: okCmdJSON, match: "cmd"))
199 stub.enqueue(.init(status: 200, json: orgProfileJSON, match: "argv=profile&argv=show"))
200 let model = ProfileViewModel(client: client, name: "krz")
201
202 let created = await model.createAboutFile()
203
204 #expect(created)
205 let writes = try postArgv(stub)
206 #expect(writes.count == 1)
207 #expect(Array(writes[0].0.prefix(4)) == ["repo", "commit-file", "krz/.gitbay", "profile/README.md"])
208 #expect(writes[0].1 == "# krz\n\nThis is the about text on your profile.\n")
209 }
210
211 /// A refused create must not go on to write a file into nothing.
212 @Test func aRefusedCreateStopsAndSurfaces() async throws {
213 let (client, stub) = try makeClient()
214 stub.enqueue(.init(status: 404, json:
215 #"{"protocol_version":1,"error":"no repository krz/.gitbay","exit_code":3}"#,
216 match: "argv=repo&argv=show"))
217 stub.enqueue(.init(status: 200, json:
218 #"{"protocol_version":1,"error":"only admins of krz can create repositories there","exit_code":4}"#,
219 match: "cmd"))
220 let model = ProfileViewModel(client: client, name: "krz")
221
222 let created = await model.createAboutFile()
223
224 #expect(created == false)
225 #expect(model.actionError?.isEmpty == false)
226 #expect(try postArgv(stub).count == 1)
227 }
228
229 @Test func theAboutRepositoryIsTheOwnersDotGitbay() throws {
230 let (client, _) = try makeClient()
231 #expect(ProfileViewModel(client: client, name: "cmc").aboutRepo == "cmc/.gitbay")
232 }
233
234 /// The about file is only read from the repository's default branch,
235 /// which is not always `main` for a repository that already exists.
236 @Test func commitsToTheExistingRepositorysDefaultBranch() async throws {
237 let (client, stub) = try makeClient()
238 stub.enqueue(.init(status: 200, json:
239 #"{"protocol_version":1,"data":{"path":"cmc/.gitbay","default_branch":"master"},"exit_code":0}"#,
240 match: "argv=repo&argv=show"))
241 stub.enqueue(.init(status: 200, json: okCmdJSON, match: "cmd"))
242 stub.enqueue(.init(status: 200, json: userProfileJSON, match: "argv=profile&argv=show"))
243 let model = ProfileViewModel(client: client, name: "cmc")
244
245 let created = await model.createAboutFile()
246
247 #expect(created)
248 let writes = try postArgv(stub)
249 #expect(writes.count == 1)
250 #expect(writes[0].0 == [
251 "repo", "commit-file", "cmc/.gitbay", "profile/README.md", "--ref", "master",
252 "--message", "add profile about", "--file", "-",
253 ])
254 }
255
256 /// Any failure but not-found on `repo show` stops the flow before any
257 /// write — a permission refusal must not fall through to creating or
258 /// committing into a repository the caller cannot even read.
259 @Test func aNonNotFoundShowFailureStopsBeforeAnyWrite() async throws {
260 let (client, stub) = try makeClient()
261 stub.enqueue(.init(status: 403, json:
262 #"{"protocol_version":1,"error":"denied","exit_code":4}"#,
263 match: "argv=repo&argv=show"))
264 let model = ProfileViewModel(client: client, name: "krz")
265
266 let created = await model.createAboutFile()
267
268 #expect(created == false)
269 #expect(model.actionError?.isEmpty == false)
270 #expect(try postArgv(stub).isEmpty)
271 }
272
273 /// A create that succeeds but a commit that then fails must not
274 /// report success or reload — the file was never written.
275 @Test func aFailedCommitAfterCreateStopsAndDoesNotReload() async throws {
276 let (client, stub) = try makeClient()
277 stub.enqueue(.init(status: 404, json:
278 #"{"protocol_version":1,"error":"no repository krz/.gitbay","exit_code":3}"#,
279 match: "argv=repo&argv=show"))
280 stub.enqueue(.init(status: 200, json: okCmdJSON, match: "cmd"))
281 stub.enqueue(.init(status: 200, json:
282 #"{"protocol_version":1,"error":"disk full","exit_code":1}"#,
283 match: "cmd"))
284 let model = ProfileViewModel(client: client, name: "krz")
285
286 let created = await model.createAboutFile()
287
288 #expect(created == false)
289 #expect(model.actionError?.isEmpty == false)
290 let writes = try postArgv(stub)
291 #expect(writes.count == 2)
292 #expect(stub.seen.contains { $0.url.query()?.contains("argv=profile&argv=show") == true } == false)
293 }
294}
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
gitbayUITests/LiveSmokeUITests.swift +21
@@ -1283,6 +1283,27 @@ extension LiveSmokeUITests {
1283 .waitForExistence(timeout: 15), "org profile offers no edit to its admin") 1283 .waitForExistence(timeout: 15), "org profile offers no edit to its admin")
1284 back() 1284 back()
1285 1285
1286 // --- saving the profile succeeds: profile set takes no about flags ---
1287 let edit = app.descendants(matching: .any)
1288 .matching(identifier: "profile-edit-button").firstMatch
1289 XCTAssertTrue(edit.waitForExistence(timeout: 10), "own profile offers no edit")
1290 edit.tap()
1291 let save = app.buttons["profile-edit-save"].firstMatch
1292 XCTAssertTrue(save.waitForExistence(timeout: 10), "edit sheet did not open")
1293 XCTAssertFalse(app.descendants(matching: .any)
1294 .matching(identifier: "profile-edit-about").firstMatch.exists,
1295 "the sheet still edits the about text")
1296 save.tap()
1297 XCTAssertTrue(app.navigationBars["Edit Profile"].waitForNonExistence(timeout: 15),
1298 "profile save did not dismiss the sheet — the server refused it")
1299 XCTAssertFalse(save.exists, "the edit sheet is still on screen")
1300
1301 // --- the about text is a file: edit it, or offer to create it ---
1302 let aboutAction = app.descendants(matching: .any).matching(NSPredicate(
1303 format: "identifier IN %@", ["profile-about-edit", "profile-about-create"])).firstMatch
1304 XCTAssertTrue(scrollTo(aboutAction),
1305 "own profile offers neither editing nor creating the about file")
1306
1286 // The graph and the repositories sit below the orgs and links, 1307 // The graph and the repositories sit below the orgs and links,
1287 // and a List does not build rows it has not shown. 1308 // and a List does not build rows it has not shown.
1288 XCTAssertTrue(scrollTo(app.staticTexts 1309 XCTAssertTrue(scrollTo(app.staticTexts