|  | 
|  | 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