From 8bb6b227959448e6da930d5696f5ba334e0033fa Mon Sep 17 00:00:00 2001 From: Alex Hoppen Date: Fri, 25 Oct 2024 15:00:54 -0700 Subject: [PATCH] Enable cross-PR testing --- .github/workflows/pull_request.yml | 15 +- .../Rules/UseShorthandTypeNames.swift | 10 +- cross-pr-checkout.py | 33 ++++ cross-pr-checkout.swift | 171 ++++++++++++++++++ 4 files changed, 219 insertions(+), 10 deletions(-) create mode 100644 cross-pr-checkout.py create mode 100644 cross-pr-checkout.swift diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index d49113a8..43138000 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -8,9 +8,14 @@ jobs: tests: name: Test uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@main - soundness: - name: Soundness - uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main with: - license_header_check_enabled: false - license_header_check_project_name: "Swift.org" + enable_windows_checks: false + linux_pre_build_command: | + swiftc cross-pr-checkout.swift -o /tmp/cross-pr-checkout + /tmp/cross-pr-checkout "${{ github.repository }}" "${{ github.event.number }}" + # soundness: + # name: Soundness + # uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main + # with: + # license_header_check_enabled: false + # license_header_check_project_name: "Swift.org" diff --git a/Sources/SwiftFormat/Rules/UseShorthandTypeNames.swift b/Sources/SwiftFormat/Rules/UseShorthandTypeNames.swift index 67a8d8aa..a0c32241 100644 --- a/Sources/SwiftFormat/Rules/UseShorthandTypeNames.swift +++ b/Sources/SwiftFormat/Rules/UseShorthandTypeNames.swift @@ -48,7 +48,7 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { switch node.name.text { case "Array": guard let argument = genericArgumentList.firstAndOnly, - case .type(let typeArgument) = argument else { + case .type(let typeArgument) = argument.argument else { newNode = nil break } @@ -62,7 +62,7 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { case "Dictionary": guard let arguments = exactlyTwoChildren(of: genericArgumentList), case .type(let type0Argument) = arguments.0.argument, - caes .type(let type1Argument) = arguments.1.argument else { + case .type(let type1Argument) = arguments.1.argument else { newNode = nil break } @@ -79,7 +79,7 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { break } guard let argument = genericArgumentList.firstAndOnly, - case .type(let typeArgument) = argument else { + case .type(let typeArgument) = argument.argument else { newNode = nil break } @@ -143,7 +143,7 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { switch expression.baseName.text { case "Array": guard let argument = genericArgumentList.firstAndOnly, - case .type(let typeArgument) = argument else { + case .type(let typeArgument) = argument.argument else { newNode = nil break } @@ -172,7 +172,7 @@ public final class UseShorthandTypeNames: SyntaxFormatRule { case "Optional": guard let argument = genericArgumentList.firstAndOnly, - case .type(let typeArgument) = argument else { + case .type(let typeArgument) = argument.argument else { newNode = nil break } diff --git a/cross-pr-checkout.py b/cross-pr-checkout.py new file mode 100644 index 00000000..ebbb07a5 --- /dev/null +++ b/cross-pr-checkout.py @@ -0,0 +1,33 @@ +import subprocess +import pathlib +import requests + +class CrossRepoPR: + org: str + repo: str + pr_num: str + + def __init__(self, org: str, repo: str, pr_num: str) -> None: + self.org = org + self.repo = repo + self.pr_num = pr_num + +def cross_repo_prs() -> list[CrossRepoPR]: + return [ + CrossRepoPR("swiftlang", "swift-syntax", "2859") + ] + +def run(cmd: list[str], cwd: str|None = None): + print(" ".join(cmd)) + subprocess.check_call(cmd, cwd=cwd) + +def main(): + for cross_repo_pr in cross_repo_prs(): + run(["git", "clone", f"https://github.com/{cross_repo_pr.org}/{cross_repo_pr.repo}.git", f"{cross_repo_pr.repo}"], cwd="..") + run(["git", "fetch", "origin", f"pull/{cross_repo_pr.pr_num}/merge:pr_merge"], cwd="../swift-syntax") + run(["git", "checkout", "main"], cwd="../swift-syntax") + run(["git", "reset", "--hard", "pr_merge"], cwd="../swift-syntax") + run(["swift", "package", "config", "set-mirror", "--package-url", "https://github.com/swiftlang/swift-syntax.git", "--mirror-url", str(pathlib.Path("../swift-syntax").resolve())]) + +if __name__ == "__main__": + main() diff --git a/cross-pr-checkout.swift b/cross-pr-checkout.swift new file mode 100644 index 00000000..bdd01525 --- /dev/null +++ b/cross-pr-checkout.swift @@ -0,0 +1,171 @@ +import Foundation +import RegexBuilder + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +struct GenericError: Error, CustomStringConvertible { + var description: String + + init(_ description: String) { + self.description = description + } +} + +/// Launch a subprocess with the given command and wait for it to finish +func run(_ executable: URL, _ arguments: String..., workingDirectory: URL? = nil) throws { + print("Running " + ([executable.path] + arguments).joined(separator: " ")) + let process = Process() + process.executableURL = executable + process.arguments = arguments + if let workingDirectory { + process.currentDirectoryURL = workingDirectory + } + + try process.run() + process.waitUntilExit() +} + +/// Find the executable with the given name +public func lookup(executable: String) throws -> URL { + // Compute search paths from PATH variable. + #if os(Windows) + let pathSeparator: Character = ";" + let pathVariable = "Path" + #else + let pathSeparator: Character = ":" + let pathVariable = "PATH" + #endif + guard let pathString = ProcessInfo.processInfo.environment[pathVariable] else { + throw GenericError("Failed to read path environment variable") + } + for searchPath in pathString.split(separator: pathSeparator) { + let candidateUrl = URL(fileURLWithPath: String(searchPath)).appendingPathComponent(executable) + if FileManager.default.isExecutableFile(atPath: candidateUrl.path) { + return candidateUrl + } + } + throw GenericError("Did not find \(executable)") +} + +struct CrossRepoPR { + let repositoryOwner: String + let repositoryName: String + let prNumber: String +} + +/// The JSON fields of the `https://api.github.com/repos/\(repository)/pulls/\(prNumber)` endpoint that we care about. +struct PRInfo: Codable { + struct Base: Codable { + let ref: String + } + let base: Base + let body: String +} + +/// - Parameters: +/// - repository: The repository's name, eg. `swiftlang/swift-syntax` +func getPRInfo(repository: String, prNumber: String) throws -> PRInfo { + guard let prInfoUrl = URL(string: "https://api.github.com/repos/\(repository)/pulls/\(prNumber)") else { + throw GenericError("Failed to form URL for GitHub API") + } + + do { + let data = try Data(contentsOf: prInfoUrl) + return try JSONDecoder().decode(PRInfo.self, from: data) + } catch { + throw GenericError("Failed to load PR info from \(prInfoUrl): \(error)") + } +} + +func getCrossRepoPrs(repository: String, prNumber: String) throws -> [CrossRepoPR] { + var result: [CrossRepoPR] = [] + let prInfo = try getPRInfo(repository: repository, prNumber: prNumber) + for line in prInfo.body.split(separator: "\n") { + guard line.lowercased().starts(with: "linked pr:") else { + continue + } + let repoRegex = Regex { + Capture { + #/swiftlang|apple/# + } + "/" + Capture { + #/[-a-zA-Z0-9_]+/# + } + ChoiceOf { + "/pull/" + "#" + } + Capture { + OneOrMore(.digit) + } + } + for match in line.matches(of: repoRegex) { + result.append( + CrossRepoPR(repositoryOwner: String(match.1), repositoryName: String(match.2), prNumber: String(match.3)) + ) + } + } + return result +} + +func main() throws { + print("Start") + print(ProcessInfo.processInfo.arguments) + + guard ProcessInfo.processInfo.arguments.count >= 3 else { + throw GenericError( + """ + Expected two arguments: + - Repository name, eg. `swiftlang/swift-syntax + - PR number + """ + ) + } + let repository = ProcessInfo.processInfo.arguments[1] + let prNumber = ProcessInfo.processInfo.arguments[2] + + let crossRepoPrs = try getCrossRepoPrs(repository: repository, prNumber: prNumber) + print("Detected cross-repo PRs: \(crossRepoPrs)") + + for crossRepoPr in crossRepoPrs { + let git = try lookup(executable: "git") + let swift = try lookup(executable: "swift") + let baseBranch = try getPRInfo( + repository: "\(crossRepoPr.repositoryOwner)/\(crossRepoPr.repositoryOwner)", + prNumber: crossRepoPr.prNumber + ).base.ref + + let workspaceDir = URL(fileURLWithPath: "..") + let repoDir = workspaceDir.appendingPathComponent(crossRepoPr.repositoryName) + try run( + git, + "clone", + "https://github.com/\(crossRepoPr.repositoryOwner)/\(crossRepoPr.repositoryName).git", + "\(crossRepoPr.repositoryName)", + workingDirectory: workspaceDir + ) + try run(git, "fetch", "origin", "pull/\(crossRepoPr.prNumber)/merge:pr_merge", workingDirectory: repoDir) + try run(git, "checkout", baseBranch, workingDirectory: repoDir) + try run(git, "reset", "--hard", "pr_merge", workingDirectory: repoDir) + try run( + swift, + "package", + "config", + "set-mirror", + "--package-url", + "https://github.com/\(crossRepoPr.repositoryOwner)/\(crossRepoPr.repositoryName).git", + "--mirror-url", + repoDir.resolvingSymlinksInPath().path + ) + } +} + +do { + try main() +} catch { + print(error) + exit(1) +}