summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-08-07 16:35:16 -0500
committerGitHub <[email protected]>2026-08-07 16:35:16 -0500
commit9671eac8eeb9d06e25563f51bf81889b5537feeb (patch)
tree6d943fc410b37d3dc7dfec72406688dbd97ac724 /Hutch
parent18b1c475983ae9e6a52fcedb2e2992193130035c (diff)
parent07156180789b02d4f22a87f22a37c912e7543190 (diff)
downloadhutch-b4db9d529fdf3ceb0266a4f995f8dc1bb4c4ec2c.tar.gz
hutch-b4db9d529fdf3ceb0266a4f995f8dc1bb4c4ec2c.tar.bz2
hutch-b4db9d529fdf3ceb0266a4f995f8dc1bb4c4ec2c.zip
Merge pull request #36 from krazywarez/deploy-keysv3.10.0
Deploy keys: manage git.sr.ht repository deploy keys (v3.10.0)
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/Networking/RepositoryDeployKeyService.swift100
-rw-r--r--Hutch/Views/Repositories/RepositoryDeployKeysView.swift272
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailView.swift16
3 files changed, 388 insertions, 0 deletions
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")