| 
 | 1 | +//===----------------------------------------------------------------------===//  | 
 | 2 | +//  | 
 | 3 | +// This source file is part of the Swift.org open source project  | 
 | 4 | +//  | 
 | 5 | +// Copyright (c) 2024 Apple Inc. and the Swift project authors  | 
 | 6 | +// Licensed under Apache License v2.0 with Runtime Library Exception  | 
 | 7 | +//  | 
 | 8 | +// See https://swift.org/LICENSE.txt for license information  | 
 | 9 | +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors  | 
 | 10 | +//  | 
 | 11 | +//===----------------------------------------------------------------------===//  | 
 | 12 | + | 
 | 13 | +import Foundation  | 
 | 14 | + | 
 | 15 | +#if canImport(FoundationNetworking)  | 
 | 16 | +// FoundationNetworking is a separate module in swift-foundation but not swift-corelibs-foundation.  | 
 | 17 | +import FoundationNetworking  | 
 | 18 | +#endif  | 
 | 19 | + | 
 | 20 | +#if canImport(WinSDK)  | 
 | 21 | +import WinSDK  | 
 | 22 | +#endif  | 
 | 23 | + | 
 | 24 | +struct GenericError: Error, CustomStringConvertible {  | 
 | 25 | +  var description: String  | 
 | 26 | + | 
 | 27 | +  init(_ description: String) {  | 
 | 28 | +    self.description = description  | 
 | 29 | +  }  | 
 | 30 | +}  | 
 | 31 | + | 
 | 32 | +/// Escape the given command to be printed for log output.  | 
 | 33 | +func escapeCommand(_ executable: URL, _ arguments: [String]) -> String {  | 
 | 34 | +  return ([executable.path] + arguments).map {  | 
 | 35 | +    if $0.contains(" ") {  | 
 | 36 | +      return "'\($0)'"  | 
 | 37 | +    }  | 
 | 38 | +    return $0  | 
 | 39 | +  }.joined(separator: " ")  | 
 | 40 | +}  | 
 | 41 | + | 
 | 42 | +/// Launch a subprocess with the given command and wait for it to finish  | 
 | 43 | +func run(_ executable: URL, _ arguments: String..., workingDirectory: URL? = nil) throws {  | 
 | 44 | +  print("Running \(escapeCommand(executable, arguments)) (working directory: \(workingDirectory?.path ?? "<nil>"))")  | 
 | 45 | +  let process = Process()  | 
 | 46 | +  process.executableURL = executable  | 
 | 47 | +  process.arguments = arguments  | 
 | 48 | +  if let workingDirectory {  | 
 | 49 | +    process.currentDirectoryURL = workingDirectory  | 
 | 50 | +  }  | 
 | 51 | + | 
 | 52 | +  try process.run()  | 
 | 53 | +  process.waitUntilExit()  | 
 | 54 | +  guard process.terminationStatus == 0 else {  | 
 | 55 | +    throw GenericError(  | 
 | 56 | +      "\(escapeCommand(executable, arguments)) failed with non-zero exit code: \(process.terminationStatus)"  | 
 | 57 | +    )  | 
 | 58 | +  }  | 
 | 59 | +}  | 
 | 60 | + | 
 | 61 | +/// Find the executable with the given name in PATH.  | 
 | 62 | +public func lookup(executable: String) throws -> URL {  | 
 | 63 | +  #if os(Windows)  | 
 | 64 | +  let pathSeparator: Character = ";"  | 
 | 65 | +  let executable = executable + ".exe"  | 
 | 66 | +  #else  | 
 | 67 | +  let pathSeparator: Character = ":"  | 
 | 68 | +  #endif  | 
 | 69 | +  for pathVariable in ["PATH", "Path"] {  | 
 | 70 | +    guard let pathString = ProcessInfo.processInfo.environment[pathVariable] else {  | 
 | 71 | +      continue  | 
 | 72 | +    }  | 
 | 73 | +    for searchPath in pathString.split(separator: pathSeparator) {  | 
 | 74 | +      let candidateUrl = URL(fileURLWithPath: String(searchPath)).appendingPathComponent(executable)  | 
 | 75 | +      if FileManager.default.isExecutableFile(atPath: candidateUrl.path) {  | 
 | 76 | +        return candidateUrl  | 
 | 77 | +      }  | 
 | 78 | +    }  | 
 | 79 | +  }  | 
 | 80 | +  throw GenericError("Did not find \(executable)")  | 
 | 81 | +}  | 
 | 82 | + | 
 | 83 | +func downloadData(from url: URL) async throws -> Data {  | 
 | 84 | +  return try await withCheckedThrowingContinuation { continuation in  | 
 | 85 | +    URLSession.shared.dataTask(with: url) { data, _, error in  | 
 | 86 | +      if let error {  | 
 | 87 | +        continuation.resume(throwing: error)  | 
 | 88 | +        return  | 
 | 89 | +      }  | 
 | 90 | +      guard let data else {  | 
 | 91 | +        continuation.resume(throwing: GenericError("Received no data for \(url)"))  | 
 | 92 | +        return  | 
 | 93 | +      }  | 
 | 94 | +      continuation.resume(returning: data)  | 
 | 95 | +    }  | 
 | 96 | +    .resume()  | 
 | 97 | +  }  | 
 | 98 | +}  | 
 | 99 | + | 
 | 100 | +/// The JSON fields of the `https://api.github.com/repos/<repository>/pulls/<prNumber>` endpoint that we care about.  | 
 | 101 | +struct PRInfo: Codable {  | 
 | 102 | +  struct Base: Codable {  | 
 | 103 | +    /// The name of the PR's base branch.  | 
 | 104 | +    let ref: String  | 
 | 105 | +  }  | 
 | 106 | +  /// The base branch of the PR  | 
 | 107 | +  let base: Base  | 
 | 108 | + | 
 | 109 | +  /// The PR's description.  | 
 | 110 | +  let body: String?  | 
 | 111 | +}  | 
 | 112 | + | 
 | 113 | +/// - Parameters:  | 
 | 114 | +///   - repository: The repository's name, eg. `swiftlang/swift-syntax`  | 
 | 115 | +func getPRInfo(repository: String, prNumber: String) async throws -> PRInfo {  | 
 | 116 | +  guard let prInfoUrl = URL(string: "https://api.github.com/repos/\(repository)/pulls/\(prNumber)") else {  | 
 | 117 | +    throw GenericError("Failed to form URL for GitHub API")  | 
 | 118 | +  }  | 
 | 119 | + | 
 | 120 | +  do {  | 
 | 121 | +    let data = try await downloadData(from: prInfoUrl)  | 
 | 122 | +    return try JSONDecoder().decode(PRInfo.self, from: data)  | 
 | 123 | +  } catch {  | 
 | 124 | +    throw GenericError("Failed to load PR info from \(prInfoUrl): \(error)")  | 
 | 125 | +  }  | 
 | 126 | +}  | 
 | 127 | + | 
 | 128 | +/// Information about a PR that should be tested with this PR.  | 
 | 129 | +struct CrossRepoPR {  | 
 | 130 | +  /// The owner of the repository, eg. `swiftlang`  | 
 | 131 | +  let repositoryOwner: String  | 
 | 132 | + | 
 | 133 | +  /// The name of the repository, eg. `swift-syntax`  | 
 | 134 | +  let repositoryName: String  | 
 | 135 | + | 
 | 136 | +  /// The PR number that's referenced.  | 
 | 137 | +  let prNumber: String  | 
 | 138 | +}  | 
 | 139 | + | 
 | 140 | +/// Retrieve all PRs that are referenced from PR `prNumber` in `repository`.  | 
 | 141 | +/// `repository` is the owner and repo name joined by `/`, eg. `swiftlang/swift-syntax`.  | 
 | 142 | +func getCrossRepoPrs(repository: String, prNumber: String) async throws -> [CrossRepoPR] {  | 
 | 143 | +  var result: [CrossRepoPR] = []  | 
 | 144 | +  let prInfo = try await getPRInfo(repository: repository, prNumber: prNumber)  | 
 | 145 | +  for line in prInfo.body?.split(separator: "\n") ?? [] {  | 
 | 146 | +    guard line.lowercased().starts(with: "linked pr:") else {  | 
 | 147 | +      continue  | 
 | 148 | +    }  | 
 | 149 | +    // We can't use Swift's Regex here because this script needs to run on Windows with Swift 5.9, which doesn't support  | 
 | 150 | +    // Swift Regex.  | 
 | 151 | +    var remainder = line[...]  | 
 | 152 | +    guard let ownerRange = remainder.firstRange(of: "swiftlang/") ?? remainder.firstRange(of: "apple/") else {  | 
 | 153 | +      continue  | 
 | 154 | +    }  | 
 | 155 | +    let repositoryOwner = remainder[ownerRange].dropLast()  | 
 | 156 | +    remainder = remainder[ownerRange.upperBound...]  | 
 | 157 | +    let repositoryName = remainder.prefix { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }  | 
 | 158 | +    if repositoryName.isEmpty {  | 
 | 159 | +      continue  | 
 | 160 | +    }  | 
 | 161 | +    remainder = remainder.dropFirst(repositoryName.count)  | 
 | 162 | +    if remainder.starts(with: "/pull/") {  | 
 | 163 | +      remainder = remainder.dropFirst(6)  | 
 | 164 | +    } else if remainder.starts(with: "#") {  | 
 | 165 | +      remainder = remainder.dropFirst()  | 
 | 166 | +    } else {  | 
 | 167 | +      continue  | 
 | 168 | +    }  | 
 | 169 | +    let pullRequestNum = remainder.prefix { $0.isNumber }  | 
 | 170 | +    if pullRequestNum.isEmpty {  | 
 | 171 | +      continue  | 
 | 172 | +    }  | 
 | 173 | +    result.append(  | 
 | 174 | +      CrossRepoPR(  | 
 | 175 | +        repositoryOwner: String(repositoryOwner),  | 
 | 176 | +        repositoryName: String(repositoryName),  | 
 | 177 | +        prNumber: String(pullRequestNum)  | 
 | 178 | +      )  | 
 | 179 | +    )  | 
 | 180 | +  }  | 
 | 181 | +  return result  | 
 | 182 | +}  | 
 | 183 | + | 
 | 184 | +func main() async throws {  | 
 | 185 | +  guard ProcessInfo.processInfo.arguments.count >= 3 else {  | 
 | 186 | +    throw GenericError(  | 
 | 187 | +      """  | 
 | 188 | +      Expected two arguments:  | 
 | 189 | +      - Repository name, eg. `swiftlang/swift-syntax  | 
 | 190 | +      - PR number  | 
 | 191 | +      """  | 
 | 192 | +    )  | 
 | 193 | +  }  | 
 | 194 | +  let repository = ProcessInfo.processInfo.arguments[1]  | 
 | 195 | +  let prNumber = ProcessInfo.processInfo.arguments[2]  | 
 | 196 | + | 
 | 197 | +  let crossRepoPrs = try await getCrossRepoPrs(repository: repository, prNumber: prNumber)  | 
 | 198 | +  if !crossRepoPrs.isEmpty {  | 
 | 199 | +    print("Detected cross-repo PRs")  | 
 | 200 | +    for crossRepoPr in crossRepoPrs {  | 
 | 201 | +      print(" - \(crossRepoPr.repositoryOwner)/\(crossRepoPr.repositoryName)#\(crossRepoPr.prNumber)")  | 
 | 202 | +    }  | 
 | 203 | +  }  | 
 | 204 | + | 
 | 205 | +  for crossRepoPr in crossRepoPrs {  | 
 | 206 | +    let git = try lookup(executable: "git")  | 
 | 207 | +    let swift = try lookup(executable: "swift")  | 
 | 208 | +    let baseBranch = try await getPRInfo(  | 
 | 209 | +      repository: "\(crossRepoPr.repositoryOwner)/\(crossRepoPr.repositoryName)",  | 
 | 210 | +      prNumber: crossRepoPr.prNumber  | 
 | 211 | +    ).base.ref  | 
 | 212 | + | 
 | 213 | +    let workspaceDir = URL(fileURLWithPath: "..").resolvingSymlinksInPath()  | 
 | 214 | +    let repoDir = workspaceDir.appendingPathComponent(crossRepoPr.repositoryName)  | 
 | 215 | +    try run(  | 
 | 216 | +      git,  | 
 | 217 | +      "clone",  | 
 | 218 | +      "https://github.com/\(crossRepoPr.repositoryOwner)/\(crossRepoPr.repositoryName).git",  | 
 | 219 | +      "\(crossRepoPr.repositoryName)",  | 
 | 220 | +      workingDirectory: workspaceDir  | 
 | 221 | +    )  | 
 | 222 | +    try run(git, "fetch", "origin", "pull/\(crossRepoPr.prNumber)/merge:pr_merge", workingDirectory: repoDir)  | 
 | 223 | +    try run(git, "checkout", baseBranch, workingDirectory: repoDir)  | 
 | 224 | +    try run(git, "reset", "--hard", "pr_merge", workingDirectory: repoDir)  | 
 | 225 | +    try run(  | 
 | 226 | +      swift,  | 
 | 227 | +      "package",  | 
 | 228 | +      "config",  | 
 | 229 | +      "set-mirror",  | 
 | 230 | +      "--package-url",  | 
 | 231 | +      "https://github.com/\(crossRepoPr.repositoryOwner)/\(crossRepoPr.repositoryName).git",  | 
 | 232 | +      "--mirror-url",  | 
 | 233 | +      repoDir.path  | 
 | 234 | +    )  | 
 | 235 | +  }  | 
 | 236 | +}  | 
 | 237 | + | 
 | 238 | +do {  | 
 | 239 | +  try await main()  | 
 | 240 | +} catch {  | 
 | 241 | +  print(error)  | 
 | 242 | +  #if os(Windows)  | 
 | 243 | +  _Exit(1)  | 
 | 244 | +  #else  | 
 | 245 | +  exit(1)  | 
 | 246 | +  #endif  | 
 | 247 | +}  | 
0 commit comments