From 1bcb8bf43fe45d49f41aa2b955ef4a652e8590f7 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Sat, 24 Oct 2026 13:48:19 +0700 Subject: [PATCH 1/2] feat: Add Pro-gated Jira sprint issue list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect a Jira Cloud site with an Atlassian API token in Settings → Integrations; the token stays in this Mac's Keychain and is never synced. A new Jira item in the sidebar Workspace section lists the user's assigned issues as a flat, filterable list with a details panel, and can start a branch or the matching Git Flow topic from an issue. The screen is gated by the new Pro-only `jiraIssues` plan feature, and the `showWorkspaceJira` toggle is added to synced settings. Co-Authored-By: Claude Opus 5.5 --- macgit/App/AccountSessionController.swift | 4 + macgit/App/AppState.swift | 14 + macgit/App/JiraConnectionController.swift | 196 +++++ macgit/App/JiraIssueListController.swift | 342 +++++++++ macgit/App/macgitApp.swift | 8 +- macgit/Models/AppSettingsSnapshot.swift | 4 + macgit/Models/BranchSheetPresentation.swift | 1 + macgit/Models/FeatureAccessNotice.swift | 8 +- macgit/Models/FeatureAccessPolicy.swift | 6 +- macgit/Models/JiraBranchNameSuggester.swift | 76 ++ macgit/Models/JiraModels.swift | 222 ++++++ .../jira.imageset/Contents.json | 21 + .../Assets.xcassets/jira.imageset/jira.png | Bin 0 -> 5445 bytes macgit/Services/FeaturePolicyStore.swift | 2 +- macgit/Services/FirestoreSettingsStore.swift | 2 + macgit/Services/JiraCloudClient.swift | 235 ++++++ macgit/Services/JiraConnectionStore.swift | 101 +++ macgit/Services/JiraJQLBuilder.swift | 80 +++ .../Account/JiraIntegrationSection.swift | 184 +++++ .../Views/Common/AppSettingsDetailView.swift | 2 +- macgit/Views/Common/BranchSheetView.swift | 10 + .../Common/IntegrationsSettingsView.swift | 11 + macgit/Views/Common/ProUpgradeSheet.swift | 2 +- macgit/Views/Jira/JiraIssueListView.swift | 674 ++++++++++++++++++ macgit/Views/MainWindow/ContentView.swift | 2 + .../MainWindow/MainWindowView+GitFlow.swift | 1 + .../MainWindow/MainWindowView+Jira.swift | 17 + .../MainWindow/MainWindowView+Sheets.swift | 6 +- macgit/Views/MainWindow/MainWindowView.swift | 62 +- .../RepositoryAIChatResizeHandle.swift | 16 +- .../MainWindow/Sidebar/SidebarItem.swift | 3 + .../Sidebar/SidebarNativeList.swift | 4 +- .../MainWindow/Sidebar/SidebarSection.swift | 15 +- .../Sidebar/WorkspaceVisibilityPopover.swift | 1 + .../MainWindow/SidebarView+NativeRows.swift | 5 +- macgit/Views/Workflow/StartGitFlowSheet.swift | 2 + macgitTests/AppSettingsSnapshotTests.swift | 9 + .../BranchSheetInitialStateTests.swift | 10 + macgitTests/CloudSettingsDocumentTests.swift | 5 + .../FeatureAccessControllerTests.swift | 8 + macgitTests/FeatureAccessNoticeTests.swift | 10 + macgitTests/FeatureAccessPolicyTests.swift | 29 + .../JiraBranchNameSuggesterTests.swift | 71 ++ macgitTests/JiraCloudClientTests.swift | 176 +++++ .../JiraConnectionControllerTests.swift | 140 ++++ macgitTests/JiraConnectionStoreTests.swift | 121 ++++ .../JiraIssueListControllerTests.swift | 236 ++++++ macgitTests/JiraJQLBuilderTests.swift | 90 +++ macgitTests/JiraSiteNormalizerTests.swift | 37 + macgitTests/SidebarNativeListTests.swift | 14 + .../add-jira-sprint-issues/.openspec.yaml | 2 + .../changes/add-jira-sprint-issues/design.md | 117 +++ .../add-jira-sprint-issues/proposal.md | 34 + .../specs/jira-account-connection/spec.md | 97 +++ .../specs/jira-issue-list/spec.md | 192 +++++ .../changes/add-jira-sprint-issues/tasks.md | 91 +++ 56 files changed, 3802 insertions(+), 26 deletions(-) create mode 100644 macgit/App/JiraConnectionController.swift create mode 100644 macgit/App/JiraIssueListController.swift create mode 100644 macgit/Models/JiraBranchNameSuggester.swift create mode 100644 macgit/Models/JiraModels.swift create mode 100644 macgit/Resources/Assets.xcassets/jira.imageset/Contents.json create mode 100644 macgit/Resources/Assets.xcassets/jira.imageset/jira.png create mode 100644 macgit/Services/JiraCloudClient.swift create mode 100644 macgit/Services/JiraConnectionStore.swift create mode 100644 macgit/Services/JiraJQLBuilder.swift create mode 100644 macgit/Views/Account/JiraIntegrationSection.swift create mode 100644 macgit/Views/Jira/JiraIssueListView.swift create mode 100644 macgit/Views/MainWindow/MainWindowView+Jira.swift create mode 100644 macgitTests/JiraBranchNameSuggesterTests.swift create mode 100644 macgitTests/JiraCloudClientTests.swift create mode 100644 macgitTests/JiraConnectionControllerTests.swift create mode 100644 macgitTests/JiraConnectionStoreTests.swift create mode 100644 macgitTests/JiraIssueListControllerTests.swift create mode 100644 macgitTests/JiraJQLBuilderTests.swift create mode 100644 macgitTests/JiraSiteNormalizerTests.swift create mode 100644 openspec/changes/add-jira-sprint-issues/.openspec.yaml create mode 100644 openspec/changes/add-jira-sprint-issues/design.md create mode 100644 openspec/changes/add-jira-sprint-issues/proposal.md create mode 100644 openspec/changes/add-jira-sprint-issues/specs/jira-account-connection/spec.md create mode 100644 openspec/changes/add-jira-sprint-issues/specs/jira-issue-list/spec.md create mode 100644 openspec/changes/add-jira-sprint-issues/tasks.md diff --git a/macgit/App/AccountSessionController.swift b/macgit/App/AccountSessionController.swift index 96d842ba..70e449bd 100644 --- a/macgit/App/AccountSessionController.swift +++ b/macgit/App/AccountSessionController.swift @@ -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 diff --git a/macgit/App/AppState.swift b/macgit/App/AppState.swift index df5cf01c..5025b12c 100644 --- a/macgit/App/AppState.swift +++ b/macgit/App/AppState.swift @@ -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" @@ -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) @@ -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 @@ -358,6 +368,7 @@ final class AppState: ObservableObject { showWorkspaceReflog: showWorkspaceReflog, showWorkspacePullRequests: showWorkspacePullRequests, showWorkspaceGitLFS: showWorkspaceGitLFS, + showWorkspaceJira: showWorkspaceJira, showTags: showTags, showWorktrees: showWorktrees, showSubmodules: showSubmodules, @@ -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 @@ -428,6 +440,7 @@ final class AppState: ObservableObject { showWorkspaceReflog: showWorkspaceReflog, showWorkspacePullRequests: showWorkspacePullRequests, showWorkspaceGitLFS: showWorkspaceGitLFS, + showWorkspaceJira: showWorkspaceJira, showTags: showTags, showWorktrees: showWorktrees, showSubmodules: showSubmodules, @@ -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 diff --git a/macgit/App/JiraConnectionController.swift b/macgit/App/JiraConnectionController.swift new file mode 100644 index 00000000..fbdbe2cb --- /dev/null +++ b/macgit/App/JiraConnectionController.swift @@ -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 + } +} diff --git a/macgit/App/JiraIssueListController.swift b/macgit/App/JiraIssueListController.swift new file mode 100644 index 00000000..7c9f056c --- /dev/null +++ b/macgit/App/JiraIssueListController.swift @@ -0,0 +1,342 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Combine +import Foundation + +protocol JiraIssueFilterStoring { + func filter(forRepositoryKey key: String) async -> JiraIssueFilter? + func save(_ filter: JiraIssueFilter, forRepositoryKey key: String) async +} + +/// Remembers each repository's Jira filter on this Mac. Search text is never stored. +final class LocalJiraIssueFilterStore: JiraIssueFilterStoring { + static let collection = "jiraIssueFilters" + private let dataStore: LocalDataStore + + init(dataStore: LocalDataStore? = nil) { + self.dataStore = dataStore ?? .shared + } + + func filter(forRepositoryKey key: String) async -> JiraIssueFilter? { + try? await dataStore.readValue(JiraIssueFilter.self, in: Self.collection, id: key) + } + + func save(_ filter: JiraIssueFilter, forRepositoryKey key: String) async { + _ = try? await dataStore.transaction { transaction in + if filter.isDefault { + transaction.remove(in: Self.collection, id: key) + } else { + try transaction.set(filter, in: Self.collection, id: key) + } + } + } +} + +struct JiraProjectOption: Hashable, Identifiable { + var key: String + var name: String + + var id: String { key } +} + +/// The Jira issue list for one repository window. +@MainActor +final class JiraIssueListController: ObservableObject { + @Published private(set) var items: [JiraIssue] = [] + @Published private(set) var isLoading = false + @Published private(set) var isLoadingMore = false + @Published private(set) var hasLoadedList = false + @Published private(set) var error: JiraError? + @Published private(set) var nextPageToken: String? + /// Whether the user has any assigned issues at all; checked only when a filtered list is empty. + @Published private(set) var hasAnyAssignedIssues = true + @Published private(set) var filter = JiraIssueFilter.default + @Published var searchText = "" { + didSet { + guard searchText != oldValue else { return } + scheduleSearch() + } + } + @Published private(set) var sprintOptions: [JiraSprintRef] = [] + @Published private(set) var projectOptions: [JiraProjectOption] = [] + @Published private(set) var issueTypeOptions: [String] = [] + + private let connectionController: JiraConnectionController + private let filterStore: JiraIssueFilterStoring + private let repositoryKey: String + private let searchDebounce: Duration + private var hasRestoredFilter = false + private var isActive = false + private var generation = 0 + private var loadTask: Task? + private var searchTask: Task? + private var loadedConnectionID: String? + private var knownSprints: [Int: JiraSprintRef] = [:] + private var knownProjects: [String: String] = [:] + private var knownIssueTypes: Set = [] + private var connectionObservation: AnyCancellable? + + init( + repositoryURL: URL, + connectionController: JiraConnectionController, + filterStore: JiraIssueFilterStoring = LocalJiraIssueFilterStore(), + searchDebounce: Duration = .milliseconds(300) + ) { + self.connectionController = connectionController + self.filterStore = filterStore + self.repositoryKey = repositoryURL.standardizedFileURL.path + self.searchDebounce = searchDebounce + connectionObservation = connectionController.$connection + .map { $0?.id } + .removeDuplicates() + .dropFirst() + .sink { [weak self] _ in + Task { @MainActor in self?.connectionDidChange() } + } + } + + var connection: JiraConnection? { connectionController.connection } + + var supportsSprints: Bool { connection?.supportsSprints ?? false } + + var isFiltered: Bool { !filter.isDefault || !searchText.trimmingCharacters(in: .whitespaces).isEmpty } + + /// Called while the Jira screen is shown and access is allowed. The first call restores the filter and loads. + func activate() async { + isActive = true + if !hasRestoredFilter { + hasRestoredFilter = true + if let stored = await filterStore.filter(forRepositoryKey: repositoryKey) { + filter = stored + rememberSelection(of: stored) + } + } + if !hasLoadedList || loadedConnectionID != connection?.id { + reload() + } + } + + func deactivate() { + isActive = false + } + + func refresh() { + reload() + } + + func updateFilter(_ update: (inout JiraIssueFilter) -> Void) { + var updated = filter + update(&updated) + guard updated != filter else { return } + filter = updated + rememberSelection(of: updated) + let filterStore = filterStore + let repositoryKey = repositoryKey + Task { await filterStore.save(updated, forRepositoryKey: repositoryKey) } + reload() + } + + func resetFilters() { + searchTask?.cancel() + if !searchText.isEmpty { + // Avoid a second, debounced reload from the didSet. + searchText = "" + searchTask?.cancel() + } + if filter.isDefault { + reload() + } else { + updateFilter { $0 = .default } + } + } + + func loadMore() { + guard let nextPageToken, !isLoading, !isLoadingMore, error == nil else { return } + guard let connection, let credential = connectionController.credential() else { return } + let generation = generation + let query = currentFilterWithSearch + isLoadingMore = true + loadTask = Task { + defer { if generation == self.generation { isLoadingMore = false } } + do { + let page = try await search(query, connection: connection, credential: credential, pageToken: nextPageToken) + guard generation == self.generation else { return } + items += page.issues.filter { issue in !items.contains { $0.id == issue.id } } + self.nextPageToken = page.nextPageToken + learnOptions(from: page.issues) + } catch { + guard generation == self.generation, !(error is CancellationError) else { return } + await handle(error) + } + } + } + + // MARK: - Loading + + private var currentFilterWithSearch: JiraIssueFilter { + var query = filter + query.searchText = searchText + return query + } + + private func scheduleSearch() { + searchTask?.cancel() + let delay = searchDebounce + searchTask = Task { + try? await Task.sleep(for: delay) + guard !Task.isCancelled else { return } + reload() + } + } + + private func reload() { + loadTask?.cancel() + generation += 1 + let generation = generation + guard let connection else { + items = [] + nextPageToken = nil + error = nil + isLoading = false + isLoadingMore = false + loadedConnectionID = nil + return + } + guard let credential = connectionController.credential() else { + isLoading = false + error = .needsReconnect + return + } + let query = currentFilterWithSearch + let probeNeeded = query != Self.allAssignedIssues + isLoading = true + isLoadingMore = false + loadTask = Task { + do { + let page = try await search(query, connection: connection, credential: credential, pageToken: nil) + guard generation == self.generation else { return } + var hasAny = true + if page.issues.isEmpty, probeNeeded { + hasAny = try await hasAssignedIssues(connection: connection, credential: credential) + guard generation == self.generation else { return } + } + items = page.issues + nextPageToken = page.nextPageToken + hasAnyAssignedIssues = hasAny + error = nil + hasLoadedList = true + loadedConnectionID = connection.id + learnOptions(from: page.issues) + } catch { + guard generation == self.generation, !(error is CancellationError) else { return } + await handle(error) + hasLoadedList = true + loadedConnectionID = connection.id + } + if generation == self.generation { isLoading = false } + } + } + + private func search( + _ query: JiraIssueFilter, + connection: JiraConnection, + credential: JiraCredential, + pageToken: String? + ) async throws -> JiraIssuePage { + let client = connectionController.client + let jql = JiraJQLBuilder.jql(for: query, supportsSprints: connection.supportsSprints) + do { + return try await client.searchIssues( + baseURL: connection.apiBaseURL, + credential: credential, + jql: jql, + sprintFieldID: connection.sprintFieldID, + pageToken: pageToken + ) + } catch JiraError.invalidQuery where JiraJQLBuilder.issueKey(in: query.searchText) != nil { + // Jira rejects `key = X-1` when project X does not exist; fall back to text search. + return try await client.searchIssues( + baseURL: connection.apiBaseURL, + credential: credential, + jql: JiraJQLBuilder.jql(for: query, supportsSprints: connection.supportsSprints, includeKeyClause: false), + sprintFieldID: connection.sprintFieldID, + pageToken: pageToken + ) + } + } + + private static let allAssignedIssues: JiraIssueFilter = { + var filter = JiraIssueFilter() + filter.sprintScope = .any + filter.statusCategories = [] + return filter + }() + + private func hasAssignedIssues(connection: JiraConnection, credential: JiraCredential) async throws -> Bool { + let page = try await search(Self.allAssignedIssues, connection: connection, credential: credential, pageToken: nil) + learnOptions(from: page.issues) + return !page.issues.isEmpty + } + + private func handle(_ error: Error) async { + let jiraError = error as? JiraError ?? .unreachable + self.error = jiraError + isLoading = false + isLoadingMore = false + if jiraError == .needsReconnect { + await connectionController.markNeedsReconnect() + } + } + + private func connectionDidChange() { + items = [] + nextPageToken = nil + error = nil + hasLoadedList = false + loadedConnectionID = nil + knownSprints = [:] + knownProjects = [:] + knownIssueTypes = [] + rememberSelection(of: filter) + if isActive { reload() } + } + + // MARK: - Filter options + + private func rememberSelection(of filter: JiraIssueFilter) { + if case .sprint(let id, let name) = filter.sprintScope, knownSprints[id] == nil { + knownSprints[id] = JiraSprintRef(id: id, name: name, state: "") + } + for key in filter.projectKeys where knownProjects[key] == nil { + knownProjects[key] = key + } + knownIssueTypes.formUnion(filter.issueTypeNames) + publishOptions() + } + + private func learnOptions(from issues: [JiraIssue]) { + for issue in issues { + for sprint in issue.sprints { knownSprints[sprint.id] = sprint } + if !issue.projectKey.isEmpty { knownProjects[issue.projectKey] = issue.projectName } + if !issue.issueTypeName.isEmpty { knownIssueTypes.insert(issue.issueTypeName) } + } + publishOptions() + } + + private func publishOptions() { + func rank(_ sprint: JiraSprintRef) -> Int { + switch sprint.state { + case "active": 0 + case "future": 1 + default: 2 + } + } + sprintOptions = knownSprints.values.sorted { + rank($0) != rank($1) ? rank($0) < rank($1) : $0.id > $1.id + } + projectOptions = knownProjects + .map { JiraProjectOption(key: $0.key, name: $0.value) } + .sorted { $0.key < $1.key } + issueTypeOptions = knownIssueTypes.sorted() + } +} diff --git a/macgit/App/macgitApp.swift b/macgit/App/macgitApp.swift index d3e7fd31..c5780d41 100644 --- a/macgit/App/macgitApp.swift +++ b/macgit/App/macgitApp.swift @@ -28,6 +28,7 @@ struct macgitApp: App { @StateObject private var notificationController: GitHubNotificationController @StateObject private var appNotificationController: AppNotificationController @StateObject private var providerAccountController: GitProviderAccountController + @StateObject private var jiraConnectionController: JiraConnectionController @StateObject private var aiProviderController: AIProviderController @StateObject private var featureAccessController: FeatureAccessController @StateObject private var repositoryVisibilityController: RepositoryVisibilityController @@ -106,6 +107,8 @@ struct macgitApp: App { } ) _providerAccountController = StateObject(wrappedValue: providerAccountController) + let jiraConnectionController = JiraConnectionController() + _jiraConnectionController = StateObject(wrappedValue: jiraConnectionController) let notificationController = GitHubNotificationController(tokenVault: providerTokenVault) _notificationController = StateObject(wrappedValue: notificationController) _appNotificationController = StateObject(wrappedValue: AppNotificationController()) @@ -175,7 +178,8 @@ struct macgitApp: App { async let bookmarks: Void = repositoryBookmarkController.updateAccount( cloudSyncEnabled ? account : nil ) - _ = await (providerAccounts, bookmarks) + async let jira: Void = jiraConnectionController.updateMacgitAccount(uid: account?.uid) + _ = await (providerAccounts, bookmarks, jira) } ) ) @@ -265,6 +269,7 @@ struct macgitApp: App { .environmentObject(repositoryBookmarkController) .environmentObject(customActionStore) .environmentObject(gitFlowConfigurationSyncController) + .environmentObject(jiraConnectionController) .preferredColorScheme(appState.appearance.colorScheme) .onChange(of: appState.appearance, initial: true) { _, appearance in NSApp.appearance = appearance.nsAppearance @@ -621,6 +626,7 @@ struct macgitApp: App { selectedSection: $selectedAppSettingsSection ) .environmentObject(featureAccessController) + .environmentObject(jiraConnectionController) .preferredColorScheme(appState.appearance.colorScheme) .onChange(of: appState.appearance, initial: true) { _, appearance in NSApp.appearance = appearance.nsAppearance diff --git a/macgit/Models/AppSettingsSnapshot.swift b/macgit/Models/AppSettingsSnapshot.swift index 47c21c0d..b41436ff 100644 --- a/macgit/Models/AppSettingsSnapshot.swift +++ b/macgit/Models/AppSettingsSnapshot.swift @@ -40,6 +40,7 @@ struct AppSettingsSnapshot: Codable, Equatable, Sendable { var showWorkspaceReflog: Bool var showWorkspacePullRequests: Bool var showWorkspaceGitLFS: Bool + var showWorkspaceJira: Bool var showTags: Bool var showWorktrees: Bool var showSubmodules: Bool @@ -74,6 +75,7 @@ struct AppSettingsSnapshot: Codable, Equatable, Sendable { showWorkspaceReflog: Bool = true, showWorkspacePullRequests: Bool = true, showWorkspaceGitLFS: Bool = true, + showWorkspaceJira: Bool = true, showTags: Bool = true, showWorktrees: Bool = true, showSubmodules: Bool, @@ -108,6 +110,7 @@ struct AppSettingsSnapshot: Codable, Equatable, Sendable { self.showWorkspaceReflog = showWorkspaceReflog self.showWorkspacePullRequests = showWorkspacePullRequests self.showWorkspaceGitLFS = showWorkspaceGitLFS + self.showWorkspaceJira = showWorkspaceJira self.showTags = showTags self.showWorktrees = showWorktrees self.showSubmodules = showSubmodules @@ -145,6 +148,7 @@ struct AppSettingsSnapshot: Codable, Equatable, Sendable { showWorkspaceReflog = try container.decodeIfPresent(Bool.self, forKey: .showWorkspaceReflog) ?? true showWorkspacePullRequests = try container.decodeIfPresent(Bool.self, forKey: .showWorkspacePullRequests) ?? true showWorkspaceGitLFS = try container.decodeIfPresent(Bool.self, forKey: .showWorkspaceGitLFS) ?? true + showWorkspaceJira = try container.decodeIfPresent(Bool.self, forKey: .showWorkspaceJira) ?? true showTags = try container.decodeIfPresent(Bool.self, forKey: .showTags) ?? true showWorktrees = try container.decodeIfPresent(Bool.self, forKey: .showWorktrees) ?? true showSubmodules = try container.decode(Bool.self, forKey: .showSubmodules) diff --git a/macgit/Models/BranchSheetPresentation.swift b/macgit/Models/BranchSheetPresentation.swift index c4ae3bd7..478dd08b 100644 --- a/macgit/Models/BranchSheetPresentation.swift +++ b/macgit/Models/BranchSheetPresentation.swift @@ -21,4 +21,5 @@ import Foundation struct BranchSheetPresentation: Identifiable { let id = UUID() let startPoint: GitBranchStartPoint? + var initialBranchName: String? = nil } diff --git a/macgit/Models/FeatureAccessNotice.swift b/macgit/Models/FeatureAccessNotice.swift index 4e93450a..05ec737a 100644 --- a/macgit/Models/FeatureAccessNotice.swift +++ b/macgit/Models/FeatureAccessNotice.swift @@ -41,6 +41,8 @@ struct FeatureAccessNotice: Identifiable, Equatable { var message: String { switch denial { + case .requiresPro where feature == .jiraIssues: + "\(feature.displayName) is available on Commit+ Pro. Upgrade to see your Jira issues in Commit+." case .requiresPro: "\(feature.displayName) is not available for this repository on the Free plan. Upgrade to Commit+ Pro to continue." case .repositoryVisibilityUnavailable: @@ -62,7 +64,9 @@ struct FeatureAccessNotice: Identifiable, Equatable { func message(isSignedIn: Bool) -> String { guard denial == .requiresPro else { return message } guard isSignedIn else { - return "Sign in to Commit+ to use \(feature.displayName.lowercased())." + // Jira is a product name, so it keeps its capitalization. + let name = feature == .jiraIssues ? feature.displayName : feature.displayName.lowercased() + return "Sign in to Commit+ to use \(name)." } guard feature == .privateRepositories else { return message } return "Upgrade to Commit+ Pro from $3.25/month (billed annually) to use private repositories." @@ -94,6 +98,8 @@ extension PlanFeature { "Multiple Git provider accounts" case .branchMergeDetection: "PR-based merge detection" + case .jiraIssues: + "Jira Issues" } } } diff --git a/macgit/Models/FeatureAccessPolicy.swift b/macgit/Models/FeatureAccessPolicy.swift index 5c8ab504..dc9b4d55 100644 --- a/macgit/Models/FeatureAccessPolicy.swift +++ b/macgit/Models/FeatureAccessPolicy.swift @@ -30,6 +30,7 @@ enum PlanFeature: String, CaseIterable, Codable, Hashable { case aiBringYourOwnKey case multipleProviderAccounts case branchMergeDetection + case jiraIssues } enum FeatureRepositoryScope: String, Codable, Equatable { @@ -78,7 +79,7 @@ struct FeatureAccessPolicy: Codable, Equatable { static let bundled = FeatureAccessPolicy( schemaVersion: supportedSchemaVersion, - revision: 7, + revision: 8, features: [ .privateRepositories: FeaturePolicyRule( enabled: true, @@ -106,7 +107,8 @@ struct FeatureAccessPolicy: Codable, Equatable { .aiConflictResolution: proOnlyRule, .aiBringYourOwnKey: proOnlyRule, .multipleProviderAccounts: proOnlyRule, - .branchMergeDetection: proOnlyRule + .branchMergeDetection: proOnlyRule, + .jiraIssues: proOnlyRule ] ) diff --git a/macgit/Models/JiraBranchNameSuggester.swift b/macgit/Models/JiraBranchNameSuggester.swift new file mode 100644 index 00000000..84b3c9cf --- /dev/null +++ b/macgit/Models/JiraBranchNameSuggester.swift @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation + +/// Suggests `-` branch names for Jira issues, e.g. `PROJ-123-fix-login-crash`. +enum JiraBranchNameSuggester { + static let maxLength = 60 + + static func name(key: String, summary: String) -> String { + let slug = slug(from: summary) + guard !slug.isEmpty else { return key } + let full = "\(key)-\(slug)" + guard full.count > maxLength else { return full } + + // Cut at a word boundary so the name never ends mid-word, but always keep the key. + let limited = String(full.prefix(maxLength)) + guard let lastHyphen = limited.lastIndex(of: "-"), + limited.distance(from: limited.startIndex, to: lastHyphen) > key.count else { + return key + } + return String(limited[.. String { + // "đ" is a separate letter, not "d" with a diacritic, so stripping marks leaves it alone. + let latin = summary + .replacingOccurrences(of: "đ", with: "d") + .replacingOccurrences(of: "Đ", with: "D") + .applyingTransform(.toLatin, reverse: false) ?? summary + let folded = (latin.applyingTransform(.stripDiacritics, reverse: false) ?? latin).lowercased() + var slug = "" + for character in folded { + if character.isASCII, character.isLetter || character.isNumber { + slug.append(character) + } else if !slug.isEmpty, slug.last != "-" { + slug.append("-") + } + } + while slug.hasSuffix("-") { slug.removeLast() } + return slug + } +} + +/// How a branch is started from a Jira issue: a plain branch, or a Git Flow topic. +enum JiraBranchTarget: Hashable, Identifiable { + case plainBranch + case gitFlow(GitFlowTopicKind) + + var id: String { + switch self { + case .plainBranch: "branch" + case .gitFlow(let kind): kind.rawValue + } + } + + var title: String { + switch self { + case .plainBranch: "New Branch…" + case .gitFlow(let kind): "Start \(kind.displayName)…" + } + } + + /// Release topics are named after versions, not issues, so they are not offered. + static func options(gitFlowEnabled: Bool) -> [JiraBranchTarget] { + guard gitFlowEnabled else { return [.plainBranch] } + return [.gitFlow(.feature), .gitFlow(.bugfix), .gitFlow(.hotfix), .plainBranch] + } + + static func suggested(forIssueType issueTypeName: String, gitFlowEnabled: Bool) -> JiraBranchTarget { + guard gitFlowEnabled else { return .plainBranch } + switch issueTypeName.lowercased() { + case "bug", "defect": return .gitFlow(.bugfix) + default: return .gitFlow(.feature) + } + } +} diff --git a/macgit/Models/JiraModels.swift b/macgit/Models/JiraModels.swift new file mode 100644 index 00000000..a04cc7ec --- /dev/null +++ b/macgit/Models/JiraModels.swift @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation + +/// Where Jira REST requests are sent. Scoped API tokens only work through Atlassian's gateway. +enum JiraAPIBase: Codable, Equatable, Sendable { + case site + case gateway(cloudID: String) +} + +/// Non-secret metadata for the Jira Cloud site connected on this Mac. The token lives in the Keychain. +struct JiraConnection: Codable, Equatable, Identifiable, Sendable { + var macgitUID: String + var siteURL: URL + var email: String + var accountID: String + var displayName: String + var apiBase: JiraAPIBase + /// The custom field holding sprints; nil on sites without Jira Software. + var sprintFieldID: String? + var needsReconnect = false + var connectedAt = Date() + + var id: String { "\(macgitUID):\(siteHost):\(accountID)" } + + var siteHost: String { siteURL.host(percentEncoded: false)?.lowercased() ?? siteURL.absoluteString } + + var apiBaseURL: URL { + switch apiBase { + case .site: + siteURL + case .gateway(let cloudID): + URL(string: "https://api.atlassian.com/ex/jira")!.appending(path: cloudID) + } + } + + var supportsSprints: Bool { sprintFieldID != nil } + + func browseURL(for issueKey: String) -> URL { + siteURL.appending(path: "browse").appending(path: issueKey) + } +} + +struct JiraCredential: Equatable, Sendable { + var email: String + var token: String + + var authorizationHeader: String { + "Basic " + Data("\(email):\(token)".utf8).base64EncodedString() + } +} + +enum JiraStatusCategory: String, Codable, CaseIterable, Hashable, Sendable { + case toDo = "new" + case inProgress = "indeterminate" + case done + + var title: String { + switch self { + case .toDo: "To Do" + case .inProgress: "In Progress" + case .done: "Done" + } + } +} + +struct JiraSprintRef: Codable, Hashable, Identifiable, Sendable { + var id: Int + var name: String + var state: String + + var isOpen: Bool { state == "active" || state == "future" } +} + +struct JiraIssue: Identifiable, Equatable, Sendable { + var id: String + var key: String + var summary: String + var statusName: String + var statusCategory: JiraStatusCategory? + var issueTypeName: String + var priorityName: String? + var projectKey: String + var projectName: String + var updated: Date? + var sprints: [JiraSprintRef] + + /// The sprint shown on the row: the active one, else the next open one, else the most recent. + var displaySprint: JiraSprintRef? { + sprints.first { $0.state == "active" } ?? sprints.first { $0.isOpen } ?? sprints.last + } +} + +struct JiraIssuePage: Equatable, Sendable { + var issues: [JiraIssue] + var nextPageToken: String? +} + +struct JiraIssueFilter: Codable, Equatable, Sendable { + enum SprintScope: Codable, Hashable, Sendable { + case openSprints + case sprint(id: Int, name: String) + case backlog + case any + } + + var sprintScope: SprintScope = .openSprints + /// Empty means any status. + var statusCategories: Set = [.toDo, .inProgress] + /// Empty means any project. + var projectKeys: Set = [] + /// Empty means any issue type. Names, because team-managed projects use per-project type ids. + var issueTypeNames: Set = [] + /// Not persisted: search text applies only to the current session. + var searchText = "" + + static let `default` = JiraIssueFilter() + + var isDefault: Bool { + var withoutSearch = self + withoutSearch.searchText = "" + return withoutSearch == .default + } + + private enum CodingKeys: String, CodingKey { + case sprintScope, statusCategories, projectKeys, issueTypeNames + } + + init() {} + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + sprintScope = try container.decode(SprintScope.self, forKey: .sprintScope) + statusCategories = try container.decode(Set.self, forKey: .statusCategories) + projectKeys = try container.decode(Set.self, forKey: .projectKeys) + issueTypeNames = try container.decode(Set.self, forKey: .issueTypeNames) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(sprintScope, forKey: .sprintScope) + try container.encode(statusCategories, forKey: .statusCategories) + try container.encode(projectKeys, forKey: .projectKeys) + try container.encode(issueTypeNames, forKey: .issueTypeNames) + } +} + +enum JiraError: LocalizedError, Equatable { + case unsupportedSite + case invalidCredentials + case needsReconnect + case forbidden + case rateLimited(retryAfter: Int?) + case unreachable + case invalidResponse + case invalidQuery + case notSignedIn + case notConnected + case storage(String) + + var errorDescription: String? { + switch self { + case .unsupportedSite: + "Only Jira Cloud sites (https://your-site.atlassian.net) are supported." + case .invalidCredentials: + "Jira did not accept this email or API token." + case .needsReconnect: + "Jira no longer accepts the saved API token. Reconnect Jira in Settings → Integrations." + case .forbidden: + "Your Jira account does not have permission to view these issues." + case .rateLimited(let retryAfter): + if let retryAfter, retryAfter > 0 { + "Jira is busy. Try again in \(retryAfter) seconds." + } else { + "Jira is busy. Try again in a moment." + } + case .unreachable: + "The Jira site could not be reached. Check the site address and your network connection." + case .invalidResponse: + "The Jira site did not respond as Jira Cloud." + case .invalidQuery: + "Jira could not run this filter." + case .notSignedIn: + "Sign in to Commit+ to connect Jira." + case .notConnected: + "No Jira site is connected." + case .storage(let message): + message + } + } +} + +enum JiraSiteNormalizer { + /// Accepts `acme`, `acme.atlassian.net`, or `https://acme.atlassian.net[/...]` and returns the site root. + static func normalize(_ input: String) throws -> URL { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw JiraError.unsupportedSite } + + let candidate: String + if trimmed.contains("://") { + candidate = trimmed + } else if trimmed.contains(".") || trimmed.contains("/") { + candidate = "https://" + trimmed + } else { + candidate = "https://\(trimmed).atlassian.net" + } + + guard let components = URLComponents(string: candidate), + components.scheme?.lowercased() == "https", + components.user == nil, components.password == nil, + components.port == nil || components.port == 443, + let host = components.host?.lowercased(), + host.hasSuffix(".atlassian.net"), + let name = host.split(separator: ".").first, + host == "\(name).atlassian.net", + name.allSatisfy({ ($0.isASCII && ($0.isLetter || $0.isNumber)) || $0 == "-" }), + let url = URL(string: "https://\(host)") else { + throw JiraError.unsupportedSite + } + return url + } +} diff --git a/macgit/Resources/Assets.xcassets/jira.imageset/Contents.json b/macgit/Resources/Assets.xcassets/jira.imageset/Contents.json new file mode 100644 index 00000000..a699ae1c --- /dev/null +++ b/macgit/Resources/Assets.xcassets/jira.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "jira.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/macgit/Resources/Assets.xcassets/jira.imageset/jira.png b/macgit/Resources/Assets.xcassets/jira.imageset/jira.png new file mode 100644 index 0000000000000000000000000000000000000000..e40781459cedae8fe0e6cbc7d7c7e58b2a2f32b8 GIT binary patch literal 5445 zcmXwd1z6MH_y65s^e8E51`;Bn(h{ST1_6~W1*y>~HBl4^0ToahK@h1)jD{fy(#_~n zkQDe>;E3Ow@BjHf&-T1;yw0ujI_KPbo|zckK+I$EXxz@Q=wP*Ff18^8B1 z(1+Sb$I>4F`p&aIELEJI3jmhCx?1YyAz3@~5y|Eg*dknC?8{`gG;3Lo$G= zKN2GQvxn-CR*{xLj$x6(qof;u3E-;c-(M|fzr!Q4gn%K2Z^{va4d36<@zH2IAqgBy z?n`6@i#*tS%1vz7+>nRigy@A;av!>Na?^%f5$rsHcPY@pUYXN~qjXrnxrkQuwkUHc zSrP22pPCVB_KWs*8XQly^-$O$0>ZqJC{f6v#hM7+rk?vUO`-PLQ=8d`OOcDJ^y>)D zxjrX4L>OqKOa&~76L!|m+3A_lgiwSi@ggf_Z4FfQl+OArmgtw{9HN)~v}it(S1j_H+rsq_Fr`I5vm0*`H(;2=ouLQ-LMBy4K=Y z$^ptB{-yFudf;2vzoC3x)s%2ga+k)-@+Loi$x~@N)d6dhuk1ZU76<)`9fjVHyx7Jg z6#g||(v!BW@yu(?prPyZ(w?@+g1wM~9cca5*^J$RCC}zG(F7i9Z`Uv#SU1)2&2oZ# zoiZj1%^>5}*eQ=|KAOs$KWes+7+UO{!71mjaQjYOiF=xaG!b$+v-7*txH*5`=d# zt+%zf{TUBj^OGoPIpN=gxpOoV?F+|kXq4})7dqto1BQHEwG>m?7*DPN>jh2hoUW<| z%_SYJxnRv!5!zK&(BP$g)oMdz1tCG`3=d*hyGCBmB^$|9$zhBiup8G70}Ym^D(IWy z&$T*liB+{=VYxKl#>gyUIoo21<0+UXGEN+2a?{7+o{BiF&cLe_jSttIRIm+l?1HEt zoH*oPF3`{&`Cv_I1!LxiX)q@H4P0jJ<6}97y{&)v(?grJr9Eo@=%>|*;fue~kvVwk z6r}Qgf^rUuD=bT-83o^bA2Njmc%95prvF^li2SI!~Crylag&UFQHCe!53z$Z`B7g zEL|Tz&_1eo?rBXqh{T@%K}4Y_vfQ>>$Q&{ro(+6|=E3=_hQ-Q03q{lletF+jVPcGI zxVTLRXYi<66C|E$M>|twA;E8;6}_6pi>>&}Mx90toFU%86g=ypbuNDKb4b{@sMQ&J zd6jT)>{e_n)62ex;)ZtwP}glN=@VpaV`4cj&k7?Z^y8To8d?eOE48e#5v4^{ z2_uQOiu(2npBJ#%ikvz%(iKnKf+rT0GTu+{DqE?g{PijrXT@OMj4O%PE`2FvI$zZ1 zt5NU-s80HhU$KoYPUN-hX^Y%!Da*O~#JAvsVfxp{0;}lqjfEp8DgzrItSK2< zv*~iSPhN>4(BR0$qc?!s^eqd3?e*^_`Sy-xfQDo^KVUBLIth+{0P_<-pwUOJw?-jU zG=v(NOLOVjeV!qysh0rfM@Qn6+b^Gb`=-K00bD3$b8Xg`M+O?35rHOLTFh9PMmAF3 zu7c!bNN*l(E99xb-vX+_n$+b<1ZG>YFgsek7*M^}`jA<95`2y4MxY%!Sq#8E?{tf) z%C_C=cnHh@xlP#U%35f-%<+ExhG{22V3YAz=FVg8pTtw|oTuVIV6*l+QmI=HTr=?u zl(BI1;78}7rvBH&-mG`oR|^>a>~CsnGa;adZ z1TB87|vuIEVtzyl3KJiDf@ei{6^%1DrZ) zqWo@+XQ3ka3-wiV6*M^{roN*aZ_QpKM(#PDQ!`#&l>#R?HYpiFb^NA;L=!$9 zPC!8Q{gT7-?O{_#>!S+G4$veDY({fx>O<|shrHIW*v5T#Ta1Zd0g`&w=UabkQC72c z*Ot}l%mh0!Ah58$x}y4%_J~a+%W?_v`9&>E9*{0naoaB!QL0}hk*1rsY{ynZjAnXq z-zEy6Z7URTn_7__jn=B z@XTC(@zn5B!1+_R4%I%HW-y>Tfp(l+r5bKNZ!n1%gPWjGhij@6IN!R#X+S;-`-&*^d z#3KEDZUnabT{+#3Zr|iqEnn93dI}kM$DrBzKe3-jGy<*U+%CeNws^*XrG~QHjA7RZyTy+!g=tp2K z9n6Tf`WR+!_~~U7!< z2iwf_w{DtQvQ34i)}%TfPO?CRa(!RA)JR=xBkw3me9on(VD8+gObodQ7N$y{abHXH zDtmjO!o;w!F?9d8q+Pg5n1-35^ZBou6A28ruDaa2AbZQ1j?Z)uCy=u#{r$5U47ZaV ztQhp4)HuWM#ZxLV9_Tc4{}dmNMV>}2g?zXGDNYJAR-y3jA z`inmd=RWJJ^2v9q$7~Zu9`hXz9p%BqgKX+uq2D07!yg8ekdtm!-b~y%bXZ7>d@Zk_ z0O5VXl+y_jBSHrF|NY1Ghg%8LNM}D2VG>oTzB~;ehW)S_b%dH8=wJH|@2G}>Y3snX zw)gP@+mp;{tn0t`)mJtEcH3lWLM>O?_DN-7Ih}Rwwi2T#61$RI~LHi~g) z3{eIATyT545G+rl@sM1b$K?Ku`cgc=&bi@eXJoi>J!`fiwJTRZ_&Jb_e0VKtZm?1= z;Em*G_S?-VP+V2$zXMIbOlVUXX`eUd9d~a>sWcerR~!!p!-l`6Zzb(--1t)+nl2g3 zQMQJ!IXMX*tb+t(cXE&0BTt-lpLSJ0s&TDNvmw2yB+Kjmnb~*%X<7*x#F*x7xWQDK zgg}9%xiN>`(6!`~o9s)FU1v(Fb2GmN>nZpv%7-&I&&aiTyvrD&sJrDjFE5>3J0N1Q zUu5L;pP(@hjeA|oQ=_YY4~^DT7kfxjB`ZZe9j`fgdnfKb9ad=m6cBpBd9eEF!9l8K zAb;r)xe|CV8@4VJZ>L+ep1NH~bQZjLUj9P{LtEREu4UE)x3ZOp-ST_)Y!MJk?mv&C zhr-O~x?H6%E0zLd?_v{Kz_F{b6%@TD6>Ee~1o=2VdG9uSO8Fbq`(L?Ojzw5PrD3JK zZQPWQ0-vK(JSq2D;p(L7TaX+LX=u@AG(Yii!wCMNk|1ZEEyt*~&#av@$hGL>8C&cqR+H%u&>e-`u|kmu73b!~`s2#2 z!~Pqaua%3s0xU=4;b`u3DDYT}G4XmRX+Xv@CNk+aP%rQpvKg*h1NO4FO>He*8vCJW z8(RvQ>n(4d`xDmp>)46rX(J|g+HnG_;|vla5mt4~4cajNuK)SE>>h{c95}~YV?{9p z4bU8I3^&um!U@S=ciktlUKaytp_I{$PNy8GsU|hQT3>+QMGVln|L=N7t$@I)lNiK7 z=Ow7%UAyVWd|4mnFw7Ok3Y^m5ggne{bDvl_CF3I&KmpChb%=ZR^9i=AP$XIUOl%4$#B<~WQ5a+~FYY8kS33B6 z?qQh#Y6~1nz56DHp7~C;D5VULI0Jkt5-jRVc2K-cACOU@QI+#xM|Ad&TPUu+sG58D z(K<6k)$pxnkF0nvdFi@QnK)(4jw=7NMj|>{yL%r#-njV9M$P6;-bPZl)Cre%+)Gi) zAn1}U`O8=>vHB}PY|J^fZAX$a2pU@Q6?D66-2-9t8{_=G{eylEnHv`iz0U%5Yn#0a z%(oZs=a36EML~M6lXSg%JS$?DjykEXROUsB3>Oeyr0E3ulRlAY*&Ag@Y99%*COtW) z5(dL_NoY%6#5lhug^Qd;=zA91IG#Z-3M+3MqZk1quUB|T?gKK@vfn;(AcKw#h0rT( zd;@{yRp)w7kMi}!DV?P=&?>;#!%N?vICSlx^U*Y~`j%r~fbI&<7OeL{Bx_u!9572r z6~w<*{g{?1!tk}=?o;Vdg8^*Y-^K=#>$5GUy8x4Lqr(h#F)YGYg$4{+&xS|W4onO3 zo{@6%KTar;I&HtZ1(~5!CyvIww2#B8yxIZ2ZIIL!3}j%zP4@8$7g9D|<_FJ}jaeBh z65lAcE%1?*jywQNZTcygEp#P_z0tw8m+%aF56Y?y)dnto(p1k4YB+nay=;lP8FUhb z)OH#mkP~~EnCiA6@VT(4{K}H0cap5oL#;6n^OqI0`Q$S~4<`hM6n!>a)5Fw?SAr-n z%QTPK@9W;OU&!ds-j^9fB0Gr=8zR$8{Tr@zqwjOjV^>LZ=PZS12N?yF#E7J}aPfd%b!pBGa&wj8e;zNy$WcEFF^uz^ zv=?cq%;_RC6S04@ zWi^;TG?y0Aw>@Hw0S}3NKC-bM(9fsrn5Xo-v?q&Nd7gN0)^&lxhH5L)6y{ z6XRm<(!==%?ypqVMJGKOhz{|J@DW;y;AP0A4 zPD%GSks~kwC1N;xo)AK=Ef{N2jBDWcgV{*e@ivU<;@>E0Y%Dt6BnZeLnMhm-<**>} zoW-LjWGJJl=ZT4Yo|~{=Us&Z>4e8>hFoJS{q_FDa=j(HYoq_92`LVWkph7(8Q>7H1 z9yZ7x*SQA!t8}QM748*zdFW88YXpG*g2`UB7sbCz-X>^hRH<1KN|_`lGJ z0q;#chP=8tJ|C1cSGL@x7pPe_;l}xccJx{696@90r|=fz<$LFS(yix@TRD(e-h7>H zVF$?!p;r!5RLVXd^{>sDFvErg<<+FQ>Zg6gP(~Ki%1&=f7js|G{10qZr29qPGLG`2w+yzPvthU{axFX*ug|3BM?&W18-|6af& z>6O`&o)9@JE=c82pN(3^T+j3xW}tcQsng_j2bE#+vcbRCb4-^TfP(!Gohr91=hmlrBd_Gyk&>u!I)Mrrb@>Honc~PLNZKPG9;Sl|QIw1?n literal 0 HcmV?d00001 diff --git a/macgit/Services/FeaturePolicyStore.swift b/macgit/Services/FeaturePolicyStore.swift index d99b515d..d65aa9c7 100644 --- a/macgit/Services/FeaturePolicyStore.swift +++ b/macgit/Services/FeaturePolicyStore.swift @@ -99,7 +99,7 @@ private extension PlanFeature { case .privateRepositories, .pullRequests, .gitFlow, .gitLFS: true case .aiCommitMessage, .repositoryChat, .repositoryAIActions, .aiConflictResolution, .aiBringYourOwnKey, - .multipleProviderAccounts, .branchMergeDetection: + .multipleProviderAccounts, .branchMergeDetection, .jiraIssues: false } } diff --git a/macgit/Services/FirestoreSettingsStore.swift b/macgit/Services/FirestoreSettingsStore.swift index ce2a704c..df4a54ab 100644 --- a/macgit/Services/FirestoreSettingsStore.swift +++ b/macgit/Services/FirestoreSettingsStore.swift @@ -32,6 +32,7 @@ enum CloudSettingsDocument { "showWorkspaceReflog": snapshot.showWorkspaceReflog, "showWorkspacePullRequests": snapshot.showWorkspacePullRequests, "showWorkspaceGitLFS": snapshot.showWorkspaceGitLFS, + "showWorkspaceJira": snapshot.showWorkspaceJira, "showTags": snapshot.showTags, "showWorktrees": snapshot.showWorktrees, "showSubmodules": snapshot.showSubmodules, @@ -118,6 +119,7 @@ enum CloudSettingsDocument { showWorkspaceReflog: data["showWorkspaceReflog"] as? Bool ?? true, showWorkspacePullRequests: data["showWorkspacePullRequests"] as? Bool ?? true, showWorkspaceGitLFS: data["showWorkspaceGitLFS"] as? Bool ?? true, + showWorkspaceJira: data["showWorkspaceJira"] as? Bool ?? true, showTags: data["showTags"] as? Bool ?? true, showWorktrees: data["showWorktrees"] as? Bool ?? true, showSubmodules: showSubmodules, diff --git a/macgit/Services/JiraCloudClient.swift b/macgit/Services/JiraCloudClient.swift new file mode 100644 index 00000000..a1f5e26d --- /dev/null +++ b/macgit/Services/JiraCloudClient.swift @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation + +struct JiraValidatedAccount: Equatable { + var accountID: String + var displayName: String + var email: String + var apiBase: JiraAPIBase +} + +protocol JiraIssueServing { + func validate(siteURL: URL, credential: JiraCredential) async throws -> JiraValidatedAccount + func sprintFieldID(baseURL: URL, credential: JiraCredential) async throws -> String? + func searchIssues( + baseURL: URL, + credential: JiraCredential, + jql: String, + sprintFieldID: String?, + pageToken: String? + ) async throws -> JiraIssuePage +} + +/// Jira Cloud REST v3 over Basic auth. Error values never carry request headers or response bodies. +struct JiraCloudClient: JiraIssueServing { + static let pageSize = 50 + static let gatewayURL = URL(string: "https://api.atlassian.com/ex/jira")! + private static let issueFields = ["summary", "status", "issuetype", "priority", "project", "updated"] + + private let httpClient: GitProviderHTTPClient + + init(httpClient: GitProviderHTTPClient = URLSessionGitProviderHTTPClient()) { + self.httpClient = httpClient + } + + /// Classic tokens work against the site; scoped tokens are rejected there and must use the gateway. + func validate(siteURL: URL, credential: JiraCredential) async throws -> JiraValidatedAccount { + do { + let myself = try await fetchMyself(baseURL: siteURL, credential: credential) + return account(from: myself, credential: credential, apiBase: .site) + } catch JiraError.needsReconnect { + guard let cloudID = try? await fetchCloudID(siteURL: siteURL) else { + throw JiraError.invalidCredentials + } + do { + let gatewayBase = Self.gatewayURL.appending(path: cloudID) + let myself = try await fetchMyself(baseURL: gatewayBase, credential: credential) + return account(from: myself, credential: credential, apiBase: .gateway(cloudID: cloudID)) + } catch JiraError.needsReconnect, JiraError.forbidden { + throw JiraError.invalidCredentials + } + } catch JiraError.forbidden { + throw JiraError.invalidCredentials + } + } + + func sprintFieldID(baseURL: URL, credential: JiraCredential) async throws -> String? { + let request = makeRequest(url: baseURL.appending(path: "rest/api/3/field"), credential: credential) + let data = try await send(request) + guard let fields = try? JSONDecoder().decode([FieldResponse].self, from: data) else { + throw JiraError.invalidResponse + } + return fields.first { $0.schema?.custom == "com.pyxis.greenhopper.jira:gh-sprint" }?.id + } + + func searchIssues( + baseURL: URL, + credential: JiraCredential, + jql: String, + sprintFieldID: String?, + pageToken: String? + ) async throws -> JiraIssuePage { + var request = makeRequest(url: baseURL.appending(path: "rest/api/3/search/jql"), credential: credential) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + var body: [String: Any] = [ + "jql": jql, + "maxResults": Self.pageSize, + "fields": Self.issueFields + (sprintFieldID.map { [$0] } ?? []), + ] + if let pageToken { body["nextPageToken"] = pageToken } + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let data = try await send(request) + guard let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let rawIssues = payload["issues"] as? [[String: Any]] else { + throw JiraError.invalidResponse + } + let issues = rawIssues.compactMap { Self.issue(from: $0, sprintFieldID: sprintFieldID) } + let isLast = payload["isLast"] as? Bool ?? true + let nextPageToken = isLast ? nil : payload["nextPageToken"] as? String + return JiraIssuePage(issues: issues, nextPageToken: nextPageToken) + } + + // MARK: - Requests + + private func fetchMyself(baseURL: URL, credential: JiraCredential) async throws -> MyselfResponse { + let request = makeRequest(url: baseURL.appending(path: "rest/api/3/myself"), credential: credential) + let data = try await send(request) + guard let myself = try? JSONDecoder().decode(MyselfResponse.self, from: data), + !myself.accountId.isEmpty else { + throw JiraError.invalidResponse + } + return myself + } + + private func fetchCloudID(siteURL: URL) async throws -> String { + var request = URLRequest(url: siteURL.appending(path: "_edge/tenant_info"), timeoutInterval: 30) + request.setValue("application/json", forHTTPHeaderField: "Accept") + let data = try await send(request) + guard let tenant = try? JSONDecoder().decode(TenantInfoResponse.self, from: data), + !tenant.cloudId.isEmpty, + tenant.cloudId.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-") }) else { + throw JiraError.invalidResponse + } + return tenant.cloudId + } + + private func makeRequest(url: URL, credential: JiraCredential) -> URLRequest { + var request = URLRequest(url: url, timeoutInterval: 30) + request.setValue(credential.authorizationHeader, forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + return request + } + + private func send(_ request: URLRequest) async throws -> Data { + let data: Data + let response: HTTPURLResponse + do { + (data, response) = try await httpClient.data(for: request) + } catch is URLError { + throw JiraError.unreachable + } catch is CancellationError { + throw CancellationError() + } catch { + throw JiraError.unreachable + } + try Self.validate(response) + return data + } + + static func validate(_ response: HTTPURLResponse) throws { + switch response.statusCode { + case 200..<300: + return + case 400: + throw JiraError.invalidQuery + case 401: + throw JiraError.needsReconnect + case 403: + throw JiraError.forbidden + case 429: + let retryAfter = (response.value(forHTTPHeaderField: "Retry-After")).flatMap { Int($0) } + throw JiraError.rateLimited(retryAfter: retryAfter) + case 500...: + throw JiraError.unreachable + default: + throw JiraError.invalidResponse + } + } + + private func account( + from myself: MyselfResponse, + credential: JiraCredential, + apiBase: JiraAPIBase + ) -> JiraValidatedAccount { + JiraValidatedAccount( + accountID: myself.accountId, + displayName: myself.displayName ?? credential.email, + email: myself.emailAddress ?? credential.email, + apiBase: apiBase + ) + } + + // MARK: - Decoding + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + return formatter + }() + + static func issue(from raw: [String: Any], sprintFieldID: String?) -> JiraIssue? { + guard let id = raw["id"] as? String, + let key = raw["key"] as? String, + let fields = raw["fields"] as? [String: Any] else { + return nil + } + let status = fields["status"] as? [String: Any] + let category = (status?["statusCategory"] as? [String: Any])?["key"] as? String + let issueType = fields["issuetype"] as? [String: Any] + let priority = fields["priority"] as? [String: Any] + let project = fields["project"] as? [String: Any] + let rawSprints = sprintFieldID.flatMap { fields[$0] as? [[String: Any]] } ?? [] + let sprints = rawSprints.compactMap { sprint -> JiraSprintRef? in + guard let id = (sprint["id"] as? NSNumber)?.intValue, + let name = sprint["name"] as? String else { return nil } + return JiraSprintRef(id: id, name: name, state: sprint["state"] as? String ?? "") + } + + return JiraIssue( + id: id, + key: key, + summary: fields["summary"] as? String ?? "", + statusName: status?["name"] as? String ?? "", + statusCategory: category.flatMap(JiraStatusCategory.init(rawValue:)), + issueTypeName: issueType?["name"] as? String ?? "", + priorityName: priority?["name"] as? String, + projectKey: project?["key"] as? String ?? "", + projectName: project?["name"] as? String ?? "", + updated: (fields["updated"] as? String).flatMap(dateFormatter.date(from:)), + sprints: sprints + ) + } + + private struct MyselfResponse: Decodable { + var accountId: String + var displayName: String? + var emailAddress: String? + } + + private struct TenantInfoResponse: Decodable { + var cloudId: String + } + + private struct FieldResponse: Decodable { + struct Schema: Decodable { + var custom: String? + } + + var id: String + var schema: Schema? + } +} diff --git a/macgit/Services/JiraConnectionStore.swift b/macgit/Services/JiraConnectionStore.swift new file mode 100644 index 00000000..9c5a60f1 --- /dev/null +++ b/macgit/Services/JiraConnectionStore.swift @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation +import Security + +protocol JiraTokenStoring { + func token(for connection: JiraConnection) throws -> String? + func saveToken(_ token: String, for connection: JiraConnection) throws + func deleteToken(for connection: JiraConnection) throws +} + +/// Jira API tokens, kept on this Mac only and scoped to the Commit+ account through `JiraConnection.id`. +final class KeychainJiraTokenStore: JiraTokenStoring { + private let service = "com.commitplus.macgit.jira-tokens" + private static let cacheLock = NSLock() + private static var tokenCache: [String: String] = [:] + + func token(for connection: JiraConnection) throws -> String? { + if let cached = Self.cacheLock.withLock({ Self.tokenCache[connection.id] }) { + return cached + } + var query = baseQuery(for: connection) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { throw GitProviderTokenVaultError(status: status) } + guard let data = item as? Data, let token = String(data: data, encoding: .utf8) else { + throw GitProviderTokenVaultError(status: errSecDecode) + } + Self.cacheLock.withLock { Self.tokenCache[connection.id] = token } + return token + } + + func saveToken(_ token: String, for connection: JiraConnection) throws { + let data = Data(token.utf8) + var item = baseQuery(for: connection) + item[kSecValueData as String] = data + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + + var status = SecItemAdd(item as CFDictionary, nil) + if status == errSecDuplicateItem { + status = SecItemUpdate( + baseQuery(for: connection) as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + } + guard status == errSecSuccess else { throw GitProviderTokenVaultError(status: status) } + Self.cacheLock.withLock { Self.tokenCache[connection.id] = token } + } + + func deleteToken(for connection: JiraConnection) throws { + let status = SecItemDelete(baseQuery(for: connection) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw GitProviderTokenVaultError(status: status) + } + Self.cacheLock.withLock { _ = Self.tokenCache.removeValue(forKey: connection.id) } + } + + private func baseQuery(for connection: JiraConnection) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: connection.id, + ] + } +} + +protocol JiraConnectionPersisting { + func connection(for macgitUID: String) async throws -> JiraConnection? + func save(_ connection: JiraConnection) async throws + func removeConnection(for macgitUID: String) async throws +} + +/// Connection metadata in local SQLite, one per Commit+ account. Never synced. +final class LocalJiraConnectionStore: JiraConnectionPersisting { + static let collection = "jiraConnections" + private let dataStore: LocalDataStore + + init(dataStore: LocalDataStore? = nil) { + self.dataStore = dataStore ?? .shared + } + + func connection(for macgitUID: String) async throws -> JiraConnection? { + try await dataStore.readValue(JiraConnection.self, in: Self.collection, id: macgitUID) + } + + func save(_ connection: JiraConnection) async throws { + try await dataStore.transaction { transaction in + try transaction.set(connection, in: Self.collection, id: connection.macgitUID) + } + } + + func removeConnection(for macgitUID: String) async throws { + try await dataStore.transaction { transaction in + transaction.remove(in: Self.collection, id: macgitUID) + } + } +} diff --git a/macgit/Services/JiraJQLBuilder.swift b/macgit/Services/JiraJQLBuilder.swift new file mode 100644 index 00000000..d00dd027 --- /dev/null +++ b/macgit/Services/JiraJQLBuilder.swift @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation + +/// Builds JQL from a typed filter. Every value is quoted, so user input cannot change the query structure. +enum JiraJQLBuilder { + static func jql( + for filter: JiraIssueFilter, + supportsSprints: Bool, + includeKeyClause: Bool = true + ) -> String { + var clauses = ["assignee = currentUser()"] + + if supportsSprints { + switch filter.sprintScope { + case .openSprints: clauses.append("sprint in openSprints()") + case .sprint(let id, _): clauses.append("sprint = \(id)") + case .backlog: clauses.append("sprint is EMPTY") + case .any: break + } + } + + if !filter.statusCategories.isEmpty { + let categories = JiraStatusCategory.allCases + .filter(filter.statusCategories.contains) + .map { quoted($0.title) } + clauses.append("statusCategory in (\(categories.joined(separator: ", ")))") + } + if !filter.projectKeys.isEmpty { + clauses.append("project in (\(filter.projectKeys.sorted().map(quoted).joined(separator: ", ")))") + } + if !filter.issueTypeNames.isEmpty { + clauses.append("issuetype in (\(filter.issueTypeNames.sorted().map(quoted).joined(separator: ", ")))") + } + if let search = searchClause(for: filter.searchText, includeKeyClause: includeKeyClause) { + clauses.append(search) + } + + return clauses.joined(separator: " AND ") + " ORDER BY updated DESC" + } + + /// Whether the search text looks like an issue key, which Jira matches exactly instead of by text. + static func issueKey(in searchText: String) -> String? { + let candidate = searchText.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard candidate.range(of: "^[A-Z][A-Z0-9_]+-[0-9]+$", options: .regularExpression) != nil else { return nil } + return candidate + } + + private static func searchClause(for searchText: String, includeKeyClause: Bool) -> String? { + var parts: [String] = [] + let terms = textSearchTerms(searchText) + if !terms.isEmpty { + parts.append("summary ~ \(quoted(terms))") + } + if includeKeyClause, let key = issueKey(in: searchText) { + parts.append("key = \(quoted(key))") + } + switch parts.count { + case 0: return nil + case 1: return parts[0] + default: return "(\(parts.joined(separator: " OR ")))" + } + } + + /// Text search is Lucene-backed; its operator characters are dropped rather than escaped. + private static func textSearchTerms(_ searchText: String) -> String { + let reserved = Set("+-&|!(){}[]^~*?\\:\"'/") + let cleaned = String(searchText.map { reserved.contains($0) ? " " : $0 }) + return cleaned.split(whereSeparator: \.isWhitespace).joined(separator: " ") + } + + static func quoted(_ value: String) -> String { + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + return "\"\(escaped)\"" + } +} diff --git a/macgit/Views/Account/JiraIntegrationSection.swift b/macgit/Views/Account/JiraIntegrationSection.swift new file mode 100644 index 00000000..a23719ec --- /dev/null +++ b/macgit/Views/Account/JiraIntegrationSection.swift @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import SwiftUI + +/// The Jira section of Settings → Integrations. +struct JiraIntegrationSection: View { + @ObservedObject var controller: JiraConnectionController + let onSignIn: () -> Void + @State private var sheetMode: JiraConnectSheet.Mode? + @State private var isConfirmingRemoval = false + + var body: some View { + Section { + if !controller.isSignedIn { + LabeledContent("Status") { + Text("Sign in to Commit+ to connect Jira") + .foregroundStyle(.secondary) + } + actions { + Button("Sign In", action: onSignIn) + } + } else if let connection = controller.connection { + LabeledContent("Site") { + Text(connection.siteHost) + .foregroundStyle(.secondary) + } + LabeledContent("Account") { + Text("\(connection.displayName) · \(connection.email)") + .foregroundStyle(.secondary) + } + if connection.needsReconnect { + Label("Jira no longer accepts the saved API token. Reconnect to keep using Jira.", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + if let errorMessage = controller.errorMessage, sheetMode == nil { + Label(errorMessage, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } + actions { + if controller.isWorking { ProgressView().controlSize(.small) } + Button("Reconnect…") { sheetMode = .reconnect } + .disabled(controller.isWorking) + Button("Remove…", role: .destructive) { isConfirmingRemoval = true } + .disabled(controller.isWorking) + } + } else { + LabeledContent("Status") { + Text("Not connected") + .foregroundStyle(.secondary) + } + actions { + Button("Connect Jira…") { sheetMode = .connect } + .disabled(!controller.hasLoaded || controller.isWorking) + } + } + } header: { + Label { + Text("Jira") + } icon: { + Image("jira") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + } footer: { + Text("Connect a Jira Cloud site with an Atlassian API token to see your sprint issues in the Jira workspace screen. The token is stored only in this Mac's Keychain.") + } + } + + /// Sheets and alerts hang off the action row so they attach to a single view inside the form. + private func actions(@ViewBuilder _ content: () -> Content) -> some View { + SettingsActionRow(content: content) + .replacingSheet(item: $sheetMode) { mode in + JiraConnectSheet(controller: controller, mode: mode) + } + .alert("Remove Jira Connection?", isPresented: $isConfirmingRemoval) { + Button("Cancel", role: .cancel) {} + Button("Remove", role: .destructive) { + Task { await controller.remove() } + } + } message: { + Text("This removes the Jira site and its API token from this Mac. The token is not revoked at Atlassian; revoke it from your Atlassian account if you no longer need it.") + } + } +} + +struct JiraConnectSheet: View { + enum Mode: String, Identifiable { + case connect + case reconnect + + var id: String { rawValue } + } + + static let apiTokenPageURL = URL(string: "https://id.atlassian.com/manage-profile/security/api-tokens")! + + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + @ObservedObject var controller: JiraConnectionController + let mode: Mode + @State private var site = "" + @State private var email = "" + @State private var token = "" + + private var canSubmit: Bool { + let hasToken = !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + switch mode { + case .connect: + return hasToken + && !site.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case .reconnect: + return hasToken + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Form { + Section { + if mode == .connect { + TextField("Site", text: $site, prompt: Text("your-site.atlassian.net")) + TextField("Email", text: $email, prompt: Text("Atlassian account email")) + .textContentType(.username) + } else if let connection = controller.connection { + LabeledContent("Site", value: connection.siteHost) + LabeledContent("Email", value: connection.email) + } + SecureField("API Token", text: $token) + } header: { + Text(mode == .connect ? "Connect Jira Cloud" : "Reconnect Jira") + } footer: { + VStack(alignment: .leading, spacing: 6) { + Text("Only Jira Cloud sites are supported. Create a Jira API token in your Atlassian account; a Bitbucket API token does not include Jira access. For a scoped token, include the read:jira-work and read:jira-user scopes.") + Button("Create an API token…") { openURL(Self.apiTokenPageURL) } + .buttonStyle(.link) + } + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + if let errorMessage = controller.errorMessage { + Section { + Label(errorMessage, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } + } + } + .formStyle(.grouped) + + HStack { + if controller.isWorking { + ProgressView() + .controlSize(.small) + Text("Checking with Jira…") + .foregroundStyle(.secondary) + } + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button(mode == .connect ? "Connect" : "Reconnect", action: submit) + .keyboardShortcut(.defaultAction) + .disabled(!canSubmit || controller.isWorking) + } + .padding() + } + .frame(width: 460) + .onAppear { controller.errorMessage = nil } + .onDisappear { if !controller.isWorking { controller.errorMessage = nil } } + } + + private func submit() { + Task { + let succeeded = switch mode { + case .connect: + await controller.connect(site: site, email: email, token: token) + case .reconnect: + await controller.reconnect(token: token) + } + if succeeded { dismiss() } + } + } +} diff --git a/macgit/Views/Common/AppSettingsDetailView.swift b/macgit/Views/Common/AppSettingsDetailView.swift index 6eccc9b2..20e71341 100644 --- a/macgit/Views/Common/AppSettingsDetailView.swift +++ b/macgit/Views/Common/AppSettingsDetailView.swift @@ -50,7 +50,7 @@ struct AppSettingsDetailView: View { providerAccountController: providerAccountController ) case .integrations: - IntegrationsSettingsView(appState: appState) + IntegrationsSettingsView(appState: appState, accountController: accountController) case .customActions: CustomActionsSettingsView(store: customActionStore) case .aiProviders: diff --git a/macgit/Views/Common/BranchSheetView.swift b/macgit/Views/Common/BranchSheetView.swift index 92a14964..fcaba4e4 100644 --- a/macgit/Views/Common/BranchSheetView.swift +++ b/macgit/Views/Common/BranchSheetView.swift @@ -120,6 +120,7 @@ struct BranchSheetView: View { repositoryURL: URL, undoManager: GitUndoManager? = nil, initialStartPoint: GitBranchStartPoint? = nil, + initialBranchName: String? = nil, initialTab: BranchTab = .create, initiallySelectedLocalBranches: Set = [], initialForceDelete: Bool = false, @@ -144,6 +145,15 @@ struct BranchSheetView: View { self.onCommitAllChangesRequested = onCommitAllChangesRequested self._selectedTab = State(initialValue: initialTab) self._forceDelete = State(initialValue: initialForceDelete) + let nameState = Self.initialNameState(initialBranchName) + self._branchNameInput = State(initialValue: nameState.input) + self._sanitizedName = State(initialValue: nameState.sanitized) + } + + /// onChange does not fire for the initial value, so the sanitized name is derived up front too. + static func initialNameState(_ initialBranchName: String?) -> (input: String, sanitized: String) { + let input = initialBranchName ?? "" + return (input, GitBranchNameSanitizer.sanitize(input)) } private var canCreate: Bool { diff --git a/macgit/Views/Common/IntegrationsSettingsView.swift b/macgit/Views/Common/IntegrationsSettingsView.swift index 8de3ed34..84b77757 100644 --- a/macgit/Views/Common/IntegrationsSettingsView.swift +++ b/macgit/Views/Common/IntegrationsSettingsView.swift @@ -21,6 +21,9 @@ import SwiftUI struct IntegrationsSettingsView: View { @ObservedObject var appState: AppState + @ObservedObject var accountController: AccountSessionController + @EnvironmentObject private var jiraConnectionController: JiraConnectionController + @State private var authenticationMode: AuthenticationMode? @ObservedObject private var integrationSettings = IntegrationSettingsStore.shared @State private var statusMessage: String? @@ -29,6 +32,11 @@ struct IntegrationsSettingsView: View { var body: some View { Form { + JiraIntegrationSection(controller: jiraConnectionController) { + accountController.errorMessage = nil + authenticationMode = .signIn + } + Section { Picker( "Preferred editor", @@ -158,6 +166,9 @@ struct IntegrationsSettingsView: View { } } .formStyle(.grouped) + .replacingSheet(item: $authenticationMode) { mode in + AuthenticationSheet(controller: accountController, mode: mode) + } .navigationTitle("Integrations") .onAppear(perform: refreshApplications) } diff --git a/macgit/Views/Common/ProUpgradeSheet.swift b/macgit/Views/Common/ProUpgradeSheet.swift index cb8ba11d..a53e912f 100644 --- a/macgit/Views/Common/ProUpgradeSheet.swift +++ b/macgit/Views/Common/ProUpgradeSheet.swift @@ -171,7 +171,7 @@ struct ProUpgradeSheet: View { case .pullRequests, .gitFlow, .gitLFS: "Upgrade to Commit+ Pro to use \(feature.displayName) in private repositories, plus advanced workflows and AI tools across your Macs." case .aiCommitMessage, .repositoryChat, .repositoryAIActions, .aiConflictResolution, .aiBringYourOwnKey, - .multipleProviderAccounts, .branchMergeDetection: + .multipleProviderAccounts, .branchMergeDetection, .jiraIssues: "Upgrade to Commit+ Pro to use \(feature.displayName), plus advanced workflows and AI tools across your Macs." case .privateRepositories: "Upgrade to Commit+ Pro for advanced workflows and AI tools in private repositories across your Macs." diff --git a/macgit/Views/Jira/JiraIssueListView.swift b/macgit/Views/Jira/JiraIssueListView.swift new file mode 100644 index 00000000..1976a842 --- /dev/null +++ b/macgit/Views/Jira/JiraIssueListView.swift @@ -0,0 +1,674 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import AppKit +import SwiftUI + +struct JiraIssueListView: View { + @Environment(\.appTextScale) private var textScale + @ObservedObject var controller: JiraIssueListController + @ObservedObject var connectionController: JiraConnectionController + /// The screen stays mounted while hidden, so loading follows visibility rather than appearance. + let isActive: Bool + let onOpenJiraSettings: () -> Void + let gitFlowEnabled: Bool + let onCreateBranch: (JiraIssue, JiraBranchTarget) -> Void + @State private var selection: JiraIssue.ID? + @AppStorage("jira.detailPanelWidth") private var storedDetailPanelWidth = Double(JiraIssueDetailPanel.defaultWidth) + + var body: some View { + VStack(spacing: 0) { + header + Divider() + if connectionController.connection != nil { + filterBar + Divider() + } + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .task(id: isActive) { + if isActive { + await controller.activate() + } else { + controller.deactivate() + } + } + } + + // MARK: - Header and filters + + private var header: some View { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 1) { + Text("Jira") + .font(.headline.scaled(by: textScale)) + if let connection = connectionController.connection { + Text(connection.siteHost) + .font(.caption.scaled(by: textScale)) + .foregroundStyle(.secondary) + } + } + Spacer() + if connectionController.connection != nil { + TextField("Search key or summary", text: $controller.searchText) + .textFieldStyle(.roundedBorder) + .font(.body.scaled(by: textScale)) + .frame(width: 220 * textScale) + if controller.isLoading, controller.hasLoadedList { + ProgressView() + .controlSize(.small) + } + Button("Refresh Jira issues", systemImage: "arrow.clockwise", action: controller.refresh) + .buttonStyle(.borderless) + .labelStyle(.iconOnly) + .disabled(controller.isLoading) + .help("Refresh") + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color(nsColor: .controlBackgroundColor)) + } + + private var filterBar: some View { + HStack(spacing: 8) { + if controller.supportsSprints { + sprintMenu + } + statusMenu + projectMenu + typeMenu + Spacer(minLength: 0) + if controller.isFiltered { + Button("Reset Filters", action: controller.resetFilters) + .buttonStyle(.link) + } + } + .font(.callout.scaled(by: textScale)) + .menuStyle(.borderlessButton) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 16) + .padding(.vertical, 6) + } + + private var sprintMenu: some View { + Menu { + Picker("Sprint", selection: sprintBinding) { + Text("Open Sprints").tag(JiraIssueFilter.SprintScope.openSprints) + Text("Backlog").tag(JiraIssueFilter.SprintScope.backlog) + Text("Any Sprint").tag(JiraIssueFilter.SprintScope.any) + if !controller.sprintOptions.isEmpty { + Divider() + ForEach(controller.sprintOptions) { sprint in + Text(sprintTitle(sprint)) + .tag(JiraIssueFilter.SprintScope.sprint(id: sprint.id, name: sprint.name)) + } + } + } + .pickerStyle(.inline) + .labelsHidden() + } label: { + filterLabel("Sprint", value: sprintScopeTitle) + } + .fixedSize() + } + + private var statusMenu: some View { + Menu { + ForEach(JiraStatusCategory.allCases, id: \.self) { category in + Toggle(category.title, isOn: membership(category, in: \.statusCategories)) + } + } label: { + filterLabel("Status", value: summary( + JiraStatusCategory.allCases.filter(controller.filter.statusCategories.contains).map(\.title) + )) + } + .fixedSize() + } + + private var projectMenu: some View { + Menu { + if controller.projectOptions.isEmpty { + Text("No projects yet") + } + ForEach(controller.projectOptions) { project in + Toggle("\(project.key) – \(project.name)", isOn: membership(project.key, in: \.projectKeys)) + } + } label: { + filterLabel("Project", value: summary(controller.filter.projectKeys.sorted())) + } + .fixedSize() + } + + private var typeMenu: some View { + Menu { + if controller.issueTypeOptions.isEmpty { + Text("No issue types yet") + } + ForEach(controller.issueTypeOptions, id: \.self) { type in + Toggle(type, isOn: membership(type, in: \.issueTypeNames)) + } + } label: { + filterLabel("Type", value: summary(controller.filter.issueTypeNames.sorted())) + } + .fixedSize() + } + + private func filterLabel(_ title: String, value: String) -> some View { + Text("\(title): \(value)") + } + + private func summary(_ values: [String]) -> String { + switch values.count { + case 0: "Any" + case 1, 2: values.joined(separator: ", ") + default: "\(values.count) selected" + } + } + + private var sprintScopeTitle: String { + switch controller.filter.sprintScope { + case .openSprints: "Open Sprints" + case .backlog: "Backlog" + case .any: "Any" + case .sprint(_, let name): name + } + } + + private func sprintTitle(_ sprint: JiraSprintRef) -> String { + switch sprint.state { + case "active": "\(sprint.name) (Active)" + case "future": "\(sprint.name) (Future)" + default: sprint.name + } + } + + private var sprintBinding: Binding { + Binding( + get: { controller.filter.sprintScope }, + set: { scope in controller.updateFilter { $0.sprintScope = scope } } + ) + } + + private func membership( + _ value: Value, + in keyPath: WritableKeyPath> + ) -> Binding { + Binding( + get: { controller.filter[keyPath: keyPath].contains(value) }, + set: { isOn in + controller.updateFilter { filter in + if isOn { + filter[keyPath: keyPath].insert(value) + } else { + filter[keyPath: keyPath].remove(value) + } + } + } + ) + } + + // MARK: - Content + + @ViewBuilder + private var content: some View { + if let connection = connectionController.connection { + if connection.needsReconnect || controller.error == .needsReconnect { + ContentUnavailableView { + Label("Reconnect Jira", systemImage: "exclamationmark.triangle") + } description: { + Text("Jira no longer accepts the saved API token for \(connection.siteHost).") + } actions: { + Button("Open Jira Settings…", action: onOpenJiraSettings) + } + } else if controller.isLoading, !controller.hasLoadedList { + ProgressView("Loading Jira issues…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = controller.error, controller.items.isEmpty { + ContentUnavailableView { + Label(errorTitle(error), systemImage: "wifi.exclamationmark") + } description: { + Text(error.localizedDescription) + } actions: { + Button("Retry", action: controller.refresh) + } + } else if controller.items.isEmpty { + emptyState + } else { + VStack(spacing: 0) { + if let error = controller.error { + errorBanner(error) + } + issueBrowser(connection: connection) + } + } + } else { + ContentUnavailableView { + Label { + Text("Connect Jira") + } icon: { + Image("jira") + .resizable() + .scaledToFit() + .frame(width: 48, height: 48) + } + } description: { + Text("Connect a Jira Cloud site in Settings → Integrations to see the issues assigned to you.") + } actions: { + Button("Connect Jira…", action: onOpenJiraSettings) + } + } + } + + @ViewBuilder + private var emptyState: some View { + if controller.hasAnyAssignedIssues { + ContentUnavailableView { + Label("No Matching Issues", systemImage: "line.3.horizontal.decrease.circle") + } description: { + Text("None of your Jira issues match these filters.") + } actions: { + Button("Reset Filters", action: controller.resetFilters) + } + } else { + ContentUnavailableView( + "No Assigned Issues", + systemImage: "checkmark.circle", + description: Text("No Jira issues are assigned to you on this site.") + ) + } + } + + private func errorTitle(_ error: JiraError) -> String { + switch error { + case .rateLimited: "Jira Is Busy" + case .forbidden: "No Permission" + case .invalidQuery: "Filter Not Supported" + default: "Jira Unavailable" + } + } + + private func errorBanner(_ error: JiraError) -> some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(error.localizedDescription) + .foregroundStyle(.secondary) + Spacer() + Button("Retry", action: controller.refresh) + .buttonStyle(.link) + } + .font(.callout.scaled(by: textScale)) + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(Color.orange.opacity(0.08)) + } + + private var selectedIssue: JiraIssue? { + guard let selection else { return nil } + return controller.items.first { $0.id == selection } + } + + private static let issueListMinimumWidth: CGFloat = 360 + + /// The list, plus a detail panel on the right while an issue is selected. + /// The panel's width is stored so closing and reopening it keeps the size the user dragged to. + private func issueBrowser(connection: JiraConnection) -> some View { + GeometryReader { proxy in + let maximumWidth = max( + JiraIssueDetailPanel.minimumWidth, + min(JiraIssueDetailPanel.maximumWidth, proxy.size.width - Self.issueListMinimumWidth) + ) + let width = detailPanelWidthBinding(maximumWidth: maximumWidth) + HStack(spacing: 0) { + issueList(connection: connection) + .frame(minWidth: Self.issueListMinimumWidth, maxWidth: .infinity, maxHeight: .infinity) + if let issue = selectedIssue { + JiraIssueDetailPanel( + issue: issue, + gitFlowEnabled: gitFlowEnabled, + onOpen: { open(issue, in: connection) }, + onCopyKey: { copy(issue.key) }, + onCopyKeyAndSummary: { copy("\(issue.key) \(issue.summary)") }, + onCreateBranch: { onCreateBranch(issue, $0) }, + onClose: { selection = nil } + ) + .frame(width: width.wrappedValue) + .frame(maxHeight: .infinity) + .overlay(alignment: .leading) { + RepositoryAIChatResizeHandle( + panelWidth: width, + minimumWidth: JiraIssueDetailPanel.minimumWidth, + maximumWidth: maximumWidth, + accessibilityLabel: "Resize Issue Details", + lineInset: 0 + ) + .frame(width: 8) + } + } + } + } + } + + private func detailPanelWidthBinding(maximumWidth: CGFloat) -> Binding { + Binding( + get: { min(maximumWidth, max(JiraIssueDetailPanel.minimumWidth, CGFloat(storedDetailPanelWidth))) }, + set: { storedDetailPanelWidth = Double($0) } + ) + } + + @ViewBuilder + private func createBranchMenu(for issue: JiraIssue) -> some View { + let suggested = JiraBranchTarget.suggested(forIssueType: issue.issueTypeName, gitFlowEnabled: gitFlowEnabled) + let others = JiraBranchTarget.options(gitFlowEnabled: gitFlowEnabled).filter { $0 != suggested } + if others.isEmpty { + Button("Create Branch…") { onCreateBranch(issue, suggested) } + } else { + Menu("Create Branch") { + Button(suggested.title) { onCreateBranch(issue, suggested) } + Divider() + ForEach(others) { target in + Button(target.title) { onCreateBranch(issue, target) } + } + } + } + } + + private func issueList(connection: JiraConnection) -> some View { + List(selection: $selection) { + ForEach(controller.items) { issue in + JiraIssueRow(issue: issue) + .tag(issue.id) + .onAppear { + if issue.id == controller.items.last?.id { controller.loadMore() } + } + } + if controller.isLoadingMore { + HStack { + Spacer() + ProgressView().controlSize(.small) + Spacer() + } + } + } + .listStyle(.inset) + .contextMenu(forSelectionType: JiraIssue.ID.self) { ids in + if let issue = issue(for: ids) { + Button("Open in Jira") { open(issue, in: connection) } + Button("Copy Key") { copy(issue.key) } + Button("Copy Key and Summary") { copy("\(issue.key) \(issue.summary)") } + Divider() + createBranchMenu(for: issue) + } + } primaryAction: { ids in + if let issue = issue(for: ids) { open(issue, in: connection) } + } + } + + private func issue(for ids: Set) -> JiraIssue? { + guard let id = ids.first else { return nil } + return controller.items.first { $0.id == id } + } + + private func open(_ issue: JiraIssue, in connection: JiraConnection) { + NSWorkspace.shared.open(connection.browseURL(for: issue.key)) + } + + private func copy(_ text: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } +} + +private struct JiraIssueRow: View { + @Environment(\.appTextScale) private var textScale + let issue: JiraIssue + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: issue.typeSymbol) + .foregroundStyle(issue.typeTint) + .frame(width: 16) + .help(issue.issueTypeName) + Text(issue.key) + .font(.body.monospaced().scaled(by: textScale)) + .foregroundStyle(.secondary) + .fixedSize() + Text(issue.summary) + .font(.body.scaled(by: textScale)) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + Spacer(minLength: 8) + // Trailing metadata keeps its full width; only the summary truncates. + if let sprint = issue.displaySprint { + Text(sprint.name) + .font(.caption.scaled(by: textScale)) + .foregroundStyle(.secondary) + .fixedSize() + } + if let priority = issue.priorityName { + Text(priority) + .font(.caption.scaled(by: textScale)) + .foregroundStyle(.secondary) + .fixedSize() + } + JiraStatusBadge(issue: issue) + } + .padding(.vertical, 3) + } +} + +private struct JiraStatusBadge: View { + @Environment(\.appTextScale) private var textScale + @Environment(\.backgroundProminence) private var backgroundProminence + let issue: JiraIssue + + var body: some View { + // On a selected row the tint would vanish into the accent highlight; fall back to primary like RefLabel. + let isProminent = backgroundProminence == .increased + Text(issue.statusName) + .font(.caption.weight(.medium).scaled(by: textScale)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(isProminent ? Color.primary.opacity(0.12) : issue.statusTint.opacity(0.15), in: Capsule()) + .foregroundStyle(isProminent ? AnyShapeStyle(.primary) : AnyShapeStyle(issue.statusTint)) + .fixedSize() + } +} + +/// Basic issue details and the same actions as the row's context menu. +private struct JiraIssueDetailPanel: View { + static let minimumWidth: CGFloat = 280 + static let defaultWidth: CGFloat = 360 + static let maximumWidth: CGFloat = 640 + + @Environment(\.appTextScale) private var textScale + let issue: JiraIssue + let gitFlowEnabled: Bool + let onOpen: () -> Void + let onCopyKey: () -> Void + let onCopyKeyAndSummary: () -> Void + let onCreateBranch: (JiraBranchTarget) -> Void + let onClose: () -> Void + @State private var otherTypesAnchor: NSView? + + private var suggestedTarget: JiraBranchTarget { + JiraBranchTarget.suggested(forIssueType: issue.issueTypeName, gitFlowEnabled: gitFlowEnabled) + } + + private var otherTargets: [JiraBranchTarget] { + JiraBranchTarget.options(gitFlowEnabled: gitFlowEnabled).filter { $0 != suggestedTarget } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Image(systemName: issue.typeSymbol) + .foregroundStyle(issue.typeTint) + Text(issue.key) + .font(.headline.monospaced().scaled(by: textScale)) + .textSelection(.enabled) + Spacer() + Button("Close details", systemImage: "xmark", action: onClose) + .buttonStyle(.borderless) + .labelStyle(.iconOnly) + .help("Close") + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text(issue.summary) + .font(.title3.weight(.semibold).scaled(by: textScale)) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + + actions + + Divider() + + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 12, verticalSpacing: 8) { + GridRow { + fieldLabel("Status") + JiraStatusBadge(issue: issue) + } + field("Type", issue.issueTypeName) + if let priority = issue.priorityName { field("Priority", priority) } + field("Project", issue.projectName.isEmpty ? issue.projectKey : "\(issue.projectName) (\(issue.projectKey))") + if !issue.sprints.isEmpty { + field("Sprint", issue.sprints.map(\.name).joined(separator: ", ")) + } + if let updated = issue.updated { + field("Updated", updated.formatted(date: .abbreviated, time: .shortened)) + } + } + .font(.callout.scaled(by: textScale)) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .background(Color(nsColor: .windowBackgroundColor)) + } + + /// Buttons stretch to the panel's width: the branch action on its own row, the secondary + /// actions side by side when they fit and stacked otherwise. + private var actions: some View { + VStack(spacing: 8) { + HStack(spacing: 6) { + Button { onCreateBranch(suggestedTarget) } label: { + Label(suggestedTarget.title, systemImage: "arrow.branch") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .buttonStyle(.borderedProminent) + // The issue type picks the default; the arrow offers every other flow. + if !otherTargets.isEmpty { + // A SwiftUI Menu renders as a fixed-height pop-up button, so this is a Button + // that opens a native menu; its label fills the row so both bezels match. + Button(action: showOtherBranchTypes) { + Label("Other Branch Types", systemImage: "chevron.down") + .labelStyle(.iconOnly) + .frame(maxHeight: .infinity) + } + .buttonStyle(.bordered) + .background(MenuAnchorView(view: $otherTypesAnchor)) + .help("Other Branch Types") + } + } + .fixedSize(horizontal: false, vertical: true) + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { secondaryActions } + VStack(spacing: 8) { secondaryActions } + } + } + .controlSize(.large) + .font(.body.scaled(by: textScale)) + } + + private func showOtherBranchTypes() { + guard let anchor = otherTypesAnchor else { return } + let menu = SidebarNativeMenu() + for target in otherTargets { + menu.item(target.title) { onCreateBranch(target) } + } + let below = anchor.isFlipped ? anchor.bounds.height + 4 : -4 + menu.popUp(positioning: nil, at: NSPoint(x: 0, y: below), in: anchor) + } + + @ViewBuilder + private var secondaryActions: some View { + actionButton("Open in Jira", systemImage: "arrow.up.right.square", action: onOpen) + actionButton("Copy Key", systemImage: "doc.on.doc", action: onCopyKey) + actionButton("Copy Key and Summary", systemImage: "doc.text", action: onCopyKeyAndSummary) + } + + private func actionButton(_ title: String, systemImage: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Label(title, systemImage: systemImage) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + + private func fieldLabel(_ title: String) -> some View { + Text(title) + .foregroundStyle(.secondary) + .gridColumnAlignment(.trailing) + } + + private func field(_ title: String, _ value: String) -> some View { + GridRow { + fieldLabel(title) + Text(value) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } +} + +private extension JiraIssue { + var typeSymbol: String { + switch issueTypeName.lowercased() { + case "bug": "ladybug" + case "story": "bookmark" + case "epic": "bolt" + case "sub-task", "subtask": "arrow.turn.down.right" + default: "checkmark.square" + } + } + + var typeTint: Color { + switch issueTypeName.lowercased() { + case "bug": .red + case "story": .green + case "epic": .purple + default: .blue + } + } + + var statusTint: Color { + switch statusCategory { + case .done: .green + case .inProgress: .blue + case .toDo, nil: .secondary + } + } +} + +/// Exposes the AppKit view behind a SwiftUI control so a native menu can be anchored to it. +private struct MenuAnchorView: NSViewRepresentable { + @Binding var view: NSView? + + func makeNSView(context: Context) -> NSView { + let anchor = NSView() + DispatchQueue.main.async { view = anchor } + return anchor + } + + func updateNSView(_ nsView: NSView, context: Context) {} +} diff --git a/macgit/Views/MainWindow/ContentView.swift b/macgit/Views/MainWindow/ContentView.swift index ce388be0..f6737534 100644 --- a/macgit/Views/MainWindow/ContentView.swift +++ b/macgit/Views/MainWindow/ContentView.swift @@ -24,6 +24,7 @@ struct ContentView: View { @EnvironmentObject private var appUpdateController: AppUpdateController @EnvironmentObject private var featureAccessController: FeatureAccessController @EnvironmentObject private var repositoryBookmarkController: RepositoryBookmarkController + @EnvironmentObject private var jiraConnectionController: JiraConnectionController @Environment(\.openWindow) private var openWindow @Environment(\.dismissWindow) private var dismissWindow @ObservedObject var accountController: AccountSessionController @@ -98,6 +99,7 @@ struct ContentView: View { initialShowsHistory: initialShowsHistory, initialShowsPullRequests: initialShowsPullRequests, providerAccountController: providerAccountController, + jiraConnectionController: jiraConnectionController, aiProviderController: aiProviderController, onOpenConnections: accountController.presentConnections, windowContext: windowContext, diff --git a/macgit/Views/MainWindow/MainWindowView+GitFlow.swift b/macgit/Views/MainWindow/MainWindowView+GitFlow.swift index 2be812d9..7e2b0588 100644 --- a/macgit/Views/MainWindow/MainWindowView+GitFlow.swift +++ b/macgit/Views/MainWindow/MainWindowView+GitFlow.swift @@ -82,6 +82,7 @@ extension MainWindowView { func requestStartGitFlow(_ kind: GitFlowTopicKind) { if gitFlowConfiguration.isEnabled { + pendingGitFlowInitialTopicName = nil pendingGitFlowTopicKind = kind } else { requestPresentGitFlowSettings() diff --git a/macgit/Views/MainWindow/MainWindowView+Jira.swift b/macgit/Views/MainWindow/MainWindowView+Jira.swift new file mode 100644 index 00000000..e481a712 --- /dev/null +++ b/macgit/Views/MainWindow/MainWindowView+Jira.swift @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation + +extension MainWindowView { + /// Opens the existing branch or Git Flow start flow with a name suggested from the issue; nothing is written to Jira. + func prepareCreateBranch(from issue: JiraIssue, target: JiraBranchTarget) { + let name = JiraBranchNameSuggester.name(key: issue.key, summary: issue.summary) + switch target { + case .gitFlow(let kind) where gitFlowConfiguration.isEnabled: + pendingGitFlowInitialTopicName = name + pendingGitFlowTopicKind = kind + case .gitFlow, .plainBranch: + branchSheetPresentation = BranchSheetPresentation(startPoint: nil, initialBranchName: name) + } + } +} diff --git a/macgit/Views/MainWindow/MainWindowView+Sheets.swift b/macgit/Views/MainWindow/MainWindowView+Sheets.swift index 67028aea..9d10abe5 100644 --- a/macgit/Views/MainWindow/MainWindowView+Sheets.swift +++ b/macgit/Views/MainWindow/MainWindowView+Sheets.swift @@ -142,11 +142,12 @@ extension MainWindowView { } @ViewBuilder - func branchSheet(startPoint: GitBranchStartPoint?) -> some View { + func branchSheet(startPoint: GitBranchStartPoint?, initialBranchName: String? = nil) -> some View { BranchSheetView( repositoryURL: repositoryURL, undoManager: undoManager, initialStartPoint: startPoint, + initialBranchName: initialBranchName, onRunBackgroundRemoteBranchDeletion: runBackgroundRemoteBranchDeletion, onCommitAllChangesRequested: { commitSheetCommitsAllChanges = true @@ -406,11 +407,12 @@ extension MainWindowView { } @ViewBuilder - func startGitFlowSheet(for kind: GitFlowTopicKind) -> some View { + func startGitFlowSheet(for kind: GitFlowTopicKind, initialTopicName: String? = nil) -> some View { StartGitFlowSheet( kind: kind, configuration: gitFlowConfiguration, worktreeRootURL: gitFlowWorktreeRootURL ?? repositoryURL, + initialTopicName: initialTopicName, onRunRepositoryOperation: runRepositoryOperation, onStart: startGitFlow ) diff --git a/macgit/Views/MainWindow/MainWindowView.swift b/macgit/Views/MainWindow/MainWindowView.swift index e7d9924d..8d461779 100644 --- a/macgit/Views/MainWindow/MainWindowView.swift +++ b/macgit/Views/MainWindow/MainWindowView.swift @@ -106,6 +106,7 @@ struct MainWindowView: View { let repositoryURL: URL @ObservedObject var providerAccountController: GitProviderAccountController + @ObservedObject var jiraConnectionController: JiraConnectionController @ObservedObject var aiProviderController: AIProviderController let onOpenConnections: () -> Void let windowContext: RepositoryWindowContext @@ -165,9 +166,11 @@ struct MainWindowView: View { @State private var lfsTrackingPath: String? @State private var hasOpenedPullRequests = false @State private var hasOpenedGitLFS = false + @State private var hasOpenedJira = false @StateObject var syncState = SyncState() @StateObject var undoManager = GitUndoManager() @StateObject var pullRequestController: PullRequestController + @StateObject var jiraIssueListController: JiraIssueListController @StateObject var branchMergeDetectionController: BranchMergeDetectionController @State var pullRequestAccessDecision: FeatureAccessDecision? @State var featureAccessNotice: FeatureAccessNotice? @@ -204,6 +207,7 @@ struct MainWindowView: View { @State var gitFlowConfigurationIssue: GitFlowLocalStateIssue? @State var didPerformInitialLoad = false @State var pendingGitFlowTopicKind: GitFlowTopicKind? + @State var pendingGitFlowInitialTopicName: String? @State var pendingGitFlowFinishPlan: RepositorySheetRequest? @State var gitFlowCurrentBranch = "" @State var gitFlowWorktreeRootURL: URL? @@ -236,6 +240,7 @@ struct MainWindowView: View { initialShowsHistory: Bool? = nil, initialShowsPullRequests: Bool = false, providerAccountController: GitProviderAccountController, + jiraConnectionController: JiraConnectionController, aiProviderController: AIProviderController, onOpenConnections: @escaping () -> Void = {}, windowContext: RepositoryWindowContext, @@ -246,6 +251,7 @@ struct MainWindowView: View { : initialShowsHistory == true ? .item(.history) : .item(.fileStatus)) self.repositoryURL = repositoryURL self.providerAccountController = providerAccountController + self.jiraConnectionController = jiraConnectionController self.aiProviderController = aiProviderController self.onOpenConnections = onOpenConnections self.windowContext = windowContext @@ -272,6 +278,10 @@ struct MainWindowView: View { _syncState = StateObject(wrappedValue: syncState) _undoManager = StateObject(wrappedValue: undoManager) _pullRequestController = StateObject(wrappedValue: pullRequestController) + _jiraIssueListController = StateObject(wrappedValue: JiraIssueListController( + repositoryURL: repositoryURL, + connectionController: jiraConnectionController + )) _branchMergeDetectionController = StateObject(wrappedValue: BranchMergeDetectionController( providerAccountController: providerAccountController )) @@ -461,7 +471,7 @@ struct MainWindowView: View { .replacingSheet(isPresented: $showingAddSubmoduleSheet) { addSubmoduleSheet } .replacingSheet(isPresented: $showingAddLinkSubtreeSheet) { addLinkSubtreeSheet } .replacingSheet(item: $branchSheetPresentation) { presentation in - branchSheet(startPoint: presentation.startPoint) + branchSheet(startPoint: presentation.startPoint, initialBranchName: presentation.initialBranchName) } .replacingSheet(isPresented: $showingTagSheet, onDismiss: resetTagSheet) { tagSheet } .replacingSheet(isPresented: $showingNewTagSheet) { newTagSheet } @@ -485,7 +495,7 @@ struct MainWindowView: View { } } .replacingSheet(item: $pendingGitFlowTopicKind) { kind in - startGitFlowSheet(for: kind) + startGitFlowSheet(for: kind, initialTopicName: pendingGitFlowInitialTopicName) } .replacingSheet(item: $pendingGitFlowFinishPlan) { request in RepositorySheetLoadingView(title: "Finish \(request.input.displayName)", load: { @@ -801,6 +811,10 @@ struct MainWindowView: View { .onChange(of: appState.autoFetchEnabled) { _, globalAutoFetchEnabled in startBackgroundSyncIfLoaded(globalAutoFetchEnabled: globalAutoFetchEnabled) } + .onChange(of: appState.showWorkspaceJira) { _, isVisible in + // A hidden sidebar row cannot stay selected. + if !isVisible, selectedItem == .item(.jira) { selectedItem = .item(.fileStatus) } + } .onChange(of: selectedItem) { _, newItem in if newItem != .item(.gitLFS) { lfsTrackingPath = nil } if case .branch(let name) = newItem { @@ -1457,7 +1471,7 @@ struct MainWindowView: View { } case .item(.reflog): EmptyView() - case .item(.pullRequests): + case .item(.pullRequests), .item(.jira): EmptyView() case .stash(let ref): StashView( @@ -1519,7 +1533,7 @@ struct MainWindowView: View { FeatureAccessUnavailableView( notice: FeatureAccessNotice(feature: .pullRequests, denial: denial), isSignedIn: accountController.account != nil, - onAccountAction: presentFeatureAccessAccountAction, + onAccountAction: { presentFeatureAccessAccountAction() }, onRetry: { Task { _ = await authorizePullRequestAccess( @@ -1538,6 +1552,32 @@ struct MainWindowView: View { .repositoryScreenVisibility(isActive: selectedItem == .item(.pullRequests)) } + if selectedItem == .item(.jira) || hasOpenedJira { + Group { + switch jiraAccessDecision { + case .allowed: + JiraIssueListView( + controller: jiraIssueListController, + connectionController: jiraConnectionController, + isActive: selectedItem == .item(.jira), + onOpenJiraSettings: accountController.presentIntegrations, + gitFlowEnabled: gitFlowConfiguration.isEnabled, + onCreateBranch: prepareCreateBranch(from:target:) + ) + case .denied(let denial): + FeatureAccessUnavailableView( + notice: FeatureAccessNotice(feature: .jiraIssues, denial: denial), + isSignedIn: accountController.account != nil, + onAccountAction: { presentFeatureAccessAccountAction(for: .jiraIssues) }, + onRetry: {} + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { hasOpenedJira = true } + .repositoryScreenVisibility(isActive: selectedItem == .item(.jira)) + } + if selectedItem == .item(.gitLFS) || hasOpenedGitLFS { GitLFSAccessView(repositoryURL: repositoryURL) { authorize in GitLFSView(repositoryURL: repositoryURL, initialPath: lfsTrackingPath, credentialResolver: { @MainActor remote in @@ -1675,12 +1715,17 @@ struct MainWindowView: View { ].joined(separator: "|") } - private func presentFeatureAccessAccountAction() { + /// Jira is not repository-scoped, so its decision is immediate and follows policy and entitlement changes. + private var jiraAccessDecision: FeatureAccessDecision { + featureAccessController.decision(for: .jiraIssues, entitlement: accountController.entitlement) + } + + private func presentFeatureAccessAccountAction(for feature: PlanFeature = .pullRequests) { if accountController.account == nil { accountController.presentAuthentication(.signIn) } else { proUpgradeErrorMessage = nil - proUpgradePresentation = ProUpgradePresentation(feature: .pullRequests) + proUpgradePresentation = ProUpgradePresentation(feature: feature) } } @@ -1694,7 +1739,7 @@ struct MainWindowView: View { message: message, primaryButton: .default( Text(isSignedIn ? "View Pricing" : "Sign In"), - action: presentFeatureAccessAccountAction + action: { presentFeatureAccessAccountAction() } ), secondaryButton: .cancel() ) @@ -1723,7 +1768,8 @@ struct MainWindowView: View { _ = await authorizeGitFlowAccess(forceRefresh: true) } case .privateRepositories, .gitLFS, .aiCommitMessage, .repositoryChat, .repositoryAIActions, - .aiConflictResolution, .aiBringYourOwnKey, .multipleProviderAccounts, .branchMergeDetection: + .aiConflictResolution, .aiBringYourOwnKey, .multipleProviderAccounts, .branchMergeDetection, + .jiraIssues: break } } diff --git a/macgit/Views/MainWindow/RepositoryAIChatResizeHandle.swift b/macgit/Views/MainWindow/RepositoryAIChatResizeHandle.swift index d9827c5a..8443ffff 100644 --- a/macgit/Views/MainWindow/RepositoryAIChatResizeHandle.swift +++ b/macgit/Views/MainWindow/RepositoryAIChatResizeHandle.swift @@ -23,12 +23,17 @@ struct RepositoryAIChatResizeHandle: NSViewRepresentable { @Binding var panelWidth: CGFloat let minimumWidth: CGFloat let maximumWidth: CGFloat + var accessibilityLabel = "Resize AI Chat" + /// Vertical inset of the drawn line, so it stops short of the panel's rounded corners. + var lineInset = RepositoryToolbarShortcutPanel.cornerRadius func makeNSView(context: Context) -> ResizeHandleView { ResizeHandleView( panelWidth: panelWidth, minimumWidth: minimumWidth, maximumWidth: maximumWidth, + accessibilityLabel: accessibilityLabel, + lineInset: lineInset, onWidthChange: { panelWidth = $0 } ) } @@ -37,6 +42,7 @@ struct RepositoryAIChatResizeHandle: NSViewRepresentable { nsView.panelWidth = panelWidth nsView.minimumWidth = minimumWidth nsView.maximumWidth = maximumWidth + nsView.lineInset = lineInset nsView.onWidthChange = { panelWidth = $0 } } @@ -44,6 +50,7 @@ struct RepositoryAIChatResizeHandle: NSViewRepresentable { var panelWidth: CGFloat var minimumWidth: CGFloat var maximumWidth: CGFloat + var lineInset: CGFloat var onWidthChange: (CGFloat) -> Void private var dragStartScreenX: CGFloat? @@ -55,15 +62,18 @@ struct RepositoryAIChatResizeHandle: NSViewRepresentable { panelWidth: CGFloat, minimumWidth: CGFloat, maximumWidth: CGFloat, + accessibilityLabel: String, + lineInset: CGFloat, onWidthChange: @escaping (CGFloat) -> Void ) { self.panelWidth = panelWidth self.minimumWidth = minimumWidth self.maximumWidth = maximumWidth + self.lineInset = lineInset self.onWidthChange = onWidthChange super.init(frame: .zero) setAccessibilityRole(.splitter) - setAccessibilityLabel("Resize AI Chat") + setAccessibilityLabel(accessibilityLabel) setAccessibilityOrientation(.vertical) } @@ -137,9 +147,9 @@ struct RepositoryAIChatResizeHandle: NSViewRepresentable { ) let lineRect = NSRect( x: 0, - y: RepositoryToolbarShortcutPanel.cornerRadius, + y: lineInset, width: lineWidth, - height: max(0, bounds.height - RepositoryToolbarShortcutPanel.cornerRadius * 2) + height: max(0, bounds.height - lineInset * 2) ) lineColor.setFill() lineRect.fill() diff --git a/macgit/Views/MainWindow/Sidebar/SidebarItem.swift b/macgit/Views/MainWindow/Sidebar/SidebarItem.swift index 9a8d7c48..a5680a60 100644 --- a/macgit/Views/MainWindow/Sidebar/SidebarItem.swift +++ b/macgit/Views/MainWindow/Sidebar/SidebarItem.swift @@ -21,6 +21,7 @@ enum SidebarItem: String, CaseIterable, Identifiable { case history = "History" case reflog = "Reflog" case pullRequests = "Pull Requests" + case jira = "Jira" case search = "Search" case gitLFS = "Git LFS" case gitFlow = "Git Flow" @@ -33,6 +34,8 @@ enum SidebarItem: String, CaseIterable, Identifiable { case .history: return "clock.arrow.circlepath" case .reflog: return "list.bullet.rectangle" case .pullRequests: return "arrow.triangle.pull" + // An asset-catalog image, not an SF Symbol. + case .jira: return "jira" case .search: return "magnifyingglass" case .gitLFS: return "externaldrive" case .gitFlow: return "point.3.connected.trianglepath.dotted" diff --git a/macgit/Views/MainWindow/Sidebar/SidebarNativeList.swift b/macgit/Views/MainWindow/Sidebar/SidebarNativeList.swift index 47e4f1d3..e1fa3f13 100644 --- a/macgit/Views/MainWindow/Sidebar/SidebarNativeList.swift +++ b/macgit/Views/MainWindow/Sidebar/SidebarNativeList.swift @@ -598,7 +598,9 @@ struct SidebarNativeList: NSViewRepresentable { } } badge.toolTip = value.badgeToolTip.isEmpty ? nil : value.badgeToolTip - icon.image = value.icon.isEmpty ? nil : NSImage(systemSymbolName: value.icon, accessibilityDescription: nil) + // Brand icons (e.g. Jira) live in the asset catalog; everything else is an SF Symbol. + icon.image = value.icon.isEmpty ? nil + : NSImage(systemSymbolName: value.icon, accessibilityDescription: nil) ?? NSImage(named: value.icon) icon.contentTintColor = value.emphasized ? .controlAccentColor : tint(value.tint) if value.spinning { progress.startAnimation(nil) } else { progress.stopAnimation(nil) } button.image = value.accessory.isEmpty ? nil : NSImage(systemSymbolName: value.accessory, accessibilityDescription: value.accessoryLabel) diff --git a/macgit/Views/MainWindow/Sidebar/SidebarSection.swift b/macgit/Views/MainWindow/Sidebar/SidebarSection.swift index 7d24f4e7..4b637fff 100644 --- a/macgit/Views/MainWindow/Sidebar/SidebarSection.swift +++ b/macgit/Views/MainWindow/Sidebar/SidebarSection.swift @@ -78,9 +78,22 @@ enum SidebarSection: String, CaseIterable { var items: [SidebarItem] { switch self { case .workspace: - return [.fileStatus, .history, .reflog, .pullRequests, .gitLFS, .search] + return [.fileStatus, .history, .reflog, .pullRequests, .jira, .gitLFS, .search] default: return [] } } + + /// Workspace rows after applying the "Customize Workspace" toggles. + static func visibleWorkspaceItems(appState: AppState) -> [SidebarItem] { + (workspace.items + (appState.showGitFlow ? [.gitFlow] : [])).filter { item in + switch item { + case .reflog: appState.showWorkspaceReflog + case .pullRequests: appState.showWorkspacePullRequests + case .jira: appState.showWorkspaceJira + case .gitLFS: appState.showWorkspaceGitLFS + default: true + } + } + } } diff --git a/macgit/Views/MainWindow/Sidebar/WorkspaceVisibilityPopover.swift b/macgit/Views/MainWindow/Sidebar/WorkspaceVisibilityPopover.swift index 9829490a..6e321a18 100644 --- a/macgit/Views/MainWindow/Sidebar/WorkspaceVisibilityPopover.swift +++ b/macgit/Views/MainWindow/Sidebar/WorkspaceVisibilityPopover.swift @@ -28,6 +28,7 @@ struct WorkspaceVisibilityPopover: View { Toggle("Reflog", isOn: $appState.showWorkspaceReflog) Toggle("Pull Requests", isOn: $appState.showWorkspacePullRequests) + Toggle("Jira", isOn: $appState.showWorkspaceJira) Toggle("Git LFS", isOn: $appState.showWorkspaceGitLFS) Toggle("Git Flow", isOn: $appState.showGitFlow) } diff --git a/macgit/Views/MainWindow/SidebarView+NativeRows.swift b/macgit/Views/MainWindow/SidebarView+NativeRows.swift index e095e050..d35b34ab 100644 --- a/macgit/Views/MainWindow/SidebarView+NativeRows.swift +++ b/macgit/Views/MainWindow/SidebarView+NativeRows.swift @@ -53,10 +53,7 @@ extension SidebarView { header(.workspace, expanded: true, accessory: "line.3.horizontal.decrease.circle", label: "Customize Workspace") { view in SidebarNativePopover.show(WorkspaceVisibilityPopover(appState: appState), from: view) } - for item in SidebarSection.workspace.items + (appState.showGitFlow ? [.gitFlow] : []) { - if item == .reflog && !appState.showWorkspaceReflog { continue } - if item == .pullRequests && !appState.showWorkspacePullRequests { continue } - if item == .gitLFS && !appState.showWorkspaceGitLFS { continue } + for item in SidebarSection.visibleWorkspaceItems(appState: appState) { var row = SidebarNativeRow(id: .init(section: .workspace, kind: "item", key: item.rawValue), appearance: .init(title: item.rawValue, icon: item.icon)) if item == .fileStatus && (stagedFileCount > 0 || changedFileCount > 0) { diff --git a/macgit/Views/Workflow/StartGitFlowSheet.swift b/macgit/Views/Workflow/StartGitFlowSheet.swift index a1347e4e..47f6a579 100644 --- a/macgit/Views/Workflow/StartGitFlowSheet.swift +++ b/macgit/Views/Workflow/StartGitFlowSheet.swift @@ -40,10 +40,12 @@ struct StartGitFlowSheet: View { kind: GitFlowTopicKind, configuration: GitFlowConfiguration, worktreeRootURL: URL, + initialTopicName: String? = nil, onRunRepositoryOperation: @escaping RepositoryOperationRunner, onStart: @escaping @MainActor (GitFlowStartPlan, Bool) async -> Bool ) { self.kind = kind + _topicName = State(initialValue: initialTopicName ?? "") self.configuration = configuration self.worktreeRootURL = worktreeRootURL self.onRunRepositoryOperation = onRunRepositoryOperation diff --git a/macgitTests/AppSettingsSnapshotTests.swift b/macgitTests/AppSettingsSnapshotTests.swift index b0cb597e..b602db7f 100644 --- a/macgitTests/AppSettingsSnapshotTests.swift +++ b/macgitTests/AppSettingsSnapshotTests.swift @@ -29,6 +29,7 @@ final class AppSettingsSnapshotTests: XCTestCase { showWorkspaceReflog: false, showWorkspacePullRequests: true, showWorkspaceGitLFS: false, + showWorkspaceJira: false, showTags: false, showWorktrees: true, showSubmodules: true, @@ -63,6 +64,7 @@ final class AppSettingsSnapshotTests: XCTestCase { "showWorkspaceReflog", "showWorkspacePullRequests", "showWorkspaceGitLFS", + "showWorkspaceJira", "showTags", "showWorktrees", "showSubmodules", @@ -118,6 +120,7 @@ final class AppSettingsSnapshotTests: XCTestCase { showWorkspaceReflog: false, showWorkspacePullRequests: false, showWorkspaceGitLFS: false, + showWorkspaceJira: false, showSubmodules: false, showSubtrees: false ) @@ -126,6 +129,7 @@ final class AppSettingsSnapshotTests: XCTestCase { object.removeValue(forKey: "showWorkspaceReflog") object.removeValue(forKey: "showWorkspacePullRequests") object.removeValue(forKey: "showWorkspaceGitLFS") + object.removeValue(forKey: "showWorkspaceJira") let decoded = try JSONDecoder().decode( AppSettingsSnapshot.self, @@ -135,6 +139,7 @@ final class AppSettingsSnapshotTests: XCTestCase { XCTAssertTrue(decoded.showWorkspaceReflog) XCTAssertTrue(decoded.showWorkspacePullRequests) XCTAssertTrue(decoded.showWorkspaceGitLFS) + XCTAssertTrue(decoded.showWorkspaceJira) } func testSnapshotDecodingDefaultsMissingTagsAndWorktreesVisibilityToTrue() throws { @@ -255,10 +260,12 @@ final class AppSettingsSnapshotTests: XCTestCase { XCTAssertTrue(state.showWorkspaceReflog) XCTAssertTrue(state.showWorkspacePullRequests) XCTAssertTrue(state.showWorkspaceGitLFS) + XCTAssertTrue(state.showWorkspaceJira) state.showWorkspaceReflog = false state.showWorkspacePullRequests = false state.showWorkspaceGitLFS = false + state.showWorkspaceJira = false let reloaded = AppState(userDefaults: defaults) XCTAssertFalse(reloaded.showWorkspaceReflog) @@ -267,6 +274,8 @@ final class AppSettingsSnapshotTests: XCTestCase { XCTAssertFalse(reloaded.snapshot.showWorkspaceReflog) XCTAssertFalse(reloaded.snapshot.showWorkspacePullRequests) XCTAssertFalse(reloaded.snapshot.showWorkspaceGitLFS) + XCTAssertFalse(reloaded.showWorkspaceJira) + XCTAssertFalse(reloaded.snapshot.showWorkspaceJira) } func testTagsAndWorktreesVisibilityDefaultsOnAndPersistsLocally() { diff --git a/macgitTests/BranchSheetInitialStateTests.swift b/macgitTests/BranchSheetInitialStateTests.swift index 975ec0c2..ecf47930 100644 --- a/macgitTests/BranchSheetInitialStateTests.swift +++ b/macgitTests/BranchSheetInitialStateTests.swift @@ -20,6 +20,16 @@ import XCTest @MainActor final class BranchSheetInitialStateTests: XCTestCase { + func testInitialBranchNameIsPrefilledAndAlreadySanitized() { + let suggested = JiraBranchNameSuggester.name(key: "PROJ-123", summary: "Fix login crash") + let state = BranchSheetView.initialNameState(suggested) + + XCTAssertEqual(state.input, "PROJ-123-fix-login-crash") + XCTAssertEqual(state.sanitized, "PROJ-123-fix-login-crash") + XCTAssertEqual(BranchSheetView.initialNameState(nil).input, "") + XCTAssertEqual(BranchSheetView.initialNameState(nil).sanitized, "") + } + func testDefaultInitialStatePrefersWorkingCopyParentWhenNoStartPointIsInjected() { let state = BranchSheetView.initialCreateState( initialStartPoint: nil, diff --git a/macgitTests/CloudSettingsDocumentTests.swift b/macgitTests/CloudSettingsDocumentTests.swift index b9a8723b..73c7d036 100644 --- a/macgitTests/CloudSettingsDocumentTests.swift +++ b/macgitTests/CloudSettingsDocumentTests.swift @@ -28,6 +28,7 @@ final class CloudSettingsDocumentTests: XCTestCase { showWorkspaceReflog: false, showWorkspacePullRequests: true, showWorkspaceGitLFS: false, + showWorkspaceJira: false, showTags: false, showWorktrees: true, showSubmodules: true, @@ -71,6 +72,7 @@ final class CloudSettingsDocumentTests: XCTestCase { "showWorkspaceReflog", "showWorkspacePullRequests", "showWorkspaceGitLFS", + "showWorkspaceJira", "showTags", "showWorktrees", "showSubmodules", @@ -107,6 +109,7 @@ final class CloudSettingsDocumentTests: XCTestCase { XCTAssertEqual(document["showWorkspaceReflog"] as? Bool, false) XCTAssertEqual(document["showWorkspacePullRequests"] as? Bool, true) XCTAssertEqual(document["showWorkspaceGitLFS"] as? Bool, false) + XCTAssertEqual(document["showWorkspaceJira"] as? Bool, false) XCTAssertEqual(document["showTags"] as? Bool, false) XCTAssertEqual(document["showWorktrees"] as? Bool, true) XCTAssertEqual(document["showSubmodules"] as? Bool, true) @@ -169,12 +172,14 @@ final class CloudSettingsDocumentTests: XCTestCase { document.removeValue(forKey: "showWorkspaceReflog") document.removeValue(forKey: "showWorkspacePullRequests") document.removeValue(forKey: "showWorkspaceGitLFS") + document.removeValue(forKey: "showWorkspaceJira") let decoded = try CloudSettingsDocument.decode(document) XCTAssertTrue(decoded.showWorkspaceReflog) XCTAssertTrue(decoded.showWorkspacePullRequests) XCTAssertTrue(decoded.showWorkspaceGitLFS) + XCTAssertTrue(decoded.showWorkspaceJira) } func testDecodingDefaultsMissingTagsAndWorktreesVisibilityToTrue() throws { diff --git a/macgitTests/FeatureAccessControllerTests.swift b/macgitTests/FeatureAccessControllerTests.swift index 77289eb8..9f3b88fe 100644 --- a/macgitTests/FeatureAccessControllerTests.swift +++ b/macgitTests/FeatureAccessControllerTests.swift @@ -55,6 +55,14 @@ final class FeatureAccessControllerTests: XCTestCase { XCTAssertTrue(controller.isUsingCachedPolicy) } + func testJiraIssuesDecisionFollowsEntitlementWithoutRemotePolicy() { + let controller = FeatureAccessController(provider: nil, cache: FakeFeaturePolicyCache()) + let activePro = AccountEntitlement(plan: .pro, access: .active, billingStatus: .active) + + XCTAssertEqual(controller.decision(for: .jiraIssues, entitlement: .free), .denied(.requiresPro)) + XCTAssertEqual(controller.decision(for: .jiraIssues, entitlement: activePro), .allowed) + } + func testValidRemotePolicyReplacesAndPersistsCache() { let provider = FakeFeaturePolicyProvider() let cache = FakeFeaturePolicyCache() diff --git a/macgitTests/FeatureAccessNoticeTests.swift b/macgitTests/FeatureAccessNoticeTests.swift index 11507cbb..acf17a17 100644 --- a/macgitTests/FeatureAccessNoticeTests.swift +++ b/macgitTests/FeatureAccessNoticeTests.swift @@ -57,6 +57,16 @@ final class FeatureAccessNoticeTests: XCTestCase { XCTAssertTrue(notice.message.contains("try again")) } + func testJiraNoticeRequestsSignInOrUpgradeWithoutRepositoryWording() { + let notice = FeatureAccessNotice(feature: .jiraIssues, denial: .requiresPro) + + XCTAssertEqual(notice.title(isSignedIn: false), "Sign In Required") + XCTAssertEqual(notice.message(isSignedIn: false), "Sign in to Commit+ to use Jira Issues.") + XCTAssertEqual(notice.title(isSignedIn: true), "Commit+ Pro Required") + XCTAssertTrue(notice.message(isSignedIn: true).contains("Jira Issues")) + XCTAssertFalse(notice.message(isSignedIn: true).contains("repository")) + } + func testDisabledNoticeDoesNotSuggestUpgrade() { let notice = FeatureAccessNotice(feature: .pullRequests, denial: .featureDisabled) diff --git a/macgitTests/FeatureAccessPolicyTests.swift b/macgitTests/FeatureAccessPolicyTests.swift index 52e3797c..338b6301 100644 --- a/macgitTests/FeatureAccessPolicyTests.swift +++ b/macgitTests/FeatureAccessPolicyTests.swift @@ -299,6 +299,35 @@ final class FeatureAccessPolicyTests: XCTestCase { ) } + func testJiraIssuesIsProOnly() { + let resolver = FeatureAccessResolver(policy: .bundled) + XCTAssertEqual(resolver.decision(for: .jiraIssues, entitlement: .free), .denied(.requiresPro)) + XCTAssertEqual(resolver.decision(for: .jiraIssues, entitlement: inactivePro), .denied(.requiresPro)) + XCTAssertEqual(resolver.decision(for: .jiraIssues, entitlement: activePro), .allowed) + } + + func testRemotePolicyWithoutJiraIssuesUsesBundledProOnlyRule() { + var diagnostics: [String] = [] + let policy = FeaturePolicyDocumentDecoder.decode(document(), onDiagnostic: { diagnostics.append($0) }) + + XCTAssertEqual(policy?.rule(for: .jiraIssues), FeatureAccessPolicy.bundled.rule(for: .jiraIssues)) + XCTAssertTrue(diagnostics.contains { $0.contains("jiraIssues") }) + let resolver = FeatureAccessResolver(policy: policy!) + XCTAssertEqual(resolver.decision(for: .jiraIssues, entitlement: .free), .denied(.requiresPro)) + XCTAssertEqual(resolver.decision(for: .jiraIssues, entitlement: activePro), .allowed) + } + + func testJiraIssuesRemoteKillSwitchDisablesEveryPlan() { + let policy = FeaturePolicyDocumentDecoder.decode(document(overrides: ["jiraIssues": [ + "enabled": false, + "plans": ["free": ["enabled": false], "pro": ["enabled": true]] + ]])) + let resolver = FeatureAccessResolver(policy: policy!) + + XCTAssertEqual(resolver.decision(for: .jiraIssues, entitlement: .free), .denied(.featureDisabled)) + XCTAssertEqual(resolver.decision(for: .jiraIssues, entitlement: activePro), .denied(.featureDisabled)) + } + private var inactivePro: AccountEntitlement { AccountEntitlement(plan: .pro, access: .inactive, billingStatus: .canceled) } diff --git a/macgitTests/JiraBranchNameSuggesterTests.swift b/macgitTests/JiraBranchNameSuggesterTests.swift new file mode 100644 index 00000000..e64d8781 --- /dev/null +++ b/macgitTests/JiraBranchNameSuggesterTests.swift @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import XCTest +@testable import macgit + +final class JiraBranchNameSuggesterTests: XCTestCase { + private func name(_ summary: String, key: String = "PROJ-123") -> String { + JiraBranchNameSuggester.name(key: key, summary: summary) + } + + func testKeyAndSlug() { + XCTAssertEqual(name("Fix login crash"), "PROJ-123-fix-login-crash") + } + + func testPunctuationCollapsesToSingleHyphens() { + XCTAssertEqual(name(" [iOS] Fix: login -- crash (again)! "), "PROJ-123-ios-fix-login-crash-again") + } + + func testDiacriticsAreFoldedToASCII() { + XCTAssertEqual(name("Sửa lỗi đăng nhập"), "PROJ-123-sua-loi-dang-nhap") + XCTAssertEqual(name("Crème brûlée façade"), "PROJ-123-creme-brulee-facade") + } + + func testEmojiAndSymbolsAreDropped() { + XCTAssertEqual(name("🚀 Launch 🎉 page"), "PROJ-123-launch-page") + } + + func testEmptyOrSymbolOnlySummaryKeepsKey() { + XCTAssertEqual(name(""), "PROJ-123") + XCTAssertEqual(name("🔥!!"), "PROJ-123") + } + + func testLongSummaryIsCutAtHyphenAndKeepsKey() { + let result = name("Refactor the repository synchronization pipeline to support incremental background refreshes") + XCTAssertLessThanOrEqual(result.count, JiraBranchNameSuggester.maxLength) + XCTAssertTrue(result.hasPrefix("PROJ-123-refactor-the-repository")) + XCTAssertFalse(result.hasSuffix("-")) + XCTAssertTrue("Refactor the repository synchronization pipeline to support incremental background refreshes" + .lowercased().replacingOccurrences(of: " ", with: "-") + .hasPrefix(String(result.dropFirst("PROJ-123-".count))), "must end on a whole word") + } + + func testSingleVeryLongWordFallsBackToKey() { + XCTAssertEqual(name(String(repeating: "a", count: 80)), "PROJ-123") + } + + func testBugsSuggestBugfixAndOtherTypesSuggestFeature() { + XCTAssertEqual(JiraBranchTarget.suggested(forIssueType: "Bug", gitFlowEnabled: true), .gitFlow(.bugfix)) + XCTAssertEqual(JiraBranchTarget.suggested(forIssueType: "Defect", gitFlowEnabled: true), .gitFlow(.bugfix)) + XCTAssertEqual(JiraBranchTarget.suggested(forIssueType: "Story", gitFlowEnabled: true), .gitFlow(.feature)) + XCTAssertEqual(JiraBranchTarget.suggested(forIssueType: "Task", gitFlowEnabled: true), .gitFlow(.feature)) + } + + func testWithoutGitFlowOnlyPlainBranchIsOffered() { + XCTAssertEqual(JiraBranchTarget.suggested(forIssueType: "Bug", gitFlowEnabled: false), .plainBranch) + XCTAssertEqual(JiraBranchTarget.options(gitFlowEnabled: false), [.plainBranch]) + } + + func testGitFlowOptionsExcludeReleaseAndKeepPlainBranch() { + let options = JiraBranchTarget.options(gitFlowEnabled: true) + XCTAssertEqual(options, [.gitFlow(.feature), .gitFlow(.bugfix), .gitFlow(.hotfix), .plainBranch]) + XCTAssertEqual(options.map(\.title), ["Start Feature…", "Start Bugfix…", "Start Hotfix…", "New Branch…"]) + } + + func testSuggestionSurvivesBranchSanitizerUnchanged() { + for summary in ["Fix login crash", "Sửa lỗi đăng nhập", "a..b @{x} ~^:?*[\\ end.lock"] { + let suggested = name(summary) + XCTAssertEqual(GitBranchNameSanitizer.sanitize(suggested), suggested, summary) + } + } +} diff --git a/macgitTests/JiraCloudClientTests.swift b/macgitTests/JiraCloudClientTests.swift new file mode 100644 index 00000000..eb108f17 --- /dev/null +++ b/macgitTests/JiraCloudClientTests.swift @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import XCTest +@testable import macgit + +final class JiraCloudClientTests: XCTestCase { + private let site = URL(string: "https://acme.atlassian.net")! + private let credential = JiraCredential(email: "dev@example.com", token: "secret-token-123") + + func testValidateUsesBasicAuthAgainstTheSite() async throws { + let http = StubJiraHTTPClient([ + .init(path: "/rest/api/3/myself", status: 200, body: #"{"accountId":"abc","displayName":"Dev","emailAddress":"dev@example.com"}"#), + ]) + let account = try await JiraCloudClient(httpClient: http).validate(siteURL: site, credential: credential) + + XCTAssertEqual(account, JiraValidatedAccount(accountID: "abc", displayName: "Dev", email: "dev@example.com", apiBase: .site)) + let expected = "Basic " + Data("dev@example.com:secret-token-123".utf8).base64EncodedString() + XCTAssertEqual(http.requests.first?.value(forHTTPHeaderField: "Authorization"), expected) + XCTAssertEqual(http.requests.first?.url?.host(), "acme.atlassian.net") + } + + func testScopedTokenFallsBackToGateway() async throws { + let http = StubJiraHTTPClient([ + .init(path: "/rest/api/3/myself", status: 401, body: ""), + .init(path: "/_edge/tenant_info", status: 200, body: #"{"cloudId":"11111111-2222-3333-4444-555555555555"}"#), + .init(path: "/ex/jira/11111111-2222-3333-4444-555555555555/rest/api/3/myself", status: 200, body: #"{"accountId":"abc"}"#), + ]) + let account = try await JiraCloudClient(httpClient: http).validate(siteURL: site, credential: credential) + + XCTAssertEqual(account.apiBase, .gateway(cloudID: "11111111-2222-3333-4444-555555555555")) + XCTAssertEqual(http.requests.last?.url?.host(), "api.atlassian.com") + XCTAssertNil(http.requests[1].value(forHTTPHeaderField: "Authorization"), "tenant_info must not receive the token") + XCTAssertEqual(account.displayName, "dev@example.com") + } + + func testRejectedEverywhereIsInvalidCredentials() async { + let http = StubJiraHTTPClient([ + .init(path: "/rest/api/3/myself", status: 401, body: ""), + .init(path: "/_edge/tenant_info", status: 200, body: #"{"cloudId":"abc"}"#), + .init(path: "/ex/jira/abc/rest/api/3/myself", status: 401, body: ""), + ]) + await assertThrows(.invalidCredentials) { + _ = try await JiraCloudClient(httpClient: http).validate(siteURL: self.site, credential: self.credential) + } + } + + func testNonJiraSiteIsInvalidResponseAndNetworkFailureIsUnreachable() async { + await assertThrows(.invalidResponse) { + let http = StubJiraHTTPClient([.init(path: "/rest/api/3/myself", status: 200, body: "")]) + _ = try await JiraCloudClient(httpClient: http).validate(siteURL: self.site, credential: self.credential) + } + await assertThrows(.unreachable) { + let http = StubJiraHTTPClient([], error: URLError(.cannotFindHost)) + _ = try await JiraCloudClient(httpClient: http).validate(siteURL: self.site, credential: self.credential) + } + } + + func testStatusMapping() { + func error(_ status: Int, headers: [String: String] = [:]) -> JiraError? { + let response = HTTPURLResponse(url: site, statusCode: status, httpVersion: nil, headerFields: headers)! + do { try JiraCloudClient.validate(response); return nil } catch { return error as? JiraError } + } + XCTAssertNil(error(200)) + XCTAssertEqual(error(400), .invalidQuery) + XCTAssertEqual(error(401), .needsReconnect) + XCTAssertEqual(error(403), .forbidden) + XCTAssertEqual(error(429, headers: ["Retry-After": "12"]), .rateLimited(retryAfter: 12)) + XCTAssertEqual(error(429), .rateLimited(retryAfter: nil)) + XCTAssertEqual(error(503), .unreachable) + XCTAssertEqual(error(404), .invalidResponse) + } + + func testErrorDescriptionsNeverContainTheToken() { + let errors: [JiraError] = [.unsupportedSite, .invalidCredentials, .needsReconnect, .forbidden, + .rateLimited(retryAfter: 5), .unreachable, .invalidResponse, .invalidQuery, + .notSignedIn, .notConnected] + for error in errors { + XCTAssertFalse(error.localizedDescription.contains(credential.token)) + } + } + + func testSprintFieldDiscovery() async throws { + let withSprints = StubJiraHTTPClient([.init(path: "/rest/api/3/field", status: 200, body: #""" + [{"id":"summary"},{"id":"customfield_10020","schema":{"custom":"com.pyxis.greenhopper.jira:gh-sprint"}}] + """#)]) + let fieldID = try await JiraCloudClient(httpClient: withSprints).sprintFieldID(baseURL: site, credential: credential) + XCTAssertEqual(fieldID, "customfield_10020") + + let withoutSprints = StubJiraHTTPClient([.init(path: "/rest/api/3/field", status: 200, body: #"[{"id":"summary"}]"#)]) + let missing = try await JiraCloudClient(httpClient: withoutSprints).sprintFieldID(baseURL: site, credential: credential) + XCTAssertNil(missing) + XCTAssertFalse(JiraJQLBuilder.jql(for: .default, supportsSprints: missing != nil).contains("sprint")) + } + + func testSearchSendsJQLAndParsesIssuesAndNextPage() async throws { + let http = StubJiraHTTPClient([.init(path: "/rest/api/3/search/jql", status: 200, body: #""" + {"isLast":false,"nextPageToken":"page-2","issues":[{"id":"10001","key":"PROJ-1","fields":{ + "summary":"Fix login crash","updated":"2026-10-20T10:15:30.123+0700", + "status":{"name":"In Review","statusCategory":{"key":"indeterminate"}}, + "issuetype":{"name":"Bug"},"priority":{"name":"High"}, + "project":{"key":"PROJ","name":"Project"}, + "customfield_10020":[{"id":7,"name":"Sprint 6","state":"closed"},{"id":8,"name":"Sprint 7","state":"active"}]}}]} + """#)]) + let page = try await JiraCloudClient(httpClient: http).searchIssues( + baseURL: site, credential: credential, jql: "assignee = currentUser()", + sprintFieldID: "customfield_10020", pageToken: "page-1" + ) + + XCTAssertEqual(page.nextPageToken, "page-2") + let issue = try XCTUnwrap(page.issues.first) + XCTAssertEqual(issue.key, "PROJ-1") + XCTAssertEqual(issue.statusCategory, .inProgress) + XCTAssertEqual(issue.priorityName, "High") + XCTAssertEqual(issue.displaySprint?.name, "Sprint 7") + XCTAssertNotNil(issue.updated) + + let request = try XCTUnwrap(http.requests.first) + XCTAssertEqual(request.httpMethod, "POST") + let body = try XCTUnwrap(JSONSerialization.jsonObject(with: request.httpBody ?? Data()) as? [String: Any]) + XCTAssertEqual(body["jql"] as? String, "assignee = currentUser()") + XCTAssertEqual(body["nextPageToken"] as? String, "page-1") + XCTAssertEqual(body["maxResults"] as? Int, JiraCloudClient.pageSize) + XCTAssertTrue((body["fields"] as? [String])?.contains("customfield_10020") == true) + } + + func testLastPageHasNoNextToken() async throws { + let http = StubJiraHTTPClient([.init(path: "/rest/api/3/search/jql", status: 200, body: #"{"isLast":true,"nextPageToken":"x","issues":[]}"#)]) + let page = try await JiraCloudClient(httpClient: http).searchIssues( + baseURL: site, credential: credential, jql: "x", sprintFieldID: nil, pageToken: nil + ) + XCTAssertNil(page.nextPageToken) + } + + private func assertThrows( + _ expected: JiraError, + file: StaticString = #filePath, + line: UInt = #line, + _ body: () async throws -> Void + ) async { + do { + try await body() + XCTFail("Expected \(expected)", file: file, line: line) + } catch { + XCTAssertEqual(error as? JiraError, expected, file: file, line: line) + } + } +} + +final class StubJiraHTTPClient: GitProviderHTTPClient { + struct Route { + var path: String + var status: Int + var body: String + var headers: [String: String] = [:] + } + + private var routes: [Route] + private let error: Error? + private(set) var requests: [URLRequest] = [] + + init(_ routes: [Route], error: Error? = nil) { + self.routes = routes + self.error = error + } + + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + requests.append(request) + if let error { throw error } + guard let index = routes.firstIndex(where: { $0.path == request.url?.path() }) else { + return (Data(), HTTPURLResponse(url: request.url!, statusCode: 404, httpVersion: nil, headerFields: nil)!) + } + let route = routes.remove(at: index) + let response = HTTPURLResponse(url: request.url!, statusCode: route.status, httpVersion: nil, headerFields: route.headers)! + return (Data(route.body.utf8), response) + } +} diff --git a/macgitTests/JiraConnectionControllerTests.swift b/macgitTests/JiraConnectionControllerTests.swift new file mode 100644 index 00000000..713c1eaa --- /dev/null +++ b/macgitTests/JiraConnectionControllerTests.swift @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import XCTest +@testable import macgit + +@MainActor +final class JiraConnectionControllerTests: XCTestCase { + private var client: StubJiraIssueService! + private var tokens: InMemoryJiraTokenStore! + private var store: InMemoryJiraConnectionStore! + private var controller: JiraConnectionController! + + private let account = JiraValidatedAccount(accountID: "acc-1", displayName: "Dev", email: "dev@example.com", apiBase: .site) + + override func setUp() async throws { + client = StubJiraIssueService() + tokens = InMemoryJiraTokenStore() + store = InMemoryJiraConnectionStore() + controller = JiraConnectionController(client: client, tokenStore: tokens, connectionStore: store) + await controller.updateMacgitAccount(uid: "uid-a") + } + + func testSignedOutCannotConnect() async { + await controller.updateMacgitAccount(uid: nil) + client.validateResults = [.success(account)] + + let connected = await controller.connect(site: "acme", email: "dev@example.com", token: "t") + XCTAssertFalse(connected) + XCTAssertEqual(controller.errorMessage, JiraError.notSignedIn.localizedDescription) + XCTAssertTrue(store.connections.isEmpty) + } + + func testConnectSavesMetadataAndToken() async { + client.validateResults = [.success(account)] + + let connected = await controller.connect(site: "acme", email: " dev@example.com ", token: " t1 ") + XCTAssertTrue(connected) + let connection = try! XCTUnwrap(controller.connection) + XCTAssertEqual(connection.siteURL, URL(string: "https://acme.atlassian.net")) + XCTAssertEqual(connection.sprintFieldID, "customfield_10020") + XCTAssertEqual(store.connections["uid-a"], connection) + XCTAssertEqual(tokens.tokens[connection.id], "t1") + XCTAssertEqual(controller.credential(), JiraCredential(email: "dev@example.com", token: "t1")) + } + + func testRejectedTokenSavesNothing() async { + client.validateResults = [.failure(.invalidCredentials)] + + let connected = await controller.connect(site: "acme", email: "dev@example.com", token: "bad") + XCTAssertFalse(connected) + XCTAssertEqual(controller.errorMessage, JiraError.invalidCredentials.localizedDescription) + XCTAssertNil(controller.connection) + XCTAssertTrue(store.connections.isEmpty) + XCTAssertTrue(tokens.tokens.isEmpty) + } + + func testUnsupportedSiteIsRejectedBeforeAnyRequest() async { + client.validateResults = [.success(account)] + let connected = await controller.connect(site: "https://jira.example.com", email: "dev@example.com", token: "t") + XCTAssertFalse(connected) + XCTAssertEqual(controller.errorMessage, JiraError.unsupportedSite.localizedDescription) + XCTAssertEqual(client.validateResults.count, 1, "validate must not be called") + } + + func testMetadataFailureRollsBackToken() async { + client.validateResults = [.success(account)] + store.failSave = true + + let connected = await controller.connect(site: "acme", email: "dev@example.com", token: "t1") + XCTAssertFalse(connected) + XCTAssertTrue(tokens.tokens.isEmpty) + XCTAssertNil(controller.connection) + } + + func testSecondConnectionIsRefused() async { + client.validateResults = [.success(account), .success(account)] + await controller.connect(site: "acme", email: "dev@example.com", token: "t1") + + let second = await controller.connect(site: "other", email: "dev@example.com", token: "t2") + XCTAssertFalse(second) + XCTAssertEqual(controller.connection?.siteHost, "acme.atlassian.net") + } + + func testInvalidReconnectKeepsOldToken() async { + client.validateResults = [.success(account), .failure(.invalidCredentials)] + await controller.connect(site: "acme", email: "dev@example.com", token: "t1") + await controller.markNeedsReconnect() + + let reconnected = await controller.reconnect(token: "bad") + XCTAssertFalse(reconnected) + XCTAssertEqual(controller.credential()?.token, "t1") + XCTAssertEqual(controller.connection?.needsReconnect, true) + } + + func testValidReconnectReplacesTokenAndClearsFlag() async { + client.validateResults = [.success(account), .success(account)] + await controller.connect(site: "acme", email: "dev@example.com", token: "t1") + await controller.markNeedsReconnect() + XCTAssertEqual(store.connections["uid-a"]?.needsReconnect, true) + + let reconnected = await controller.reconnect(token: "t2") + XCTAssertTrue(reconnected) + XCTAssertEqual(controller.credential()?.token, "t2") + XCTAssertEqual(controller.connection?.needsReconnect, false) + XCTAssertEqual(store.connections["uid-a"]?.needsReconnect, false) + } + + func testReconnectWithDifferentAccountIsRefused() async { + var otherAccount = account + otherAccount.accountID = "acc-2" + client.validateResults = [.success(account), .success(otherAccount)] + await controller.connect(site: "acme", email: "dev@example.com", token: "t1") + + let reconnected = await controller.reconnect(token: "t2") + XCTAssertFalse(reconnected) + XCTAssertEqual(controller.credential()?.token, "t1") + } + + func testRemoveDeletesTokenAndMetadata() async { + client.validateResults = [.success(account)] + await controller.connect(site: "acme", email: "dev@example.com", token: "t1") + + await controller.remove() + XCTAssertNil(controller.connection) + XCTAssertTrue(tokens.tokens.isEmpty) + XCTAssertTrue(store.connections.isEmpty) + } + + func testDifferentCommitPlusAccountDoesNotSeeConnection() async { + client.validateResults = [.success(account)] + await controller.connect(site: "acme", email: "dev@example.com", token: "t1") + + await controller.updateMacgitAccount(uid: "uid-b") + XCTAssertNil(controller.connection) + XCTAssertNil(controller.credential()) + + await controller.updateMacgitAccount(uid: "uid-a") + XCTAssertEqual(controller.connection?.macgitUID, "uid-a") + } +} diff --git a/macgitTests/JiraConnectionStoreTests.swift b/macgitTests/JiraConnectionStoreTests.swift new file mode 100644 index 00000000..8908bef3 --- /dev/null +++ b/macgitTests/JiraConnectionStoreTests.swift @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import XCTest +@testable import macgit + +@MainActor +final class JiraConnectionStoreTests: XCTestCase { + private var fixture: LocalDataStoreTestFixture! + + override func setUp() async throws { + fixture = try LocalDataStoreTestFixture() + try await fixture.store.prepare() + } + + override func tearDown() async throws { + fixture.cleanup() + } + + func testSaveLoadAndRemovePerCommitPlusAccount() async throws { + let store = LocalJiraConnectionStore(dataStore: fixture.store) + let connection = JiraConnection.sample(uid: "uid-a") + + try await store.save(connection) + let loaded = try await store.connection(for: "uid-a") + XCTAssertEqual(loaded, connection) + let other = try await store.connection(for: "uid-b") + XCTAssertNil(other) + + let reopened = LocalJiraConnectionStore(dataStore: try await fixture.reopen()) + let persisted = try await reopened.connection(for: "uid-a") + XCTAssertEqual(persisted, connection) + + try await store.removeConnection(for: "uid-a") + let removed = try await store.connection(for: "uid-a") + XCTAssertNil(removed) + } + + func testTokenKeyIsScopedToCommitPlusAccount() { + XCTAssertNotEqual(JiraConnection.sample(uid: "uid-a").id, JiraConnection.sample(uid: "uid-b").id) + } + + func testFilterCodingSkipsSearchText() throws { + var filter = JiraIssueFilter() + filter.sprintScope = .sprint(id: 3, name: "Sprint 3") + filter.projectKeys = ["PROJ"] + filter.searchText = "login" + + let decoded = try JSONDecoder().decode(JiraIssueFilter.self, from: JSONEncoder().encode(filter)) + XCTAssertEqual(decoded.sprintScope, .sprint(id: 3, name: "Sprint 3")) + XCTAssertEqual(decoded.projectKeys, ["PROJ"]) + XCTAssertEqual(decoded.searchText, "") + } +} + +extension JiraConnection { + static func sample(uid: String = "uid-a", sprintFieldID: String? = "customfield_10020") -> JiraConnection { + JiraConnection( + macgitUID: uid, + siteURL: URL(string: "https://acme.atlassian.net")!, + email: "dev@example.com", + accountID: "acc-1", + displayName: "Dev", + apiBase: .site, + sprintFieldID: sprintFieldID, + connectedAt: Date(timeIntervalSince1970: 1_800_000_000) + ) + } +} + +final class InMemoryJiraTokenStore: JiraTokenStoring { + var tokens: [String: String] = [:] + var failSave = false + + func token(for connection: JiraConnection) throws -> String? { tokens[connection.id] } + + func saveToken(_ token: String, for connection: JiraConnection) throws { + if failSave { throw JiraError.storage("keychain") } + tokens[connection.id] = token + } + + func deleteToken(for connection: JiraConnection) throws { tokens[connection.id] = nil } +} + +final class InMemoryJiraConnectionStore: JiraConnectionPersisting { + var connections: [String: JiraConnection] = [:] + var failSave = false + + func connection(for macgitUID: String) async throws -> JiraConnection? { connections[macgitUID] } + + func save(_ connection: JiraConnection) async throws { + if failSave { throw JiraError.storage("disk") } + connections[connection.macgitUID] = connection + } + + func removeConnection(for macgitUID: String) async throws { connections[macgitUID] = nil } +} + +final class StubJiraIssueService: JiraIssueServing { + var validateResults: [Result] = [] + var sprintField: String? = "customfield_10020" + var searchHandler: ((String, String?) async throws -> JiraIssuePage)? + private(set) var searchedJQL: [String] = [] + + func validate(siteURL: URL, credential: JiraCredential) async throws -> JiraValidatedAccount { + guard !validateResults.isEmpty else { throw JiraError.invalidCredentials } + return try validateResults.removeFirst().get() + } + + func sprintFieldID(baseURL: URL, credential: JiraCredential) async throws -> String? { sprintField } + + func searchIssues( + baseURL: URL, + credential: JiraCredential, + jql: String, + sprintFieldID: String?, + pageToken: String? + ) async throws -> JiraIssuePage { + searchedJQL.append(jql) + return try await searchHandler?(jql, pageToken) ?? JiraIssuePage(issues: [], nextPageToken: nil) + } +} diff --git a/macgitTests/JiraIssueListControllerTests.swift b/macgitTests/JiraIssueListControllerTests.swift new file mode 100644 index 00000000..4dba0692 --- /dev/null +++ b/macgitTests/JiraIssueListControllerTests.swift @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import XCTest +@testable import macgit + +@MainActor +final class JiraIssueListControllerTests: XCTestCase { + private var client: StubJiraIssueService! + private var connectionController: JiraConnectionController! + private var filters: InMemoryJiraIssueFilterStore! + private let repositoryA = URL(fileURLWithPath: "/tmp/repo-a") + private let repositoryB = URL(fileURLWithPath: "/tmp/repo-b") + + override func setUp() async throws { + client = StubJiraIssueService() + client.validateResults = [.success(JiraValidatedAccount(accountID: "acc-1", displayName: "Dev", email: "dev@example.com", apiBase: .site))] + connectionController = JiraConnectionController( + client: client, + tokenStore: InMemoryJiraTokenStore(), + connectionStore: InMemoryJiraConnectionStore() + ) + await connectionController.updateMacgitAccount(uid: "uid-a") + let connected = await connectionController.connect(site: "acme", email: "dev@example.com", token: "t") + XCTAssertTrue(connected) + filters = InMemoryJiraIssueFilterStore() + } + + private func makeController(repository: URL? = nil) -> JiraIssueListController { + JiraIssueListController( + repositoryURL: repository ?? repositoryA, + connectionController: connectionController, + filterStore: filters, + searchDebounce: .zero + ) + } + + func testDefaultLoadUsesDefaultJQL() async throws { + client.searchHandler = { _, _ in JiraIssuePage(issues: [.stub("PROJ-1")], nextPageToken: nil) } + let controller = makeController() + + await controller.activate() + try await waitForIdle(controller) + + XCTAssertEqual(controller.items.map(\.key), ["PROJ-1"]) + XCTAssertEqual(client.searchedJQL.first, JiraJQLBuilder.jql(for: .default, supportsSprints: true)) + XCTAssertEqual(controller.projectOptions.map(\.key), ["PROJ"]) + XCTAssertEqual(controller.sprintOptions.map(\.name), ["Sprint 7"]) + } + + func testStaleResponseIsDropped() async throws { + client.searchHandler = { jql, _ in + if jql.contains("openSprints") { + // Ignores cancellation so a late result really arrives after the newer one. + await withCheckedContinuation { continuation in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { continuation.resume() } + } + return JiraIssuePage(issues: [.stub("OLD-1")], nextPageToken: nil) + } + return JiraIssuePage(issues: [.stub("NEW-1")], nextPageToken: nil) + } + let controller = makeController() + + await controller.activate() + controller.updateFilter { $0.sprintScope = .backlog } + try await waitForIdle(controller) + try await Task.sleep(for: .milliseconds(500)) + + XCTAssertEqual(controller.items.map(\.key), ["NEW-1"]) + } + + func testFilterIsRestoredPerRepositoryAndSearchTextIsNotPersisted() async throws { + let controller = makeController() + await controller.activate() + try await waitForIdle(controller) + + controller.searchText = "login" + controller.updateFilter { $0.projectKeys = ["PROJ"] } + try await waitForIdle(controller) + try await Task.sleep(for: .milliseconds(50)) + + let stored = try XCTUnwrap(filters.filters[repositoryA.standardizedFileURL.path]) + XCTAssertEqual(stored.projectKeys, ["PROJ"]) + XCTAssertEqual(stored.searchText, "") + + let reopened = makeController() + await reopened.activate() + XCTAssertEqual(reopened.filter.projectKeys, ["PROJ"]) + XCTAssertEqual(reopened.searchText, "") + + let other = makeController(repository: repositoryB) + await other.activate() + XCTAssertEqual(other.filter, .default) + } + + func testResetFiltersRestoresDefaultAndClearsStoredFilter() async throws { + let controller = makeController() + await controller.activate() + controller.updateFilter { $0.sprintScope = .any } + try await waitForIdle(controller) + + controller.resetFilters() + try await waitForIdle(controller) + try await Task.sleep(for: .milliseconds(50)) + + XCTAssertEqual(controller.filter, .default) + XCTAssertNil(filters.filters[repositoryA.standardizedFileURL.path]) + } + + func testUnauthorizedMarksConnectionForReconnect() async throws { + client.searchHandler = { _, _ in throw JiraError.needsReconnect } + let controller = makeController() + + await controller.activate() + try await waitForIdle(controller) + + XCTAssertEqual(controller.error, .needsReconnect) + XCTAssertEqual(connectionController.connection?.needsReconnect, true) + } + + func testEmptyStateDistinguishesNoMatchFromNoIssues() async throws { + client.searchHandler = { jql, _ in + jql.contains("openSprints") + ? JiraIssuePage(issues: [], nextPageToken: nil) + : JiraIssuePage(issues: [.stub("PROJ-9")], nextPageToken: nil) + } + let controller = makeController() + await controller.activate() + try await waitForIdle(controller) + XCTAssertTrue(controller.items.isEmpty) + XCTAssertTrue(controller.hasAnyAssignedIssues) + + client.searchHandler = { _, _ in JiraIssuePage(issues: [], nextPageToken: nil) } + controller.refresh() + try await waitForIdle(controller) + XCTAssertFalse(controller.hasAnyAssignedIssues) + } + + func testRowsStayVisibleWhenRefreshFails() async throws { + client.searchHandler = { _, _ in JiraIssuePage(issues: [.stub("PROJ-1")], nextPageToken: nil) } + let controller = makeController() + await controller.activate() + try await waitForIdle(controller) + + client.searchHandler = { _, _ in throw JiraError.unreachable } + controller.refresh() + try await waitForIdle(controller) + + XCTAssertEqual(controller.error, .unreachable) + XCTAssertEqual(controller.items.map(\.key), ["PROJ-1"]) + } + + func testLoadMoreAppendsNextPage() async throws { + client.searchHandler = { _, token in + token == nil + ? JiraIssuePage(issues: [.stub("PROJ-1")], nextPageToken: "p2") + : JiraIssuePage(issues: [.stub("PROJ-2")], nextPageToken: nil) + } + let controller = makeController() + await controller.activate() + try await waitForIdle(controller) + + controller.loadMore() + try await waitForIdle(controller) + + XCTAssertEqual(controller.items.map(\.key), ["PROJ-1", "PROJ-2"]) + XCTAssertNil(controller.nextPageToken) + } + + func testUnknownProjectKeySearchFallsBackToTextSearch() async throws { + client.searchHandler = { jql, _ in + if jql.contains("key = ") { throw JiraError.invalidQuery } + return JiraIssuePage(issues: [], nextPageToken: nil) + } + let controller = makeController() + await controller.activate() + try await waitForIdle(controller) + + controller.searchText = "NOPE-1" + try await Task.sleep(for: .milliseconds(50)) + try await waitForIdle(controller) + + XCTAssertNil(controller.error) + XCTAssertTrue(client.searchedJQL.contains { $0.contains("summary ~ \"NOPE 1\"") && !$0.contains("key = ") }) + } + + func testRemovingConnectionClearsList() async throws { + client.searchHandler = { _, _ in JiraIssuePage(issues: [.stub("PROJ-1")], nextPageToken: nil) } + let controller = makeController() + await controller.activate() + try await waitForIdle(controller) + + await connectionController.remove() + try await Task.sleep(for: .milliseconds(50)) + + XCTAssertTrue(controller.items.isEmpty) + XCTAssertNil(controller.connection) + } + + private func waitForIdle(_ controller: JiraIssueListController, timeout: TimeInterval = 3) async throws { + let deadline = Date().addingTimeInterval(timeout) + repeat { + try await Task.sleep(for: .milliseconds(10)) + } while (controller.isLoading || controller.isLoadingMore) && Date() < deadline + XCTAssertFalse(controller.isLoading || controller.isLoadingMore, "Timed out waiting for Jira load") + } +} + +final class InMemoryJiraIssueFilterStore: JiraIssueFilterStoring { + var filters: [String: JiraIssueFilter] = [:] + + func filter(forRepositoryKey key: String) async -> JiraIssueFilter? { filters[key] } + + func save(_ filter: JiraIssueFilter, forRepositoryKey key: String) async { + // Round-trip through Codable so tests see exactly what is persisted. + let data = try! JSONEncoder().encode(filter) + filters[key] = filter.isDefault ? nil : try! JSONDecoder().decode(JiraIssueFilter.self, from: data) + } +} + +extension JiraIssue { + static func stub(_ key: String) -> JiraIssue { + JiraIssue( + id: key, + key: key, + summary: "Summary \(key)", + statusName: "To Do", + statusCategory: .toDo, + issueTypeName: "Task", + priorityName: "Medium", + projectKey: String(key.split(separator: "-").first ?? ""), + projectName: "Project", + updated: nil, + sprints: [JiraSprintRef(id: 7, name: "Sprint 7", state: "active")] + ) + } +} diff --git a/macgitTests/JiraJQLBuilderTests.swift b/macgitTests/JiraJQLBuilderTests.swift new file mode 100644 index 00000000..cb74160d --- /dev/null +++ b/macgitTests/JiraJQLBuilderTests.swift @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import XCTest +@testable import macgit + +final class JiraJQLBuilderTests: XCTestCase { + func testDefaultFilterShowsOwnNotDoneIssuesInOpenSprints() { + XCTAssertEqual( + JiraJQLBuilder.jql(for: .default, supportsSprints: true), + #"assignee = currentUser() AND sprint in openSprints() AND statusCategory in ("To Do", "In Progress") ORDER BY updated DESC"# + ) + } + + func testSprintScopes() { + var filter = JiraIssueFilter() + filter.statusCategories = [] + + filter.sprintScope = .sprint(id: 42, name: "Sprint 42") + XCTAssertEqual(JiraJQLBuilder.jql(for: filter, supportsSprints: true), "assignee = currentUser() AND sprint = 42 ORDER BY updated DESC") + filter.sprintScope = .backlog + XCTAssertEqual(JiraJQLBuilder.jql(for: filter, supportsSprints: true), "assignee = currentUser() AND sprint is EMPTY ORDER BY updated DESC") + filter.sprintScope = .any + XCTAssertEqual(JiraJQLBuilder.jql(for: filter, supportsSprints: true), "assignee = currentUser() ORDER BY updated DESC") + } + + func testSitesWithoutSprintsDropTheSprintClause() { + XCTAssertFalse(JiraJQLBuilder.jql(for: .default, supportsSprints: false).contains("sprint")) + } + + func testProjectTypeAndStatusFiltersAreQuotedAndOrdered() { + var filter = JiraIssueFilter() + filter.sprintScope = .any + filter.statusCategories = [.done, .toDo] + filter.projectKeys = ["WEB", "APP"] + filter.issueTypeNames = ["Bug", "Story"] + + XCTAssertEqual( + JiraJQLBuilder.jql(for: filter, supportsSprints: true), + #"assignee = currentUser() AND statusCategory in ("To Do", "Done") AND project in ("APP", "WEB") AND issuetype in ("Bug", "Story") ORDER BY updated DESC"# + ) + } + + func testSearchByKeyAddsExactKeyClause() { + var filter = JiraIssueFilter() + filter.sprintScope = .any + filter.statusCategories = [] + filter.searchText = " proj-12 " + + XCTAssertEqual( + JiraJQLBuilder.jql(for: filter, supportsSprints: true), + #"assignee = currentUser() AND (summary ~ "proj 12" OR key = "PROJ-12") ORDER BY updated DESC"# + ) + XCTAssertEqual( + JiraJQLBuilder.jql(for: filter, supportsSprints: true, includeKeyClause: false), + #"assignee = currentUser() AND summary ~ "proj 12" ORDER BY updated DESC"# + ) + } + + func testSearchTextCannotEscapeItsQuotedValue() { + var filter = JiraIssueFilter() + filter.sprintScope = .any + filter.statusCategories = [] + + for attack in [ + #"x" OR assignee is not EMPTY OR summary ~ "y"#, + #"x\" OR project = SECRET"#, + "login) OR (assignee = EMPTY", + "fix\nORDER BY created", + ] { + filter.searchText = attack + let jql = JiraJQLBuilder.jql(for: filter, supportsSprints: true) + XCTAssertTrue(jql.hasPrefix("assignee = currentUser() AND summary ~ \""), jql) + XCTAssertTrue(jql.hasSuffix("\" ORDER BY updated DESC"), jql) + let value = jql.dropFirst("assignee = currentUser() AND summary ~ \"".count).dropLast("\" ORDER BY updated DESC".count) + XCTAssertFalse(value.contains("\""), jql) + XCTAssertFalse(value.contains("("), jql) + XCTAssertFalse(value.contains("\n"), jql) + } + } + + func testQuotedEscapesBackslashesAndQuotes() { + XCTAssertEqual(JiraJQLBuilder.quoted(#"a\"b"#), #""a\\\"b""#) + } + + func testOnlyOperatorCharactersMeansNoSearchClause() { + var filter = JiraIssueFilter() + filter.searchText = "*?!" + XCTAssertEqual(JiraJQLBuilder.jql(for: filter, supportsSprints: true), JiraJQLBuilder.jql(for: .default, supportsSprints: true)) + } +} diff --git a/macgitTests/JiraSiteNormalizerTests.swift b/macgitTests/JiraSiteNormalizerTests.swift new file mode 100644 index 00000000..2483a715 --- /dev/null +++ b/macgitTests/JiraSiteNormalizerTests.swift @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import XCTest +@testable import macgit + +final class JiraSiteNormalizerTests: XCTestCase { + func testBareNameExpandsToAtlassianSite() throws { + XCTAssertEqual(try JiraSiteNormalizer.normalize("acme"), URL(string: "https://acme.atlassian.net")) + XCTAssertEqual(try JiraSiteNormalizer.normalize(" Acme-Team \n"), URL(string: "https://acme-team.atlassian.net")) + } + + func testHostAndURLFormsReduceToSiteRoot() throws { + let expected = URL(string: "https://acme.atlassian.net") + XCTAssertEqual(try JiraSiteNormalizer.normalize("acme.atlassian.net"), expected) + XCTAssertEqual(try JiraSiteNormalizer.normalize("https://ACME.atlassian.net/jira/software/projects"), expected) + XCTAssertEqual(try JiraSiteNormalizer.normalize("https://acme.atlassian.net:443/"), expected) + } + + func testRejectsNonCloudOrUnsafeSites() { + for input in [ + "", + "http://acme.atlassian.net", + "https://jira.example.com", + "https://acme.atlassian.net.evil.com", + "https://evilatlassian.net", + "https://a.b.atlassian.net", + "https://user:pass@acme.atlassian.net", + "https://acme.atlassian.net:8443", + "ftp://acme.atlassian.net", + "äcme", + ] { + XCTAssertThrowsError(try JiraSiteNormalizer.normalize(input), input) { error in + XCTAssertEqual(error as? JiraError, .unsupportedSite, input) + } + } + } +} diff --git a/macgitTests/SidebarNativeListTests.swift b/macgitTests/SidebarNativeListTests.swift index af6a6c2d..a36fc90a 100644 --- a/macgitTests/SidebarNativeListTests.swift +++ b/macgitTests/SidebarNativeListTests.swift @@ -46,6 +46,20 @@ final class SidebarNativeListTests: XCTestCase { return (owner, table) } + func testJiraWorkspaceRowIsVisibleByDefaultAndFollowsItsToggle() { + let suiteName = "SidebarNativeListTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let state = AppState(userDefaults: defaults) + + let items = SidebarSection.visibleWorkspaceItems(appState: state) + XCTAssertEqual(items.firstIndex(of: .jira), items.firstIndex(of: .pullRequests).map { $0 + 1 }) + + state.showWorkspaceJira = false + XCTAssertFalse(SidebarSection.visibleWorkspaceItems(appState: state).contains(.jira)) + XCTAssertTrue(SidebarSection.visibleWorkspaceItems(appState: state).contains(.pullRequests)) + } + func testSelectionDoesNotRebuildTenThousandRows() { var builds = 0 let rows = (0..<10_000).map { row("branch-\($0)") } diff --git a/openspec/changes/add-jira-sprint-issues/.openspec.yaml b/openspec/changes/add-jira-sprint-issues/.openspec.yaml new file mode 100644 index 00000000..1f5d2804 --- /dev/null +++ b/openspec/changes/add-jira-sprint-issues/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-24 diff --git a/openspec/changes/add-jira-sprint-issues/design.md b/openspec/changes/add-jira-sprint-issues/design.md new file mode 100644 index 00000000..75b5f061 --- /dev/null +++ b/openspec/changes/add-jira-sprint-issues/design.md @@ -0,0 +1,117 @@ +# Design + +## Context + +See proposal.md for motivation and specs/ for required behavior. Current state that shapes the approach: + +- **Workspace items.** `SidebarItem` lists the Workspace screens, and `SidebarSection.workspace.items` orders them. `SidebarView+NativeRows.swift` filters them with `AppState` toggles (`showWorkspaceReflog`, `showWorkspacePullRequests`, `showWorkspaceGitLFS`, `showGitFlow`), which `WorkspaceVisibilityPopover` edits. Those toggles are `AppSettingsSnapshot` fields, are encoded by `CloudSettingsDocument` (`FirestoreSettingsStore.swift`), and are allowlisted in `validAppSettings` in `../landing-page/firestore.rules`. +- **Gated screens.** `MainWindowView.cachedRepositoryScreens` keeps the Pull Requests screen alive once opened (`hasOpenedPullRequests` plus `.repositoryScreenVisibility`). It switches on a `FeatureAccessDecision` to show `PullRequestListView`, `FeatureAccessUnavailableView`, or a progress view, and re-evaluates access through a task keyed on policy revision, entitlement, and selection (`pullRequestAccessTaskID`). `ProUpgradeSheet` and `FeatureAccessNotice` switch exhaustively over `PlanFeature`. +- **Feature policy.** `FeatureAccessPolicy.bundled` (revision 7) is the local fallback. `FeaturePolicyStore` decodes each `PlanFeature` independently and falls back to the bundled entry when a key is missing, so the app can ship a new key before the remote policy has it. +- **Atlassian credentials today.** `GitProviderAccountController.connectBitbucket` stores a Bitbucket username plus an Atlassian API token through `GitProviderTokenVault` (Keychain), and the account metadata syncs through `FirestoreGitProviderAccountStore`. Its `provider` is limited by `firestore.rules` to Git hosts. GitHub/GitLab OAuth (`GitProviderOAuthModels`, the `macgit://git-provider` callback, and the commitplus.app token exchange) is Git-provider specific. +- **Branch creation.** `BranchSheetView` creates branches through `GitStatusService.createBranch`, registers undo (`deleteLocalBranch` with `expectedTip`), and its `onCompleted` refreshes `SyncState` and posts `.repositoryDidChange`. `StartGitFlowSheet` builds a `GitFlowStartPlan` from a typed `topicName` and calls `MainWindowView.startGitFlow`. +- **Local persistence.** Non-secret local state uses `LocalDataStore` collections (for example `providerPreferences`). + +## Goals / Non-Goals + +**Goals:** +- Reuse the existing gating, sidebar, sheet, and refresh flows so the Jira screen behaves like Pull Requests from the user's point of view. +- Keep every Jira request and credential on the Mac. Add no new backend service and change no Firebase contract except the settings toggle and the feature-policy key. +- Make the Jira client testable without network access. + +**Non-Goals:** +- A generic issue-tracker abstraction (Linear, GitHub Issues, and others). The Jira types are concrete. Generalize only when a second tracker exists. +- Linking issues to commits or branches after creation, smart commits, or showing Jira data in History. +- Background polling or notifications for Jira changes. + +## Decisions + +### 1. A separate `JiraConnectionController`, not a new `GitProviderKind` +Add an app-level `@MainActor final class JiraConnectionController: ObservableObject` in `macgit/App/`. Create it in `macgitApp` next to `providerAccountController`, inject it as an environment object, and update it with the signed-in `macgitUID` when the Commit+ account changes, the same way `providerAccountController.updateMacgitAccount` is driven. + +- *Why not extend `GitProviderAccountController` / `GitProviderKind`:* Jira is not a Git host. Adding a case would force every exhaustive switch (remote recognition, credential injection, PR services, SSH) to handle a provider that has no remotes. It would also sync the account through `FirestoreGitProviderAccountStore`, whose rules reject unknown providers. Decision: share only the Keychain and HTTP patterns. +- Storage: + - The token goes in a new `KeychainJiraTokenStore`, modeled on `KeychainGitProviderTokenVault`: service `com.commitplus.macgit.jira-tokens`, account key `::`, with the same in-memory cache approach. + - Non-secret metadata (site URL, email, Jira `accountId`, display name, status, whether the connection needs reconnection) goes in the `LocalDataStore` collection `jiraConnections`, keyed by `macgitUID`. +- Behavior follows `connectBitbucket`: validate first, then save metadata and token together, and roll back the token if the metadata write fails. Reconnect verifies the new token before replacing the old one. Remove deletes both. + +- Settings UI: Jira is its own section in Settings → Integrations (`JiraIntegrationSection`), not part of the Connections account list. That list, its add-account sheet, and the multiple-account limit are typed to `GitProviderAccount`. Adding a non-Git account would mix Jira into the Free account-count gate and the Git editor panel. The Jira screen's "Connect Jira" and "Open Jira Settings" actions call `AccountSessionController.presentIntegrations()`. + +### 2. API token over Basic auth against the site URL +Authenticate with `Authorization: Basic base64(email:token)` against `https://.atlassian.net`. Validate with `GET /rest/api/3/myself`, which returns `accountId`, `displayName`, and `emailAddress`. + +- *Alternative, OAuth 2.0 (3LO):* rejected for this change by user decision. Atlassian requires a client secret for code exchange and for rotating refresh, so it needs new commitplus.app routes, an Atlassian developer app, and backend deployment. The client is structured so a later OAuth credential can replace the Basic header: `JiraCloudClient` takes a credential value, not raw strings. +- *Scoped API tokens:* Atlassian's newer scoped tokens must be sent through `https://api.atlassian.com/ex/jira/`. At connect time, resolve `cloudId` from `https:///_edge/tenant_info`, try the site URL first, and on 401 retry `/myself` through the gateway. Store which base worked (`apiBase: site | gateway`) with the connection. The connect form tells users which read scopes a scoped token needs (`read:jira-work`, `read:jira-user`). +- Site validation is done before any request: HTTPS only, and the host must end in `.atlassian.net`. A bare name expands to `https://.atlassian.net`. This prevents sending the token to an arbitrary host. + +### 3. `JiraCloudClient` as a plain service, outside `GitStatusService` +Add `macgit/Services/JiraCloudClient.swift`, a `Sendable` struct that takes `GitProviderHTTPClient`, the existing HTTP seam (renaming it is out of scope). It is not git work, so it does not belong in `GitStatusService`. + +- **Search:** `POST /rest/api/3/search/jql` with `nextPageToken` pagination (the older `/rest/api/3/search` is deprecated). Request only the fields the list needs: `summary`, `status`, `issuetype`, `priority`, `project`, `updated`, and the sprint field. Page size is 50. +- **Sprint field:** Jira stores sprints in a custom field. Discover its id once per connection from `GET /rest/api/3/field`, where the schema custom type is `com.pyxis.greenhopper.jira:gh-sprint`, and cache it with the connection metadata. If it is missing (a site without Jira Software), hide the sprint filter, and the default query drops the sprint clause. +- **Error mapping:** 401 → `needsReconnect`; 403 → no permission; 429 → rate limited, honoring `Retry-After` for the Retry message; 5xx or a network failure → unreachable. Error messages never include request headers or the response body verbatim. + +### 4. JQL is built from a typed filter, never from free text +`JiraIssueFilter` (Codable) holds the sprint scope (`openSprints | sprint(id) | backlog | any`), a status-category set, a project key set, an issue type id set, and an optional search text. `JiraJQLBuilder` turns it into JQL: + +- It always starts with `assignee = currentUser()`. +- The sprint scope becomes `sprint in openSprints()`, `sprint = `, `sprint is EMPTY`, or nothing. +- The other parts are `statusCategory in (...)`, `project in (...)`, `issuetype in (...)`, and `ORDER BY updated DESC`. +- Search text becomes `(text ~ "" OR key = "")`, with the key clause used only when the text matches `^[A-Z][A-Z0-9_]+-\d+$`. +- Values are quoted and escaped, so a crafted search string cannot change the query. + +The default filter is open sprints with status categories To Do and In Progress. + +Sprint and project filter options are derived from the issues loaded with the current non-sprint, non-project constraints, plus the remembered selection. This avoids the Agile board API, which needs board ids the user does not have. Trade-off: a sprint with no issues assigned to the user is not offered, which is acceptable because the list only shows their own issues. + +### 5. Per-window `JiraIssueListController`, owned by `MainWindowView` +Add `@MainActor final class JiraIssueListController: ObservableObject` in `macgit/App/`, shaped like `PullRequestController`: `items`, `isLoading`, `hasLoadedList`, `errorState`, `nextPageToken`, `filter`, and `searchText`. `MainWindowView` creates it as a `@StateObject` next to `pullRequestController`, so each repository window has its own instance and its own filter. + +- Loads are generation-guarded, like `SyncState`: a filter change cancels the in-flight task, and late results are dropped. Rows are kept during refresh. Search text is debounced (300 ms). +- Filters are persisted per repository in the `LocalDataStore` collection `jiraIssueFilters`, keyed by the standardized repository path, excluding `searchText`. A missing or undecodable value falls back to the default. +- The controller reads the connection from `JiraConnectionController` and observes it, so connect, reconnect, and remove in Settings update open windows without a relaunch. + +### 6. Gating mirrors Pull Requests +- Add `PlanFeature.jiraIssues` with the bundled rule `proOnlyRule`. Bump the bundled revision to 8 and set `requiresRepositoryScope` to `false`. +- Add the display name "Jira Issues" to `ProUpgradeSheet.upgradeDescription` (in the non-scoped group) and to `FeatureAccessNotice`. +- In `MainWindowView`, add `jiraAccessDecision` and `jiraAccessTaskID`, built the same way as the Pull Requests versions. Add a `hasOpenedJira` branch in `cachedRepositoryScreens` that shows `JiraIssueListView` when allowed, `FeatureAccessUnavailableView(feature: .jiraIssues, …)` when denied, and a progress view while the decision is unknown. +- The `body` switch returns `EmptyView()` for `.item(.jira)`, as it does for `.pullRequests`. +- The controller never loads unless the decision is `.allowed`, which satisfies "no Jira request when denied". + +### 7. Sidebar and visibility toggle +- Add `SidebarItem.jira = "Jira"` with SF Symbol `checklist`, inserted in `SidebarSection.workspace.items` after `.pullRequests`. +- Add `AppState.showWorkspaceJira` (default `true`) through the full settings path: `AppSettingsSnapshot` (with `decodeIfPresent ?? true`), `CloudSettingsDocument` encode and decode, `AppState` load, apply, and snapshot, and a toggle in `WorkspaceVisibilityPopover`. +- When the toggle turns off while `.item(.jira)` is selected, `MainWindowView` selects `.item(.fileStatus)`. Implement this in an `onChange` of `appState.showWorkspaceJira`. No existing toggle does this, so it is new behavior scoped to Jira. +- Row context menu: none for v1. Actions live on the issue rows. + +### 8. Branch from issue reuses existing sheets +- `JiraBranchNameSuggester` builds the name `-`. The slug is the summary folded to ASCII (`applyingTransform(.toLatin)` then `.stripDiacritics`), lowercased, with runs of characters outside `[a-z0-9]` replaced by a single `-`, and trimmed. The whole name is limited to 60 characters at a hyphen boundary, and the key is always kept. +- `BranchSheetView` gets an optional `initialBranchName` parameter that seeds its name field. `StartGitFlowSheet` gets an optional `initialTopicName` parameter that seeds `topicName`. Both default to `nil`, so existing call sites are unchanged. +- `JiraBranchTarget` (`plainBranch` or `gitFlow(kind)`) describes the choice. `suggested(forIssueType:gitFlowEnabled:)` maps Bug/Defect to bugfix and everything else to feature. `options` offers feature, bugfix, hotfix, and a plain branch; release is excluded because its name is a version. +- `JiraIssueListView` calls back to `MainWindowView` (`prepareCreateBranch(from:target:)`). `MainWindowView` presents `startGitFlowSheet(for: kind, initialTopicName:)` for a Git Flow target when `gitFlowConfiguration` is enabled, otherwise `branchSheet(startPoint: nil, initialBranchName:)`. +- Details panel: single-clicking a row shows `JiraIssueDetailPanel` in an `HSplitView` beside the list. It uses only fields already returned by the search (no extra request) and repeats the context-menu actions, with a split button whose default is the suggested branch target. It goes through the existing sheet presentation state, so undo registration, `SyncState.refresh`, and `.repositoryDidChange` come from the existing sheets with no new mutation code. + +### 9. Window scoping +This change adds no menu-bar commands, toolbar items, or `NotificationCenter` routes. Refresh, filter, open, copy, and create-branch actions are buttons and context-menu items inside the Jira view of one window, and their callbacks go to that window's `MainWindowView`. Connection changes reach all windows through the shared `JiraConnectionController`, which is intended because the connection is app-wide. If a later change adds a "Refresh Jira" menu command, it must use `WindowScopedNotification.post` like the other repository commands. + +### 10. Repository AI +Not exposed. No Jira tool is added to `RepositoryAIAgentHarness`, and the Jira token is never available to model context. + +## Risks / Trade-offs + +- [Scoped API tokens need the `api.atlassian.com` gateway, and Atlassian is moving users toward them] → Detect at connect time and store `apiBase`. Cover both paths with HTTP stub tests. If verification shows classic tokens also work through the gateway, simplify to gateway-only. +- [`/rest/api/3/search/jql` pagination differs from the old endpoint (no `total`, token-based)] → The UI shows only "load more" behavior and never a total count. +- [Sprint options come only from loaded issues] → This is documented in the spec ("from those the user's issues belong to"). The Agile API is a later improvement. +- [Adding `showWorkspaceJira` to the cloud settings document] → If the app ships before the updated `validAppSettings` rules are deployed, every settings write from that version is rejected. Mitigation: the rules deployment is an explicit rollout step that must come before the release (see Migration Plan). +- [Rate limits on large Jira sites] → No polling. Load on open, on filter change, and on Refresh only. Back off on 429 using `Retry-After`. +- [Token stored per Mac, not synced] → Users connect once per Mac. This is the intended privacy property and is stated in the spec. + +## Migration Plan + +1. **landing-page, before the app release:** allow `showWorkspaceJira` (bool) in `validAppSettings`, run that repository's non-emulator checks, and, with explicit authorization, deploy **Firestore rules only** to the production project. +2. **landing-page:** add `jiraIssues` to `admin/lib/schemas.ts` `featureKeys` (non-scoped) and to `scripts/firebase/set-feature-policy.mjs` as `proOnly`, at revision 8. With explicit authorization, publish the release feature policy. This can happen before or after the app release, because the bundled fallback is also Pro-only. +3. Release the macOS app. +4. **Rollback:** hide the screen for everyone by publishing `jiraIssues.enabled = false`. The rules change is additive and does not need a rollback. + +## Open Questions + +- Whether classic (unscoped) API tokens work through the `api.atlassian.com` gateway. This decides whether the site-first fallback can be removed later, but it does not change the specs or tasks. diff --git a/openspec/changes/add-jira-sprint-issues/proposal.md b/openspec/changes/add-jira-sprint-issues/proposal.md new file mode 100644 index 00000000..0d4a457e --- /dev/null +++ b/openspec/changes/add-jira-sprint-issues/proposal.md @@ -0,0 +1,34 @@ +# Proposal + +## Why + +Developers using Commit+ track their sprint work in Jira and switch to the browser to see what they are assigned, then retype issue keys into branch names. A focused Jira issue list inside the repository window, limited to the user's own issues and filterable by sprint, removes that context switch and is a natural Commit+ Pro capability. + +## What Changes + +- Add a Jira Cloud connection in Settings → Integrations using the user's site URL, Atlassian account email, and an Atlassian API token. This mirrors the existing Bitbucket connection, which also uses an Atlassian API token (it is not OAuth). A Bitbucket token cannot be reused: Jira needs its own token with Jira access. The connection is stored on this Mac only (metadata locally, token in the Keychain) and is not synced through Firebase. +- Add a "Jira" item to the sidebar Workspace section, which the user can hide from "Customize Workspace" like Reflog, Pull Requests, and Git LFS. +- Selecting it opens a Jira issues screen: a flat list (no board) of issues assigned to the connected user, defaulting to issues in open sprints that are not done. +- Filters: sprint (open sprints, a specific sprint, backlog, or any), status category, project, issue type, and text search on key or summary. The filter is remembered per repository. +- Issue actions: open in Jira, copy key, copy key and summary, and create a branch from the issue. Branch creation prefills a name from the issue key and summary and uses the existing Git Flow start sheet when Git Flow is configured, otherwise the existing branch sheet. Nothing is written to Jira. +- Gate the screen behind a new Pro-only plan feature, `jiraIssues`. Free and signed-out users see the existing feature-access/upgrade view instead of the list. +- Out of scope: OAuth (3LO), Jira Data Center/Server, editing or transitioning issues, boards, multiple Jira sites, cloud sync of the connection, and Repository AI tools for Jira. + +## Capabilities + +### New Capabilities +- `jira-account-connection`: Connecting, validating, reconnecting, and removing a Jira Cloud site with an Atlassian API token in Settings → Integrations, and keeping the credential local to this Mac. +- `jira-issue-list`: The Pro-gated Jira item in the sidebar Workspace section and its issue list screen: which issues are shown, filtering, loading/error/empty states, and per-issue actions including creating a branch from an issue. + +### Modified Capabilities + + +## Impact + +- **Native app (`macgit`)**: new `PlanFeature.jiraIssues` with a Pro-only bundled rule; new Jira client, connection controller, Keychain token store, and issue list controller; `SidebarItem`, `SidebarSection`, `WorkspaceVisibilityPopover`, `AppState` (visibility toggle), `MainWindowView` (screen routing and gating), `IntegrationsSettingsView`, `ProUpgradeSheet`/feature display names, and branch/Git Flow start sheets (prefilled name). +- **External API**: Jira Cloud REST API v3 (`/rest/api/3/myself`, `/rest/api/3/search/jql`, `/rest/api/3/field`, `/rest/api/3/project/search`) over HTTPS with Basic auth. +- **Synced settings**: the new `showWorkspaceJira` toggle is a `CloudSettingsDocument` field, like the other Workspace toggles. +- **Cross-repository (`../landing-page`)**: + - `firestore.rules` `validAppSettings` must allow `showWorkspaceJira` as a bool. These rules must be deployed before the app version ships, because otherwise production rejects the whole settings document. + - Add `jiraIssues` to the feature-policy schema (`admin/lib/schemas.ts`) and provisioning script (`scripts/firebase/set-feature-policy.mjs`, next revision). Until that policy is published, the app uses its bundled Pro-only rule. + - Both deployments require explicit authorization. No indexes or Cloud Functions change, and the Jira connection itself is never written to Firestore. diff --git a/openspec/changes/add-jira-sprint-issues/specs/jira-account-connection/spec.md b/openspec/changes/add-jira-sprint-issues/specs/jira-account-connection/spec.md new file mode 100644 index 00000000..be831d75 --- /dev/null +++ b/openspec/changes/add-jira-sprint-issues/specs/jira-account-connection/spec.md @@ -0,0 +1,97 @@ +# Spec Delta + +## Purpose + +Defines how Commit+ connects one Jira Cloud site to this Mac with an Atlassian API token in Settings → Integrations, so the Jira issue list can read the user's issues without sending the credential to Commit+ services. + +## ADDED Requirements + +### Requirement: Jira Cloud connects with site, email, and API token +Settings → Integrations SHALL offer a Jira section with "Connect Jira" that asks for the Jira Cloud site, the Atlassian account email, and an Atlassian API token, and SHALL link to Atlassian's API token page. Only Jira Cloud sites on `atlassian.net` over HTTPS SHALL be accepted; a bare site name SHALL be completed to `https://.atlassian.net`. + +#### Scenario: Open the connect form +- **WHEN** the user chooses "Connect Jira" in Settings → Integrations +- **THEN** a form asks for the site, email, and API token +- **AND** it offers a link that opens Atlassian's API token management page in the browser + +#### Scenario: Bare site name +- **WHEN** the user enters `acme` as the site +- **THEN** Commit+ uses `https://acme.atlassian.net` + +#### Scenario: Unsupported site +- **WHEN** the user enters an HTTP URL or a host outside `atlassian.net` +- **THEN** the form shows that only Jira Cloud sites are supported and does not contact the host + +### Requirement: Credentials are validated before they are saved +Commit+ SHALL verify the site, email, and token against Jira before saving the connection, and SHALL save nothing when verification fails. A successful connection SHALL show the Jira account's display name, email, and site. + +#### Scenario: Valid credentials +- **WHEN** the user submits a site, email, and token that Jira accepts +- **THEN** the connection is saved and the Jira section shows the account name, email, and site + +#### Scenario: Rejected credentials +- **WHEN** Jira rejects the email or token +- **THEN** the form shows that the email or API token was not accepted +- **AND** no connection or token is saved + +#### Scenario: Site unreachable +- **WHEN** the site cannot be reached or does not respond as Jira Cloud +- **THEN** the form shows that the Jira site could not be reached +- **AND** no connection or token is saved + +### Requirement: The Jira credential stays on this Mac +The API token SHALL be stored only in the macOS Keychain, scoped to the signed-in Commit+ account. The connection SHALL NOT be synced through Commit+ Cloud and SHALL NOT be sent to Commit+ services; it SHALL be sent only to the connected Jira site. The token SHALL NOT appear in logs, error messages, or the UI after it is saved. + +#### Scenario: Another Mac +- **WHEN** the user signs in to the same Commit+ account on another Mac +- **THEN** no Jira connection appears there until the user connects Jira on that Mac + +#### Scenario: Different Commit+ account +- **WHEN** a different Commit+ account signs in on the same Mac +- **THEN** that account does not see or use the previous account's Jira connection + +#### Scenario: Error while loading issues +- **WHEN** a Jira request fails +- **THEN** the error shown to the user does not include the token + +### Requirement: Only one Jira site is connected at a time +Commit+ SHALL keep at most one Jira connection per Commit+ account on this Mac. Connecting while a connection exists SHALL require removing or replacing the existing one. + +#### Scenario: Already connected +- **WHEN** a Jira connection exists +- **THEN** the Jira section shows that connection with Reconnect and Remove, not "Connect Jira" + +### Requirement: Rejected tokens can be reconnected +When Jira rejects a saved token, the connection SHALL be marked as needing reconnection and Commit+ SHALL offer "Reconnect", which asks for a new token for the same site and email and replaces the saved token only after the new one is verified. + +#### Scenario: Token revoked or expired +- **WHEN** a Jira request returns an authentication failure for the saved token +- **THEN** the Jira section and the Jira issues screen show that the connection needs to be reconnected + +#### Scenario: Reconnect with a valid token +- **WHEN** the user reconnects with a new token that Jira accepts +- **THEN** the old token is replaced and the connection is usable again + +#### Scenario: Reconnect with an invalid token +- **WHEN** the user reconnects with a token that Jira rejects +- **THEN** the saved connection is left unchanged and still needs reconnection + +### Requirement: Removing the connection deletes the credential +"Remove" SHALL delete the Jira connection and its Keychain token from this Mac after confirmation. It SHALL NOT revoke the token at Atlassian, and the confirmation SHALL say so. + +#### Scenario: Remove +- **WHEN** the user confirms removing the Jira connection +- **THEN** the connection and token are deleted from this Mac +- **AND** the Jira issues screen shows that no Jira site is connected + +### Requirement: Connecting Jira requires sign-in but not Pro +Connecting Jira SHALL require a signed-in Commit+ account, because the token is scoped to that account. Signed-in users SHALL be able to connect and remove a Jira site on any plan; the Pro requirement applies to the Jira issues screen. + +#### Scenario: Signed out +- **WHEN** no Commit+ account is signed in +- **THEN** the Jira section offers Sign In instead of "Connect Jira" + +#### Scenario: Free user connects Jira +- **WHEN** a signed-in Free user connects Jira in Settings → Integrations +- **THEN** the connection is saved +- **AND** opening the Jira issues screen still shows the Pro upgrade view diff --git a/openspec/changes/add-jira-sprint-issues/specs/jira-issue-list/spec.md b/openspec/changes/add-jira-sprint-issues/specs/jira-issue-list/spec.md new file mode 100644 index 00000000..70c14e9e --- /dev/null +++ b/openspec/changes/add-jira-sprint-issues/specs/jira-issue-list/spec.md @@ -0,0 +1,192 @@ +# Spec Delta + +## Purpose + +Defines the Pro-gated Jira item in the repository sidebar's Workspace section and its issue list screen, which shows the connected user's own Jira issues as a filterable flat list and lets them start a branch from an issue. + +## ADDED Requirements + +### Requirement: Jira appears in the sidebar Workspace section +The repository sidebar's Workspace section SHALL include a "Jira" item, shown by default. "Customize Workspace" SHALL include a "Jira" toggle that hides or shows it, and the choice SHALL persist across launches like the other Workspace toggles. + +#### Scenario: Default visibility +- **WHEN** the user opens a repository window for the first time after updating +- **THEN** the Workspace section lists "Jira" + +#### Scenario: Hide Jira +- **WHEN** the user turns off "Jira" in "Customize Workspace" +- **THEN** the Jira item disappears from the Workspace section in every repository window and stays hidden after relaunch + +#### Scenario: Hidden while selected +- **WHEN** the user hides Jira while the Jira screen is selected +- **THEN** the window selects File status + +### Requirement: The Jira issues screen requires Commit+ Pro +Selecting Jira SHALL show the issue list only when the `jiraIssues` plan feature is allowed for the signed-in account; it is allowed for active Pro and denied for Free. Otherwise the screen SHALL show the existing feature-access view with the matching sign-in or upgrade action and SHALL NOT contact Jira. While access is being checked, the screen SHALL show a progress state. + +#### Scenario: Pro user +- **WHEN** a signed-in user with active Pro selects Jira +- **THEN** the Jira issues screen is shown + +#### Scenario: Free user +- **WHEN** a signed-in Free user selects Jira +- **THEN** the screen explains that Jira requires Commit+ Pro and offers Upgrade +- **AND** no Jira request is made + +#### Scenario: Signed out +- **WHEN** no Commit+ account is signed in and the user selects Jira +- **THEN** the screen asks the user to sign in + +#### Scenario: Pro ends while the screen is open +- **WHEN** the account's Pro access ends while the Jira screen is shown +- **THEN** the screen switches to the upgrade view + +### Requirement: Feature policy fallback keeps Jira Pro-only +When the remote feature policy has no valid `jiraIssues` entry or cannot be loaded, Commit+ SHALL use its bundled rule, which allows Jira issues for Pro and denies it for Free. A remote entry that disables the feature SHALL hide the issue list for all plans. + +#### Scenario: Remote policy predates Jira +- **WHEN** the published feature policy has no `jiraIssues` entry +- **THEN** Pro users can open the Jira issues screen and Free users see the upgrade view + +#### Scenario: Remote kill switch +- **WHEN** the published feature policy sets `jiraIssues` to disabled +- **THEN** the Jira screen shows that the feature is unavailable for every plan + +### Requirement: The screen guides users who have not connected Jira +When access is allowed but no Jira site is connected, the screen SHALL explain that a Jira Cloud connection is needed and offer "Connect Jira", which opens Settings → Integrations. When the connection needs reconnection, the screen SHALL say so and offer to reconnect. + +#### Scenario: No connection +- **WHEN** a Pro user opens the Jira screen without a Jira connection +- **THEN** the screen offers "Connect Jira" + +#### Scenario: Connection needs reconnection +- **WHEN** Jira rejects the saved token while loading issues +- **THEN** the screen shows that the Jira connection needs to be reconnected and offers to open Settings → Integrations + +### Requirement: The issue list shows the user's own issues +The screen SHALL list only issues assigned to the connected Jira user, as a flat list without columns or swimlanes. By default it SHALL show issues in open sprints whose status category is not Done, most recently updated first. Each row SHALL show the issue type, key, summary, status, priority, and sprint name when present. + +#### Scenario: Default list +- **WHEN** a Pro user with a Jira connection opens the Jira screen +- **THEN** the list shows their assigned, not-done issues in open sprints, most recently updated first + +#### Scenario: Issue assigned to someone else +- **WHEN** an issue in the user's sprint is assigned to another person +- **THEN** it does not appear in the list + +#### Scenario: Many issues +- **WHEN** more issues match than fit in one Jira response +- **THEN** the list loads further results as the user scrolls to the end + +### Requirement: The issue list can be filtered +The screen SHALL provide filters for sprint (open sprints, one specific sprint, backlog without a sprint, or any), status category (To Do, In Progress, Done), project, and issue type, plus a text search on key and summary. Specific sprints and projects SHALL be offered from those the user's issues belong to. Changing a filter SHALL reload the list, and a "Reset Filters" action SHALL restore the default. + +#### Scenario: Filter by sprint +- **WHEN** the user chooses a specific sprint +- **THEN** only their issues in that sprint are listed + +#### Scenario: Backlog +- **WHEN** the user chooses the backlog +- **THEN** only their issues that are not in any sprint are listed + +#### Scenario: Include done issues +- **WHEN** the user adds Done to the status filter +- **THEN** done issues that match the other filters are listed too + +#### Scenario: Search by key +- **WHEN** the user types `PROJ-12` in search +- **THEN** the list shows matching issues by key or summary + +#### Scenario: Reset filters +- **WHEN** the user chooses "Reset Filters" +- **THEN** the default filter is restored and the list reloads + +### Requirement: Filters are remembered per repository +The selected filters SHALL be remembered for each repository on this Mac and restored when that repository's Jira screen is opened again, including after relaunch. Text search SHALL NOT be remembered. + +#### Scenario: Return to a repository +- **WHEN** the user filters by project PROJ in repository A, then later reopens Jira in repository A +- **THEN** the PROJ project filter is still applied + +#### Scenario: Different repository +- **WHEN** the user opens Jira in repository B +- **THEN** repository B uses its own remembered filters, or the default + +### Requirement: Loading, empty, and error states are explicit +The screen SHALL show a loading state on first load, keep the current rows visible while refreshing, show an empty state that distinguishes "no matching issues" from "no issues at all", and show Jira errors with a Retry action. A "Refresh" action SHALL reload the list. + +#### Scenario: No issues match the filter +- **WHEN** the filters match no issues +- **THEN** the screen says no issues match and offers "Reset Filters" + +#### Scenario: Network failure +- **WHEN** Jira cannot be reached +- **THEN** the screen shows that Jira could not be reached and offers Retry +- **AND** previously loaded rows, if any, stay visible + +#### Scenario: Rate limited +- **WHEN** Jira responds that requests are rate limited +- **THEN** the screen says Jira is busy and offers Retry + +### Requirement: Issues can be opened and copied +Each issue SHALL offer "Open in Jira", which opens the issue in the default browser, "Copy Key", and "Copy Key and Summary". Double-clicking or pressing Return on a row SHALL open it in Jira. + +#### Scenario: Open in Jira +- **WHEN** the user double-clicks an issue +- **THEN** the issue's page on the connected Jira site opens in the default browser + +#### Scenario: Copy key and summary +- **WHEN** the user chooses "Copy Key and Summary" on PROJ-123 "Fix login crash" +- **THEN** the pasteboard contains `PROJ-123 Fix login crash` + +### Requirement: Selecting an issue shows its details +Single-clicking an issue SHALL show a details panel on the right of the list with the issue's key, type, summary, status, priority, project, sprints, and last update. The panel SHALL offer the same actions as the issue's context menu: create a branch, "Open in Jira", "Copy Key", and "Copy Key and Summary". Closing the panel or clearing the selection SHALL hide it. + +#### Scenario: Select an issue +- **WHEN** the user single-clicks PROJ-123 in the list +- **THEN** the details panel shows PROJ-123's summary, status, type, priority, project, sprints, and last update +- **AND** it offers the branch, open, and copy actions + +#### Scenario: Close the panel +- **WHEN** the user chooses Close in the details panel +- **THEN** the panel hides and the list uses the full width + +#### Scenario: Selected issue leaves the list +- **WHEN** a refresh or filter change removes the selected issue from the list +- **THEN** the details panel hides + +### Requirement: A branch can be created from an issue +Each issue SHALL offer to create a branch with a prefilled, editable name built from the issue key and summary, through the existing branch or Git Flow start flow of the current repository. With Git Flow configured, the choices SHALL be Start Feature, Start Bugfix, Start Hotfix, and New Branch, and the suggested choice SHALL be Start Bugfix for Bug or Defect issues and Start Feature otherwise. Without Git Flow, only New Branch is offered. Commit+ SHALL NOT change the issue in Jira. + +#### Scenario: Plain repository +- **WHEN** the user chooses "Create Branch…" on PROJ-123 "Fix login crash" in a repository without Git Flow +- **THEN** the branch sheet opens with the name `PROJ-123-fix-login-crash` +- **AND** creating it behaves like any other branch creation, including undo and repository refresh + +#### Scenario: Bug in a Git Flow repository +- **WHEN** the user creates a branch from a Bug issue PROJ-123 "Fix login crash" using the suggested choice in a repository with Git Flow configured +- **THEN** the Git Flow bugfix start sheet opens with `PROJ-123-fix-login-crash` as the topic name + +#### Scenario: Story in a Git Flow repository +- **WHEN** the user creates a branch from a Story issue using the suggested choice in a repository with Git Flow configured +- **THEN** the Git Flow feature start sheet opens with the suggested name + +#### Scenario: Choose a different flow +- **WHEN** the user picks Start Hotfix or New Branch instead of the suggested choice +- **THEN** that flow's sheet opens with the suggested name + +#### Scenario: Release is not offered +- **WHEN** the user opens the branch choices for an issue in a Git Flow repository +- **THEN** Start Release is not listed, because release names are versions rather than issues + +#### Scenario: Summary with unsupported characters +- **WHEN** the issue summary contains punctuation, non-ASCII letters, or is very long +- **THEN** the suggested name keeps the issue key, uses only lowercase ASCII letters, digits, and single hyphens after it, and is at most 60 characters + +#### Scenario: Branch already exists +- **WHEN** a branch with the suggested name already exists +- **THEN** the branch flow reports the conflict as it does for any existing name + +#### Scenario: Jira unchanged +- **WHEN** a branch is created from an issue +- **THEN** no request that modifies the issue is sent to Jira diff --git a/openspec/changes/add-jira-sprint-issues/tasks.md b/openspec/changes/add-jira-sprint-issues/tasks.md new file mode 100644 index 00000000..177ae9d1 --- /dev/null +++ b/openspec/changes/add-jira-sprint-issues/tasks.md @@ -0,0 +1,91 @@ +# Tasks + +## 1. Plan feature and gating fallback + +- [x] 1.1 Add `PlanFeature.jiraIssues`. Give it `proOnlyRule` in `FeatureAccessPolicy.bundled`, bump the bundled revision to 8, and add it to the non-scoped group in `FeaturePolicyStore.requiresRepositoryScope`. Verify with new `FeatureAccessPolicyTests` cases: Pro is allowed, Free is denied, a remote policy without the key falls back to the bundled entry, and a remote `enabled: false` disables it. +- [x] 1.2 Add the "Jira Issues" display name and copy to `ProUpgradeSheet.upgradeDescription` and `FeatureAccessNotice`. Verify with a `FeatureAccessNoticeTests` case for `.jiraIssues` (signed out and Free). + +## 2. Jira Cloud client + +- [x] 2.1 Add the Jira models (`JiraConnection`, `JiraIssue`, `JiraIssueFilter`, `JiraSprintRef`, and the error enum) and the site normalizer: HTTPS only, `*.atlassian.net` only, bare names expand to `https://.atlassian.net`. Verify with `JiraSiteNormalizerTests` covering accepted, expanded, and rejected inputs. +- [x] 2.2 Add `JiraJQLBuilder` as described in design §4: always `assignee = currentUser()`, the sprint scopes, status categories, projects, types, escaped search text, and the key-match clause. Verify with `JiraJQLBuilderTests`, including quote, backslash, and JQL-keyword injection attempts in the search text. +- [x] 2.3 Add `JiraCloudClient` on top of `GitProviderHTTPClient`: + - `/myself` validation with the site-then-gateway fallback (`_edge/tenant_info` → `cloudId`) + - sprint field discovery through `/rest/api/3/field` + - `POST /rest/api/3/search/jql` with `nextPageToken` pagination and a fixed field list + - status error mapping: 401, 403, 429 with `Retry-After`, 5xx, and network failures + + Verify with `JiraCloudClientTests` using a stub HTTP client. The tests must assert the Basic header format, that no token appears in any error description, and that a missing sprint field disables sprint filtering. + +## 3. Connection storage and controller + +- [x] 3.1 Add `KeychainJiraTokenStore` (service `com.commitplus.macgit.jira-tokens`, key `::`) behind a protocol with an in-memory test double, and store connection metadata in the `LocalDataStore` collection `jiraConnections`, keyed by `macgitUID`. Verify with `JiraConnectionStoreTests` using a temporary `LocalDataStore`: save, load, and remove, and confirm that a different `macgitUID` cannot see the connection. +- [x] 3.2 Add `JiraConnectionController` (`@MainActor`, app-level). Create it in `macgitApp`, inject it as an environment object, and drive it from the signed-in account. It needs connect (validate, then save, rolling back the token if the metadata write fails), reconnect (verify, then replace), remove, and mark-needs-reconnect on 401. Verify with `JiraConnectionControllerTests`: a rejected token saves nothing, an invalid reconnect leaves the old token, remove deletes both stores, and a signed-out state cannot connect. + +## 4. Settings → Integrations UI + +- [x] 4.1 Add a Jira section to `IntegrationsSettingsView`, styled like its other grouped sections (status rows plus a `SettingsActionRow`): + - Connect form with site, email, token, a link to Atlassian's API token page, and the scoped-token scope hint + - Connected row showing name, email, and site, with Reconnect and Remove + - Remove confirmation stating that the token is not revoked at Atlassian + - Sign In shown when signed out + + Verify with a build: `xcodebuild -project macgit.xcodeproj -scheme macgit -destination 'platform=macOS' build`. + +## 5. Sidebar item and synced visibility toggle + +- [x] 5.1 Add `SidebarItem.jira` ("Jira", `checklist`) after `.pullRequests` in `SidebarSection.workspace.items`, and add the visibility filter in `SidebarView+NativeRows.swift`. Verify with a `SidebarNativeListTests` case: the row is present by default and absent when the toggle is off. +- [x] 5.2 Add `showWorkspaceJira` (default `true`) through `AppSettingsSnapshot`, `CloudSettingsDocument` encode and decode, `AppState` load, apply, and snapshot, and `WorkspaceVisibilityPopover`. Verify with `AppSettingsSnapshotTests` and `CloudSettingsDocumentTests` cases: it round-trips, and a missing field decodes as `true`. +- [x] 5.3 In `MainWindowView`, select `.item(.fileStatus)` when `showWorkspaceJira` turns off while `.item(.jira)` is selected, and return `EmptyView()` for `.item(.jira)` in the main `body` switch. Verify with a build. + +## 6. Issue list controller and screen + +- [x] 6.1 Add `JiraIssueListController` as described in design §5: generation-guarded loads, cancellation on filter change, 300 ms search debounce, kept rows during refresh, load-more on `nextPageToken`, sprint and project options derived from loaded issues, and the per-repository filter in the `LocalDataStore` collection `jiraIssueFilters`, excluding search text. Verify with `JiraIssueListControllerTests` using a stub client: a stale response is dropped, the filter is restored per repository, search text is not persisted, and a 401 marks the connection as needing reconnection. +- [x] 6.2 Add `JiraIssueListView`: + - Filter bar with Sprint, Status, Project, Type, search, Reset Filters, and Refresh + - Flat list rows showing type icon, key, summary, status, priority, and sprint + - States: loading, "no matching issues", "no issues at all", error with Retry, rate-limited, not connected with "Connect Jira", and needs reconnection + - Row context menu with Open in Jira, Copy Key, Copy Key and Summary, and Create Branch… + - Double-click or Return opens the issue in Jira + + Verify with a build. +- [x] 6.3 Wire the screen into `MainWindowView`: a `@StateObject` `JiraIssueListController`, `jiraAccessDecision` and `jiraAccessTaskID` following the Pull Requests versions, a `hasOpenedJira` branch in `cachedRepositoryScreens` that shows the list, `FeatureAccessUnavailableView(feature: .jiraIssues, …)`, or a progress view, and "Connect Jira" opening Settings → Integrations through the existing `onOpenConnections`. Verify with a `FeatureAccessControllerTests` case for `.jiraIssues` and a build. The controller must not load unless access is `.allowed`. + +## 7. Create branch from issue + +- [x] 7.1 Add `JiraBranchNameSuggester` (key + ASCII slug, at most 60 characters at a hyphen boundary, key always kept). Verify with `JiraBranchNameSuggesterTests`: punctuation, Vietnamese and other diacritics, emoji, an empty summary, and a very long summary. +- [x] 7.2 Add an optional `initialBranchName` to `BranchSheetView` and an optional `initialTopicName` to `StartGitFlowSheet`, both defaulting to `nil`. Thread them through `MainWindowView+Sheets.swift` (`branchSheet`, `startGitFlowSheet`). Verify with a `BranchSheetInitialStateTests` case for the prefilled name, and confirm existing call sites compile unchanged. +- [x] 7.3 Route "Create Branch…" from `JiraIssueListView` to `MainWindowView`: present the Git Flow feature start sheet when Git Flow is configured, otherwise the branch sheet, through the existing sheet presentation state. Verify with a build and with the manual review in 8.2, which checks that undo and refresh work after creation. + +## 7a. Issue details panel and branch choices + +- [x] 7a.1 Add `JiraBranchTarget` with type-based suggestions (Bug/Defect → bugfix, otherwise feature; plain branch without Git Flow; release excluded). Verify with `JiraBranchNameSuggesterTests` cases for suggestion and options. +- [x] 7a.2 Show `JiraIssueDetailPanel` on single click (key, type, summary, status, priority, project, sprints, updated) with a branch split button plus Open in Jira, Copy Key, and Copy Key and Summary. Make the context menu's Create Branch a submenu of the same choices, and route `prepareCreateBranch(from:target:)` to the matching sheet. Verify with a build, and cover runtime behavior in 8.2. + +## 8. Integration validation + +- [x] 8.1 Run the new and touched XCTest classes in one invocation and confirm they pass: + `xcodebuild -project macgit.xcodeproj -scheme macgit -destination 'platform=macOS' -only-testing:macgitTests/FeatureAccessPolicyTests -only-testing:macgitTests/FeatureAccessNoticeTests -only-testing:macgitTests/FeatureAccessControllerTests -only-testing:macgitTests/JiraSiteNormalizerTests -only-testing:macgitTests/JiraJQLBuilderTests -only-testing:macgitTests/JiraCloudClientTests -only-testing:macgitTests/JiraConnectionStoreTests -only-testing:macgitTests/JiraConnectionControllerTests -only-testing:macgitTests/JiraIssueListControllerTests -only-testing:macgitTests/JiraBranchNameSuggesterTests -only-testing:macgitTests/BranchSheetInitialStateTests -only-testing:macgitTests/SidebarNativeListTests -only-testing:macgitTests/AppSettingsSnapshotTests -only-testing:macgitTests/CloudSettingsDocumentTests test` +- [ ] 8.2 Manual UI review in the running app against a real Jira Cloud site: + - Connect, reject a bad token, reconnect, and remove in Settings → Integrations + - The Jira row in Workspace, and hiding it from Customize Workspace + - The Free/signed-out upgrade view versus the Pro list + - Default list, each filter, Reset Filters, load-more, and filter memory per repository + - Error and empty states + - Open and copy actions + - Details panel on single click, its actions, and closing it + - Create Branch in a plain repository, and the suggested and alternative Git Flow choices in a Git Flow repository, followed by undo + + Check the layout at narrow and wide window sizes, and record anything not checked. +- [x] 8.3 Run `git diff --check` and confirm there is no output. + +## 9. Cross-repository Firebase contract (`../landing-page`) + +- [x] 9.1 In `../landing-page/firestore.rules`, add `showWorkspaceJira` to the `validAppSettings` allowlist with an `is bool` check. Verify by reviewing the diff and running that repository's non-emulator checks. Do not run emulator tests; report them as not run. +- [x] 9.2 Add `jiraIssues` to `featureKeys` in `../landing-page/admin/lib/schemas.ts` (non-scoped) and to `../landing-page/scripts/firebase/set-feature-policy.mjs` as `proOnly` at revision 8. Verify by reviewing the diff and running that repository's type check. +- [ ] 9.3 With explicit user authorization, deploy **Firestore rules only** to the production project before the app release. Verify that the CLI reports success for that project and target, and report what is local, committed, pushed, and deployed. +- [ ] 9.4 With explicit user authorization, publish the release feature policy at revision 8. Verify the published `featurePolicies/release` document contains `jiraIssues`. + +## Workflow follow-up + +- Archive the change with `/opsx:archive` after implementation, validation, and the Firebase rollout steps are complete. From f10f03e61ab0320fbcd10c5712239aa1966e96e2 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Sat, 24 Oct 2026 14:16:56 +0700 Subject: [PATCH 2/2] fix: Address Jira issue list review feedback - Reload the issue list when a reconnect clears needsReconnect - Keep the next page token when the search response omits isLast Co-Authored-By: Claude Opus 5.5 --- macgit/App/JiraIssueListController.swift | 12 ++++++++++-- macgit/Services/JiraCloudClient.swift | 2 +- macgitTests/JiraCloudClientTests.swift | 8 ++++++++ macgitTests/JiraIssueListControllerTests.swift | 18 ++++++++++++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/macgit/App/JiraIssueListController.swift b/macgit/App/JiraIssueListController.swift index 7c9f056c..492f035f 100644 --- a/macgit/App/JiraIssueListController.swift +++ b/macgit/App/JiraIssueListController.swift @@ -76,6 +76,11 @@ final class JiraIssueListController: ObservableObject { private var knownIssueTypes: Set = [] private var connectionObservation: AnyCancellable? + private struct ConnectionState: Equatable { + let id: String + let needsReconnect: Bool + } + init( repositoryURL: URL, connectionController: JiraConnectionController, @@ -86,11 +91,14 @@ final class JiraIssueListController: ObservableObject { self.filterStore = filterStore self.repositoryKey = repositoryURL.standardizedFileURL.path self.searchDebounce = searchDebounce + // Reconnecting keeps the connection id and only clears `needsReconnect`, so observe both. + // A transition to `needsReconnect` is already handled by `handle(_:)`. connectionObservation = connectionController.$connection - .map { $0?.id } + .map { $0.map { ConnectionState(id: $0.id, needsReconnect: $0.needsReconnect) } } .removeDuplicates() .dropFirst() - .sink { [weak self] _ in + .sink { [weak self] state in + guard state?.needsReconnect != true else { return } Task { @MainActor in self?.connectionDidChange() } } } diff --git a/macgit/Services/JiraCloudClient.swift b/macgit/Services/JiraCloudClient.swift index a1f5e26d..3b7043eb 100644 --- a/macgit/Services/JiraCloudClient.swift +++ b/macgit/Services/JiraCloudClient.swift @@ -87,7 +87,7 @@ struct JiraCloudClient: JiraIssueServing { throw JiraError.invalidResponse } let issues = rawIssues.compactMap { Self.issue(from: $0, sprintFieldID: sprintFieldID) } - let isLast = payload["isLast"] as? Bool ?? true + let isLast = payload["isLast"] as? Bool == true let nextPageToken = isLast ? nil : payload["nextPageToken"] as? String return JiraIssuePage(issues: issues, nextPageToken: nextPageToken) } diff --git a/macgitTests/JiraCloudClientTests.swift b/macgitTests/JiraCloudClientTests.swift index eb108f17..fde2e836 100644 --- a/macgitTests/JiraCloudClientTests.swift +++ b/macgitTests/JiraCloudClientTests.swift @@ -131,6 +131,14 @@ final class JiraCloudClientTests: XCTestCase { XCTAssertNil(page.nextPageToken) } + func testMissingIsLastKeepsNextPageToken() async throws { + let http = StubJiraHTTPClient([.init(path: "/rest/api/3/search/jql", status: 200, body: #"{"nextPageToken":"page-2","issues":[]}"#)]) + let page = try await JiraCloudClient(httpClient: http).searchIssues( + baseURL: site, credential: credential, jql: "x", sprintFieldID: nil, pageToken: nil + ) + XCTAssertEqual(page.nextPageToken, "page-2") + } + private func assertThrows( _ expected: JiraError, file: StaticString = #filePath, diff --git a/macgitTests/JiraIssueListControllerTests.swift b/macgitTests/JiraIssueListControllerTests.swift index 4dba0692..c52b5aef 100644 --- a/macgitTests/JiraIssueListControllerTests.swift +++ b/macgitTests/JiraIssueListControllerTests.swift @@ -117,6 +117,24 @@ final class JiraIssueListControllerTests: XCTestCase { XCTAssertEqual(connectionController.connection?.needsReconnect, true) } + func testReconnectReloadsList() async throws { + client.searchHandler = { _, _ in throw JiraError.needsReconnect } + let controller = makeController() + await controller.activate() + try await waitForIdle(controller) + XCTAssertEqual(controller.error, .needsReconnect) + + client.searchHandler = { _, _ in JiraIssuePage(issues: [.stub("PROJ-1")], nextPageToken: nil) } + client.validateResults = [.success(JiraValidatedAccount(accountID: "acc-1", displayName: "Dev", email: "dev@example.com", apiBase: .site))] + let reconnected = await connectionController.reconnect(token: "t2") + XCTAssertTrue(reconnected) + try await Task.sleep(for: .milliseconds(50)) + try await waitForIdle(controller) + + XCTAssertNil(controller.error) + XCTAssertEqual(controller.items.map(\.key), ["PROJ-1"]) + } + func testEmptyStateDistinguishesNoMatchFromNoIssues() async throws { client.searchHandler = { jql, _ in jql.contains("openSprints")