Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,34 @@ public actor GitHubPullRequestRequestCoordinator {
private static let maximumConcurrentTransportCount = 3
private static let maximumRateLimitIdentityCount = 32

/// Product token that identifies cmux's pull-request poller in the GitHub `User-Agent` header.
private static let userAgentProductToken = "cmux-workspace-pr-poller"

/// `User-Agent` sent with every pull-request probe, formatted as `Product/Version`.
///
/// The version is resolved once from the host app bundle's
/// `CFBundleShortVersionString`, so it always reflects the shipped release
/// without any manual update. Formatting is factored into
/// `userAgentValue(appVersion:)` so it can be unit-tested without depending
/// on the bundle.
private static let userAgentHeaderValue = userAgentValue(
appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
)

/// Builds the poller `User-Agent`, appending the app version when available.
///
/// The product token is always emitted as the leading component so
/// server-side matching on `cmux-workspace-pr-poller` keeps working. When no
/// version is available the token is paired with `unknown` to preserve the
/// `Product/Version` shape.
static func userAgentValue(appVersion: String?) -> String {
let version = appVersion?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let version, !version.isEmpty else {
return "\(userAgentProductToken)/unknown"
}
return "\(userAgentProductToken)/\(version)"
}

internal struct RequestKey: Hashable, Sendable {
let endpoint: String
let authorizationFingerprint: Data
Expand Down Expand Up @@ -173,7 +201,7 @@ public actor GitHubPullRequestRequestCoordinator {
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
request.setValue("cmux-workspace-pr-poller", forHTTPHeaderField: "User-Agent")
request.setValue(Self.userAgentHeaderValue, forHTTPHeaderField: "User-Agent")
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
let cachedResponse = cachedResponseByRequestKey[requestKey]
if let cachedResponse {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -420,4 +420,47 @@ struct GitHubPullRequestRequestTests {
#expect(await survivor.value?.statusCode == 200)
#expect(GitHubPullRequestStubURLProtocol.capturedRequests().count == 1)
}

@Test func userAgentValueAppendsAppVersion() {
#expect(
GitHubPullRequestRequestCoordinator.userAgentValue(appVersion: "1.2.3")
== "cmux-workspace-pr-poller/1.2.3"
)
}

@Test func userAgentValueTrimsSurroundingWhitespace() {
#expect(
GitHubPullRequestRequestCoordinator.userAgentValue(appVersion: " 0.64.21 ")
== "cmux-workspace-pr-poller/0.64.21"
)
}

@Test func userAgentValueFallsBackWhenVersionMissing() {
#expect(
GitHubPullRequestRequestCoordinator.userAgentValue(appVersion: nil)
== "cmux-workspace-pr-poller/unknown"
)
#expect(
GitHubPullRequestRequestCoordinator.userAgentValue(appVersion: " ")
== "cmux-workspace-pr-poller/unknown"
)
}

@Test func pollerRequestSendsProductTokenUserAgent() async {
GitHubPullRequestStubURLProtocol.reset(stubs: [
.init(statusCode: 200, data: Data("[]".utf8)),
])
let coordinator = GitHubPullRequestRequestCoordinator(session: makeSession())

_ = await coordinator.response(endpoint: endpoint, authHeader: "******")

let userAgent = GitHubPullRequestStubURLProtocol.capturedRequests()
.first?
.value(forHTTPHeaderField: "User-Agent")
let expected = GitHubPullRequestRequestCoordinator.userAgentValue(
appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
)
#expect(userAgent == expected)
#expect(userAgent?.hasPrefix("cmux-workspace-pr-poller/") == true)
}
Comment thread
joshfree marked this conversation as resolved.
}