Skip to content

Commit b6af73b

Browse files
committed
Enable cross-PR testing
1 parent d82d736 commit b6af73b

File tree

7 files changed

+522
-11
lines changed

7 files changed

+522
-11
lines changed

.github/workflows/pull_request.yml

+15-6
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,19 @@ on:
77
jobs:
88
tests:
99
name: Test
10-
uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@main
11-
soundness:
12-
name: Soundness
13-
uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main
10+
uses: ahoppen/github-workflows/.github/workflows/swift_package_test.yml@windows-error-propagation
1411
with:
15-
license_header_check_enabled: false
16-
license_header_check_project_name: "Swift.org"
12+
linux_pre_build_command: |
13+
cd cross-pr-checkout/Sources/cross-pr-checkout
14+
swift cross-pr-checkout.swift "${{ github.repository }}" "${{ github.event.number }}"
15+
windows_pre_build_command: |
16+
mkdir $env:TEMP\cross-pr-checkout
17+
cp cross-pr-checkout\Sources\cross-pr-checkout\main.swift $env:TEMP\cross-pr-checkout
18+
swiftc -sdk $env:SDKROOT $env:TEMP\cross-pr-checkout\main.swift -o $env:TEMP\cross-pr-checkout\main.exe
19+
$env:TEMP\cross-pr-checkout\main.exe "${{ github.repository }}" "${{ github.event.number }}"
20+
# soundness:
21+
# name: Soundness
22+
# uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main
23+
# with:
24+
# license_header_check_enabled: false
25+
# license_header_check_project_name: "Swift.org"

Sources/SwiftFormat/Rules/UseShorthandTypeNames.swift

+5-5
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public final class UseShorthandTypeNames: SyntaxFormatRule {
4848
switch node.name.text {
4949
case "Array":
5050
guard let argument = genericArgumentList.firstAndOnly,
51-
case .type(let typeArgument) = argument else {
51+
case .type(let typeArgument) = argument.argument else {
5252
newNode = nil
5353
break
5454
}
@@ -62,7 +62,7 @@ public final class UseShorthandTypeNames: SyntaxFormatRule {
6262
case "Dictionary":
6363
guard let arguments = exactlyTwoChildren(of: genericArgumentList),
6464
case .type(let type0Argument) = arguments.0.argument,
65-
caes .type(let type1Argument) = arguments.1.argument else {
65+
case .type(let type1Argument) = arguments.1.argument else {
6666
newNode = nil
6767
break
6868
}
@@ -79,7 +79,7 @@ public final class UseShorthandTypeNames: SyntaxFormatRule {
7979
break
8080
}
8181
guard let argument = genericArgumentList.firstAndOnly,
82-
case .type(let typeArgument) = argument else {
82+
case .type(let typeArgument) = argument.argument else {
8383
newNode = nil
8484
break
8585
}
@@ -143,7 +143,7 @@ public final class UseShorthandTypeNames: SyntaxFormatRule {
143143
switch expression.baseName.text {
144144
case "Array":
145145
guard let argument = genericArgumentList.firstAndOnly,
146-
case .type(let typeArgument) = argument else {
146+
case .type(let typeArgument) = argument.argument else {
147147
newNode = nil
148148
break
149149
}
@@ -172,7 +172,7 @@ public final class UseShorthandTypeNames: SyntaxFormatRule {
172172

173173
case "Optional":
174174
guard let argument = genericArgumentList.firstAndOnly,
175-
case .type(let typeArgument) = argument else {
175+
case .type(let typeArgument) = argument.argument else {
176176
newNode = nil
177177
break
178178
}

cross-pr-checkout.py

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import subprocess
2+
import pathlib
3+
import requests
4+
5+
class CrossRepoPR:
6+
org: str
7+
repo: str
8+
pr_num: str
9+
10+
def __init__(self, org: str, repo: str, pr_num: str) -> None:
11+
self.org = org
12+
self.repo = repo
13+
self.pr_num = pr_num
14+
15+
def cross_repo_prs() -> list[CrossRepoPR]:
16+
return [
17+
CrossRepoPR("swiftlang", "swift-syntax", "2859")
18+
]
19+
20+
def run(cmd: list[str], cwd: str|None = None):
21+
print(" ".join(cmd))
22+
subprocess.check_call(cmd, cwd=cwd)
23+
24+
def main():
25+
for cross_repo_pr in cross_repo_prs():
26+
run(["git", "clone", f"https://github.com/{cross_repo_pr.org}/{cross_repo_pr.repo}.git", f"{cross_repo_pr.repo}"], cwd="..")
27+
run(["git", "fetch", "origin", f"pull/{cross_repo_pr.pr_num}/merge:pr_merge"], cwd="../swift-syntax")
28+
run(["git", "checkout", "main"], cwd="../swift-syntax")
29+
run(["git", "reset", "--hard", "pr_merge"], cwd="../swift-syntax")
30+
run(["swift", "package", "config", "set-mirror", "--package-url", "https://github.com/swiftlang/swift-syntax.git", "--mirror-url", str(pathlib.Path("../swift-syntax").resolve())])
31+
32+
if __name__ == "__main__":
33+
main()

cross-pr-checkout.swift

+217
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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+
}

cross-pr-checkout/.gitignore

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
xcuserdata/
5+
DerivedData/
6+
.swiftpm/configuration/registries.json
7+
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8+
.netrc

cross-pr-checkout/Package.swift

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// swift-tools-version: 5.8
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "cross-pr-checkout",
6+
platforms: [.macOS(.v13)],
7+
targets: [
8+
.executableTarget(name: "cross-pr-checkout")
9+
]
10+
)

0 commit comments

Comments
 (0)