Commit dc071b9572

dc071b95722b1f0d32d299e934bae31d97a0ca9a

parent: d58e5ef7d8

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-22 18:23 UTC

profile: edit the about file, or create it

The about section links to profile/README.* in <owner>/.gitbay for a
viewer who may write it, and offers to create the repository and a
starter file when there is none, as the web's settings page does.

Layout: unified · split

gitbay/Discovery/ProfileViewModel.swift +35
@@ -139,6 +139,41 @@ final class ProfileViewModel {
139139 return actionError == nil
140140 }
141141
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.
150 func createAboutFile() async -> Bool {
151 working = true
152 actionError = nil
153 defer { working = false }
154 do {
155 do {
156 _ = try await client.read(["repo", "show", aboutRepo], as: Exists.self)
157 } catch let error as GitbayError where error.isEmptyState {
158 try await client.run(["repo", "create", aboutRepo])
159 }
160 try await client.run(
161 ["repo", "commit-file", aboutRepo, "profile/README.md", "--ref", "main",
162 "--message", "add profile about", "--file", "-"],
163 stdin: "# \(name)\n\nThis is the about text on your profile.\n")
164 await load()
165 return true
166 } catch let error as GitbayError {
167 actionError = error.userFacingMessage
168 } catch {
169 actionError = GitbayError.transport(error).userFacingMessage
170 }
171 return false
172 }
173
174 /// `repo show` is read only to learn whether the repository exists.
175 nonisolated private struct Exists: Decodable, Sendable {}
176
142177 private func perform(_ argv: [String], stdin: String? = nil) async {
143178 working = true
144179 actionError = nil
gitbay/Views/Discovery/ProfileView.swift +24 −1
@@ -137,13 +137,36 @@ struct ProfileView: View {
137137 }
138138 }
139139
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.
141143 @ViewBuilder
142144 private func aboutSection(_ profile: ProfileViewModel.Profile) -> some View {
145 let canEdit = profile.canEdit(by: session.current?.username)
143146 if let about = profile.about, !about.isEmpty {
144147 Section("About") {
145148 ReadmeView(name: profile.aboutPath ?? "", content: about)
146149 .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.")
147170 }
148171 }
149172 }
gitbayTests/ProfileCommitTests.swift +83
@@ -148,3 +148,86 @@ struct CommitDetailTests {
148148 }
149149 }
150150}
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}