Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions macgit/App/AccountSessionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ final class AccountSessionController: ObservableObject {
openSettings(.connections)
}

func presentIntegrations() {
openSettings(.integrations)
}

private func openSettings(_ section: AppSettingsSection) {
// Route through a repository/welcome host even when an auxiliary window is key.
let sourceWindow = NSApp.orderedWindows.first { window in
Expand Down
14 changes: 14 additions & 0 deletions macgit/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ final class AppState: ObservableObject {
private static let showWorkspaceReflogKey = "showWorkspaceReflog"
private static let showWorkspacePullRequestsKey = "showWorkspacePullRequests"
private static let showWorkspaceGitLFSKey = "showWorkspaceGitLFS"
private static let showWorkspaceJiraKey = "showWorkspaceJira"
private static let showTagsKey = "showTags"
private static let showWorktreesKey = "showWorktrees"
private static let showSubmodulesKey = "showSubmodules"
Expand Down Expand Up @@ -111,6 +112,14 @@ final class AppState: ObservableObject {
}
}
}
@Published var showWorkspaceJira: Bool {
didSet {
userDefaults.set(showWorkspaceJira, forKey: Self.showWorkspaceJiraKey)
if !isApplyingSnapshot {
currentSettingsSnapshot = snapshot
}
}
}
@Published var showTags: Bool {
didSet {
userDefaults.set(showTags, forKey: Self.showTagsKey)
Expand Down Expand Up @@ -320,6 +329,7 @@ final class AppState: ObservableObject {
let showWorkspaceReflog = userDefaults.object(forKey: Self.showWorkspaceReflogKey) as? Bool ?? true
let showWorkspacePullRequests = userDefaults.object(forKey: Self.showWorkspacePullRequestsKey) as? Bool ?? true
let showWorkspaceGitLFS = userDefaults.object(forKey: Self.showWorkspaceGitLFSKey) as? Bool ?? true
let showWorkspaceJira = userDefaults.object(forKey: Self.showWorkspaceJiraKey) as? Bool ?? true
let showTags = userDefaults.object(forKey: Self.showTagsKey) as? Bool ?? true
let showWorktrees = userDefaults.object(forKey: Self.showWorktreesKey) as? Bool ?? true
let showSubmodules = userDefaults.object(forKey: Self.showSubmodulesKey) as? Bool ?? false
Expand Down Expand Up @@ -358,6 +368,7 @@ final class AppState: ObservableObject {
showWorkspaceReflog: showWorkspaceReflog,
showWorkspacePullRequests: showWorkspacePullRequests,
showWorkspaceGitLFS: showWorkspaceGitLFS,
showWorkspaceJira: showWorkspaceJira,
showTags: showTags,
showWorktrees: showWorktrees,
showSubmodules: showSubmodules,
Expand Down Expand Up @@ -393,6 +404,7 @@ final class AppState: ObservableObject {
self.showWorkspaceReflog = showWorkspaceReflog
self.showWorkspacePullRequests = showWorkspacePullRequests
self.showWorkspaceGitLFS = showWorkspaceGitLFS
self.showWorkspaceJira = showWorkspaceJira
self.showTags = showTags
self.showWorktrees = showWorktrees
self.showSubmodules = showSubmodules
Expand Down Expand Up @@ -428,6 +440,7 @@ final class AppState: ObservableObject {
showWorkspaceReflog: showWorkspaceReflog,
showWorkspacePullRequests: showWorkspacePullRequests,
showWorkspaceGitLFS: showWorkspaceGitLFS,
showWorkspaceJira: showWorkspaceJira,
showTags: showTags,
showWorktrees: showWorktrees,
showSubmodules: showSubmodules,
Expand Down Expand Up @@ -477,6 +490,7 @@ final class AppState: ObservableObject {
showWorkspaceReflog = snapshot.showWorkspaceReflog
showWorkspacePullRequests = snapshot.showWorkspacePullRequests
showWorkspaceGitLFS = snapshot.showWorkspaceGitLFS
showWorkspaceJira = snapshot.showWorkspaceJira
showTags = snapshot.showTags
showWorktrees = snapshot.showWorktrees
showSubmodules = snapshot.showSubmodules
Expand Down
196 changes: 196 additions & 0 deletions macgit/App/JiraConnectionController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

import Combine
import Foundation

/// Owns the single Jira Cloud connection for the signed-in Commit+ account on this Mac.
@MainActor
final class JiraConnectionController: ObservableObject {
@Published private(set) var connection: JiraConnection?
@Published private(set) var isWorking = false
@Published private(set) var hasLoaded = false
@Published var errorMessage: String?
private(set) var macgitUID: String?

let client: JiraIssueServing
private let tokenStore: JiraTokenStoring
private let connectionStore: JiraConnectionPersisting

init(
client: JiraIssueServing = JiraCloudClient(),
tokenStore: JiraTokenStoring = KeychainJiraTokenStore(),
connectionStore: JiraConnectionPersisting = LocalJiraConnectionStore()
) {
self.client = client
self.tokenStore = tokenStore
self.connectionStore = connectionStore
}

var isSignedIn: Bool { macgitUID != nil }

func updateMacgitAccount(uid: String?) async {
guard uid != macgitUID || !hasLoaded else { return }
macgitUID = uid
connection = nil
errorMessage = nil
guard let uid else {
hasLoaded = true
return
}
let stored = try? await connectionStore.connection(for: uid)
guard macgitUID == uid else { return }
connection = stored
hasLoaded = true
}

/// The saved credential, or nil when nothing is connected or the token is missing from the Keychain.
func credential() -> JiraCredential? {
guard let connection, let token = try? tokenStore.token(for: connection), !token.isEmpty else {
return nil
}
return JiraCredential(email: connection.email, token: token)
}

@discardableResult
func connect(site: String, email: String, token: String) async -> Bool {
guard let uid = macgitUID else {
errorMessage = JiraError.notSignedIn.localizedDescription
return false
}
guard connection == nil else {
errorMessage = "Remove the connected Jira site before connecting another one."
return false
}
let email = email.trimmingCharacters(in: .whitespacesAndNewlines)
let token = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !email.isEmpty, !token.isEmpty else {
errorMessage = "Enter your Atlassian account email and API token."
return false
}

isWorking = true
defer { isWorking = false }
errorMessage = nil
do {
let siteURL = try JiraSiteNormalizer.normalize(site)
let credential = JiraCredential(email: email, token: token)
let account = try await client.validate(siteURL: siteURL, credential: credential)
var connection = JiraConnection(
macgitUID: uid,
siteURL: siteURL,
email: account.email,
accountID: account.accountID,
displayName: account.displayName,
apiBase: account.apiBase
)
connection.sprintFieldID = try await discoverSprintField(connection: connection, credential: credential)
guard macgitUID == uid else { return false }
try persist(connection, token: token)
try await saveMetadata(connection)
self.connection = connection
return true
} catch {
errorMessage = Self.message(for: error)
return false
}
}

/// Verifies a new token for the same site and account before replacing the saved one.
@discardableResult
func reconnect(token: String) async -> Bool {
guard let current = connection else {
errorMessage = JiraError.notConnected.localizedDescription
return false
}
let token = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !token.isEmpty else {
errorMessage = "Enter a new Jira API token."
return false
}

isWorking = true
defer { isWorking = false }
errorMessage = nil
do {
let credential = JiraCredential(email: current.email, token: token)
let account = try await client.validate(siteURL: current.siteURL, credential: credential)
guard account.accountID == current.accountID else {
errorMessage = "This API token belongs to a different Jira account. Remove the connection to switch accounts."
return false
}
var updated = current
updated.apiBase = account.apiBase
updated.displayName = account.displayName
updated.needsReconnect = false
updated.sprintFieldID = try await discoverSprintField(connection: updated, credential: credential)
?? current.sprintFieldID
guard connection?.id == current.id else { return false }
let previousToken = try? tokenStore.token(for: current)
try persist(updated, token: token)
do {
try await connectionStore.save(updated)
} catch {
if let previousToken { try? tokenStore.saveToken(previousToken, for: current) }
throw JiraError.storage("Commit+ could not save the Jira connection on this Mac.")
}
connection = updated
return true
} catch {
errorMessage = Self.message(for: error)
return false
}
}

func remove() async {
guard let current = connection else { return }
isWorking = true
defer { isWorking = false }
do {
try tokenStore.deleteToken(for: current)
try await connectionStore.removeConnection(for: current.macgitUID)
connection = nil
errorMessage = nil
} catch {
errorMessage = "Commit+ could not remove the Jira connection: \(error.localizedDescription)"
}
}

/// Called when Jira rejects the saved token during normal use.
func markNeedsReconnect() async {
guard var current = connection, !current.needsReconnect else { return }
current.needsReconnect = true
connection = current
try? await connectionStore.save(current)
}

private func discoverSprintField(connection: JiraConnection, credential: JiraCredential) async throws -> String? {
do {
return try await client.sprintFieldID(baseURL: connection.apiBaseURL, credential: credential)
} catch JiraError.forbidden, JiraError.invalidResponse {
// Without field access the list still works; only sprint filtering is unavailable.
return nil
}
}

private func persist(_ connection: JiraConnection, token: String) throws {
do {
try tokenStore.saveToken(token, for: connection)
} catch {
throw JiraError.storage("Commit+ could not save the Jira API token in the Keychain.")
}
}

private func saveMetadata(_ connection: JiraConnection) async throws {
do {
try await connectionStore.save(connection)
} catch {
try? tokenStore.deleteToken(for: connection)
throw JiraError.storage("Commit+ could not save the Jira connection on this Mac.")
}
}

static func message(for error: Error) -> String {
if let error = error as? JiraError { return error.localizedDescription }
return JiraError.unreachable.localizedDescription
}
}
Loading