diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Probe/GitHubPullRequestRequestCoordinator.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Probe/GitHubPullRequestRequestCoordinator.swift index db608983fe0..759f399db22 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Probe/GitHubPullRequestRequestCoordinator.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Probe/GitHubPullRequestRequestCoordinator.swift @@ -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 @@ -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 { diff --git a/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitHubPullRequestRequestTests.swift b/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitHubPullRequestRequestTests.swift index 6f84c095912..32a5b262117 100644 --- a/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitHubPullRequestRequestTests.swift +++ b/Packages/macOS/CmuxGit/Tests/CmuxGitTests/GitHubPullRequestRequestTests.swift @@ -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) + } }