diff options
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 24 | ||||
| -rw-r--r-- | Hutch/Networking/RepositoryDeployKeyService.swift | 100 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryDeployKeysView.swift | 272 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryDetailView.swift | 16 | ||||
| -rw-r--r-- | HutchTests/RepositoryDeployKeyTests.swift | 27 | ||||
| -rw-r--r-- | ROADMAP.txt | 21 |
6 files changed, 438 insertions, 22 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index efff26f..48a1c0f 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 = 94; + CURRENT_PROJECT_VERSION = 95; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -614,7 +614,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.9.0; + MARKETING_VERSION = 3.10.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 = 94; + CURRENT_PROJECT_VERSION = 95; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -651,7 +651,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.9.0; + MARKETING_VERSION = 3.10.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 = 94; + CURRENT_PROJECT_VERSION = 95; 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.9.0; + MARKETING_VERSION = 3.10.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 = 94; + CURRENT_PROJECT_VERSION = 95; 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.9.0; + MARKETING_VERSION = 3.10.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 = 94; + CURRENT_PROJECT_VERSION = 95; 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.9.0; + MARKETING_VERSION = 3.10.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 = 94; + CURRENT_PROJECT_VERSION = 95; 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.9.0; + MARKETING_VERSION = 3.10.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Hutch/Networking/RepositoryDeployKeyService.swift b/Hutch/Networking/RepositoryDeployKeyService.swift new file mode 100644 index 0000000..39e2da1 --- /dev/null +++ b/Hutch/Networking/RepositoryDeployKeyService.swift @@ -0,0 +1,100 @@ +import Foundation + +/// A per-repository deploy key (git.sr.ht `SSHKey` under `Repository.deployKeys`). +struct RepositoryDeployKey: Decodable, Sendable, Identifiable, Hashable { + let rid: String + let keyType: String + let fingerprintSHA256: String + let comment: String? + let access: AccessMode + + var id: String { rid } +} + +private struct DeployKeysQueryResponse: Decodable, Sendable { + let repository: DeployKeysRepository? +} + +private struct DeployKeysRepository: Decodable, Sendable { + let deployKeys: DeployKeysPage +} + +private struct DeployKeysPage: Decodable, Sendable { + let results: [RepositoryDeployKey] +} + +/// `createDeployKey`'s response returns an empty `access` (the stored value is +/// correct — the list query reports it), so we select only `rid` here and let +/// callers reload rather than decode the partial key. +private struct CreateDeployKeyResponse: Decodable, Sendable {} + +/// Delete returns the removed key; only success matters here. +private struct DeleteDeployKeyResponse: Decodable, Sendable {} + +/// Deploy keys are a git.sr.ht capability (`createDeployKey` / `deleteDeployKey`), +/// owner-only, alongside repository ACLs. +struct RepositoryDeployKeyService: Sendable { + private let client: SRHTClient + + init(client: SRHTClient) { + self.client = client + } + + func fetchDeployKeys(repositoryRid: String) async throws -> [RepositoryDeployKey] { + let response = try await client.execute( + service: .git, + query: Self.deployKeysQuery, + variables: ["rid": repositoryRid], + responseType: DeployKeysQueryResponse.self + ) + return response.repository?.deployKeys.results ?? [] + } + + func createDeployKey(repositoryRid: String, mode: AccessMode, key: String) async throws { + _ = try await client.execute( + service: .git, + query: Self.createDeployKeyMutation, + variables: ["repo": repositoryRid, "mode": mode.rawValue, "key": key], + responseType: CreateDeployKeyResponse.self + ) + } + + func deleteDeployKey(rid: String) async throws { + _ = try await client.execute( + service: .git, + query: Self.deleteDeployKeyMutation, + variables: ["rid": rid], + responseType: DeleteDeployKeyResponse.self + ) + } +} + +private extension RepositoryDeployKeyService { + static let deployKeysQuery = """ + query repositoryDeployKeys($rid: ID!) { + repository(rid: $rid) { + deployKeys { + results { + rid + keyType + fingerprintSHA256 + comment + access + } + } + } + } + """ + + static let createDeployKeyMutation = """ + mutation createDeployKey($repo: ID!, $mode: AccessMode!, $key: String!) { + createDeployKey(repo: $repo, mode: $mode, key: $key) { rid } + } + """ + + static let deleteDeployKeyMutation = """ + mutation deleteDeployKey($rid: ID!) { + deleteDeployKey(rid: $rid) { rid } + } + """ +} diff --git a/Hutch/Views/Repositories/RepositoryDeployKeysView.swift b/Hutch/Views/Repositories/RepositoryDeployKeysView.swift new file mode 100644 index 0000000..71e252b --- /dev/null +++ b/Hutch/Views/Repositories/RepositoryDeployKeysView.swift @@ -0,0 +1,272 @@ +import SwiftUI + +@Observable +@MainActor +final class RepositoryDeployKeysViewModel { + private(set) var keys: [RepositoryDeployKey] = [] + private(set) var isLoading = false + private(set) var isSaving = false + private(set) var deletingRID: String? + var loadError: String? + var error: String? + var saveError: String? + + let repositoryRid: String + private let service: RepositoryDeployKeyService + + init(repositoryRid: String, service: RepositoryDeployKeyService) { + self.repositoryRid = repositoryRid + self.service = service + } + + func load() async { + guard !isLoading else { return } + isLoading = true + loadError = nil + defer { isLoading = false } + do { + keys = try await service.fetchDeployKeys(repositoryRid: repositoryRid) + } catch { + if keys.isEmpty { + loadError = error.userFacingMessage + } else { + self.error = error.userFacingMessage + } + } + } + + func addKey(publicKey: String, mode: AccessMode) async -> Bool { + let trimmed = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !isSaving else { return false } + isSaving = true + saveError = nil + defer { isSaving = false } + do { + try await service.createDeployKey(repositoryRid: repositoryRid, mode: mode, key: trimmed) + // The create response omits the key's fields, so reload the list. + keys = try await service.fetchDeployKeys(repositoryRid: repositoryRid) + return true + } catch { + saveError = error.userFacingMessage + return false + } + } + + func deleteKey(_ key: RepositoryDeployKey) async { + guard deletingRID == nil else { return } + deletingRID = key.rid + error = nil + defer { deletingRID = nil } + do { + try await service.deleteDeployKey(rid: key.rid) + keys.removeAll { $0.rid == key.rid } + } catch { + self.error = error.userFacingMessage + } + } +} + +struct RepositoryDeployKeysView: View { + let repository: RepositorySummary + let client: SRHTClient + var showsDoneButton = false + + @Environment(\.dismiss) private var dismiss + @State private var viewModel: RepositoryDeployKeysViewModel? + @State private var showAddSheet = false + @State private var pendingDeletion: RepositoryDeployKey? + + var body: some View { + Group { + if let viewModel { + content(viewModel) + } else { + SRHTLoadingStateView(message: "Loading deploy keys…") + } + } + .navigationTitle("Deploy Keys") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if showsDoneButton { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + if viewModel != nil { + ToolbarItem(placement: .topBarTrailing) { + Button { + showAddSheet = true + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("Add deploy key") + } + } + } + .task { + if viewModel == nil { + let vm = RepositoryDeployKeysViewModel( + repositoryRid: repository.rid, + service: RepositoryDeployKeyService(client: client) + ) + viewModel = vm + await vm.load() + } + } + } + + @ViewBuilder + private func content(_ viewModel: RepositoryDeployKeysViewModel) -> some View { + Group { + if viewModel.isLoading, viewModel.keys.isEmpty, viewModel.loadError == nil { + SRHTLoadingStateView(message: "Loading deploy keys…") + } else if let loadError = viewModel.loadError, viewModel.keys.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Deploy Keys", + message: loadError, + retryAction: { await viewModel.load() } + ) + } else { + List { + if viewModel.keys.isEmpty { + Section { + ContentUnavailableView( + "No Deploy Keys", + systemImage: "key", + description: Text("Add an SSH public key to grant this repository read or read/write access for automation.") + ) + .themedRow() + } + } else { + Section { + ForEach(viewModel.keys) { key in + DeployKeyRow(key: key, isDeleting: viewModel.deletingRID == key.rid) + .themedRow() + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + pendingDeletion = key + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } footer: { + Text("Deploy keys are SSH keys scoped to this repository only.") + } + } + } + .themedList() + .refreshable { await viewModel.load() } + } + } + .srhtErrorBanner(error: Binding(get: { viewModel.error }, set: { viewModel.error = $0 })) + .confirmationDialog( + "Delete this deploy key?", + isPresented: Binding(get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } }), + titleVisibility: .visible + ) { + Button("Cancel", role: .cancel) { pendingDeletion = nil } + Button("Delete", role: .destructive) { + if let key = pendingDeletion { + pendingDeletion = nil + Task { await viewModel.deleteKey(key) } + } + } + } message: { + Text("This revokes the key's access to \(repository.name). This cannot be undone.") + } + .sheet(isPresented: $showAddSheet) { + AddDeployKeyView(viewModel: viewModel) + } + } +} + +private struct DeployKeyRow: View { + let key: RepositoryDeployKey + let isDeleting: Bool + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(key.comment?.isEmpty == false ? key.comment! : key.keyType) + .font(.body) + .lineLimit(1) + Text(key.fingerprintSHA256) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + if isDeleting { + ProgressView().controlSize(.small) + } else { + Text(key.access.displayName) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) + } +} + +private struct AddDeployKeyView: View { + let viewModel: RepositoryDeployKeysViewModel + + @Environment(\.dismiss) private var dismiss + @State private var publicKey = "" + @State private var mode: AccessMode = .ro + + var body: some View { + NavigationStack { + Form { + Section("SSH Public Key") { + TextField("ssh-ed25519 AAAA… comment", text: $publicKey, axis: .vertical) + .lineLimit(3...8) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.body.monospaced()) + .themedRow() + } + Section { + Picker("Access", selection: $mode) { + Text("Read Only").tag(AccessMode.ro) + Text("Read/Write").tag(AccessMode.rw) + } + .themedRow() + } footer: { + Text("Read/Write lets the key push to this repository.") + } + if let saveError = viewModel.saveError, !saveError.isEmpty { + Section { + Text(saveError).foregroundStyle(.red).themedRow() + } + } + } + .themedList() + .navigationTitle("Add Deploy Key") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + if await viewModel.addKey(publicKey: publicKey, mode: mode) { + dismiss() + } + } + } label: { + if viewModel.isSaving { + ProgressView().controlSize(.small) + } else { + Text("Add") + } + } + .disabled(publicKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSaving) + } + } + } + } +} diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift index 8f466ba..a4ac25e 100644 --- a/Hutch/Views/Repositories/RepositoryDetailView.swift +++ b/Hutch/Views/Repositories/RepositoryDetailView.swift @@ -12,6 +12,7 @@ struct RepositoryDetailView: View { @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary @State private var showSettings = false @State private var showACLs = false + @State private var showDeployKeys = false @State private var currentRepository: RepositorySummary @State private var pinChangeCount = 0 @@ -82,6 +83,15 @@ struct RepositoryDetailView: View { ) } } + .sheet(isPresented: $showDeployKeys) { + NavigationStack { + RepositoryDeployKeysView( + repository: currentRepository, + client: appState.client, + showsDoneButton: true + ) + } + } .task { if viewModel == nil { viewModel = RepositoryDetailViewModel( @@ -201,6 +211,12 @@ struct RepositoryDetailView: View { } Button { + showDeployKeys = true + } label: { + Label("Deploy Keys", systemImage: "key") + } + + Button { showSettings = true } label: { Label("Repository Settings", systemImage: "gear") diff --git a/HutchTests/RepositoryDeployKeyTests.swift b/HutchTests/RepositoryDeployKeyTests.swift new file mode 100644 index 0000000..c6ff667 --- /dev/null +++ b/HutchTests/RepositoryDeployKeyTests.swift @@ -0,0 +1,27 @@ +import Foundation +import Testing +@testable import Hutch + +struct RepositoryDeployKeyTests { + @Test + func decodesDeployKey() throws { + let json = """ + { "rid": "k1", "keyType": "ssh-ed25519", "fingerprintSHA256": "SHA256:abc", "comment": "laptop", "access": "RW" } + """ + let key = try JSONDecoder().decode(RepositoryDeployKey.self, from: Data(json.utf8)) + #expect(key.rid == "k1") + #expect(key.id == "k1") + #expect(key.access == .rw) + #expect(key.comment == "laptop") + } + + @Test + func decodesDeployKeyWithNullComment() throws { + let json = """ + { "rid": "k2", "keyType": "ssh-rsa", "fingerprintSHA256": "SHA256:def", "comment": null, "access": "RO" } + """ + let key = try JSONDecoder().decode(RepositoryDeployKey.self, from: Data(json.utf8)) + #expect(key.comment == nil) + #expect(key.access == .ro) + } +} diff --git a/ROADMAP.txt b/ROADMAP.txt index 6c4765e..25c32e5 100644 --- a/ROADMAP.txt +++ b/ROADMAP.txt @@ -144,7 +144,7 @@ so "breaking change" does not apply. These buckets track *user-visible scale*. | v3.8.1 | SonarCloud triage; housekeeping | No behaviour change at all | | v3.8.2 | Home system status moved to a title-bar status badge | Small UI relocation, no new surface | | v3.9.0 | ~~hub.sr.ht project writes + discovery (#12–#15); multi-language highlighting (#16); App Intents expansion (#17); man-page catalog sync (#7); checklist / recent-activity / pull-to-refresh fixes (#18, #11, #9)~~ | Shipped — the cut this session | -| v3.10.0 | "What's cooking" ingest; doc truth-up; deploy keys; revisit `mailingListSubscribe` | Ships one feature, corrects the map | +| v3.10.0 | ~~git.sr.ht deploy keys~~ (shipped); "What's cooking" ingest; doc truth-up; revisit `mailingListSubscribe` | Ships one feature, corrects the map | | v3.11.0 | Accessibility | Independent, device-verified | | v4.0.0 | Localization *with* translations | The only true re-presentation | | — | Swift 6 language mode; cache reads | Internal; ride along, no tag | @@ -276,7 +276,7 @@ exception earns the release its own line: the `forceRefresh` fix changes what pull-to-refresh does, so it needs a manual pass on a device before v3.8.1 ships, not just a green suite. -### Ingest "What's cooking on SourceHut?" — v3.9.0 +### Ingest "What's cooking on SourceHut?" — v3.10.0 sr.ht posts a quarterly update to `~sircmpwn/sr.ht-announce`, mirrored at <https://sourcehut.org/blog/>. Nothing in Hutch tracks it, so the API grows and @@ -294,19 +294,20 @@ flags two openings: - **hub.sr.ht gained a writable GraphQL API** for managing projects and project resources. ~~Rechecked and shipped~~: project create/edit, resource link/unlink, and public discovery landed (#12–#15) — see "hub.sr.ht writes" - below. `SCOPE.txt` still needs its "hub has no public API / no discovery" - claim corrected. `mailingListSubscribe` was *not* unblocked — that needs a + below. `SCOPE.txt`'s "hub has no public API / no discovery" claim has since + been corrected. `mailingListSubscribe` was *not* unblocked — that needs a per-list subscription field lists.sr.ht still lacks. -- **git.sr.ht deploy keys are complete** (`createDeployKey` / `deleteDeployKey` - are in the SDL). Hutch never calls them. +- ~~git.sr.ht deploy keys are complete~~ — **shipped** (v3.10.0). + `createDeployKey` / `deleteDeployKey` (and `Repository.deployKeys`) are wired + into the repository actions menu, owner-only, alongside ACLs. Start from Q1 2026 forward — that is roughly when the current `Docs/API` dumps were captured. -Research does not ship, so v3.9.0 pairs the ingest with **deploy keys** — the one -self-contained feature it has already surfaced and that the SDL confirms exists. -That gives the release something a user can see. Everything else the ingest turns -up gets filed, not built, and hub.sr.ht gets its own bucket below. +Deploy keys — the one self-contained feature the ingest had already surfaced and +that the SDL confirmed — shipped in v3.10.0, so this bucket is now the ingest +itself: research that files what changed rather than building. Everything else it +turns up gets filed, not built. ### hub.sr.ht writes — projects and discovery done |
