-
Notifications
You must be signed in to change notification settings - Fork 18
Deduplicate GitHub Copilot accounts #140
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,6 +33,20 @@ actor CopilotCLIProvider: ProviderProtocol { | |
| let usage: ProviderUsage | ||
| let details: DetailedUsage | ||
| let sourcePriority: Int | ||
| let isPlaceholder: Bool | ||
|
|
||
| var dedupeInput: CopilotCandidateDedupeInput { | ||
| CopilotCandidateDedupeInput( | ||
| accountId: accountId, | ||
| email: details.email, | ||
| planType: details.planType, | ||
| totalEntitlement: usage.totalEntitlement, | ||
| remainingQuota: usage.remainingQuota, | ||
| usedRequests: details.copilotUsedRequests, | ||
| limitRequests: details.copilotLimitRequests, | ||
| isPlaceholder: isPlaceholder | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| // MARK: - ProviderProtocol Implementation | ||
|
|
@@ -256,7 +270,8 @@ actor CopilotCLIProvider: ProviderProtocol { | |
| accountId: login, | ||
| usage: providerUsage, | ||
| details: details, | ||
| sourcePriority: sourcePriority | ||
| sourcePriority: sourcePriority, | ||
| isPlaceholder: false | ||
| ) | ||
| } | ||
|
|
||
|
|
@@ -287,7 +302,8 @@ actor CopilotCLIProvider: ProviderProtocol { | |
| accountId: info.accountId ?? info.login, | ||
| usage: providerUsage, | ||
| details: details, | ||
| sourcePriority: sourcePriority(info.source) | ||
| sourcePriority: sourcePriority(info.source), | ||
| isPlaceholder: false | ||
| ) | ||
| } | ||
|
|
||
|
|
@@ -297,10 +313,11 @@ actor CopilotCLIProvider: ProviderProtocol { | |
| candidates: [CopilotAccountCandidate], | ||
| cookieCandidate: CopilotAccountCandidate? | ||
| ) -> ProviderResult { | ||
| let candidates = removePlaceholderCandidatesWhenRealUsageExists(candidates) | ||
| let merged = CandidateDedupe.merge( | ||
| candidates, | ||
| accountId: { $0.accountId }, | ||
| isSameUsage: { _, _ in false }, | ||
| accountId: { CopilotCandidateDedupe.normalizedIdentity($0.accountId) }, | ||
| isSameUsage: { isDuplicateCopilotUsage($0, $1) }, | ||
| priority: { $0.sourcePriority } | ||
| ) | ||
| let sorted = merged.sorted { $0.sourcePriority > $1.sourcePriority } | ||
|
|
@@ -338,7 +355,7 @@ actor CopilotCLIProvider: ProviderProtocol { | |
| ) | ||
| } | ||
|
|
||
| let primaryDetails = cookieCandidate?.details ?? accountResults.first?.details | ||
| let primaryDetails = accountResults.first?.details ?? cookieCandidate?.details | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The CLI provider now prefers Useful? React with 👍 / 👎. |
||
|
|
||
| logger.info("CopilotCLIProvider: Finalized \(accountResults.count) account row(s)") | ||
|
|
||
|
|
@@ -349,6 +366,29 @@ actor CopilotCLIProvider: ProviderProtocol { | |
| ) | ||
| } | ||
|
|
||
| private func removePlaceholderCandidatesWhenRealUsageExists( | ||
| _ candidates: [CopilotAccountCandidate] | ||
| ) -> [CopilotAccountCandidate] { | ||
| let hasRealUsage = candidates.contains { candidate in | ||
| (candidate.usage.totalEntitlement ?? 0) > 0 | ||
| } | ||
| guard hasRealUsage else { return candidates } | ||
|
|
||
| return candidates.filter { candidate in | ||
| !CopilotCandidateDedupe.shouldDropPlaceholder( | ||
| candidate.dedupeInput, | ||
| whenAnyCandidateHasRealUsage: hasRealUsage | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private func isDuplicateCopilotUsage( | ||
| _ lhs: CopilotAccountCandidate, | ||
| _ rhs: CopilotAccountCandidate | ||
| ) -> Bool { | ||
| CopilotCandidateDedupe.isSameAccountUsage(lhs.dedupeInput, rhs.dedupeInput) | ||
| } | ||
|
|
||
|
Comment on lines
+369
to
+391
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
since the only thing tying them to the enclosing type is the private
otherwise the next time someone tweaks dedup behavior (and the previous review already shows that's a frequent area), they'll have to remember to mirror the change in both files — and the GUI/CLI drift problem the previous review flagged is right back. |
||
| // MARK: - Cookie-based Fetching (fallback) | ||
|
|
||
| private func fetchCustomerId(cookies: GitHubCookies) async -> String? { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -168,6 +168,20 @@ final class CopilotProvider: ProviderProtocol { | |
| let usage: ProviderUsage | ||
| let details: DetailedUsage | ||
| let sourcePriority: Int | ||
| let isPlaceholder: Bool | ||
|
|
||
| var dedupeInput: CopilotCandidateDedupeInput { | ||
| CopilotCandidateDedupeInput( | ||
| accountId: accountId, | ||
| email: details.email, | ||
| planType: details.planType, | ||
| totalEntitlement: usage.totalEntitlement, | ||
| remainingQuota: usage.remainingQuota, | ||
| usedRequests: details.copilotUsedRequests, | ||
| limitRequests: details.copilotLimitRequests, | ||
| isPlaceholder: isPlaceholder | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private func sourcePriority(_ source: CopilotAuthSource?) -> Int { | ||
|
|
@@ -289,7 +303,8 @@ final class CopilotProvider: ProviderProtocol { | |
| accountId: login, | ||
| usage: providerUsage, | ||
| details: details, | ||
| sourcePriority: sourcePriority | ||
| sourcePriority: sourcePriority, | ||
| isPlaceholder: false | ||
| ) | ||
| } | ||
|
|
||
|
|
@@ -320,7 +335,8 @@ final class CopilotProvider: ProviderProtocol { | |
| accountId: info.accountId ?? info.login, | ||
| usage: providerUsage, | ||
| details: details, | ||
| sourcePriority: sourcePriority(info.source) | ||
| sourcePriority: sourcePriority(info.source), | ||
| isPlaceholder: false | ||
| ) | ||
| } | ||
|
|
||
|
|
@@ -331,18 +347,20 @@ final class CopilotProvider: ProviderProtocol { | |
| accountId: details.email, | ||
| usage: cachedResult.usage, | ||
| details: details, | ||
| sourcePriority: 0 | ||
| sourcePriority: 0, | ||
| isPlaceholder: true | ||
| ) | ||
| } | ||
|
|
||
| private func finalizeResult( | ||
| candidates: [CopilotAccountCandidate], | ||
| cookieCandidate: CopilotAccountCandidate? | ||
| ) -> ProviderResult { | ||
| let candidates = removePlaceholderCandidatesWhenRealUsageExists(candidates) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
probably safer to prefer |
||
| let merged = CandidateDedupe.merge( | ||
| candidates, | ||
| accountId: { $0.accountId }, | ||
| isSameUsage: { _, _ in false }, | ||
| accountId: { CopilotCandidateDedupe.normalizedIdentity($0.accountId) }, | ||
| isSameUsage: { isDuplicateCopilotUsage($0, $1) }, | ||
| priority: { $0.sourcePriority } | ||
| ) | ||
|
Comment on lines
+359
to
365
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
since both providers share the exact same |
||
| let sorted = merged.sorted { $0.sourcePriority > $1.sourcePriority } | ||
|
|
@@ -380,7 +398,7 @@ final class CopilotProvider: ProviderProtocol { | |
| ) | ||
| } | ||
|
|
||
| let primaryDetails = cookieCandidate?.details ?? accountResults.first?.details | ||
| let primaryDetails = accountResults.first?.details ?? cookieCandidate?.details | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Selecting Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In Useful? React with 👍 / 👎. |
||
|
|
||
| logger.info("CopilotProvider: Finalized \(accountResults.count) account row(s)") | ||
|
|
||
|
|
@@ -391,6 +409,29 @@ final class CopilotProvider: ProviderProtocol { | |
| ) | ||
| } | ||
|
|
||
| private func removePlaceholderCandidatesWhenRealUsageExists( | ||
| _ candidates: [CopilotAccountCandidate] | ||
| ) -> [CopilotAccountCandidate] { | ||
| let hasRealUsage = candidates.contains { candidate in | ||
| (candidate.usage.totalEntitlement ?? 0) > 0 | ||
| } | ||
| guard hasRealUsage else { return candidates } | ||
|
|
||
| return candidates.filter { candidate in | ||
| !CopilotCandidateDedupe.shouldDropPlaceholder( | ||
| candidate.dedupeInput, | ||
| whenAnyCandidateHasRealUsage: hasRealUsage | ||
| ) | ||
| } | ||
|
Comment on lines
+412
to
+425
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
also |
||
| } | ||
|
|
||
| private func isDuplicateCopilotUsage( | ||
| _ lhs: CopilotAccountCandidate, | ||
| _ rhs: CopilotAccountCandidate | ||
| ) -> Bool { | ||
| CopilotCandidateDedupe.isSameAccountUsage(lhs.dedupeInput, rhs.dedupeInput) | ||
| } | ||
|
|
||
| // MARK: - Customer ID Fetching | ||
|
|
||
| private func fetchCustomerId(cookies: GitHubCookies) async -> String? { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -142,6 +142,87 @@ final class CopilotProviderTests: XCTestCase { | |
| XCTAssertEqual(CopilotAuthSource.vscodeApps.priority, 0) | ||
| } | ||
|
|
||
| // MARK: - CopilotCandidateDedupe | ||
|
|
||
| func testCopilotDedupeRejectsMatchingUsageWithDifferentIdentities() { | ||
| let personal = makeDedupeInput(accountId: "personal", email: "personal@example.com") | ||
| let work = makeDedupeInput(accountId: "work", email: "work@example.com") | ||
|
|
||
| XCTAssertFalse(CopilotCandidateDedupe.isSameAccountUsage(personal, work)) | ||
| } | ||
|
|
||
| func testCopilotDedupeAcceptsMatchingUsageWithCaseInsensitiveIdentity() { | ||
| let first = makeDedupeInput(accountId: "Foo", email: "Foo@Example.com") | ||
| let second = makeDedupeInput(accountId: "foo", email: "foo@example.com") | ||
|
|
||
| XCTAssertTrue(CopilotCandidateDedupe.isSameAccountUsage(first, second)) | ||
| } | ||
|
|
||
| func testCopilotDedupeRejectsIdentitylessMatchingUsage() { | ||
| let identified = makeDedupeInput(accountId: "foo", email: "foo@example.com") | ||
| let identityless = makeDedupeInput(accountId: nil, email: nil) | ||
|
|
||
| XCTAssertFalse(CopilotCandidateDedupe.isSameAccountUsage(identified, identityless)) | ||
| } | ||
|
|
||
| func testCopilotDedupeDropsOnlyPlaceholderWithoutRealUsage() { | ||
| let placeholder = makeDedupeInput( | ||
| accountId: nil, | ||
| email: nil, | ||
| entitlement: 0, | ||
| remaining: 0, | ||
| isPlaceholder: true | ||
| ) | ||
| let emptyRealCandidate = makeDedupeInput( | ||
| accountId: nil, | ||
| email: nil, | ||
| entitlement: 0, | ||
| remaining: 0, | ||
| isPlaceholder: false | ||
| ) | ||
|
|
||
| XCTAssertTrue( | ||
| CopilotCandidateDedupe.shouldDropPlaceholder( | ||
| placeholder, | ||
| whenAnyCandidateHasRealUsage: true | ||
| ) | ||
| ) | ||
| XCTAssertFalse( | ||
| CopilotCandidateDedupe.shouldDropPlaceholder( | ||
| emptyRealCandidate, | ||
| whenAnyCandidateHasRealUsage: true | ||
| ) | ||
| ) | ||
| XCTAssertFalse( | ||
| CopilotCandidateDedupe.shouldDropPlaceholder( | ||
| placeholder, | ||
| whenAnyCandidateHasRealUsage: false | ||
| ) | ||
| ) | ||
| } | ||
|
Comment on lines
+147
to
+202
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| private func makeDedupeInput( | ||
| accountId: String?, | ||
| email: String?, | ||
| entitlement: Int = 300, | ||
| remaining: Int = 284, | ||
| usedRequests: Int? = 16, | ||
| limitRequests: Int? = 300, | ||
| planType: String? = "Individual", | ||
| isPlaceholder: Bool = false | ||
| ) -> CopilotCandidateDedupeInput { | ||
| CopilotCandidateDedupeInput( | ||
| accountId: accountId, | ||
| email: email, | ||
| planType: planType, | ||
| totalEntitlement: entitlement, | ||
| remainingQuota: remaining, | ||
| usedRequests: usedRequests, | ||
| limitRequests: limitRequests, | ||
| isPlaceholder: isPlaceholder | ||
| ) | ||
| } | ||
|
|
||
| private func loadFixture(named: String) -> Data { | ||
| let bundle = Bundle(for: type(of: self)) | ||
| guard let url = bundle.url(forResource: named, withExtension: nil) else { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The same precedence change in the CLI provider makes
detailscome from the first sorted account instead of the browser candidate; since token-derived candidates omitcopilotOverageCostandcopilotOverageRequests, CLI output can silently lose those fields whenever a token account outranks the browser account. This causes downstream tooling to read$0/missing add-on usage even when cookie-based usage data was successfully fetched.Useful? React with 👍 / 👎.