diff options
| author | Christian Cleberg <[email protected]> | 2026-08-07 16:29:04 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-08-07 16:29:04 -0500 |
| commit | 07156180789b02d4f22a87f22a37c912e7543190 (patch) | |
| tree | 6d943fc410b37d3dc7dfec72406688dbd97ac724 | |
| parent | 18b1c475983ae9e6a52fcedb2e2992193130035c (diff) | |
| download | hutch-07156180789b02d4f22a87f22a37c912e7543190.tar.gz hutch-07156180789b02d4f22a87f22a37c912e7543190.tar.bz2 hutch-07156180789b02d4f22a87f22a37c912e7543190.zip | |
Deploy keys: manage git.sr.ht repository deploy keys (v3.10.0)
Wire git.sr.ht createDeployKey / deleteDeployKey and Repository.deployKeys
into an owner-only Deploy Keys sheet in the repository actions menu, next to
ACLs: list keys (fingerprint, comment, access), add an SSH public key with
RO/RW access, and delete behind a confirmation.
Verified live against the ~hutch account. The createDeployKey response
returns an empty access value, so create selects only rid and the list is
reloaded rather than decoding the partial key.
Bumps to 3.10.0 (build 95); roadmap marks deploy keys shipped.
| -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 |
