diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 6eebf190b499..981decc3116d 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -43,6 +43,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, private let t3ConnectDeviceManager: any T3ConnectDeviceManaging private let hasMatchingT3ConnectController: Bool private let settingsStore: UserDefaults + private static let gitHubRoutingKey = "swift-ios.github-routing.v1" + private var routedPullRequests: [FeaturePullRequestTarget: Set] = [:] private var cachedSettings: FeatureSettings? private let projectFaviconStore: FeatureProjectFaviconStore private let fallbackPollingInitialDelay: Duration @@ -487,6 +489,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, try await runtime.revokeCredential(id: id) } try await runtime.remove(id: id) + saveGitHubRoutingGrants(gitHubRoutingGrants.filter { $0.environmentID != id }) if removesActiveEnvironment { await clearActiveEnvironment(disconnectClient: false) } @@ -709,30 +712,134 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, } } + private var gitHubRoutingGrants: [GitHubRoutingGrant] { + guard let data = settingsStore.data(forKey: Self.gitHubRoutingKey) else { return [] } + return (try? JSONDecoder().decode([GitHubRoutingGrant].self, from: data)) ?? [] + } + + private func saveGitHubRoutingGrants(_ grants: [GitHubRoutingGrant]) { + settingsStore.set(try? JSONEncoder().encode(grants), forKey: Self.gitHubRoutingKey) + } + + func gitHubRoutingPermission(environmentID: String) async throws -> GitHubRoutingPermission { + guard let environment = try await runtime.environments().first(where: { $0.id == environmentID }) else { + throw NativeFeatureClientError.environmentNotFound + } + return GitHubRoutingGrant.permission(for: environment, grants: gitHubRoutingGrants) + } + + func setGitHubRoutingPermission(environmentID: String, permission: GitHubRoutingPermission) async throws { + guard let environment = try await runtime.environments().first(where: { $0.id == environmentID }), + let key = GitHubRoutingGrant.connectionKey(environment) else { + throw NativeFeatureClientError.environmentNotFound + } + var grants = gitHubRoutingGrants.filter { $0.environmentID != environmentID } + if permission != .off { + grants.append(GitHubRoutingGrant(environmentID: environmentID, connectionKey: key, permission: permission)) + } + saveGitHubRoutingGrants(grants) + } + + private func routingAllowed(origin: Environment, destination: Environment, write: Bool) async -> Bool { + guard let current = try? await runtime.environments(), + let source = current.first(where: { $0.id == origin.id }), + let target = current.first(where: { $0.id == destination.id }), + GitHubRoutingGrant.connectionKey(source) == GitHubRoutingGrant.connectionKey(origin), + GitHubRoutingGrant.connectionKey(target) == GitHubRoutingGrant.connectionKey(destination) else { return false } + return GitHubRoutingGrant.allowed(origin: source, destination: target, grants: gitHubRoutingGrants, write: write) + } + + /// Only verified GitHub accounts can route. A dispatched write is never retried elsewhere. + private func withPullRequestRoute( + _ target: FeaturePullRequestTarget, write: Bool = false, allowStaleFallback: Bool = false, + operation: (T3Client, PullRequestRef, PullRequestRoutingIdentity?) async throws -> Result + ) async throws -> Result { + let client = try await projectCreationClient(environmentID: target.environmentID) + let environments = try await runtime.environments() + let origin = client.environment + let alternatives = environments.filter { + GitHubRoutingGrant.allowed(origin: origin, destination: $0, grants: gitHubRoutingGrants, write: write) + && environmentConnectionStates[$0.id] == .connected + }.sorted { left, right in + let localHosts = ["localhost", "127.0.0.1", "::1"] + let leftLocal = localHosts.contains(left.httpBaseURL.host ?? "") + let rightLocal = localHosts.contains(right.httpBaseURL.host ?? "") + return leftLocal == rightLocal ? left.id < right.id : leftLocal + } + guard !alternatives.isEmpty, + let identity = try? await client.pullRequestRouting(target.reference), + identity.provider == .github else { + try Task.checkCancellation() + return try await operation(client, target.reference, nil) + } + let reference = PullRequestRef(projectId: target.reference.projectId, + repository: target.reference.repository, number: target.reference.number, + host: identity.host, expectedAccountId: identity.accountId, + allowStale: write ? target.reference.allowStale : false) + for environment in alternatives { + try Task.checkCancellation() + guard await routingAllowed(origin: origin, destination: environment, write: write) else { continue } + guard let alternate = try? await projectCreationClient(environmentID: environment.id), + GitHubRoutingGrant.connectionKey(alternate.environment) == GitHubRoutingGrant.connectionKey(environment), + let account = try? await alternate.pullRequestRoutingIdentity(host: identity.host), + account.provider == .github, account.accountId == identity.accountId, + account.host.caseInsensitiveCompare(identity.host) == .orderedSame, + await routingAllowed(origin: origin, destination: environment, write: write) else { continue } + do { + let result = try await operation(alternate, reference, identity) + if routedPullRequests.count >= 256 { routedPullRequests.removeAll(keepingCapacity: true) } + routedPullRequests[target, default: []].insert(environment.id) + if write { try? await invalidatePullRequests(target) } + return result + } catch { + if write || Task.isCancelled { throw error } + } + } + try Task.checkCancellation() + do { + let result = try await operation(client, reference, identity) + if write { try? await invalidatePullRequests(target) } + return result + } catch { + try Task.checkCancellation() + guard allowStaleFallback, !write, target.reference.allowStale != false else { throw error } + return try await operation(client, target.reference, nil) + } + } + func pullRequestDetail(_ target: FeaturePullRequestTarget) async throws -> PullRequestDetail { - try await projectCreationClient(environmentID: target.environmentID) - .pullRequestDetail(target.reference) + try await withPullRequestRoute(target, allowStaleFallback: true) { client, reference, identity in + var detail = try await client.pullRequestDetail(reference) + detail.projectId = target.reference.projectId + if let title = identity?.projectTitle { detail.projectTitle = title } + if let root = identity?.workspaceRoot { detail.workspaceRoot = root } + return detail + } } func pullRequestActivity(_ target: FeaturePullRequestTarget) async throws -> PullRequestActivity { - try await projectCreationClient(environmentID: target.environmentID) - .pullRequestActivity(target.reference) + try await withPullRequestRoute(target) { client, reference, _ in + try await client.pullRequestActivity(reference) + } } func pullRequestDiff(_ target: FeaturePullRequestTarget, cursor: String?) async throws -> PullRequestDiffResult { - try await projectCreationClient(environmentID: target.environmentID).pullRequestDiff( - PullRequestDiffInput( - projectId: target.reference.projectId, - repository: target.reference.repository, - number: target.reference.number, + try await withPullRequestRoute(target) { client, reference, _ in + try await client.pullRequestDiff(PullRequestDiffInput( + projectId: reference.projectId, + repository: reference.repository, + number: reference.number, cursor: cursor, - commit: nil - ) - ) + commit: nil, + host: reference.host, + expectedAccountId: reference.expectedAccountId, + allowStale: reference.allowStale + )) + } } func runPullRequestAction( @@ -741,12 +848,9 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, mergeMethod: PullRequestMergeMethod?, updateMethod: PullRequestUpdateMethod? ) async throws { - try await projectCreationClient(environmentID: target.environmentID).runPullRequestAction( - target.reference, - action: action, - mergeMethod: mergeMethod, - updateMethod: updateMethod - ) + try await withPullRequestRoute(target, write: true) { client, reference, _ in + try await client.runPullRequestAction(reference, action: action, mergeMethod: mergeMethod, updateMethod: updateMethod) + } } func updatePullRequest( @@ -754,16 +858,15 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, title: String?, body: String? ) async throws { - try await projectCreationClient(environmentID: target.environmentID).updatePullRequest( - target.reference, - title: title, - body: body - ) + try await withPullRequestRoute(target, write: true) { client, reference, _ in + try await client.updatePullRequest(reference, title: title, body: body) + } } func commentOnPullRequest(_ target: FeaturePullRequestTarget, body: String) async throws { - try await projectCreationClient(environmentID: target.environmentID) - .commentOnPullRequest(target.reference, body: body) + try await withPullRequestRoute(target, write: true) { client, reference, _ in + try await client.commentOnPullRequest(reference, body: body) + } } func submitPullRequestReview( @@ -772,13 +875,14 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, body: String, comments: [PullRequestReviewCommentDraft] ) async throws { - try await projectCreationClient(environmentID: target.environmentID) - .submitPullRequestReview( - target.reference, + try await withPullRequestRoute(target, write: true) { client, reference, _ in + try await client.submitPullRequestReview( + reference, verdict: verdict, body: body, comments: comments ) + } } func replyToPullRequestThread( @@ -786,8 +890,9 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, threadID: String, body: String ) async throws { - try await projectCreationClient(environmentID: target.environmentID) - .replyToPullRequestThread(target.reference, threadID: threadID, body: body) + try await withPullRequestRoute(target, write: true) { client, reference, _ in + try await client.replyToPullRequestThread(reference, threadID: threadID, body: body) + } } func setPullRequestThreadResolved( @@ -795,12 +900,13 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, threadID: String, resolved: Bool ) async throws { - try await projectCreationClient(environmentID: target.environmentID) - .setPullRequestThreadResolved( - target.reference, + try await withPullRequestRoute(target, write: true) { client, reference, _ in + try await client.setPullRequestThreadResolved( + reference, threadID: threadID, resolved: resolved ) + } } func setPullRequestReaction( @@ -809,20 +915,22 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, content: PullRequestReactionContent, reacted: Bool ) async throws { - try await projectCreationClient(environmentID: target.environmentID) - .setPullRequestReaction( - target.reference, + try await withPullRequestRoute(target, write: true) { client, reference, _ in + try await client.setPullRequestReaction( + reference, subjectID: subjectID, content: content, reacted: reacted ) + } } func pullRequestReviewerCandidates(_ target: FeaturePullRequestTarget) async throws -> PullRequestReviewerCandidateList { - try await projectCreationClient(environmentID: target.environmentID) - .pullRequestReviewerCandidates(target.reference) + try await withPullRequestRoute(target) { client, reference, _ in + try await client.pullRequestReviewerCandidates(reference) + } } func requestPullRequestReviewers( @@ -830,18 +938,27 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, reviewers: [PullRequestReviewerCandidate], requested: Bool ) async throws { - try await projectCreationClient(environmentID: target.environmentID) - .requestPullRequestReviewers( - target.reference, + try await withPullRequestRoute(target, write: true) { client, reference, _ in + try await client.requestPullRequestReviewers( + reference, reviewers: reviewers, requested: requested ) + } } func invalidatePullRequests(_ target: FeaturePullRequestTarget?) async throws { if let target { try await projectCreationClient(environmentID: target.environmentID) .invalidatePullRequests(target.reference) + let environments = try await runtime.environments() + if let origin = environments.first(where: { $0.id == target.environmentID }) { + for id in routedPullRequests[target] ?? [] { + guard let destination = environments.first(where: { $0.id == id }), + await routingAllowed(origin: origin, destination: destination, write: false) else { continue } + try? await projectCreationClient(environmentID: id).invalidatePullRequests(target.reference) + } + } return } let environments = try await runtime.environments().filter(\.isEnabled) @@ -1819,6 +1936,35 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, return confirmed } + func setThreadPullRequest(id: String, url: String, linked: Bool) async throws { + let route = try threadRoute(for: id) + guard let thread = cachedThread(id: route.uiID), + thread.supportsMultiplePullRequests == true || thread.supportsPullRequestLinking == true else { + throw NativeFeatureClientError.invalidPullRequestLink + } + let existing = thread.pullRequests?.first { $0.url == url } + let legacyKey = thread.linkedPullRequest.flatMap { link -> ThreadPullRequestKey? in + guard link.url == url, let host = URL(string: link.url)?.host else { return nil } + return ThreadPullRequestKey(host: host, repository: link.repository, number: link.number) + } + guard let key = existing?.id ?? ThreadPullRequests.parseURL(url) ?? legacyKey else { + throw NativeFeatureClientError.invalidPullRequestLink + } + let project = latestSnapshot?.projects.first { + $0.environmentID == route.environmentID + && $0.repositoryIdentity.map { key.matchesRepository($0.canonicalKey) } == true + } + guard let command = ThreadPullRequests.mutation( + threadID: route.wireID, key: key, url: url, linked: linked, + multiple: thread.supportsMultiplePullRequests == true, + legacyProjectID: project.flatMap { projectWireIDs[$0.id] }, + legacyRepository: key.host == "dev.azure.com" ? key.repository.components(separatedBy: "/_git/").last : nil + ) else { throw NativeFeatureClientError.invalidPullRequestLink } + _ = try await route.client.dispatch(command) + try? await refresh(client: route.client) + if activeThreadID == route.uiID { try? await refreshThread(id: route.uiID, client: route.client) } + } + func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws { let route = try threadRoute(for: id) _ = try await route.client.setRuntimeMode( @@ -3295,6 +3441,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, branch: thread.branch, worktreePath: thread.worktreePath, linkedPullRequest: thread.linkedPullRequest, + pullRequests: thread.pullRequests, branchPullRequest: thread.branchPullRequest, latestTurn: thread.latestTurn, createdAt: thread.createdAt, @@ -4952,6 +5099,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, detail.thread.supportsPinning = capabilities?.threadPinning detail.thread.supportsTitleRegeneration = capabilities?.threadTitleRegeneration detail.thread.supportsPullRequestLinking = capabilities?.threadPullRequestLinking + detail.thread.supportsMultiplePullRequests = capabilities?.threadPullRequests let sessionIsLive = shellThread.session?.status == "starting" || shellThread.session?.status == "running" detail.thread.state = Self.resolveThreadState( @@ -5424,6 +5572,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, branch: thread.branch, worktreePath: thread.worktreePath, linkedPullRequest: thread.linkedPullRequest, + pullRequests: thread.pullRequests, branchPullRequest: thread.branchPullRequest, createdAt: parseDate(thread.createdAt), updatedAt: parseDate(thread.updatedAt), @@ -5464,6 +5613,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, supportsActiveReorder: capabilities?.threadActiveReorder, supportsTitleRegeneration: capabilities?.threadTitleRegeneration, supportsPullRequestLinking: capabilities?.threadPullRequestLinking, + supportsMultiplePullRequests: capabilities?.threadPullRequests, isRegeneratingTitle: thread.titleRegeneration != nil, attentionAt: failureDate( latestTurn: thread.latestTurn, @@ -5513,6 +5663,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, branch: thread.branch, worktreePath: thread.worktreePath, linkedPullRequest: thread.linkedPullRequest, + pullRequests: thread.pullRequests, branchPullRequest: thread.branchPullRequest, createdAt: parseDate(thread.createdAt), updatedAt: parseDate(thread.updatedAt), @@ -5553,6 +5704,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, supportsActiveReorder: capabilities?.threadActiveReorder, supportsTitleRegeneration: capabilities?.threadTitleRegeneration, supportsPullRequestLinking: capabilities?.threadPullRequestLinking, + supportsMultiplePullRequests: capabilities?.threadPullRequests, isRegeneratingTitle: thread.titleRegeneration != nil, attentionAt: failureDate( latestTurn: thread.latestTurn, @@ -5848,6 +6000,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, branch: loaded.branch, worktreePath: loaded.worktreePath, linkedPullRequest: loaded.linkedPullRequest, + pullRequests: loaded.pullRequests, branchPullRequest: loaded.branchPullRequest, latestTurn: loaded.latestTurn, createdAt: loaded.createdAt, @@ -6333,6 +6486,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, thread.pinnedAt = shell.pinnedAt.flatMap(parseValidDate) thread.pinOrderKey = shell.pinOrderKey thread.linkedPullRequest = shell.linkedPullRequest + thread.pullRequests = shell.pullRequests thread.branchPullRequest = shell.branchPullRequest thread.settlementFacts = settlementFacts( override: shell.settledOverride, @@ -7392,6 +7546,8 @@ enum NativeThreadDetailReducer { occurredAt: occurredAt, thread: thread ) + case "thread.pull-request-linked", "thread.pull-request-unlinked", "thread.pull-request-synced": + result = reducePullRequest(type: type, payload: payload, thread: thread) case "thread.message-sent": result = reduceMessage( payload: payload, @@ -7472,6 +7628,45 @@ enum NativeThreadDetailReducer { ) } + private static func reducePullRequest( + type: String, payload: JSONValue, thread: OrchestrationThread + ) -> NativeThreadDetailReductionResult { + guard let updatedAt = payload["updatedAt"]?.stringValue else { return .refresh } + var updated = replacing(thread, updatedAt: updatedAt) + var links = thread.pullRequests ?? [] + if type == "thread.pull-request-linked" { + guard let link = try? payload["link"]?.decode(ThreadPullRequestLink.self) else { return .refresh } + if let index = links.firstIndex(where: { $0.id == link.id }) { links[index] = link } + else { links.append(link) } + } else { + guard let host = payload["host"]?.stringValue, + let repository = payload["repository"]?.stringValue, + let number = intValue(payload["number"]) else { return .refresh } + let key = ThreadPullRequestKey(host: host, repository: repository, number: number) + guard let index = links.firstIndex(where: { $0.id == key }) else { return .unchanged } + if type == "thread.pull-request-unlinked" { + links.remove(at: index) + } else { + guard let snapshot = try? payload["snapshot"]?.decode(ThreadPullRequestSnapshot.self), + let stackValue = payload["stack"] else { return .refresh } + let stack: ThreadPullRequestStack? + if stackValue == .null { stack = nil } + else { + guard let decoded = try? stackValue.decode(ThreadPullRequestStack.self) else { return .refresh } + stack = decoded + } + links[index].snapshot = snapshot + links[index].stack = stack + } + } + updated.pullRequests = links + if let legacy = updated.linkedPullRequest, + !links.contains(where: { $0.isVisible && $0.number == legacy.number && $0.url == legacy.url }) { + updated.linkedPullRequest = nil + } + return .updated(updated) + } + private static func reduceMetadata( payload: JSONValue, occurredAt: String, @@ -7788,6 +7983,7 @@ enum NativeThreadDetailReducer { branch: thread.branch, worktreePath: thread.worktreePath, linkedPullRequest: thread.linkedPullRequest, + pullRequests: thread.pullRequests, branchPullRequest: thread.branchPullRequest, latestTurn: latestTurn ?? thread.latestTurn, createdAt: thread.createdAt, @@ -8067,9 +8263,12 @@ private enum NativeFeatureClientError: LocalizedError { case tooManyAttachments case invalidAutomaticSettlementDays case remoteStatusUnavailable + case invalidPullRequestLink var errorDescription: String? { switch self { + case .invalidPullRequestLink: + "Use a supported pull request URL. Older servers need a project for its repository." case .notConnected: "Connect to a T3 environment first." case .environmentNotFound: "That T3 environment is no longer available." case .projectNotFound: "The selected project is no longer available." diff --git a/apps/swift-ios/Core/GitHubRouting.swift b/apps/swift-ios/Core/GitHubRouting.swift new file mode 100644 index 000000000000..a56765ccb42c --- /dev/null +++ b/apps/swift-ios/Core/GitHubRouting.swift @@ -0,0 +1,46 @@ +import Foundation + +public enum GitHubRoutingPermission: String, Codable, CaseIterable, Sendable { + case off + case read + case readWrite = "read-write" + + public var label: String { + switch self { + case .off: "Off" + case .read: "Read" + case .readWrite: "Read and write" + } + } +} + +/// Permission belongs to the saved endpoint, not an identity advertised by a server. +public struct GitHubRoutingGrant: Codable, Equatable, Sendable { + public let environmentID: String + public let connectionKey: String + public let permission: GitHubRoutingPermission + + public static func connectionKey(_ environment: Environment) -> String? { + let http = environment.httpBaseURL + let ws = environment.webSocketBaseURL + guard ["http", "https"].contains(http.scheme ?? ""), ["ws", "wss"].contains(ws.scheme ?? ""), + http.host != nil, ws.host != nil, http.user == nil, http.password == nil, + ws.user == nil, ws.password == nil else { return nil } + return [environment.kind.rawValue, environment.id, + http.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")), + ws.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))] + .joined(separator: "\u{0}") + } + + public static func permission(for environment: Environment, grants: [Self]) -> GitHubRoutingPermission { + guard let key = connectionKey(environment) else { return .off } + return grants.first { $0.environmentID == environment.id && $0.connectionKey == key }?.permission ?? .off + } + + public static func allowed(origin: Environment, destination: Environment, grants: [Self], write: Bool) -> Bool { + guard origin.isEnabled, destination.isEnabled, origin.id != destination.id else { return false } + let source = permission(for: origin, grants: grants) + let target = permission(for: destination, grants: grants) + return write ? source == .readWrite && target == .readWrite : source != .off && target != .off + } +} diff --git a/apps/swift-ios/Core/Models.swift b/apps/swift-ios/Core/Models.swift index a9b07fa8e85d..5a62deba4e34 100644 --- a/apps/swift-ios/Core/Models.swift +++ b/apps/swift-ios/Core/Models.swift @@ -116,6 +116,8 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { public let threadActiveReorder: Bool? public let threadTitleRegeneration: Bool? public let threadPullRequestLinking: Bool? + public var threadPullRequests: Bool? = nil + public var pullRequestStackActions: Bool? = nil public let serverSelfUpdate: String? public let serverSelfUpdateProgress: Bool? public var environmentIcon: Bool? = nil @@ -138,6 +140,8 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { case threadActiveReorder case threadTitleRegeneration case threadPullRequestLinking + case threadPullRequests + case pullRequestStackActions case serverSelfUpdate case serverSelfUpdateProgress case environmentIcon @@ -185,6 +189,8 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { Bool.self, forKey: .threadPullRequestLinking ) + threadPullRequests = try container.decodeIfPresent(Bool.self, forKey: .threadPullRequests) + pullRequestStackActions = try container.decodeIfPresent(Bool.self, forKey: .pullRequestStackActions) serverSelfUpdate = try container.decodeIfPresent(String.self, forKey: .serverSelfUpdate) serverSelfUpdateProgress = try container.decodeIfPresent( Bool.self, @@ -483,6 +489,7 @@ public struct OrchestrationThreadShell: Codable, Identifiable, Equatable, Sendab public let branch: String? public let worktreePath: String? public var linkedPullRequest: ThreadLinkedPullRequest? = nil + public var pullRequests: [ThreadPullRequestLink]? = nil public var branchPullRequest: ThreadLinkedPullRequest? = nil public let latestTurn: OrchestrationLatestTurn? public let createdAt: String @@ -562,6 +569,7 @@ public struct OrchestrationThread: Codable, Identifiable, Equatable, Sendable { public let branch: String? public let worktreePath: String? public var linkedPullRequest: ThreadLinkedPullRequest? = nil + public var pullRequests: [ThreadPullRequestLink]? = nil public var branchPullRequest: ThreadLinkedPullRequest? = nil public let latestTurn: OrchestrationLatestTurn? public let createdAt: String diff --git a/apps/swift-ios/Core/PullRequestWireModels.swift b/apps/swift-ios/Core/PullRequestWireModels.swift index a7303e64499d..cf9e88bfdbc5 100644 --- a/apps/swift-ios/Core/PullRequestWireModels.swift +++ b/apps/swift-ios/Core/PullRequestWireModels.swift @@ -65,7 +65,7 @@ public enum PullRequestBaseComparison: String, Codable, Sendable { case unknown } -public struct PullRequestActor: Codable, Equatable, Sendable { +public struct PullRequestActor: Codable, Hashable, Sendable { public let login: String public let name: String? public let avatarUrl: String? @@ -351,11 +351,18 @@ public struct PullRequestListResult: Codable, Equatable, Sendable { public struct PullRequestRef: Codable, Equatable, Hashable, Sendable { public let projectId: String + public let host: String? + public let expectedAccountId: String? + public let allowStale: Bool? public let repository: String public let number: Int - public init(projectId: String, repository: String, number: Int) { + public init(projectId: String, repository: String, number: Int, host: String? = nil, + expectedAccountId: String? = nil, allowStale: Bool? = nil) { self.projectId = projectId + self.host = host + self.expectedAccountId = expectedAccountId + self.allowStale = allowStale self.repository = repository self.number = number } @@ -368,13 +375,22 @@ public struct PullRequestRef: Codable, Equatable, Hashable, Sendable { } } +public struct PullRequestRoutingIdentity: Codable, Equatable, Sendable { + public let accountId: String + public let host: String + public let provider: SourceControlProviderKind + public let viewer: String + public let projectTitle: String? + public let workspaceRoot: String? +} + public struct PullRequestDetail: Codable, Equatable, Sendable { public let provider: SourceControlProviderKind public let capabilities: PullRequestCapabilities public let viewerPermissions: PullRequestViewerPermissions - public let projectId: String - public let projectTitle: String - public let workspaceRoot: String + public var projectId: String + public var projectTitle: String + public var workspaceRoot: String public let repository: String public let number: Int public let title: String @@ -420,6 +436,9 @@ public struct PullRequestDiffInput: Codable, Equatable, Sendable { public let number: Int public let cursor: String? public let commit: String? + public var host: String? = nil + public var expectedAccountId: String? = nil + public var allowStale: Bool? = nil } public struct PullRequestOmittedFileStat: Codable, Equatable, Sendable { diff --git a/apps/swift-ios/Core/T3Client.swift b/apps/swift-ios/Core/T3Client.swift index 2461bb94ac81..e1c8f086ff32 100644 --- a/apps/swift-ios/Core/T3Client.swift +++ b/apps/swift-ios/Core/T3Client.swift @@ -269,6 +269,14 @@ public actor T3Client { ) } + public func pullRequestRouting(_ reference: PullRequestRef) async throws -> PullRequestRoutingIdentity { + try await rpc.request(RPCMethod.pullRequestsRouting.rawValue, payload: try JSONValue.encode(reference), as: PullRequestRoutingIdentity.self) + } + + public func pullRequestRoutingIdentity(host: String) async throws -> PullRequestRoutingIdentity { + try await rpc.request(RPCMethod.pullRequestsRoutingIdentity.rawValue, payload: .object(["host": .string(host)]), as: PullRequestRoutingIdentity.self) + } + public func pullRequestActivity(_ reference: PullRequestRef) async throws -> PullRequestActivity { @@ -1955,6 +1963,8 @@ public enum RPCMethod: String, Sendable { case serverGetUsageSummary = "server.getUsageSummary" case pullRequestsList = "pullRequests.list" case pullRequestsDetail = "pullRequests.detail" + case pullRequestsRouting = "pullRequests.routing" + case pullRequestsRoutingIdentity = "pullRequests.routingIdentity" case pullRequestsActivity = "pullRequests.activity" case pullRequestsRunAction = "pullRequests.runAction" case pullRequestsUpdate = "pullRequests.update" diff --git a/apps/swift-ios/Core/ThreadPullRequests.swift b/apps/swift-ios/Core/ThreadPullRequests.swift new file mode 100644 index 000000000000..a6d6a393c70e --- /dev/null +++ b/apps/swift-ios/Core/ThreadPullRequests.swift @@ -0,0 +1,213 @@ +import Foundation + +public struct ThreadPullRequestKey: Codable, Hashable, Sendable { + public let host: String + public let repository: String + public let number: Int + + public init(host: String, repository: String, number: Int) { + let host = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let repository = repository.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let suffix = ".visualstudio.com" + let parts = repository.split(separator: "/") + if ["ssh.dev.azure.com", "vs-ssh.visualstudio.com"].contains(host), parts.count == 4, parts[0] == "v3" { + self.host = "dev.azure.com" + self.repository = "\(parts[1])/\(parts[2])/_git/\(parts[3])" + } else if host.hasSuffix(suffix), repository.contains("/_git/") { + let organization = String(host.dropLast(suffix.count)) + self.host = "dev.azure.com" + self.repository = "\(organization)/\(repository.hasPrefix("defaultcollection/") ? String(repository.dropFirst("defaultcollection/".count)) : repository)" + } else { + self.host = host + self.repository = repository + } + self.number = number + } + + public func matchesRepository(_ canonicalKey: String) -> Bool { + guard let slash = canonicalKey.firstIndex(of: "/") else { return false } + return self == Self(host: String(canonicalKey[.. String? { + guard let url = URL(string: text), let host = url.host else { return nil } + return url.port.map { "\(host):\($0)" } ?? host + } + + public static func parseURL(_ text: String) -> ThreadPullRequestKey? { + guard let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)), + ["https", "http"].contains(url.scheme?.lowercased() ?? ""), + let host = url.host?.lowercased(), url.user == nil, url.password == nil else { return nil } + var routes: [(pattern: String, host: String)] = [] + if host == "github.com" || host.hasSuffix(".github.com") || host.split(separator: ".").contains("github") { + routes.append((#"^/([^/]+/[^/]+)/pull/([0-9]+)(?:/|$)"#, host)) + } + routes.append((#"^/([^/]+(?:/[^/]+)+)/-/merge_requests/([0-9]+)(?:/|$)"#, host)) + routes.append((#"^/([^/]+(?:/[^/]+)+)/pulls/([0-9]+)(?:/|$)"#, authority(of: url.absoluteString) ?? host)) + if host == "bitbucket.org" || host.hasSuffix(".bitbucket.org") || host.split(separator: ".").contains("bitbucket") { + routes.append((#"^/([^/]+/[^/]+)/pull-requests/([0-9]+)(?:/|$)"#, host)) + } + if host == "dev.azure.com" || host.hasSuffix(".dev.azure.com") || host.hasSuffix(".visualstudio.com") { + routes.append((#"^/((?:[^/]+/)*_git/[^/]+)/pullrequest/([0-9]+)(?:/|$)"#, host)) + } + let path = url.path + for route in routes { + guard let regex = try? NSRegularExpression(pattern: route.pattern), + let match = regex.firstMatch(in: path, range: NSRange(path.startIndex..., in: path)), + let repositoryRange = Range(match.range(at: 1), in: path), + let numberRange = Range(match.range(at: 2), in: path), + let number = Int(path[numberRange]), number > 0 else { continue } + return ThreadPullRequestKey(host: route.host, repository: String(path[repositoryRange]), number: number) + } + return nil + } + + public static func mutation(threadID: String, key: ThreadPullRequestKey, url: String, + linked: Bool, multiple: Bool, legacyProjectID: String?, legacyRepository: String? = nil, + commandID: String = UUID().uuidString) -> JSONValue? { + var payload: [String: JSONValue] = ["commandId": .string(commandID), "threadId": .string(threadID)] + if multiple { + payload["type"] = .string(linked ? "thread.pull-request.link" : "thread.pull-request.unlink") + payload["host"] = .string(key.host) + payload["repository"] = .string(key.repository) + payload["number"] = .number(Double(key.number)) + if linked { + payload["url"] = .string(url) + payload["source"] = .string("manual") + } + } else { + guard !linked || legacyProjectID != nil else { return nil } + payload["type"] = .string("thread.meta.update") + payload["linkedPullRequest"] = linked ? .object([ + "projectId": .string(legacyProjectID ?? ""), "repository": .string(legacyRepository ?? key.repository), + "number": .number(Double(key.number)), "url": .string(url), + ]) : .null + } + return .object(payload) + } + + public static func visible(_ links: [ThreadPullRequestLink]) -> [ThreadPullRequestLink] { + links.filter(\.isVisible) + } + + public static func chains(_ links: [ThreadPullRequestLink]) -> [[ThreadPullRequestLink]] { + let links = visible(links) + var placed: Set = [] + var result: [[ThreadPullRequestLink]] = [] + for link in links where link.stack != nil && !placed.contains(link.id) { + guard let stack = link.stack else { continue } + let members = links.filter { + $0.id.host == link.id.host && $0.id.repository == link.id.repository && $0.stack?.id == stack.id + } + let ordered = members.sorted { left, right in + (stack.layers.firstIndex { $0.number == left.number } ?? 0) + < (stack.layers.firstIndex { $0.number == right.number } ?? 0) + } + placed.formUnion(ordered.map(\.id)) + result.append(ordered) + } + let remaining = links.filter { !placed.contains($0.id) } + func parent(of link: ThreadPullRequestLink) -> ThreadPullRequestLink? { + guard let base = link.snapshot?.baseBranch else { return nil } + let matches = remaining.filter { + $0.id.host == link.id.host && $0.id.repository == link.id.repository + && $0.snapshot?.headBranch == base + } + return matches.count == 1 && matches[0].id != link.id ? matches[0] : nil + } + let parents = Set(remaining.compactMap { parent(of: $0)?.id }) + for top in remaining where !parents.contains(top.id) { + var chain: [ThreadPullRequestLink] = [] + var cursor: ThreadPullRequestLink? = top + while let link = cursor, placed.insert(link.id).inserted { + chain.insert(link, at: 0) + cursor = parent(of: link) + } + if !chain.isEmpty { result.append(chain) } + } + for link in remaining where !placed.contains(link.id) { result.append([link]) } + return result + } + + public static func current(_ links: [ThreadPullRequestLink]) -> ThreadPullRequestLink? { + let visible = visible(links) + let open = visible.filter(\.isOpen) + if open.count == 1 { return open.first } + let chains = chains(visible) + if !open.isEmpty { + return chains.map { $0.reversed().filter(\.isOpen) }.filter { !$0.isEmpty } + .sorted { ($0.map(\.linkedAt).max() ?? "") > ($1.map(\.linkedAt).max() ?? "") } + .first?.first + } + if chains.count == 1 { return chains.first?.last } + return visible.max { ($0.snapshot?.updatedAt ?? $0.linkedAt) < ($1.snapshot?.updatedAt ?? $1.linkedAt) } + } + + public static func searchTerms(_ links: [ThreadPullRequestLink]?, legacy: ThreadLinkedPullRequest?) -> [String] { + if let links, !links.isEmpty { + return visible(links).flatMap { ["#\($0.number)", "\($0.repository)#\($0.number)", $0.url, $0.snapshot?.title ?? ""] } + } + return legacy.map { ["#\($0.number)", "\($0.repository)#\($0.number)", $0.url] } ?? [] + } +} diff --git a/apps/swift-ios/Core/WorkspaceModels.swift b/apps/swift-ios/Core/WorkspaceModels.swift index 7a047d795262..e65600b93463 100644 --- a/apps/swift-ios/Core/WorkspaceModels.swift +++ b/apps/swift-ios/Core/WorkspaceModels.swift @@ -62,10 +62,16 @@ public struct FilesystemBrowseResult: Codable, Equatable, Sendable { public enum SourceControlProviderKind: String, Codable, CaseIterable, Sendable { case github + case forgejo case gitlab case azureDevOps = "azure-devops" case bitbucket case unknown + + public init(from decoder: any Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = Self(rawValue: raw) ?? (raw == "gitea" ? .forgejo : .unknown) + } } public struct SourceControlProviderInfo: Codable, Equatable, Sendable { diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 041c71190b32..79f70f53da1b 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -31,6 +31,9 @@ public struct ThreadDetailView: View { @State private var draftSaveError: String? @State private var toolSurface: FeatureThreadToolSurface? @State private var branchPullRequest: FeaturePullRequest? + @State private var showsLinkPullRequest = false + @State private var pullRequestURL = "" + @State private var pullRequestError: String? @State private var linkedMediaPreview: FeatureLinkedMediaPreview? @State private var linkedMediaPreviewError: String? // Plain state, not `FocusState`: the composer's UIKit text view owns @@ -69,6 +72,18 @@ public struct ThreadDetailView: View { } } .background(T3Colors.background) + .alert("Link pull request", isPresented: $showsLinkPullRequest) { + TextField("Pull request URL", text: $pullRequestURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Button("Link") { changePullRequest(url: pullRequestURL, linked: true) } + Button("Cancel", role: .cancel) {} + } + .alert("Could not update pull request", isPresented: Binding( + get: { pullRequestError != nil }, set: { if !$0 { pullRequestError = nil } } + )) { + Button("OK") { pullRequestError = nil } + } message: { Text(pullRequestError ?? "") } .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(false) .t3NavigationChrome() @@ -353,6 +368,28 @@ public struct ThreadDetailView: View { Label("Open pull request #\(pullRequest.number)", systemImage: "arrow.triangle.pull") } } + let links = ThreadPullRequests.visible(currentThread.pullRequests ?? []) + if links.count > 1 { + Menu("Linked pull requests") { + ForEach(links) { link in + if let url = URL(string: link.url) { + Button("\(link.repository)#\(link.number)") { parentOpenURL(url) } + } + } + } + } + if currentThread.supportsMultiplePullRequests == true || currentThread.supportsPullRequestLinking == true { + Button("Link pull request…") { showsLinkPullRequest = true } + if !links.isEmpty { + Menu("Unlink pull request") { + ForEach(links) { link in + Button("\(link.repository)#\(link.number)") { changePullRequest(url: link.url, linked: false) } + } + } + } else if let linked = currentThread.linkedPullRequest { + Button("Unlink pull request #\(linked.number)") { changePullRequest(url: linked.url, linked: false) } + } + } if currentThread.supportsTitleRegeneration == true { Button { Task { await model.regenerateThreadTitle(thread.id) } @@ -480,6 +517,13 @@ public struct ThreadDetailView: View { ) } + private func changePullRequest(url: String, linked: Bool) { + Task { + do { try await model.client.setThreadPullRequest(id: thread.id, url: url.trimmingCharacters(in: .whitespacesAndNewlines), linked: linked) } + catch { pullRequestError = error.localizedDescription } + } + } + private var pullRequestObservationID: String? { currentThread.pullRequestObservationIdentity } @@ -492,6 +536,14 @@ public struct ThreadDetailView: View { return } + if let links = currentThread.pullRequests, !links.isEmpty { + model.updatePullRequest( + HomeThreadPullRequestPresentation.resolve(links: links), + threadID: currentThread.id, observationIdentity: observationIdentity + ) + return + } + if let linked = currentThread.effectivePullRequest, let environmentID = currentThread.environmentID { let target = FeaturePullRequestTarget( @@ -500,7 +552,8 @@ public struct ThreadDetailView: View { reference: PullRequestRef( projectId: linked.projectId, repository: linked.repository, - number: linked.number + number: linked.number, + host: ThreadPullRequests.authority(of: linked.url) ) ) while !Task.isCancelled { diff --git a/apps/swift-ios/Features/PullRequests/PullRequestsView.swift b/apps/swift-ios/Features/PullRequests/PullRequestsView.swift index 61d3b69314fe..afc2a5dfcde3 100644 --- a/apps/swift-ios/Features/PullRequests/PullRequestsView.swift +++ b/apps/swift-ios/Features/PullRequests/PullRequestsView.swift @@ -15,7 +15,8 @@ struct FeaturePullRequestRow: Identifiable, Equatable { reference: PullRequestRef( projectId: entry.projectId, repository: entry.repository, - number: entry.number + number: entry.number, + host: entry.host ) ) } diff --git a/apps/swift-ios/Features/Settings/EnvironmentPreferencesView.swift b/apps/swift-ios/Features/Settings/EnvironmentPreferencesView.swift index 4d9a723eb544..835bc7a020e3 100644 --- a/apps/swift-ios/Features/Settings/EnvironmentPreferencesView.swift +++ b/apps/swift-ios/Features/Settings/EnvironmentPreferencesView.swift @@ -7,6 +7,7 @@ struct EnvironmentPreferencesView: View { @State private var busy = false @State private var errorMessage: String? @State private var mismatches: [String] = [] + @State private var routingPermission = GitHubRoutingPermission.off private var environment: FeatureEnvironment? { model.snapshot.environments.first { $0.id == environmentID } @@ -18,6 +19,15 @@ struct EnvironmentPreferencesView: View { var body: some View { Form { + Section { + Picker("GitHub sharing", selection: Binding( + get: { routingPermission }, set: { saveRoutingPermission($0) } + )) { + ForEach(GitHubRoutingPermission.allCases, id: \.self) { Text($0.label).tag($0) } + } + } footer: { + Text("Enable both environments to share PR data through the same GitHub account. Write access permits PR changes. Credentials stay on each environment.") + } if let settings { if let streamingMode = settings.responseStreamingMode { Section { @@ -102,7 +112,10 @@ struct EnvironmentPreferencesView: View { } private func load() async { + busy = true + defer { busy = false } do { + routingPermission = try await model.client.gitHubRoutingPermission(environmentID: environmentID) settings = try await model.client.serverPreferences(environmentID: environmentID) mismatches = model.client.sharedPreferenceMismatches(environmentID: environmentID) errorMessage = nil @@ -119,4 +132,16 @@ struct EnvironmentPreferencesView: View { } catch { errorMessage = "Could not save preferences. Check this connection and try again." } } } + + private func saveRoutingPermission(_ permission: GitHubRoutingPermission) { + guard !busy else { return } + busy = true + Task { + defer { busy = false } + do { + try await model.client.setGitHubRoutingPermission(environmentID: environmentID, permission: permission) + routingPermission = permission + } catch { errorMessage = "Could not save GitHub sharing." } + } + } } diff --git a/apps/swift-ios/Features/Shared/FeatureClient.swift b/apps/swift-ios/Features/Shared/FeatureClient.swift index 1e451d8e3e0b..817fbfcb048f 100644 --- a/apps/swift-ios/Features/Shared/FeatureClient.swift +++ b/apps/swift-ios/Features/Shared/FeatureClient.swift @@ -62,6 +62,7 @@ public protocol FeatureClient: AnyObject { section: FeatureThreadOrderSection, orderedIDs: [String] ) async throws -> [FeatureThreadOrderAssignment] + func setThreadPullRequest(id: String, url: String, linked: Bool) async throws func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws func setInteractionMode(id: String, mode: FeatureInteractionMode) async throws func deleteThread(id: String) async throws @@ -94,6 +95,8 @@ public protocol FeatureClient: AnyObject { func updateProjectPreferences(projectID: String, change: ServerProjectSettingChange) async throws func projectPreferences(projectID: String) async throws -> FeatureProjectPreferences func sharedPreferenceMismatches(environmentID: String) -> [String] + func gitHubRoutingPermission(environmentID: String) async throws -> GitHubRoutingPermission + func setGitHubRoutingPermission(environmentID: String, permission: GitHubRoutingPermission) async throws func refreshProviders(environmentID: String) async throws -> [FeatureProvider] func refreshWorkspaceProviders(environmentID: String, cwd: String, instanceID: String) async throws -> [FeatureProvider] func providerSetup(environmentID: String, instanceID: String, action: ProviderSetupAction) async throws -> ProviderSetupEvent @@ -225,6 +228,13 @@ public protocol FeatureClient: AnyObject { } public extension FeatureClient { + func gitHubRoutingPermission(environmentID: String) async throws -> GitHubRoutingPermission { .off } + func setGitHubRoutingPermission(environmentID: String, permission: GitHubRoutingPermission) async throws { + throw FeatureCapabilityUnavailable("GitHub sharing") + } + func setThreadPullRequest(id: String, url: String, linked: Bool) async throws { + throw FeatureCapabilityUnavailable("Pull request linking") + } func serverPreferences(environmentID: String) async throws -> ServerSettingsSnapshot { throw FeatureCapabilityUnavailable("Server preferences") } diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift index a6fe9749d18e..db6322d2ec2c 100644 --- a/apps/swift-ios/Features/Shared/FeatureModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -275,6 +275,7 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl public var branch: String? public var worktreePath: String? public var linkedPullRequest: ThreadLinkedPullRequest? + public var pullRequests: [ThreadPullRequestLink]? public var branchPullRequest: ThreadLinkedPullRequest? public var createdAt: Date public var updatedAt: Date @@ -304,6 +305,7 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl public var supportsActiveReorder: Bool? public var supportsTitleRegeneration: Bool? public var supportsPullRequestLinking: Bool? + public var supportsMultiplePullRequests: Bool? /// True while the server is generating a new title. Derived from the wire /// snapshot only, the same way the web and React Native clients do it. public var isRegeneratingTitle: Bool @@ -325,6 +327,7 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl branch: String? = nil, worktreePath: String? = nil, linkedPullRequest: ThreadLinkedPullRequest? = nil, + pullRequests: [ThreadPullRequestLink]? = nil, branchPullRequest: ThreadLinkedPullRequest? = nil, createdAt: Date = .now, updatedAt: Date = .now, @@ -352,6 +355,7 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl supportsActiveReorder: Bool? = nil, supportsTitleRegeneration: Bool? = nil, supportsPullRequestLinking: Bool? = nil, + supportsMultiplePullRequests: Bool? = nil, isRegeneratingTitle: Bool = false, attentionAt: Date? = nil, workingStartedAt: Date? = nil, @@ -370,6 +374,7 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl self.branch = branch self.worktreePath = worktreePath self.linkedPullRequest = linkedPullRequest + self.pullRequests = pullRequests self.branchPullRequest = branchPullRequest self.createdAt = createdAt self.updatedAt = updatedAt @@ -397,6 +402,7 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl self.supportsActiveReorder = supportsActiveReorder self.supportsTitleRegeneration = supportsTitleRegeneration self.supportsPullRequestLinking = supportsPullRequestLinking + self.supportsMultiplePullRequests = supportsMultiplePullRequests self.isRegeneratingTitle = isRegeneratingTitle self.attentionAt = attentionAt self.workingStartedAt = workingStartedAt @@ -407,7 +413,13 @@ public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codabl } public var effectivePullRequest: ThreadLinkedPullRequest? { - linkedPullRequest ?? branchPullRequest + if let pullRequests, !pullRequests.isEmpty { + return ThreadPullRequests.current(pullRequests).map { + ThreadLinkedPullRequest(projectId: projectID, repository: $0.repository, + number: $0.number, url: $0.url) + } + } + return linkedPullRequest ?? branchPullRequest } /// Missing capabilities mean unsupported. Existing states remain reversible diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift index 6b007a147792..df39c1971504 100644 --- a/apps/swift-ios/Features/Workspace/DailyUXModels.swift +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -829,12 +829,13 @@ struct DailyUXSidebarIndex { } return candidates.filter { thread in let project = projectByID[thread.projectID] - return [ + return ([ thread.title, thread.preview ?? "", project?.name ?? "", project?.path ?? "", - ].contains { $0.localizedCaseInsensitiveContains(normalizedQuery) } + ] + ThreadPullRequests.searchTerms(thread.pullRequests, legacy: thread.linkedPullRequest)) + .contains { $0.localizedCaseInsensitiveContains(normalizedQuery) } } } } @@ -867,6 +868,7 @@ struct HomeOrderKey: Equatable { let keepsActive: Bool let isSettled: Bool let title: String + let pullRequestSearchTerms: [String] /// Only the archived shelf orders by `updatedAt`; live shelves ignore it. let archivedSortDate: Date? /// Only a settled thread's position depends on its settled sort date. @@ -895,6 +897,7 @@ struct HomeOrderKey: Equatable { keepsActive = thread.keepsActive isSettled = thread.isSettled title = thread.title + pullRequestSearchTerms = ThreadPullRequests.searchTerms(thread.pullRequests, legacy: thread.linkedPullRequest) archivedSortDate = thread.isArchived ? thread.updatedAt : nil settledSortDate = thread.isEffectivelySettled() ? thread.settledSortDate : nil } diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index 1535dbfd2764..18c552f74039 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -961,16 +961,32 @@ struct HomeThreadPullRequestPresentation: Equatable { case open case merged case closed + case draft } let number: Int let state: State let updatedAt: Date? + var count = 1 + var isStack = false - var label: String { "#\(number)" } + var label: String { + count > 1 ? (isStack ? "\(count) PRs" : "#\(number) +\(count - 1)") : "#\(number)" + } var accessibilityLabel: String { - "Pull request #\(number), \(state.rawValue)" + "\(count > 1 ? "\(count) pull requests" : "Pull request #\(number)"), \(state.rawValue)" + } + + static func resolve(links: [ThreadPullRequestLink]) -> Self? { + let visible = ThreadPullRequests.visible(links) + guard let current = ThreadPullRequests.current(visible) else { return nil } + let state: State = visible.allSatisfy { $0.snapshot?.state == .open && $0.snapshot?.isDraft == true } + ? .draft : visible.contains(where: \.isOpen) ? .open + : visible.allSatisfy { $0.snapshot?.state == .merged } ? .merged : .closed + return Self(number: current.number, state: state, + updatedAt: parseDate(current.snapshot?.updatedAt), count: visible.count, + isStack: visible.count > 1 && ThreadPullRequests.chains(visible).count == 1) } static func resolve( @@ -981,6 +997,7 @@ struct HomeThreadPullRequestPresentation: Equatable { !branch.isEmpty, status.branch == branch, let pullRequest = status.pullRequest, + PullRequestState(rawValue: pullRequest.state.lowercased()) != nil, let state = State(rawValue: pullRequest.state.lowercased()) else { return nil } @@ -997,6 +1014,8 @@ struct HomeThreadPullRequestPresentation: Equatable { ) -> Self? { guard detail.number == linkedPullRequest.number, detail.repository.caseInsensitiveCompare(linkedPullRequest.repository) == .orderedSame, + URL(string: detail.url)?.host?.lowercased() == URL(string: linkedPullRequest.url)?.host?.lowercased(), + URL(string: detail.url)?.port == URL(string: linkedPullRequest.url)?.port, let state = State(rawValue: detail.state.rawValue) else { return nil } @@ -1018,6 +1037,9 @@ struct HomeThreadPullRequestPresentation: Equatable { extension FeatureThread { var pullRequestObservationIdentity: String? { let environment = environmentID ?? "" + if let pullRequests, !pullRequests.isEmpty { + return [id, environment, projectID, String(pullRequests.hashValue)].joined(separator: "\u{0}") + } if let linkedPullRequest = effectivePullRequest { return [ id, @@ -1025,6 +1047,7 @@ extension FeatureThread { projectID, linkedPullRequest.projectId, linkedPullRequest.repository.lowercased(), + linkedPullRequest.url, String(linkedPullRequest.number), ].joined(separator: "\u{0}") } @@ -1291,6 +1314,12 @@ struct FeatureThreadRow: View { @MainActor private func observePullRequest() async { + if let links = thread.pullRequests, !links.isEmpty { + let next = HomeThreadPullRequestPresentation.resolve(links: links) + pullRequest = next + onPullRequestChange(next) + return + } guard pullRequestObservationID != nil, let projectFaviconClient else { pullRequest = nil @@ -1306,7 +1335,8 @@ struct FeatureThreadRow: View { reference: PullRequestRef( projectId: linked.projectId, repository: linked.repository, - number: linked.number + number: linked.number, + host: ThreadPullRequests.authority(of: linked.url) ) ) while !Task.isCancelled { @@ -1342,7 +1372,7 @@ struct FeatureThreadRow: View { private func pullRequestIndicator(_ pullRequest: HomeThreadPullRequestPresentation) -> some View { HStack(spacing: 3) { - Image(systemName: "arrow.triangle.pull") + Image(systemName: pullRequest.isStack ? "square.stack.3d.up" : "arrow.triangle.pull") .font(.system(size: 10, weight: .semibold)) Text(pullRequest.label) .font(T3Typography.homeMetadata.monospacedDigit().weight(.medium)) @@ -1358,6 +1388,7 @@ struct FeatureThreadRow: View { case .open: T3Colors.success case .merged: T3Colors.syntaxKeyword case .closed: T3Colors.danger + case .draft: T3Colors.textSecondary } } diff --git a/apps/swift-ios/Tests/CoreTests/GitHubRoutingTests.swift b/apps/swift-ios/Tests/CoreTests/GitHubRoutingTests.swift new file mode 100644 index 000000000000..ee3deab11fcb --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/GitHubRoutingTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import T3Code + +final class GitHubRoutingTests: XCTestCase { + func testBothEndpointsMustOptInAndWritesNeedBothWriteGrants() throws { + let origin = environment("origin") + let destination = environment("destination") + let sourceGrant = try grant(origin, .readWrite) + let readGrant = try grant(destination, .read) + XCTAssertFalse(GitHubRoutingGrant.allowed(origin: origin, destination: destination, grants: [sourceGrant], write: false)) + XCTAssertTrue(GitHubRoutingGrant.allowed(origin: origin, destination: destination, grants: [sourceGrant, readGrant], write: false)) + XCTAssertFalse(GitHubRoutingGrant.allowed(origin: origin, destination: destination, grants: [sourceGrant, readGrant], write: true)) + XCTAssertTrue(GitHubRoutingGrant.allowed(origin: origin, destination: destination, + grants: [sourceGrant, try grant(destination, .readWrite)], write: true)) + } + + func testSavedGrantDoesNotMoveToChangedEndpointOrDisabledEnvironment() throws { + let original = environment("server") + let saved = try grant(original, .readWrite) + var changed = original + changed.httpBaseURL = URL(string: "https://other.example")! + XCTAssertEqual(GitHubRoutingGrant.permission(for: changed, grants: [saved]), .off) + changed = original + changed.webSocketBaseURL = URL(string: "wss://other.example/ws")! + XCTAssertEqual(GitHubRoutingGrant.permission(for: changed, grants: [saved]), .off) + changed = original + changed.isEnabled = false + let source = environment("source") + XCTAssertFalse(GitHubRoutingGrant.allowed(origin: source, destination: changed, + grants: [try grant(source, .readWrite), saved], write: false)) + } + + func testRoutedReferenceCarriesHostAndAccountGuard() throws { + let ref = PullRequestRef(projectId: "origin-project", repository: "org/repo", number: 2, + host: "github.example.com", expectedAccountId: "account", allowStale: false) + let json = try JSONValue.encode(ref) + XCTAssertEqual(json["host"], .string("github.example.com")) + XCTAssertEqual(json["expectedAccountId"], .string("account")) + XCTAssertEqual(json["allowStale"], .bool(false)) + } + + private func environment(_ id: String) -> Environment { + Environment(id: id, label: id, httpBaseURL: URL(string: "https://\(id).example")!, + webSocketBaseURL: URL(string: "wss://\(id).example/ws")!) + } + + private func grant(_ environment: Environment, _ permission: GitHubRoutingPermission) throws -> GitHubRoutingGrant { + GitHubRoutingGrant(environmentID: environment.id, + connectionKey: try XCTUnwrap(GitHubRoutingGrant.connectionKey(environment)), permission: permission) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/ThreadPullRequestsTests.swift b/apps/swift-ios/Tests/CoreTests/ThreadPullRequestsTests.swift new file mode 100644 index 000000000000..a49e39f398af --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/ThreadPullRequestsTests.swift @@ -0,0 +1,96 @@ +import XCTest +@testable import T3Code + +final class ThreadPullRequestsTests: XCTestCase { + func testStackOrderSelectsHighestOpenLayerAndHidesDismissedMembers() throws { + var first = link(1, head: "one", base: "main") + let second = link(2, head: "two", base: "one") + var hidden = link(3, head: "three", base: "two") + hidden.source = "stack-dismissed" + XCTAssertEqual(ThreadPullRequests.chains([second, hidden, first]).map { $0.map(\.number) }, [[1, 2]]) + XCTAssertEqual(ThreadPullRequests.current([first, second, hidden])?.number, 2) + XCTAssertFalse(ThreadPullRequests.searchTerms([hidden], legacy: nil).contains("#3")) + first.snapshot = snapshot(state: .merged, head: "one", base: "main") + XCTAssertEqual(ThreadPullRequests.current([first, second])?.number, 2) + } + + func testNativeStackWinsOverBranchNamesAndCompletedStackKeepsTop() throws { + let stack = try JSONDecoder.t3.decode(ThreadPullRequestStack.self, from: Data(#""" + {"kind":"native","id":"stack-1","number":1,"url":"/org/repo/stack/1", + "base":"main","layers":[{"number":2,"headBranch":"second","state":"merged"}, + {"number":1,"headBranch":"first","state":"merged"}]} + """#.utf8)) + var first = link(1, head: "first", base: "main") + var second = link(2, head: "second", base: "main") + first.stack = stack + second.stack = stack + first.snapshot = snapshot(state: .merged, head: "first", base: "main") + second.snapshot = snapshot(state: .merged, head: "second", base: "main") + XCTAssertEqual(ThreadPullRequests.chains([first, second]).first?.map(\.number), [2, 1]) + XCTAssertEqual(ThreadPullRequests.current([first, second])?.number, 1) + } + + func testHostsAndPortsDoNotShareIdentityOrBranchChains() { + let github = link(1, head: "one", base: "main") + let enterprise = ThreadPullRequestLink(host: "github.example.com", repository: "org/repo", + number: 1, url: "https://github.example.com/org/repo/pull/1", source: "manual", + linkedAt: "2026-09-01T00:00:00Z", snapshot: snapshot(head: "two", base: "one"), stack: nil) + XCTAssertNotEqual(github.id, enterprise.id) + XCTAssertEqual(ThreadPullRequests.chains([github, enterprise]).count, 2) + XCTAssertNotEqual(ThreadPullRequests.parseURL("http://git.local:3000/org/repo/pulls/1"), + ThreadPullRequests.parseURL("http://git.local:4000/org/repo/pulls/1")) + XCTAssertEqual(ThreadPullRequests.parseURL("https://GITHUB.COM/Org/Repo/pull/2")?.repository, "org/repo") + XCTAssertNil(ThreadPullRequests.parseURL("javascript:alert(1)")) + XCTAssertNil(ThreadPullRequests.parseURL("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/org/repo/issues/2")) + XCTAssertNil(ThreadPullRequests.parseURL("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/org/repo/pull/0")) + XCTAssertEqual(ThreadPullRequests.authority(of: "http://code.example:3000/org/repo/pulls/2"), "code.example:3000") + let nested = ThreadPullRequests.parseURL("https://gitlab.example/group/pull/123/repository/-/merge_requests/42") + XCTAssertEqual(nested?.number, 42) + XCTAssertEqual(nested?.repository, "group/pull/123/repository") + XCTAssertEqual(ThreadPullRequests.parseURL("https://gitlab.example/group/subgroup/pulls/123/repository/-/merge_requests/42")?.number, 42) + let azure = ThreadPullRequests.parseURL("https://org.visualstudio.com/DefaultCollection/project/_git/web/pullrequest/42") + XCTAssertEqual(azure?.host, "dev.azure.com") + XCTAssertEqual(azure?.repository, "org/project/_git/web") + XCTAssertTrue(azure?.matchesRepository("org.visualstudio.com/DefaultCollection/project/_git/web") == true) + XCTAssertTrue(azure?.matchesRepository("ssh.dev.azure.com/v3/org/project/web") == true) + } + + func testLinkCommandsNegotiateMultipleAndLegacyServers() throws { + let key = ThreadPullRequestKey(host: "GITHUB.COM", repository: "Org/Repo", number: 1) + let multiple = try XCTUnwrap(ThreadPullRequests.mutation(threadID: "thread", key: key, + url: "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/org/repo/pull/1", linked: true, multiple: true, + legacyProjectID: nil, commandID: "command")) + XCTAssertEqual(multiple["type"], .string("thread.pull-request.link")) + XCTAssertEqual(multiple["host"], .string("github.com")) + XCTAssertEqual(multiple["source"], .string("manual")) + XCTAssertNil(ThreadPullRequests.mutation(threadID: "thread", key: key, url: "url", + linked: true, multiple: false, legacyProjectID: nil)) + let legacy = ThreadPullRequests.mutation(threadID: "thread", key: key, url: "url", + linked: false, multiple: false, legacyProjectID: nil) + XCTAssertEqual(legacy?["type"], .string("thread.meta.update")) + XCTAssertEqual(legacy?["linkedPullRequest"], .null) + } + + func testUnsyncedAndFutureSourcesRemainVisible() throws { + var unsynced = link(1, head: "one", base: "main") + unsynced.source = "future-source" + unsynced.snapshot = nil + XCTAssertTrue(unsynced.isOpen) + XCTAssertEqual(ThreadPullRequests.current([unsynced])?.number, 1) + let roundTrip = try JSONDecoder.t3.decode(ThreadPullRequestLink.self, from: JSONEncoder.t3.encode(unsynced)) + XCTAssertEqual(roundTrip, unsynced) + XCTAssertEqual(try JSONDecoder.t3.decode(SourceControlProviderKind.self, from: Data(#""forgejo""#.utf8)), .forgejo) + XCTAssertEqual(try JSONDecoder.t3.decode(SourceControlProviderKind.self, from: Data(#""future-host""#.utf8)), .unknown) + } + + private func link(_ number: Int, head: String, base: String) -> ThreadPullRequestLink { + ThreadPullRequestLink(host: "github.com", repository: "org/repo", number: number, + url: "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/org/repo/pull/\(number)", source: "manual", + linkedAt: "2026-09-01T00:00:00Z", snapshot: snapshot(head: head, base: base), stack: nil) + } + + private func snapshot(state: PullRequestState = .open, head: String, base: String) -> ThreadPullRequestSnapshot { + ThreadPullRequestSnapshot(state: state, title: "Update \(head)", headBranch: head, baseBranch: base, + isDraft: false, updatedAt: nil, syncedAt: "2026-09-01T00:00:00Z") + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift index a52369378702..cc188815bb14 100644 --- a/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift @@ -1291,6 +1291,96 @@ final class NativeMultiEnvironmentTests: XCTestCase { } } +@Suite("Native GitHub routing") +@MainActor +struct NativeGitHubRoutingTests { + @Test(arguments: ["matching", "different", "old-server"]) + func readOnlyRoutesToAnAlternateWithTheSameGuardedAccount(_ scenario: String) async throws { + let server = MultiEnvironmentConfigurationServer( + routingAccounts: ["one.example": "account-one", "two.example": scenario == "different" ? "account-two" : "account-one"], + routingIdentityHosts: scenario == "old-server" ? ["one.example"] : ["one.example", "two.example"] + ) + let fixture = try await configuredFixture(server) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let activity = try await fixture.client.pullRequestActivity(target) + let requests = await server.pullRequestRequests().filter { $0.method == RPCMethod.pullRequestsActivity.rawValue } + let expectedHost = scenario == "matching" ? "two.example" : "one.example" + #expect(requests.map(\.host) == [expectedHost]) + #expect(activity.author?.login == expectedHost) + #expect(requests.first?.input["expectedAccountId"] == .string("account-one")) + #expect(requests.first?.input["host"] == .string("github.com")) + #expect(requests.first?.input["allowStale"] == .bool(false)) + await fixture.client.disconnect() + } + + @Test + func aDispatchedWriteFailureDoesNotReplayOnTheOriginOrAnotherAlternate() async throws { + let server = MultiEnvironmentConfigurationServer( + routingAccounts: ["one.example": "account", "two.example": "account", "three.example": "account"], + failPullRequestWrites: true + ) + let fixture = try await configuredFixture(server, includeThird: true) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + do { + try await fixture.client.commentOnPullRequest(target, body: "One comment") + Issue.record("Expected the dispatched write to fail") + } catch { + #expect(error is RPCError) + } + let writes = await server.pullRequestRequests().filter { $0.method == RPCMethod.pullRequestsComment.rawValue } + #expect(writes.count == 1) + #expect(writes.first?.host != "one.example") + #expect(writes.first?.input["expectedAccountId"] == .string("account")) + await fixture.client.disconnect() + } + + @Test + func readPermissionDoesNotSendWritesToTheAlternate() async throws { + let server = MultiEnvironmentConfigurationServer(routingAccounts: ["one.example": "account", "two.example": "account"]) + let fixture = try await configuredFixture(server) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + try await fixture.client.setGitHubRoutingPermission(environmentID: "two", permission: .read) + try await fixture.client.commentOnPullRequest(target, body: "Origin only") + let requests = await server.pullRequestRequests() + #expect(requests.filter { $0.method == RPCMethod.pullRequestsComment.rawValue }.map(\.host) == ["one.example"]) + #expect(!requests.contains { $0.host == "two.example" }) + await fixture.client.disconnect() + } + + @Test + func diffUsesTheSameVerifiedAccountAndEndpointAsOtherReads() async throws { + let server = MultiEnvironmentConfigurationServer(routingAccounts: ["one.example": "account", "two.example": "account"]) + let fixture = try await configuredFixture(server) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + _ = try await fixture.client.pullRequestDiff(target, cursor: "next") + let requests = await fixture.transport.pullRequestDiffRequests() + #expect(requests.map(\.host) == ["two.example"]) + #expect(requests.first?.input["expectedAccountId"] == .string("account")) + #expect(requests.first?.input["host"] == .string("github.com")) + #expect(requests.first?.input["cursor"] == .string("next")) + #expect(requests.first?.input["allowStale"] == .bool(false)) + await fixture.client.disconnect() + } + + private var target: FeaturePullRequestTarget { + FeaturePullRequestTarget(environmentID: "one", environmentName: "Origin", + reference: PullRequestRef(projectId: "project-one", repository: "org/repo", number: 1, host: "github.com")) + } + + private func configuredFixture(_ server: MultiEnvironmentConfigurationServer, includeThird: Bool = false) async throws -> MultiEnvironmentFixture { + let fixture = try await NativeMultiEnvironmentTests.makeFixture( + includeThirdEnvironment: includeThird, pullRequestsAvailable: true, + webSocketConnector: MultiEnvironmentConfigurationConnector(server: server), + rpcConnectionWaitTimeout: .seconds(2) + ) + _ = try await fixture.client.initialSnapshot() + for id in includeThird ? ["one", "two", "three"] : ["one", "two"] { + try await fixture.client.setGitHubRoutingPermission(environmentID: id, permission: .readWrite) + } + return fixture + } +} + @Suite("Native passive thread refresh") @MainActor struct NativePassiveThreadRefreshTests { @@ -1693,6 +1783,9 @@ private actor MultiEnvironmentHTTPTransport: HTTPTransport { private var shellReadCounts: [String: Int] = [:] private var dispatched: [MultiEnvironmentDispatchRecord] = [] private var hostsDroppingNextCreateReply = Set() + private var diffRequests: [(host: String, input: JSONValue)] = [] + + func pullRequestDiffRequests() -> [(host: String, input: JSONValue)] { diffRequests } init(shells: [String: OrchestrationShellSnapshot]) { self.shells = shells @@ -1753,6 +1846,10 @@ private actor MultiEnvironmentHTTPTransport: HTTPTransport { guard reachableHosts.contains(host) else { throw URLError(.cannotConnectToHost) } + if path == "/api/pull-requests/diff", let body = request.httpBody { + diffRequests.append((host, try JSONDecoder.t3.decode(JSONValue.self, from: body))) + return (Data(#"{"patch":"","truncated":false,"nextCursor":null}"#.utf8), multiEnvironmentResponse(request)) + } if path == "/api/orchestration/shell", shellReadsEnabledHosts.contains(host), let data = shellData[host] { @@ -1834,6 +1931,10 @@ private actor MultiEnvironmentConfigurationServer { private var directoryRequests: [(host: String, input: JSONValue)] = [] private let projectSettingsSupportHosts: Set private let providersByHost: [String: [JSONValue]] + private let routingAccounts: [String: String] + private let routingIdentityHosts: Set + private let failPullRequestWrites: Bool + private var prRequests: [(host: String, method: String, input: JSONValue)] = [] init( restartSupportHosts: Set = [], @@ -1841,7 +1942,10 @@ private actor MultiEnvironmentConfigurationServer { legacyEntries: ProjectEntriesResult? = nil, projectSettingsSupportHosts: Set = [], settingsByHost: [String: [String: JSONValue]] = [:], - providersByHost: [String: [JSONValue]] = [:] + providersByHost: [String: [JSONValue]] = [:], + routingAccounts: [String: String] = [:], + routingIdentityHosts: Set = ["one.example", "two.example", "three.example"], + failPullRequestWrites: Bool = false ) { self.restartSupportHosts = restartSupportHosts self.directoryEntries = directoryEntries @@ -1849,17 +1953,44 @@ private actor MultiEnvironmentConfigurationServer { self.projectSettingsSupportHosts = projectSettingsSupportHosts self.settingsByHost = settingsByHost self.providersByHost = providersByHost + self.routingAccounts = routingAccounts + self.routingIdentityHosts = routingIdentityHosts + self.failPullRequestWrites = failPullRequestWrites } func updatedHosts() -> [String] { settingsUpdateHosts } func settings(host: String) -> [String: JSONValue] { settingsByHost[host] ?? [:] } func fileRequests() -> [(host: String, input: JSONValue)] { directoryRequests } + func pullRequestRequests() -> [(host: String, method: String, input: JSONValue)] { prRequests } func response(to request: JSONValue, host: String) throws -> JSONValue? { guard let tag = request["tag"]?.stringValue, case let .number(id)? = request["id"] else { return nil } let value: JSONValue + if tag.hasPrefix("pullRequests.") { + prRequests.append((host, tag, request["payload"] ?? .object([:]))) + } switch tag { + case RPCMethod.pullRequestsRouting.rawValue, RPCMethod.pullRequestsRoutingIdentity.rawValue: + if tag == RPCMethod.pullRequestsRoutingIdentity.rawValue && !routingIdentityHosts.contains(host) { + return failure(id: id, message: "Unknown method") + } + value = .object([ + "accountId": .string(routingAccounts[host] ?? "unknown"), "host": .string("github.com"), + "provider": .string("github"), "viewer": .string("theo"), + "projectTitle": .string("Original project"), "workspaceRoot": .string("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/origin/repo"), + ]) + case RPCMethod.pullRequestsActivity.rawValue: + value = .object([ + "author": .object(["login": .string(host)]), "comments": .array([]), + "commentCount": .number(0), "commentsTruncated": .bool(false), + "reviewThreads": .array([]), "commits": .array([]), + ]) + case RPCMethod.pullRequestsComment.rawValue: + if failPullRequestWrites { return failure(id: id, message: "Write response failed after dispatch") } + value = .null + case RPCMethod.pullRequestsInvalidate.rawValue: + value = .null case RPCMethod.projectsListEntries.rawValue: let input = request["payload"] ?? .object([:]) directoryRequests.append((host, input)) @@ -1913,6 +2044,15 @@ private actor MultiEnvironmentConfigurationServer { ]) } + private func failure(id: Double, message: String) -> JSONValue { + .object([ + "_tag": .string("Exit"), "requestId": .number(id), + "exit": .object(["_tag": .string("Failure"), "cause": .object([ + "_tag": .string("Fail"), "error": .object(["message": .string(message)]), + ])]), + ]) + } + private func config(host: String) -> JSONValue { let environmentID = host == "one.example" ? "one" : "two" return .object([ diff --git a/apps/swift-ios/Tests/FeatureTests/NativeThreadMetadataTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeThreadMetadataTests.swift index 6df43a6e136c..4ad6a9a7a351 100644 --- a/apps/swift-ios/Tests/FeatureTests/NativeThreadMetadataTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/NativeThreadMetadataTests.swift @@ -5,6 +5,59 @@ import Testing @MainActor @Suite("Native thread metadata") struct NativeThreadMetadataTests { + @Test + func multiplePRLinksUpdateWithoutReloadAndHideDismissedStackMembers() throws { + let link = ThreadPullRequestLink(host: "github.com", repository: "test/repo", number: 2, + url: "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/test/repo/pull/2", source: "manual", + linkedAt: "2026-09-06T20:00:00Z", snapshot: nil, stack: nil) + let linked = NativeThreadDetailReducer.apply(event(type: "thread.pull-request-linked", + payload: ["link": try JSONValue.encode(link)]), to: thread()) + guard case let .updated(withLink) = linked.result else { + Issue.record("Expected a local PR link update") + return + } + #expect(withLink.pullRequests == [link]) + let state = ThreadPullRequestSnapshot(state: .merged, title: "Finished PR", headBranch: "task", + baseBranch: "main", isDraft: false, updatedAt: nil, syncedAt: "2026-09-06T20:00:00Z") + let key: [String: JSONValue] = ["host": .string("GITHUB.COM"), "repository": .string("TEST/REPO"), "number": .number(2)] + let synced = NativeThreadDetailReducer.apply(event(type: "thread.pull-request-synced", + payload: key.merging(["snapshot": try JSONValue.encode(state), "stack": .null]) { _, next in next }), to: withLink) + guard case let .updated(withState) = synced.result else { + Issue.record("Expected a local PR state update") + return + } + #expect(withState.pullRequests?.first?.snapshot?.state == .merged) + #expect(HomeThreadPullRequestPresentation.resolve(links: withState.pullRequests ?? [])?.state == .merged) + let removed = NativeThreadDetailReducer.apply(event(type: "thread.pull-request-unlinked", payload: key), to: withState) + guard case let .updated(withoutLink) = removed.result else { + Issue.record("Expected a local PR unlink") + return + } + #expect(withoutLink.pullRequests == []) + + var dismissed = link + dismissed.source = "stack-dismissed" + let feature = FeatureThread(id: "thread", projectID: "project", title: "Task", pullRequests: [dismissed]) + #expect(feature.effectivePullRequest == nil) + #expect(ThreadPullRequestDestination.resolve(thread: feature, branchPullRequest: nil) == nil) + #expect(HomeThreadPullRequestPresentation.resolve(links: [dismissed]) == nil) + } + + @Test + func multiplePRSnapshotsReachThreadSearchAndExternalLinks() { + let link = ThreadPullRequestLink(host: "github.example.com", repository: "test/other", number: 24, + url: "https://github.example.com/test/other/pull/24", source: "manual", + linkedAt: "2026-09-06T20:00:00Z", snapshot: nil, stack: nil) + let feature = FeatureThread(id: "thread", projectID: "project", title: "Task", pullRequests: [link]) + #expect(ThreadPullRequestDestination.resolve(thread: feature, branchPullRequest: nil)?.url.absoluteString == link.url) + #expect(ThreadPullRequests.searchTerms(feature.pullRequests, legacy: nil).contains("test/other#24")) + var changed = feature + changed.pullRequests = [] + #expect(HomeOrderKey(feature) != HomeOrderKey(changed)) + #expect(feature.pullRequestObservationIdentity == feature.pullRequestObservationIdentity) + #expect(feature.pullRequestObservationIdentity != changed.pullRequestObservationIdentity) + } + @Test func snapshotsDecodeServerPRAndOrderWithoutRequiringThemFromOlderServers() throws { let base = try JSONValue.encode(thread()) @@ -14,15 +67,19 @@ struct NativeThreadMetadataTests { } fields.removeValue(forKey: "branchPullRequest") fields.removeValue(forKey: "activeOrderKey") + fields.removeValue(forKey: "pullRequests") let older = try JSONValue.object(fields).decode(OrchestrationThread.self) #expect(older.branchPullRequest == nil) #expect(older.activeOrderKey == nil) + #expect(older.pullRequests == nil) fields["branchPullRequest"] = try JSONValue.encode(reference()) fields["activeOrderKey"] = .string("nm") + fields["pullRequests"] = .array([]) let current = try JSONValue.object(fields).decode(OrchestrationThread.self) #expect(current.branchPullRequest == reference()) #expect(current.activeOrderKey == "nm") + #expect(current.pullRequests == []) fields["latestUserMessageAt"] = .null fields["hasPendingApprovals"] = .bool(false) @@ -31,6 +88,7 @@ struct NativeThreadMetadataTests { let shell = try JSONValue.object(fields).decode(OrchestrationThreadShell.self) #expect(shell.branchPullRequest == reference()) #expect(shell.activeOrderKey == "nm") + #expect(shell.pullRequests == []) } @Test