|
| 1 | +import Foundation |
| 2 | + |
| 3 | +// PR lifecycle as GitHub reports it. `merged` is authoritative for "did it |
| 4 | +// ship?" — it closes the squash-merge gap the local OutcomeWatcher can't see. |
| 5 | +enum PRState: String, Equatable { |
| 6 | + case open = "OPEN" |
| 7 | + case closed = "CLOSED" |
| 8 | + case merged = "MERGED" |
| 9 | +} |
| 10 | + |
| 11 | +// Collapsed CI signal for a PR (from the GraphQL statusCheckRollup). |
| 12 | +enum CIStatus: String, Equatable { |
| 13 | + case pending |
| 14 | + case passing |
| 15 | + case failing |
| 16 | +} |
| 17 | + |
| 18 | +struct PullRequestInfo: Equatable { |
| 19 | + let number: Int |
| 20 | + let url: String |
| 21 | + let state: PRState |
| 22 | + let isDraft: Bool |
| 23 | + let ci: CIStatus? // nil when the PR has no checks |
| 24 | +} |
| 25 | + |
| 26 | +// gh-free GitHub reads: query a branch's newest PR (+ CI rollup) via the GraphQL |
| 27 | +// API using our own token (see GitHubAuth). GraphQL `headRefName` matches by |
| 28 | +// branch name regardless of which fork the head lives on, and statusCheckRollup |
| 29 | +// gives the CI state in the same round-trip — both awkward over REST. The HTTP |
| 30 | +// runner is injected so the query building + response parsing stay unit-testable. |
| 31 | +enum GitHubAPI { |
| 32 | + |
| 33 | + static let graphQLEndpoint = URL(string: "https://api.github.com/graphql")! |
| 34 | + |
| 35 | + // owner/repo from a remote URL — https (`https://github.com/o/r.git`) or |
| 36 | + // ssh (`git@github.com:o/r.git`). nil when it isn't a github.com remote. |
| 37 | + static func repoSlug(fromRemoteURL url: String) -> (owner: String, repo: String)? { |
| 38 | + let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) |
| 39 | + guard let marker = trimmed.range(of: "github.com") else { return nil } |
| 40 | + var path = String(trimmed[marker.upperBound...]) |
| 41 | + path = String(path.drop(while: { $0 == ":" || $0 == "/" })) // strip ssh ':' / https '/' |
| 42 | + if path.hasSuffix(".git") { path = String(path.dropLast(4)) } |
| 43 | + let parts = path.split(separator: "/") |
| 44 | + guard parts.count >= 2 else { return nil } |
| 45 | + return (String(parts[0]), String(parts[1])) |
| 46 | + } |
| 47 | + |
| 48 | + // GraphQL request body (JSON string) for the newest PR on `branch`. |
| 49 | + static func query(owner: String, repo: String, branch: String) -> String { |
| 50 | + let graphql = """ |
| 51 | + query($owner:String!,$repo:String!,$branch:String!){\ |
| 52 | + repository(owner:$owner,name:$repo){\ |
| 53 | + pullRequests(headRefName:$branch,first:1,orderBy:{field:CREATED_AT,direction:DESC}){\ |
| 54 | + nodes{number url state isDraft \ |
| 55 | + commits(last:1){nodes{commit{statusCheckRollup{state}}}}}}}} |
| 56 | + """ |
| 57 | + let body: [String: Any] = [ |
| 58 | + "query": graphql, |
| 59 | + "variables": ["owner": owner, "repo": repo, "branch": branch], |
| 60 | + ] |
| 61 | + let data = (try? JSONSerialization.data(withJSONObject: body)) ?? Data() |
| 62 | + return String(data: data, encoding: .utf8) ?? "" |
| 63 | + } |
| 64 | + |
| 65 | + // Fetch a branch's PR via the injected runner (`run(graphQLBody) -> json?`). |
| 66 | + static func pullRequest(owner: String, repo: String, branch: String, |
| 67 | + run: (String) -> String?) -> PullRequestInfo? { |
| 68 | + guard let response = run(query(owner: owner, repo: repo, branch: branch)) else { return nil } |
| 69 | + return parse(response) |
| 70 | + } |
| 71 | + |
| 72 | + static func parse(_ json: String) -> PullRequestInfo? { |
| 73 | + guard let data = json.data(using: .utf8), |
| 74 | + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], |
| 75 | + let node = firstPRNode(root), |
| 76 | + let number = node["number"] as? Int, |
| 77 | + let url = node["url"] as? String, |
| 78 | + let stateRaw = node["state"] as? String, |
| 79 | + let state = PRState(rawValue: stateRaw) |
| 80 | + else { return nil } |
| 81 | + return PullRequestInfo( |
| 82 | + number: number, |
| 83 | + url: url, |
| 84 | + state: state, |
| 85 | + isDraft: node["isDraft"] as? Bool ?? false, |
| 86 | + ci: ciStatus(fromNode: node)) |
| 87 | + } |
| 88 | + |
| 89 | + private static func firstPRNode(_ root: [String: Any]) -> [String: Any]? { |
| 90 | + let data = root["data"] as? [String: Any] |
| 91 | + let repository = data?["repository"] as? [String: Any] |
| 92 | + let pullRequests = repository?["pullRequests"] as? [String: Any] |
| 93 | + let nodes = pullRequests?["nodes"] as? [[String: Any]] |
| 94 | + return nodes?.first |
| 95 | + } |
| 96 | + |
| 97 | + // statusCheckRollup.state on the PR's latest commit → one CI signal. |
| 98 | + static func ciStatus(fromNode node: [String: Any]) -> CIStatus? { |
| 99 | + guard let commitNodes = (node["commits"] as? [String: Any])?["nodes"] as? [[String: Any]], |
| 100 | + let commit = commitNodes.first?["commit"] as? [String: Any], |
| 101 | + let rollup = commit["statusCheckRollup"] as? [String: Any], |
| 102 | + let state = (rollup["state"] as? String)?.uppercased() |
| 103 | + else { return nil } |
| 104 | + switch state { |
| 105 | + case "SUCCESS": return .passing |
| 106 | + case "FAILURE", "ERROR": return .failing |
| 107 | + case "PENDING", "EXPECTED": return .pending |
| 108 | + default: return nil |
| 109 | + } |
| 110 | + } |
| 111 | +} |
0 commit comments