Commit 64dbbfe57d

64dbbfe57dc1b865c1541d809d250b6352c96fcf

parent: 381d5e7372

Unsigned

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

Edit an organization's profile (!71)

The profile pencil shows for an org's admins and saves through org profile <org> with the same flags profile set takes. Unit tests cover the edit rule and the command; the live suite checks the pencil on krz.

Layout: unified · split

gitbay/Discovery/ProfileViewModel.swift +15 −4
@@ -32,6 +32,16 @@ final class ProfileViewModel {
32 32
33 var isOrg: Bool { kind == "org" } 33 var isOrg: Bool { kind == "org" }
34 34
35 /// Who may write this profile: a user edits their own, and an org
36 /// is edited by its admins — the rule `org profile` enforces.
37 func canEdit(by username: String?) -> Bool {
38 guard let username else { return false }
39 if isOrg {
40 return (members ?? []).contains { $0.name == username && $0.role == "admin" }
41 }
42 return name == username
43 }
44
35 /// The filename the about text is rendered under. The server has 45 /// The filename the about text is rendered under. The server has
36 /// none to dispatch on either, so the stored format picks the 46 /// none to dispatch on either, so the stored format picks the
37 /// extension and anything but org is markdown. 47 /// extension and anything but org is markdown.
@@ -123,12 +133,13 @@ final class ProfileViewModel {
123 return actionError == nil 133 return actionError == nil
124 } 134 }
125 135
126 /// `profile set` — always the caller's own profile. The about text 136 /// `profile set` for the caller's own profile, `org profile <org>` for
127 /// travels over stdin, never argv, since it is long-form and may 137 /// an organization; both take the same flags. The about text travels
128 /// carry newlines. 138 /// over stdin, never argv, since it is long-form and may carry newlines.
129 func saveProfile(_ edit: ProfileEdit) async -> Bool { 139 func saveProfile(_ edit: ProfileEdit) async -> Bool {
130 guard edit.validationError == nil else { return false } 140 guard edit.validationError == nil else { return false }
131 await perform(["profile", "set"] + edit.flags(), stdin: edit.aboutStdin) 141 let command = state.value?.isOrg == true ? ["org", "profile", name] : ["profile", "set"]
142 await perform(command + edit.flags(), stdin: edit.aboutStdin)
132 return actionError == nil 143 return actionError == nil
133 } 144 }
134 145
gitbay/Views/Discovery/ProfileView.swift +4 −2
@@ -45,8 +45,7 @@ struct ProfileView: View {
45 .navigationTitle(title ?? model.name) 45 .navigationTitle(title ?? model.name)
46 .navigationBarTitleDisplayMode(.inline) 46 .navigationBarTitleDisplayMode(.inline)
47 .toolbar { 47 .toolbar {
48 if let profile = model.state.value, 48 if let profile = model.state.value, profile.canEdit(by: session.current?.username) {
49 !profile.isOrg, session.current?.username == profile.name {
50 ToolbarItem(placement: .topBarTrailing) { 49 ToolbarItem(placement: .topBarTrailing) {
51 Button { 50 Button {
52 editingProfile = true 51 editingProfile = true
@@ -56,6 +55,9 @@ struct ProfileView: View {
56 .disabled(model.working) 55 .disabled(model.working)
57 .accessibilityIdentifier("profile-edit-button") 56 .accessibilityIdentifier("profile-edit-button")
58 } 57 }
58 }
59 if let profile = model.state.value,
60 !profile.isOrg, session.current?.username == profile.name {
59 ToolbarItem(placement: .topBarTrailing) { 61 ToolbarItem(placement: .topBarTrailing) {
60 Button { 62 Button {
61 newOrgName = "" 63 newOrgName = ""
gitbayTests/ProfileEditTests.swift +46
@@ -158,6 +158,12 @@ private let profileJSON = """
158 "activity_total":0},"exit_code":0} 158 "activity_total":0},"exit_code":0}
159 """ 159 """
160 160
161private let orgShowJSON = """
162 {"protocol_version":1,"data":{"name":"krz","kind":"org",\
163 "description":"apps","members":[{"name":"cmc","role":"admin"},{"name":"guest","role":"member"}],\
164 "repos":[],"activity_total":0},"exit_code":0}
165 """
166
161@MainActor 167@MainActor
162struct ProfileSaveTests { 168struct ProfileSaveTests {
163 169
@@ -190,6 +196,46 @@ struct ProfileSaveTests {
190 #expect(argv.contains { $0.contains("long form") } == false) 196 #expect(argv.contains { $0.contains("long form") } == false)
191 } 197 }
192 198
199 /// An org's profile is written by `org profile <org>`, with the same
200 /// flags; the loaded profile's kind decides which command runs.
201 @Test func anOrgProfileSavesThroughOrgProfile() async throws {
202 let (client, stub) = try makeClient()
203 stub.enqueue(.init(status: 200, json: orgShowJSON, match: "argv=show"))
204 let model = ProfileViewModel(client: client, name: "krz")
205 await model.load()
206 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
207 stub.enqueue(.init(status: 200, json: orgShowJSON, match: "argv=show"))
208
209 let saved = await model.saveProfile(ProfileEdit(description: "apps", website: "https://krz.sh"))
210
211 #expect(saved)
212 let write = try #require(stub.seen.first { $0.method == "POST" })
213 let (argv, _) = try argvOf(write)
214 #expect(Array(argv.prefix(3)) == ["org", "profile", "krz"])
215 #expect(argv.contains("--description"))
216 }
217
218 @Test func anOrgAdminMayEditItsProfileAndAMemberMayNot() throws {
219 let profile = try JSONDecoder().decode(
220 Envelope.self, from: Data(orgShowJSON.utf8)).data
221
222 #expect(profile.canEdit(by: "cmc"))
223 #expect(!profile.canEdit(by: "guest"))
224 #expect(!profile.canEdit(by: "nobody"))
225 }
226
227 @Test func aUserProfileIsEditableOnlyByItsOwner() throws {
228 let profile = try JSONDecoder().decode(
229 Envelope.self, from: Data(profileJSON.utf8)).data
230
231 #expect(profile.canEdit(by: "cmc"))
232 #expect(!profile.canEdit(by: "guest"))
233 }
234
235 nonisolated private struct Envelope: Decodable {
236 let data: ProfileViewModel.Profile
237 }
238
193 /// The screen shows what the server stored, not what was typed — the 239 /// The screen shows what the server stored, not what was typed — the
194 /// reload's payload, not the edit, is what the caller sees next. 240 /// reload's payload, not the edit, is what the caller sees next.
195 @Test func aSuccessfulSaveReloadsFromTheServer() async throws { 241 @Test func aSuccessfulSaveReloadsFromTheServer() async throws {
gitbayUITests/LiveSmokeUITests.swift +7
@@ -939,6 +939,13 @@ extension LiveSmokeUITests {
939 .containing(NSPredicate(format: "label BEGINSWITH 'Repositories'")).firstMatch 939 .containing(NSPredicate(format: "label BEGINSWITH 'Repositories'")).firstMatch
940 .exists, "repositories missing") 940 .exists, "repositories missing")
941 941
942 // --- an org admin can edit the org's profile ---
943 app.staticTexts["krz"].firstMatch.tap()
944 XCTAssertTrue(app.descendants(matching: .any)
945 .matching(identifier: "profile-edit-button").firstMatch
946 .waitForExistence(timeout: 15), "org profile offers no edit to its admin")
947 back()
948
942 // --- a log entry opens its commit --- 949 // --- a log entry opens its commit ---
943 app.terminate() 950 app.terminate()
944 app.launch() 951 app.launch()