Skip to content

Commit ac0ec00

Browse files
authored
feat(gh-pr-linking): link prs to gh via device mode login oauth app (#88)
1 parent be1c84e commit ac0ec00

9 files changed

Lines changed: 922 additions & 60 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import XCTest
2+
3+
@testable import StackNudgePanelCore
4+
5+
// GitHubAPI builds the GraphQL PR query and parses the response (PR state +
6+
// CI rollup), plus owner/repo extraction from a remote URL. Pure; no network.
7+
final class GitHubAPITests: XCTestCase {
8+
9+
func test_repoSlug_https() {
10+
let actual = GitHubAPI.repoSlug(fromRemoteURL: "https://github.com/StackOneHQ/stack-nudge.git")
11+
XCTAssertEqual(actual?.owner, "StackOneHQ")
12+
XCTAssertEqual(actual?.repo, "stack-nudge")
13+
}
14+
15+
func test_repoSlug_ssh() {
16+
let actual = GitHubAPI.repoSlug(fromRemoteURL: "git@github.com:StackOneHQ/stack-nudge.git")
17+
XCTAssertEqual(actual?.owner, "StackOneHQ")
18+
XCTAssertEqual(actual?.repo, "stack-nudge")
19+
}
20+
21+
func test_repoSlug_noGitSuffix() {
22+
let actual = GitHubAPI.repoSlug(fromRemoteURL: "https://github.com/o/r")
23+
XCTAssertEqual(actual?.owner, "o")
24+
XCTAssertEqual(actual?.repo, "r")
25+
}
26+
27+
func test_repoSlug_nonGitHub_isNil() {
28+
XCTAssertNil(GitHubAPI.repoSlug(fromRemoteURL: "https://gitlab.com/o/r.git"))
29+
}
30+
31+
func test_query_carriesVariables() {
32+
let actual = GitHubAPI.query(owner: "o", repo: "r", branch: "ENG-1/x")
33+
XCTAssertTrue(actual.contains("headRefName"))
34+
XCTAssertTrue(actual.contains("\"branch\":\"ENG-1\\/x\"") || actual.contains("\"branch\":\"ENG-1/x\""))
35+
XCTAssertTrue(actual.contains("statusCheckRollup"))
36+
}
37+
38+
private func response(state: String, isDraft: Bool = false, ci: String?) -> String {
39+
let rollup = ci.map { "{\"state\":\"\($0)\"}" } ?? "null"
40+
return """
41+
{"data":{"repository":{"pullRequests":{"nodes":[{
42+
"number":86,"url":"https://github.com/o/r/pull/86","state":"\(state)","isDraft":\(isDraft),
43+
"commits":{"nodes":[{"commit":{"statusCheckRollup":\(rollup)}}]}}]}}}}
44+
"""
45+
}
46+
47+
func test_parse_openWithPassingCI() {
48+
let actual = GitHubAPI.parse(response(state: "OPEN", ci: "SUCCESS"))
49+
XCTAssertEqual(actual, PullRequestInfo(number: 86, url: "https://github.com/o/r/pull/86",
50+
state: .open, isDraft: false, ci: .passing))
51+
}
52+
53+
func test_parse_mergedNoCI() {
54+
let actual = GitHubAPI.parse(response(state: "MERGED", ci: nil))
55+
XCTAssertEqual(actual?.state, .merged)
56+
XCTAssertNil(actual?.ci)
57+
}
58+
59+
func test_parse_draftPendingCI() {
60+
let actual = GitHubAPI.parse(response(state: "OPEN", isDraft: true, ci: "PENDING"))
61+
XCTAssertEqual(actual?.isDraft, true)
62+
XCTAssertEqual(actual?.ci, .pending)
63+
}
64+
65+
func test_parse_failingCI() {
66+
XCTAssertEqual(GitHubAPI.parse(response(state: "OPEN", ci: "FAILURE"))?.ci, .failing)
67+
}
68+
69+
func test_parse_noPRNodes_isNil() {
70+
XCTAssertNil(GitHubAPI.parse(#"{"data":{"repository":{"pullRequests":{"nodes":[]}}}}"#))
71+
}
72+
73+
func test_parse_malformed_isNil() {
74+
XCTAssertNil(GitHubAPI.parse("not json"))
75+
XCTAssertNil(GitHubAPI.parse(#"{"data":{"repository":null}}"#))
76+
}
77+
78+
func test_pullRequest_nilRunYieldsNil() {
79+
XCTAssertNil(GitHubAPI.pullRequest(owner: "o", repo: "r", branch: "b") { _ in nil })
80+
}
81+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import XCTest
2+
3+
@testable import StackNudgePanelCore
4+
5+
// GitHubAuth parses the OAuth device-flow responses. These pin the device-code
6+
// fields (and interval/expiry defaults) and the poll error → result mapping.
7+
final class GitHubAuthTests: XCTestCase {
8+
9+
func test_parseDeviceCode_full() {
10+
let json = """
11+
{"device_code":"dc","user_code":"4F2A-9C7B",
12+
"verification_uri":"https://github.com/login/device","interval":5,"expires_in":900}
13+
"""
14+
let actual = GitHubAuth.parseDeviceCode(Data(json.utf8))
15+
XCTAssertEqual(actual, DeviceCodeResponse(deviceCode: "dc", userCode: "4F2A-9C7B",
16+
verificationURI: "https://github.com/login/device",
17+
interval: 5, expiresIn: 900))
18+
}
19+
20+
func test_parseDeviceCode_defaultsWhenIntervalMissing() {
21+
let json = #"{"device_code":"dc","user_code":"u","verification_uri":"v"}"#
22+
let actual = GitHubAuth.parseDeviceCode(Data(json.utf8))
23+
XCTAssertEqual(actual?.interval, 5)
24+
XCTAssertEqual(actual?.expiresIn, 900)
25+
}
26+
27+
func test_parseDeviceCode_malformed_isNil() {
28+
XCTAssertNil(GitHubAuth.parseDeviceCode(Data("nope".utf8)))
29+
XCTAssertNil(GitHubAuth.parseDeviceCode(Data(#"{"user_code":"u"}"#.utf8)))
30+
}
31+
32+
func test_parsePoll_token() {
33+
let json = #"{"access_token":"gho_abc","token_type":"bearer","scope":"repo"}"#
34+
XCTAssertEqual(GitHubAuth.parsePoll(Data(json.utf8)), .token("gho_abc"))
35+
}
36+
37+
func test_parsePoll_pending() {
38+
XCTAssertEqual(GitHubAuth.parsePoll(Data(#"{"error":"authorization_pending"}"#.utf8)), .pending)
39+
}
40+
41+
func test_parsePoll_slowDown_carriesInterval() {
42+
XCTAssertEqual(GitHubAuth.parsePoll(Data(#"{"error":"slow_down","interval":10}"#.utf8)), .slowDown(10))
43+
}
44+
45+
func test_parsePoll_expired() {
46+
XCTAssertEqual(GitHubAuth.parsePoll(Data(#"{"error":"expired_token"}"#.utf8)), .expired)
47+
}
48+
49+
func test_parsePoll_denied() {
50+
XCTAssertEqual(GitHubAuth.parsePoll(Data(#"{"error":"access_denied"}"#.utf8)), .denied)
51+
}
52+
53+
func test_parsePoll_unknownError_isFailed() {
54+
XCTAssertEqual(GitHubAuth.parsePoll(Data(#"{"error":"teapot"}"#.utf8)), .failed("teapot"))
55+
}
56+
57+
func test_parsePoll_garbage_isFailed() {
58+
XCTAssertEqual(GitHubAuth.parsePoll(Data("???".utf8)), .failed("bad json"))
59+
}
60+
}

build.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,8 @@ build_app "$APP" "stack-nudge" \
248248
panel/TicketAttribution.swift \
249249
panel/GitSnapshot.swift \
250250
panel/OutcomeWatcher.swift \
251+
panel/GitHubAPI.swift \
252+
panel/GitHubAuth.swift \
251253
panel/Handoff.swift \
252254
panel/HandoffLedger.swift \
253255
panel/OutcomesView.swift \

panel/GitHubAPI.swift

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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

Comments
 (0)