diff options
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 24 | ||||
| -rw-r--r-- | Hutch/App/RootView.swift | 48 | ||||
| -rw-r--r-- | Hutch/Models/Meta.swift | 10 | ||||
| -rw-r--r-- | Hutch/Networking/SRHTClient.swift | 37 | ||||
| -rw-r--r-- | Hutch/Views/Lists/MailingListListView.swift | 368 | ||||
| -rw-r--r-- | Hutch/Views/More/ProfileView.swift | 56 | ||||
| -rw-r--r-- | Hutch/Views/Pastes/PasteListView.swift | 3 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectDetailView.swift | 11 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/ArtifactsView.swift | 154 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryDetailView.swift | 2 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryDetailViewModel.swift | 151 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryListViewModel.swift | 5 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositorySettingsViewModel.swift | 5 | ||||
| -rw-r--r-- | Hutch/Views/Settings/SettingsViewModel.swift | 42 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TrackerListView.swift | 3 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TrackerManagementView.swift | 6 | ||||
| -rw-r--r-- | README.md | 4 | ||||
| -rw-r--r-- | ROADMAP.md | 53 | ||||
| -rw-r--r-- | SCOPE.md | 36 |
19 files changed, 944 insertions, 74 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index 8f71248..32da475 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -597,7 +597,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 89; + CURRENT_PROJECT_VERSION = 90; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -614,7 +614,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.7.0; + MARKETING_VERSION = 3.8.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -634,7 +634,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 89; + CURRENT_PROJECT_VERSION = 90; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -651,7 +651,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.7.0; + MARKETING_VERSION = 3.8.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -714,7 +714,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 89; + CURRENT_PROJECT_VERSION = 90; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -724,7 +724,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.7.0; + MARKETING_VERSION = 3.8.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -743,7 +743,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 89; + CURRENT_PROJECT_VERSION = 90; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -753,7 +753,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.7.0; + MARKETING_VERSION = 3.8.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -772,7 +772,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 89; + CURRENT_PROJECT_VERSION = 90; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchSafariExtension/Info.plist; @@ -782,7 +782,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.7.0; + MARKETING_VERSION = 3.8.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -801,7 +801,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 89; + CURRENT_PROJECT_VERSION = 90; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchSafariExtension/Info.plist; @@ -811,7 +811,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.7.0; + MARKETING_VERSION = 3.8.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index 1ae4651..10720ad 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -291,39 +291,45 @@ struct RootView: View { } } + /// Replaces the target tab's path in one assignment. + /// + /// Resetting the path and appending to it afterwards races when the target tab + /// is already the one on screen: the reset starts an animated pop of the view + /// the user is standing on, and the appends land mid-animation, leaving a blank + /// screen. That is why opening a mailing list from a pinned project on Home + /// worked while the same tap under More → Projects did not — one changes tabs + /// and the other does not. + /// + /// Building the whole path first and assigning once gives SwiftUI a single + /// diff, with nothing to race. private func handleTabNavigation(_ target: AppState.TabNavigationTarget) { switch target { case .repository(let repository): - repoPath = NavigationPath() + var path = NavigationPath() + path.append(repository) + repoPath = path appState.selectedTab = .repositories - Task { - await settleNavigationTransition() - repoPath.append(repository) - } case .tracker(let tracker): - ticketsPath = NavigationPath() + var path = NavigationPath() + path.append(tracker) + ticketsPath = path appState.selectedTab = .tickets - Task { - await settleNavigationTransition() - ticketsPath.append(tracker) - } case .mailingList(let mailingList): - morePath = NavigationPath() + // .lists first so back lands on Mailing Lists rather than dead-ending. + var path = NavigationPath() + path.append(MoreRoute.lists) + path.append(MoreRoute.mailingList(mailingList)) + morePath = path appState.selectedTab = .more - Task { - await settleNavigationTransition() - morePath.append(MoreRoute.lists) - morePath.append(MoreRoute.mailingList(mailingList)) - } + case .systemStatus: - morePath = NavigationPath() + var path = NavigationPath() + path.append(MoreRoute.systemStatus) + morePath = path appState.selectedTab = .more - Task { - await settleNavigationTransition() - morePath.append(MoreRoute.systemStatus) - } + case .builds: buildsPath = NavigationPath() appState.selectedTab = .builds diff --git a/Hutch/Models/Meta.swift b/Hutch/Models/Meta.swift index 91bd7e3..f52fc76 100644 --- a/Hutch/Models/Meta.swift +++ b/Hutch/Models/Meta.swift @@ -69,3 +69,13 @@ struct PersonalAccessToken: Codable, Sendable, Identifiable { let comment: String? let grants: String? } + +/// One entry in meta.sr.ht's audit log: a security-relevant action on the +/// account, with the address it came from. +struct AuditLogEntry: Codable, Sendable, Identifiable { + let id: Int + let created: Date + let ipAddress: String + let eventType: String + let details: String? +} diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift index 94531f4..8aaa4aa 100644 --- a/Hutch/Networking/SRHTClient.swift +++ b/Hutch/Networking/SRHTClient.swift @@ -276,6 +276,43 @@ final class SRHTClient: Sendable { // MARK: - Plain-text fetch + /// Fetch the bytes at a URL using the same authorization header. + /// + /// sr.ht serves some resources from the API origin rather than the web one — + /// `Artifact.url` is `https://git.sr.ht/query/artifact/<checksum>/<filename>` + /// — and those return an auth error to anything without a bearer token. They + /// cannot be handed to a browser; they have to be fetched here. + func fetchData(url: URL) async throws -> Data { + guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else { + throw SRHTError.unauthorized + } + guard Self.isTrustedAuthenticatedTextURL(url) else { + throw SRHTError.invalidAuthenticatedURL(url) + } + + var request = URLRequest(url: url) + request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw SRHTError.networkError(error) + } + + if let http = response as? HTTPURLResponse { + if http.statusCode == 401 { + throw SRHTError.unauthorized + } + if !(200...299).contains(http.statusCode) { + throw SRHTError.httpError(http.statusCode) + } + } + + return data + } + /// Fetch the contents of a URL as plain text, using the same authorization header. /// Used for build logs and other non-GraphQL resources. func fetchText(url: URL) async throws -> String { diff --git a/Hutch/Views/Lists/MailingListListView.swift b/Hutch/Views/Lists/MailingListListView.swift index 09fa2a0..1b159bf 100644 --- a/Hutch/Views/Lists/MailingListListView.swift +++ b/Hutch/Views/Lists/MailingListListView.swift @@ -1,5 +1,9 @@ import SwiftUI +private struct ListIDPayload: Decodable, Sendable { + let id: Int +} + @Observable @MainActor final class MailingListListViewModel { @@ -35,10 +39,176 @@ final class MailingListListViewModel { } """ + private static let createMailingListMutation = """ + mutation createMailingList($name: String!, $description: String, $visibility: Visibility!) { + createMailingList(name: $name, description: $description, visibility: $visibility) { + id + rid + name + owner { canonicalName } + } + } + """ + + /// InboxMailingListReference carries only id/rid/name/owner, so the settings + /// sheet has to read the current values before it can offer to change them — + /// otherwise saving would blank the description and reset visibility. + private static let listSettingsQuery = """ + query listSettings($rid: ID!) { + list(rid: $rid) { + description + visibility + } + } + """ + + private static let updateMailingListMutation = """ + mutation updateMailingList($id: Int!, $input: MailingListInput!) { + updateMailingList(id: $id, input: $input) { id } + } + """ + + private static let deleteMailingListMutation = """ + mutation deleteMailingList($id: Int!) { + deleteMailingList(id: $id) { id } + } + """ + init(client: SRHTClient) { self.client = client } + /// Creates a list. sr.ht subscribes the owner automatically, so a reload is + /// enough to surface it — this view is built from the subscriptions query. + @discardableResult + func createMailingList(name: String, description: String, visibility: Visibility) async -> Bool { + guard !isPerformingAction else { return false } + isPerformingAction = true + error = nil + defer { isPerformingAction = false } + + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) + + do { + struct Response: Decodable, Sendable { + let createMailingList: InboxMailingListReference + } + + _ = try await client.execute( + service: .lists, + query: Self.createMailingListMutation, + variables: [ + "name": trimmedName, + "description": trimmedDescription.isEmpty ? nil as String? as Any : trimmedDescription, + "visibility": visibility.rawValue + ], + responseType: Response.self + ) + await loadMailingLists() + return true + } catch { + self.error = "Couldn't create \(trimmedName). \(error.userFacingMessage)" + return false + } + } + + /// Reads a list's current description and visibility, so the settings sheet + /// can seed itself rather than overwrite with blanks. + func listSettings(rid: String) async -> (description: String, visibility: Visibility)? { + struct Response: Decodable, Sendable { + let list: ListSettingsPayload? + } + + struct ListSettingsPayload: Decodable, Sendable { + let description: String? + let visibility: Visibility + } + + do { + let response = try await client.execute( + service: .lists, + query: Self.listSettingsQuery, + variables: ["rid": rid], + responseType: Response.self + ) + guard let list = response.list else { return nil } + return (list.description ?? "", list.visibility) + } catch { + self.error = "Couldn't load the list's settings. \(error.userFacingMessage)" + return nil + } + } + + /// Edits a list's description and visibility. + /// + /// `MailingListInput` also carries `permitMime` / `rejectMime`; those are left + /// alone rather than sent as empty, which would clear the list's filters. + @discardableResult + func updateMailingList(id: Int, description: String, visibility: Visibility) async -> Bool { + guard !isPerformingAction else { return false } + isPerformingAction = true + error = nil + defer { isPerformingAction = false } + + let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) + var input: [String: any Sendable] = ["visibility": visibility.rawValue] + if trimmedDescription.isEmpty { + // A nil subscript assignment would drop the key and leave the old + // description in place instead of clearing it. + input.updateValue(Optional<String>.none as any Sendable, forKey: "description") + } else { + input["description"] = trimmedDescription + } + + do { + struct Response: Decodable, Sendable { + let updateMailingList: ListIDPayload? + } + + _ = try await client.execute( + service: .lists, + query: Self.updateMailingListMutation, + variables: ["id": id, "input": input], + responseType: Response.self + ) + await loadMailingLists() + return true + } catch { + self.error = "Couldn't update the list. \(error.userFacingMessage)" + return false + } + } + + @discardableResult + func deleteMailingList(_ mailingList: InboxMailingListReference) async -> Bool { + guard !isPerformingAction else { return false } + isPerformingAction = true + error = nil + defer { isPerformingAction = false } + + let previousLists = mailingLists + mailingLists.removeAll { $0.rid == mailingList.rid } + + do { + struct Response: Decodable, Sendable { + let deleteMailingList: ListIDPayload? + } + + _ = try await client.execute( + service: .lists, + query: Self.deleteMailingListMutation, + variables: ["id": mailingList.id], + responseType: Response.self + ) + return true + } catch { + mailingLists = previousLists + self.error = "Couldn't delete \(mailingList.name). \(error.userFacingMessage)" + return false + } + } + /// Unsubscribes from a list and drops it from the list on success. This view /// is built from the subscriptions query, so a successful unsubscribe means /// the row no longer belongs here. @@ -149,6 +319,19 @@ struct MailingListListView: View { @Environment(AppState.self) private var appState @State private var viewModel: MailingListListViewModel? @State private var pendingUnsubscribe: InboxMailingListReference? + @State private var pendingDeletion: InboxMailingListReference? + @State private var editingList: InboxMailingListReference? + @State private var showCreateSheet = false + + /// The subscriptions query returns lists the user follows, which is not the + /// same as lists they own — only the owner may edit or delete one. + private func isOwned(_ mailingList: InboxMailingListReference) -> Bool { + guard let currentUser = appState.currentUser else { return false } + let owner = mailingList.owner.canonicalName.hasPrefix("~") + ? String(mailingList.owner.canonicalName.dropFirst()) + : mailingList.owner.canonicalName + return owner.caseInsensitiveCompare(currentUser.username) == .orderedSame + } var body: some View { Group { @@ -184,13 +367,32 @@ struct MailingListListView: View { } .padding(.vertical, 2) } - .swipeActions(edge: .trailing) { - Button { - pendingUnsubscribe = mailingList - } label: { - SwiftUI.Label("Unsubscribe", systemImage: "bell.slash") + // allowsFullSwipe: false, as in PasteListView. A destructive + // action left to full-swipe animates the row out on the gesture, + // before the confirmation is answered, so it flickers back when + // the data has not actually changed. + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if isOwned(mailingList) { + Button { + pendingDeletion = mailingList + } label: { + SwiftUI.Label("Delete", systemImage: "trash") + } + .tint(.red) + Button { + editingList = mailingList + } label: { + SwiftUI.Label("Settings", systemImage: "gear") + } + .tint(.gray) + } else { + Button { + pendingUnsubscribe = mailingList + } label: { + SwiftUI.Label("Unsubscribe", systemImage: "bell.slash") + } + .tint(.orange) } - .tint(.orange) } } .themedRow() @@ -218,6 +420,46 @@ struct MailingListListView: View { } message: { _ in Text("You will stop receiving email from this list. Hutch cannot resubscribe you — you would need to do that from the list's page on the web.") } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showCreateSheet = true + } label: { + SwiftUI.Label("New List", systemImage: "plus") + } + .disabled(viewModel.isPerformingAction) + } + } + .sheet(isPresented: $showCreateSheet) { + MailingListEditSheet(mode: .create, isPresented: $showCreateSheet) { name, description, visibility in + await viewModel.createMailingList(name: name, description: description, visibility: visibility) + } + } + .sheet(item: $editingList) { mailingList in + MailingListEditSheet( + mode: .edit(mailingList.name), + isPresented: .init(get: { true }, set: { if !$0 { editingList = nil } }), + loadInitialValues: { await viewModel.listSettings(rid: mailingList.rid) } + ) { _, description, visibility in + await viewModel.updateMailingList(id: mailingList.id, description: description, visibility: visibility) + } + } + .confirmationDialog( + pendingDeletion.map { "Delete \($0.name)?" } ?? "", + isPresented: .init( + get: { pendingDeletion != nil }, + set: { if !$0 { pendingDeletion = nil } } + ), + titleVisibility: .visible, + presenting: pendingDeletion + ) { mailingList in + Button("Delete List", role: .destructive) { + Task { await viewModel.deleteMailingList(mailingList) } + } + Button("Cancel", role: .cancel) { pendingDeletion = nil } + } message: { _ in + Text("This permanently deletes the list and its entire archive, for everyone. This cannot be undone.") + } .overlay { if viewModel.isLoading, viewModel.mailingLists.isEmpty { SRHTLoadingStateView(message: "Loading mailing lists…") @@ -243,3 +485,117 @@ struct MailingListListView: View { } } } + +// MARK: - Edit Sheet + +/// Create and settings share a sheet: sr.ht takes name only at creation, and +/// description plus visibility in both cases. +private struct MailingListEditSheet: View { + enum Mode { + case create + case edit(String) + + var title: String { + switch self { + case .create: "New Mailing List" + case .edit(let name): name + } + } + + var isCreate: Bool { + if case .create = self { return true } + return false + } + } + + let mode: Mode + @Binding var isPresented: Bool + /// Seeds the sheet with the list's current values. Editing without this would + /// save blanks over whatever is already there. + var loadInitialValues: (() async -> (description: String, visibility: Visibility)?)? + let onSubmit: (String, String, Visibility) async -> Bool + + @State private var name = "" + @State private var description = "" + @State private var visibility: Visibility = .publicVisibility + @State private var isSubmitting = false + @State private var isLoadingInitialValues = false + @State private var hasLoadedInitialValues = false + + private var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canSubmit: Bool { + guard !isSubmitting, !isLoadingInitialValues else { return false } + if mode.isCreate { return !trimmedName.isEmpty } + // Never offer to save values we have not read back yet. + return hasLoadedInitialValues + } + + var body: some View { + NavigationStack { + Form { + if mode.isCreate { + Section("Name") { + TextField("list-name", text: $name) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .themedRow() + } + } + + Section("Description") { + TextField("Description", text: $description, axis: .vertical) + .lineLimit(2...6) + .themedRow() + } + + Section("Visibility") { + Picker("Visibility", selection: $visibility) { + Text("Public").tag(Visibility.publicVisibility) + Text("Unlisted").tag(Visibility.unlisted) + Text("Private").tag(Visibility.privateVisibility) + } + .pickerStyle(.inline) + .labelsHidden() + .themedRow() + } + } + .themedList() + .navigationTitle(mode.title) + .navigationBarTitleDisplayMode(.inline) + .task { + guard let loadInitialValues, !hasLoadedInitialValues else { return } + isLoadingInitialValues = true + if let current = await loadInitialValues() { + description = current.description + visibility = current.visibility + hasLoadedInitialValues = true + } + isLoadingInitialValues = false + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { isPresented = false } + } + ToolbarItem(placement: .confirmationAction) { + Button(mode.isCreate ? "Create" : "Save") { + Task { + isSubmitting = true + let ok = await onSubmit(trimmedName, description, visibility) + isSubmitting = false + if ok { isPresented = false } + } + } + .disabled(!canSubmit) + } + } + .overlay { + if isSubmitting || isLoadingInitialValues { + ProgressView() + } + } + } + } +} diff --git a/Hutch/Views/More/ProfileView.swift b/Hutch/Views/More/ProfileView.swift index 45f7d06..fc1ed2f 100644 --- a/Hutch/Views/More/ProfileView.swift +++ b/Hutch/Views/More/ProfileView.swift @@ -84,6 +84,7 @@ struct ProfileView: View { sshKeysSection(viewModel) pgpKeysSection(viewModel) patSection(viewModel) + auditLogSection(viewModel) } } .themedList() @@ -432,6 +433,61 @@ struct ProfileView: View { } } } + + /// Loaded on demand, like the tokens above — an audit log is something you go + /// looking for, not something worth a request on every profile view. + @ViewBuilder + private func auditLogSection(_ viewModel: SettingsViewModel) -> some View { + Section { + if viewModel.isLoadingAuditLog { + HStack { + Spacer() + ProgressView() + Spacer() + } + .themedRow() + } else if let error = viewModel.auditLogError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .themedRow() + } else if viewModel.auditLog.isEmpty { + Button("Load Audit Log") { + Task { await viewModel.loadAuditLog() } + } + .themedRow() + } else { + ForEach(viewModel.auditLog) { entry in + VStack(alignment: .leading, spacing: 4) { + Text(entry.eventType) + .font(.subheadline) + + if let details = entry.details, !details.isEmpty { + Text(details) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(3) + } + + HStack(spacing: 12) { + Text(entry.created.relativeDescription) + Text(entry.ipAddress) + .monospaced() + } + .font(.caption2) + .foregroundStyle(.tertiary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(entry.eventType), \(entry.created.relativeDescription), from \(entry.ipAddress)") + } + .themedRow() + } + } header: { + Text("Audit Log") + } footer: { + Text("Recent security-relevant activity on your account, newest first. The full log lives on meta.sr.ht.") + } + } } private struct ProfileBioView: View { diff --git a/Hutch/Views/Pastes/PasteListView.swift b/Hutch/Views/Pastes/PasteListView.swift index b325153..b2c7838 100644 --- a/Hutch/Views/Pastes/PasteListView.swift +++ b/Hutch/Views/Pastes/PasteListView.swift @@ -90,11 +90,12 @@ struct PasteListView: View { } .swipeActions(edge: .trailing, allowsFullSwipe: false) { if swipeActionsEnabled { - Button(role: .destructive) { + Button { pasteToDelete = paste } label: { Label("Delete", systemImage: "trash") } + .tint(.red) } } .task { diff --git a/Hutch/Views/Projects/ProjectDetailView.swift b/Hutch/Views/Projects/ProjectDetailView.swift index b5c6cdb..c30ec88 100644 --- a/Hutch/Views/Projects/ProjectDetailView.swift +++ b/Hutch/Views/Projects/ProjectDetailView.swift @@ -196,9 +196,14 @@ struct ProjectDetailView: View { if !displayedProject.mailingLists.isEmpty { Section("Mailing Lists") { ForEach(displayedProject.mailingLists) { mailingList in - Button { - appState.openMailingList(mailingList.inboxReference) - dismiss() + // Pushed here rather than routed through AppState. Projects + // already lives in the More tab, so asking for a tab + // navigation made the path rebuild itself while dismiss() + // popped this view out from under it, leaving a blank screen. + // Sources and trackers still route, because they genuinely + // land in other tabs. + NavigationLink { + MailingListDetailView(mailingList: mailingList.inboxReference) } label: { ProjectResourceRow( title: mailingList.displayName, diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift index b8caf4c..69da0d6 100644 --- a/Hutch/Views/Repositories/ArtifactsView.swift +++ b/Hutch/Views/Repositories/ArtifactsView.swift @@ -1,24 +1,146 @@ import SwiftUI +import UniformTypeIdentifiers struct ArtifactsView: View { let viewModel: RepositoryDetailViewModel - @Environment(\.openURL) private var openURL + /// Passed in rather than recomputed: RepositoryDetailView already owns this + /// check and gates its other management surfaces on it. + var canManage: Bool = false + + @State private var uploadTargetRef: String? + @State private var isImporting = false + @State private var pendingDeletion: ArtifactInfo? + @State private var downloadedFile: DownloadedArtifact? + + private var isOwnedByCurrentUser: Bool { canManage } + + /// A menu rather than a confirmation dialog: this view already presents one + /// for delete, and two .confirmationDialog modifiers on the same view leave + /// one of them silently dead. A menu also puts the tags one tap away. + @ViewBuilder + private var uploadMenu: some View { + Menu { + if viewModel.tags.isEmpty { + Text("This repository has no tags") + } else { + ForEach(viewModel.tags.prefix(12), id: \.name) { tag in + Button(RepositorySummary.displayBranchName(for: tag.name)) { + uploadTargetRef = tag.name + isImporting = true + } + } + } + } label: { + SwiftUI.Label("Upload Artifact…", systemImage: "square.and.arrow.up") + } + // Deliberately not disabled when there are no tags. The explanation for + // that state lives inside the menu, and disabling the control makes the + // explanation unreachable — the tap just dies with no reason given. + .disabled(viewModel.isMutatingArtifact) + } var body: some View { - List { + @Bindable var vm = viewModel + + return List { + // In the list rather than the toolbar: this view is a segment inside + // RepositoryDetailView's tab switch, not its own navigation + // destination, and a toolbar declared from there does not reliably + // reach the navigation bar. It also has to be reachable when there are + // no artifacts at all, which is the state a new tag is in. + if isOwnedByCurrentUser { + uploadMenu + .themedRow() + } + ForEach(viewModel.referenceArtifacts) { refArtifacts in - Section(refArtifacts.name) { + Section { ForEach(refArtifacts.artifacts) { artifact in ArtifactRow(artifact: artifact) { - openURL(artifact.url) + Task { + // Artifact.url is on the API origin and 401s + // without a bearer token, so it cannot be handed + // to a browser. Fetch it and share the file. + if let fileURL = await viewModel.downloadArtifact(artifact) { + downloadedFile = DownloadedArtifact(url: fileURL) + } + } + } + // See MailingListListView: a full-swipe destructive + // action animates the row out before the confirmation. + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if isOwnedByCurrentUser { + Button { + pendingDeletion = artifact + } label: { + SwiftUI.Label("Delete", systemImage: "trash") + } + .tint(.red) + } } } .themedRow() + } header: { + HStack { + Text(refArtifacts.name) + if isOwnedByCurrentUser { + Spacer() + // Upload targets a specific tag, so the control belongs + // on the tag rather than in the toolbar. + Button { + uploadTargetRef = refArtifacts.name + isImporting = true + } label: { + SwiftUI.Label("Upload", systemImage: "plus.circle") + .font(.caption) + } + .disabled(viewModel.isMutatingArtifact) + } + } } } } + // isImporting drives presentation; uploadTargetRef carries the tag. They + // have to be separate: a binding derived from uploadTargetRef clears it on + // dismissal, and dismissal happens before the completion runs — so the + // completion read nil and returned without uploading anything. + .fileImporter( + isPresented: $isImporting, + allowedContentTypes: [.data] + ) { result in + let revspec = uploadTargetRef + uploadTargetRef = nil + guard let revspec, case .success(let fileURL) = result else { return } + Task { await viewModel.uploadArtifact(revspec: revspec, fileURL: fileURL) } + } + .confirmationDialog( + pendingDeletion.map { "Delete \($0.filename)?" } ?? "", + isPresented: .init( + get: { pendingDeletion != nil }, + set: { if !$0 { pendingDeletion = nil } } + ), + titleVisibility: .visible, + presenting: pendingDeletion + ) { artifact in + Button("Delete Artifact", role: .destructive) { + Task { await viewModel.deleteArtifact(id: artifact.id) } + } + Button("Cancel", role: .cancel) { pendingDeletion = nil } + } message: { _ in + Text("This permanently removes the artifact from the tag. This cannot be undone.") + } .themedList() .listStyle(.insetGrouped) + .srhtErrorBanner(error: $vm.error) + .sheet(item: $downloadedFile) { download in + FileContentShareSheet(activityItems: [download.url]) + } + .task { + // Tags drive the picker above and are not otherwise needed by this tab. + if isOwnedByCurrentUser, viewModel.tags.isEmpty { + await viewModel.loadReferences() + } + } .overlay { if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty { SRHTLoadingStateView(message: "Loading artifacts…") @@ -29,11 +151,18 @@ struct ArtifactsView: View { retryAction: { await viewModel.loadArtifacts() } ) } else if viewModel.referenceArtifacts.isEmpty { - ContentUnavailableView( - "No Artifacts", - systemImage: "archivebox", - description: Text("This repository has no release artifacts.") - ) + // The overlay covers the whole list, so the upload row above is + // hidden underneath it — and a repository with no artifacts is + // exactly the one that needs uploading. Offer it here too. + ContentUnavailableView { + SwiftUI.Label("No Artifacts", systemImage: "archivebox") + } description: { + Text("This repository has no release artifacts.") + } actions: { + if isOwnedByCurrentUser { + uploadMenu + } + } } } .task { @@ -47,6 +176,13 @@ struct ArtifactsView: View { } } +/// Wraps the downloaded file for `.sheet(item:)`. URL is not Identifiable, and +/// conforming a stdlib type retroactively is worse than a four-line struct. +private struct DownloadedArtifact: Identifiable { + let id = UUID() + let url: URL +} + private struct ArtifactRow: View { let artifact: ArtifactInfo let onDownload: () -> Void diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift index 6e7343f..8f466ba 100644 --- a/Hutch/Views/Repositories/RepositoryDetailView.swift +++ b/Hutch/Views/Repositories/RepositoryDetailView.swift @@ -121,7 +121,7 @@ struct RepositoryDetailView: View { case .refs: ReferencesListView(viewModel: viewModel) case .artifacts: - ArtifactsView(viewModel: viewModel) + ArtifactsView(viewModel: viewModel, canManage: canManageRepository) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift index 4839437..9b6b942 100644 --- a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift +++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift @@ -75,6 +75,20 @@ private struct PathObject: Decodable, Sendable { let text: String? } +private struct UploadArtifactResponse: Decodable, Sendable { + let uploadArtifact: ArtifactInfo +} + +private struct DeleteArtifactResponse: Decodable, Sendable { + /// Nullable in the schema: sr.ht returns null when there was no artifact to + /// remove, which is still a success from the caller's point of view. + let deleteArtifact: ArtifactIDPayload? +} + +private struct ArtifactIDPayload: Decodable, Sendable { + let id: Int +} + private struct ArtifactsResponse: Decodable, Sendable { let repository: ArtifactsRepository? } @@ -144,6 +158,7 @@ final class RepositoryDetailViewModel { private(set) var referenceArtifacts: [ReferenceWithArtifacts] = [] private(set) var isLoadingArtifacts = false + private(set) var isMutatingArtifact = false // MARK: - Error @@ -457,6 +472,26 @@ final class RepositoryDetailViewModel { // MARK: - Artifacts + /// `file` is a top-level Upload variable here, unlike meta's avatar upload + /// where it is nested inside an input object. + private static let uploadArtifactMutation = """ + mutation uploadArtifact($repoId: Int!, $revspec: String!, $file: Upload!) { + uploadArtifact(repoId: $repoId, revspec: $revspec, file: $file) { + id + filename + checksum + size + url + } + } + """ + + private static let deleteArtifactMutation = """ + mutation deleteArtifact($id: Int!) { + deleteArtifact(id: $id) { id } + } + """ + private static let artifactsQuery = """ query artifacts($rid: ID!) { repository(rid: $rid) { @@ -480,6 +515,122 @@ final class RepositoryDetailViewModel { } """ + /// Attaches a file to the tag named by `revspec`. + /// + /// sr.ht requires the filename to be unique among the repository's artifacts, + /// and rejects a duplicate rather than replacing it, so the error is surfaced + /// as-is rather than being retried. + @discardableResult + func uploadArtifact(revspec: String, fileURL: URL) async -> Bool { + guard !isMutatingArtifact else { return false } + isMutatingArtifact = true + error = nil + defer { isMutatingArtifact = false } + + let needsScopedAccess = fileURL.startAccessingSecurityScopedResource() + defer { + if needsScopedAccess { + fileURL.stopAccessingSecurityScopedResource() + } + } + + let fileData: Data + do { + fileData = try Data(contentsOf: fileURL) + } catch { + self.error = "Couldn't read \(fileURL.lastPathComponent)." + return false + } + + // sr.ht streams the upload into S3, which rejects a zero-part multipart + // completion with "MalformedXML" — an error that says nothing about the + // actual problem. Catch it here where we can name it. + guard !fileData.isEmpty else { + self.error = "\(fileURL.lastPathComponent) is empty. SourceHut rejects zero-byte artifacts." + return false + } + + do { + _ = try await client.executeMultipart( + service: service, + query: Self.uploadArtifactMutation, + variables: [ + "repoId": repository.id, + "revspec": revspec, + "file": nil as String? as Any + ], + file: MultipartUploadFile( + variablePath: "file", + fileData: fileData, + fileName: fileURL.lastPathComponent, + mimeType: Self.mimeType(for: fileURL) + ), + responseType: UploadArtifactResponse.self + ) + await reloadArtifacts() + return true + } catch { + self.error = "Couldn't upload \(fileURL.lastPathComponent). \(error.userFacingMessage)" + return false + } + } + + /// Downloads an artifact and returns a local file URL to share. + /// + /// `Artifact.url` points at the API origin, not the web one, and returns an + /// auth error to anything without a bearer token — so it cannot be opened in + /// a browser. Fetch it here and hand the user the file instead. + func downloadArtifact(_ artifact: ArtifactInfo) async -> URL? { + guard !isMutatingArtifact else { return nil } + isMutatingArtifact = true + error = nil + defer { isMutatingArtifact = false } + + do { + let data = try await client.fetchData(url: artifact.url) + let destination = FileManager.default.temporaryDirectory + .appendingPathComponent(artifact.filename) + try data.write(to: destination, options: .atomic) + return destination + } catch { + self.error = "Couldn't download \(artifact.filename). \(error.userFacingMessage)" + return nil + } + } + + @discardableResult + func deleteArtifact(id: Int) async -> Bool { + guard !isMutatingArtifact else { return false } + isMutatingArtifact = true + error = nil + defer { isMutatingArtifact = false } + + do { + _ = try await client.execute( + service: service, + query: Self.deleteArtifactMutation, + variables: ["id": id], + responseType: DeleteArtifactResponse.self + ) + await reloadArtifacts() + return true + } catch { + self.error = "Couldn't delete the artifact. \(error.userFacingMessage)" + return false + } + } + + private func reloadArtifacts() async { + isLoadingArtifacts = false + await loadArtifacts() + } + + /// Artifacts are release tarballs and signatures rather than media, so a + /// generic binary type is honest more often than guessing from the extension. + private nonisolated static func mimeType(for url: URL) -> String { + "application/octet-stream" + } + func loadArtifacts() async { guard !isLoadingArtifacts else { return } isLoadingArtifacts = true diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift index 695abf8..b3669e6 100644 --- a/Hutch/Views/Repositories/RepositoryListViewModel.swift +++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift @@ -189,7 +189,10 @@ final class RepositoryListViewModel { filteredResults = [] } } else { - let repositories = try await fetchAllRepositories(useCache: true) + // forceRefresh used to reach only the build statuses, so a pull to + // refresh re-served the cached list and a deleted repository stayed + // on screen. + let repositories = try await fetchAllRepositories(useCache: !forceRefresh) updateSearchIndex(with: repositories) filteredResults = repositories } diff --git a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift index dd8d7a1..0b31125 100644 --- a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift +++ b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift @@ -231,6 +231,11 @@ final class RepositorySettingsViewModel { variables: ["id": repositoryId], responseType: DeleteRepositoryResponse.self ) + // The list and Home are both served from cache, so without this the + // repository lingers on screen after it no longer exists. Creation + // already does this; deletion never did. + await client.invalidateCache(prefix: APICacheKeys.prefix(service.rawValue, "repositories")) + await client.invalidateCache(prefix: APICacheKeys.prefix("home")) didDelete = true } catch { self.error = error.userFacingMessage diff --git a/Hutch/Views/Settings/SettingsViewModel.swift b/Hutch/Views/Settings/SettingsViewModel.swift index edfaad4..8536e60 100644 --- a/Hutch/Views/Settings/SettingsViewModel.swift +++ b/Hutch/Views/Settings/SettingsViewModel.swift @@ -43,6 +43,15 @@ private struct PATListResponse: Decodable, Sendable { let personalAccessTokens: [PersonalAccessToken] } +private struct AuditLogResponse: Decodable, Sendable { + let auditLog: AuditLogPage +} + +private struct AuditLogPage: Decodable, Sendable { + let results: [AuditLogEntry] + let cursor: String? +} + // MARK: - View Model @Observable @@ -53,6 +62,11 @@ final class SettingsViewModel { private(set) var sshKeys: [SSHKey] = [] private(set) var pgpKeys: [PGPKey] = [] private(set) var personalAccessTokens: [PersonalAccessToken] = [] + private(set) var auditLog: [AuditLogEntry] = [] + private(set) var isLoadingAuditLog = false + /// Kept apart from `error` so a failed audit fetch cannot bury a profile + /// save failure, and vice versa. + var auditLogError: String? private(set) var isLoading = false private(set) var isLoadingPATs = false @@ -139,6 +153,12 @@ final class SettingsViewModel { } """ + private static let auditLogQuery = """ + query auditLog { + auditLog { results { id created ipAddress eventType details } } + } + """ + private static let personalAccessTokensQuery = """ query personalAccessTokens { personalAccessTokens { id issued expires comment grants } @@ -393,4 +413,26 @@ final class SettingsViewModel { isLoadingPATs = false } + // MARK: - Audit Log + + /// Loads the most recent audit entries. + /// + /// Deliberately one page: this is a glanceable "has anything happened to my + /// account" surface, not an archive. The full log is on meta.sr.ht. + func loadAuditLog() async { + guard !isLoadingAuditLog else { return } + isLoadingAuditLog = true + defer { isLoadingAuditLog = false } + + do { + let result = try await client.execute( + service: .meta, + query: Self.auditLogQuery, + responseType: AuditLogResponse.self + ) + auditLog = result.auditLog.results + } catch { + auditLogError = error.userFacingMessage + } + } } diff --git a/Hutch/Views/Tickets/TrackerListView.swift b/Hutch/Views/Tickets/TrackerListView.swift index 5deb513..cb4a59c 100644 --- a/Hutch/Views/Tickets/TrackerListView.swift +++ b/Hutch/Views/Tickets/TrackerListView.swift @@ -145,11 +145,12 @@ struct TrackerListView: View { TrackerRowView(tracker: tracker) } .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { + Button { pendingDeletion = tracker } label: { Label("Delete", systemImage: "trash") } + .tint(.red) Button { editingTracker = tracker diff --git a/Hutch/Views/Tickets/TrackerManagementView.swift b/Hutch/Views/Tickets/TrackerManagementView.swift index fad1ae8..73b2a08 100644 --- a/Hutch/Views/Tickets/TrackerManagementView.swift +++ b/Hutch/Views/Tickets/TrackerManagementView.swift @@ -751,11 +751,12 @@ struct TrackerACLManagementSheet: View { TrackerPermissionSummary(permissions: entry.permissions) } .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { + Button { pendingDeletion = entry } label: { Label("Delete", systemImage: "trash") } + .tint(.red) Button { editingACL = entry @@ -1132,11 +1133,12 @@ struct TrackerLabelManagementSheet: View { } .tint(.blue) - Button(role: .destructive) { + Button { pendingDeletion = label } label: { Label("Delete", systemImage: "trash") } + .tint(.red) } } .themedRow() @@ -14,13 +14,15 @@ The app currently includes: - Home dashboard with assigned tickets, recent builds, and projects - Repository browsing for Git and Mercurial repositories - Repository details including README, references, commits, diffs, files, artifacts, and settings +- Artifact upload and deletion on release tags - Tracker and ticket browsing, ticket detail views, and tracker creation - Ticket editing and deletion, with subscriptions for tickets and trackers - Build job browsing, build detail views, and build submission - Inbox and mailing list reading flows - Patchset review: cover letters, per-patch diffs, checks, version chains, and status changes +- Mailing list creation, settings, and deletion - Paste browsing, creation, and detail views -- Profile and account settings, including SSH keys, PGP keys, and personal access token management +- Profile and account settings, including SSH keys, PGP keys, personal access token management, and the audit log - Email preferences for todo.sr.ht and lists.sr.ht - Deep links for repositories, tickets, and build jobs @@ -131,22 +131,43 @@ GraphQL mutation. Treat that boundary as explicit rather than half-building it. ## Phase 3: Polish and reach -- **Localization.** The project sets `LOCALIZATION_PREFERS_STRING_CATALOGS = - YES` but ships no string catalog, so every user-facing string is hardcoded - English. -- **Accessibility.** Labels and hints appear in only 16 of roughly 130 view - files. -- `uploadArtifact` / `deleteArtifact` — artifacts are read-only today. -- Webhook management. Zero calls to any `create*Webhook` across every service. - Push notifications are out of scope because they need a relay server (see - [SCOPE.md](SCOPE.md)), but webhook management is client-side only and is a - prerequisite if that relay ever ships. -- `auditLog` (meta.sr.ht) — unused security surface. -- Build groups (`createGroup`, `startGroup`) and secret management - (`shareSecret`, the `secrets` query). Today `secrets` is only a submit toggle. -- Mailing list creation and settings (`createMailingList`, `updateMailingList`, - `deleteMailingList`). -- `events` feed (todo.sr.ht) and `archiveMessage` (lists.sr.ht). +Unlike Phases 1 and 2, this is not one shippable thing. It is several, and they +are sized very differently — measure before committing to one. + +### API features — done (v3.8.0) + +- ~~`uploadArtifact` / `deleteArtifact`~~ — artifacts were read-only. +- ~~`auditLog` (meta.sr.ht)~~ — surfaced under the tokens in Profile. +- ~~Mailing list creation and settings~~ (`createMailingList`, + `updateMailingList`, `deleteMailingList`). + +Three of the six planned. The other three did not survive contact: + +- `archiveMessage` is `@internal` and inaccessible. +- The `events` feed was built, then removed: todo.sr.ht's root `events` resolver + joins `event.participant_id` against `participant.user_id`, which are + different id spaces, so it returns an empty list for everyone. See + [SCOPE.md](SCOPE.md). +- Webhook management, `shareSecret`, and build groups are reachable but declined + on judgement — see [SCOPE.md](SCOPE.md) for the reasoning, so they do not get + re-proposed. + +### Localization + +The project sets `LOCALIZATION_PREFERS_STRING_CATALOGS = YES` but ships no +string catalog, so every user-facing string is hardcoded English. Roughly 634 +literals: 239 `Text(`, 150 `Label(`, 117 `Button(`, 77 `Section(`, 51 +`navigationTitle(`. + +Worth knowing before starting: a catalog containing only English changes nothing +for users until translations exist. It is groundwork, and it is the largest diff +in the roadmap — it touches nearly every view, with the regression risk that +implies. + +### Accessibility + +Labels and hints appear in 17 of 89 view files. Mechanical and low-risk, but it +cannot be verified from a build — it needs VoiceOver driven on a device. ### Swift 6 language mode @@ -7,3 +7,39 @@ - Explore / search (hub.sr.ht) (no public discovery API) - Pronouns on profile (not in GraphQL schema) - Revoke personal access tokens (`@internal` in schema, inaccessible) +- Archive a message to a list (`archiveMessage` is `@internal`, inaccessible) +- Ticket activity feed (todo.sr.ht's root `events` query is broken upstream and + returns an empty list for every user). `event.participant_id` references + `participant(id)`, but the resolver joins it against `participant.user_id`: + + ```sql + FROM event ev + JOIN participant p ON p.user_id = ev.participant_id -- id space vs user id space + WHERE p.user_id = <viewer> + ``` + + The rows exist — the writer inserts `participant.ID` for the submitter and for + every subscriber — but that join cannot find them. `Ticket.events` is + unaffected because it filters on `ev.ticket_id`, which is why ticket timelines + work. Nothing a client can do fixes this; revisit only if sr.ht changes the + resolver. +- Subscribe to a mailing list (`mailingListSubscribe` exists, but `MailingList` + has no `subscription` field and sr.ht has no discovery API, so there is no way + to find a list you are not already subscribed to — see hub.sr.ht above) +- Submitting patches (a `git send-email` flow, not a GraphQL mutation; Hutch + reviews patchsets but cannot send them) + +## Declined rather than blocked + +These are reachable in the API. They are left out on judgement, not capability. + +- **Webhook management** (24 fields across five services). A webhook needs an + HTTPS endpoint you control to receive POSTs. Without the relay above, this + only serves someone already running their own endpoint, and that person is not + managing it from a phone. Reconsider if `hutch-notify` ever ships. +- **`shareSecret`.** Shares a build secret — an SSH key or PAT — with another + user. A mistap grants someone else a credential, and nothing in the app can + take it back. That belongs on the web behind a full-size confirmation. The + read-only `secrets` list would be fine on its own. +- **Build groups** (`createGroup`, `startGroup`). Multi-job pipelines are + authored in `.build.yml`, not composed on a phone. |
