Skip to content

Commit 29f4d6b

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

File tree

4 files changed

+233
-10
lines changed

4 files changed

+233
-10
lines changed

.github/workflows/pull_request.yml

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,14 @@ jobs:
88
tests:
99
name: Test
1010
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
1411
with:
15-
license_header_check_enabled: false
16-
license_header_check_project_name: "Swift.org"
12+
enable_windows_checks: false
13+
linux_pre_build_command: |
14+
swiftc cross-pr-checkout.swift -o /tmp/cross-pr-checkout
15+
/tmp/cross-pr-checkout "${{ github.repository }}" "${{ github.event.number }}"
16+
# soundness:
17+
# name: Soundness
18+
# uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main
19+
# with:
20+
# license_header_check_enabled: false
21+
# license_header_check_project_name: "Swift.org"

Sources/SwiftFormat/Rules/UseShorthandTypeNames.swift

Lines changed: 5 additions & 5 deletions
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

Lines changed: 33 additions & 0 deletions
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

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import Foundation
2+
import RegexBuilder
3+
4+
#if canImport(FoundationNetworking)
5+
import FoundationNetworking
6+
#endif
7+
8+
struct GenericError: Error, CustomStringConvertible {
9+
var description: String
10+
11+
init(_ description: String) {
12+
self.description = description
13+
}
14+
}
15+
16+
func escapeCommand(_ executable: URL, _ arguments: [String]) -> String {
17+
return ([executable.path] + arguments).map {
18+
if $0.contains(" ") {
19+
return "'\($0)'"
20+
}
21+
return $0
22+
}.joined(separator: " ")
23+
}
24+
25+
/// Launch a subprocess with the given command and wait for it to finish
26+
func run(_ executable: URL, _ arguments: String..., workingDirectory: URL? = nil) throws {
27+
print("Running \(escapeCommand(executable, arguments))")
28+
let process = Process()
29+
process.executableURL = executable
30+
process.arguments = arguments
31+
if let workingDirectory {
32+
process.currentDirectoryURL = workingDirectory
33+
}
34+
35+
try process.run()
36+
process.waitUntilExit()
37+
guard process.terminationStatus == 0 else {
38+
throw GenericError(
39+
"\(escapeCommand(executable, arguments)) failed with non-zero exit code: \(process.terminationStatus)"
40+
)
41+
}
42+
}
43+
44+
/// Find the executable with the given name
45+
public func lookup(executable: String) throws -> URL {
46+
// Compute search paths from PATH variable.
47+
#if os(Windows)
48+
let pathSeparator: Character = ";"
49+
let pathVariable = "Path"
50+
#else
51+
let pathSeparator: Character = ":"
52+
let pathVariable = "PATH"
53+
#endif
54+
guard let pathString = ProcessInfo.processInfo.environment[pathVariable] else {
55+
throw GenericError("Failed to read path environment variable")
56+
}
57+
for searchPath in pathString.split(separator: pathSeparator) {
58+
let candidateUrl = URL(fileURLWithPath: String(searchPath)).appendingPathComponent(executable)
59+
if FileManager.default.isExecutableFile(atPath: candidateUrl.path) {
60+
return candidateUrl
61+
}
62+
}
63+
throw GenericError("Did not find \(executable)")
64+
}
65+
66+
struct CrossRepoPR {
67+
let repositoryOwner: String
68+
let repositoryName: String
69+
let prNumber: String
70+
}
71+
72+
/// The JSON fields of the `https://api.github.com/repos/\(repository)/pulls/\(prNumber)` endpoint that we care about.
73+
struct PRInfo: Codable {
74+
struct Base: Codable {
75+
let ref: String
76+
}
77+
let base: Base
78+
let body: String?
79+
}
80+
81+
/// - Parameters:
82+
/// - repository: The repository's name, eg. `swiftlang/swift-syntax`
83+
func getPRInfo(repository: String, prNumber: String) throws -> PRInfo {
84+
guard let prInfoUrl = URL(string: "https://api.github.com/repos/\(repository)/pulls/\(prNumber)") else {
85+
throw GenericError("Failed to form URL for GitHub API")
86+
}
87+
88+
do {
89+
let data = try Data(contentsOf: prInfoUrl)
90+
return try JSONDecoder().decode(PRInfo.self, from: data)
91+
} catch {
92+
throw GenericError("Failed to load PR info from \(prInfoUrl): \(error)")
93+
}
94+
}
95+
96+
func getCrossRepoPrs(repository: String, prNumber: String) throws -> [CrossRepoPR] {
97+
var result: [CrossRepoPR] = []
98+
let prInfo = try getPRInfo(repository: repository, prNumber: prNumber)
99+
for line in prInfo.body?.split(separator: "\n") ?? [] {
100+
guard line.lowercased().starts(with: "linked pr:") else {
101+
continue
102+
}
103+
let repoRegex = Regex {
104+
Capture {
105+
#/swiftlang|apple/#
106+
}
107+
"/"
108+
Capture {
109+
#/[-a-zA-Z0-9_]+/#
110+
}
111+
ChoiceOf {
112+
"/pull/"
113+
"#"
114+
}
115+
Capture {
116+
OneOrMore(.digit)
117+
}
118+
}
119+
for match in line.matches(of: repoRegex) {
120+
result.append(
121+
CrossRepoPR(repositoryOwner: String(match.1), repositoryName: String(match.2), prNumber: String(match.3))
122+
)
123+
}
124+
}
125+
return result
126+
}
127+
128+
func main() throws {
129+
print("Start")
130+
print(ProcessInfo.processInfo.arguments)
131+
132+
guard ProcessInfo.processInfo.arguments.count >= 3 else {
133+
throw GenericError(
134+
"""
135+
Expected two arguments:
136+
- Repository name, eg. `swiftlang/swift-syntax
137+
- PR number
138+
"""
139+
)
140+
}
141+
let repository = ProcessInfo.processInfo.arguments[1]
142+
let prNumber = ProcessInfo.processInfo.arguments[2]
143+
144+
let crossRepoPrs = try getCrossRepoPrs(repository: repository, prNumber: prNumber)
145+
print("Detected cross-repo PRs: \(crossRepoPrs)")
146+
147+
for crossRepoPr in crossRepoPrs {
148+
let git = try lookup(executable: "git")
149+
let swift = try lookup(executable: "swift")
150+
let baseBranch = try getPRInfo(
151+
repository: "\(crossRepoPr.repositoryOwner)/\(crossRepoPr.repositoryName)",
152+
prNumber: crossRepoPr.prNumber
153+
).base.ref
154+
155+
let workspaceDir = URL(fileURLWithPath: "..")
156+
let repoDir = workspaceDir.appendingPathComponent(crossRepoPr.repositoryName)
157+
try run(
158+
git,
159+
"clone",
160+
"https://github.com/\(crossRepoPr.repositoryOwner)/\(crossRepoPr.repositoryName).git",
161+
"\(crossRepoPr.repositoryName)",
162+
workingDirectory: workspaceDir
163+
)
164+
try run(git, "fetch", "origin", "pull/\(crossRepoPr.prNumber)/merge:pr_merge", workingDirectory: repoDir)
165+
try run(git, "checkout", baseBranch, workingDirectory: repoDir)
166+
try run(git, "reset", "--hard", "pr_merge", workingDirectory: repoDir)
167+
try run(
168+
swift,
169+
"package",
170+
"config",
171+
"set-mirror",
172+
"--package-url",
173+
"https://github.com/\(crossRepoPr.repositoryOwner)/\(crossRepoPr.repositoryName).git",
174+
"--mirror-url",
175+
repoDir.resolvingSymlinksInPath().path
176+
)
177+
}
178+
}
179+
180+
do {
181+
try main()
182+
} catch {
183+
print(error)
184+
exit(1)
185+
}

0 commit comments

Comments
 (0)