Profile save: drop the about flags, edit the about file !121
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. | ||
| 9 | nonisolated struct ProfileEdit: Equatable, Sendable { | 12 | nonisolated 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 @@ | |||
| 1 | import SwiftUI | 1 | import 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. | ||
| 8 | struct ProfileEditSheet: View { | 7 | struct 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) { | |||
| 15 | private let userProfileJSON = """ | 15 | private 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 | |||
| 152 | private 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 | |||
| 159 | private let okCmdJSON = #"{"protocol_version":1,"exit_code":0}"# | ||
| 160 | |||
| 161 | @MainActor | ||
| 162 | struct 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 | ||
| 138 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | 112 | private 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 |