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