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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
281 changes: 240 additions & 41 deletions apps/swift-ios/App/NativeFeatureClient.swift

Large diffs are not rendered by default.

46 changes: 46 additions & 0 deletions apps/swift-ios/Core/GitHubRouting.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
8 changes: 8 additions & 0 deletions apps/swift-ios/Core/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
29 changes: 24 additions & 5 deletions apps/swift-ios/Core/PullRequestWireModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions apps/swift-ios/Core/T3Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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"
Expand Down
213 changes: 213 additions & 0 deletions apps/swift-ios/Core/ThreadPullRequests.swift
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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[..<slash]),
repository: String(canonicalKey[canonicalKey.index(after: slash)...]), number: number)
}
}

public struct ThreadPullRequestSnapshot: Codable, Hashable, Sendable {
public let state: PullRequestState
public let title: String
public let headBranch: String
public let baseBranch: String
public let isDraft: Bool
public let updatedAt: String?
public let syncedAt: String
public var closedAt: String? = nil
public var mergedAt: String? = nil
public var author: PullRequestActor? = nil
public var additions: Int? = nil
public var deletions: Int? = nil
public var changedFiles: Int? = nil
public var reviewDecision: PullRequestReviewDecision? = nil
public var checksState: PullRequestChecksState? = nil
public var mergeability: PullRequestMergeability? = nil
}

public struct ThreadPullRequestStack: Codable, Hashable, Sendable {
public struct Layer: Codable, Hashable, Sendable {
public let number: Int
public let headBranch: String
public let state: PullRequestState
}
public let kind: String
public let id: String
public let number: Int
public let url: String
public let base: String
public let layers: [Layer]
}

public struct ThreadPullRequestLink: Codable, Hashable, Sendable, Identifiable {
public let host: String
public let repository: String
public let number: Int
public let url: String
// Keep unknown sources decodable. Only stack-dismissed is hidden.
public var source: String
public let linkedAt: String
public var snapshot: ThreadPullRequestSnapshot?
public var stack: ThreadPullRequestStack?

public var id: ThreadPullRequestKey {
let parsed = URL(string: url)
let authority: String
if let port = parsed?.port, let hostname = parsed?.host,
hostname.caseInsensitiveCompare(host) == .orderedSame {
authority = "\(hostname):\(port)"
} else { authority = host }
return ThreadPullRequestKey(host: authority, repository: repository, number: number)
}
public var isVisible: Bool { source != "stack-dismissed" }
public var isOpen: Bool { snapshot == nil || snapshot?.state == .open }
}

/// Shared selection rules for badges, search and the thread's external PR link.
public enum ThreadPullRequests {
public static func authority(of text: String) -> 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<ThreadPullRequestKey> = []
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] } ?? []
}
}
Loading
Loading