33
44using System ;
55using System . Collections . Generic ;
6- using System . Collections . Immutable ;
76using System . IO ;
87using System . Linq ;
98using System . Net ;
109using System . Net . Http ;
1110using System . Reflection ;
11+ using System . Security . Policy ;
1212using System . Text ;
1313using System . Text . RegularExpressions ;
1414using System . Threading . Tasks ;
@@ -44,6 +44,7 @@ public class GitHubClient : RemoteRepoBase, IRemoteGitRepo
4444 private readonly string _userAgent = $ "DarcLib-{ DarcLibVersion } ";
4545 private IGitHubClient ? _lazyClient = null ;
4646 private readonly Dictionary < ( string , string , string ? ) , string > _gitRefCommitCache ;
47+ private readonly IRedisCacheClient _cache ;
4748
4849 static GitHubClient ( )
4950 {
@@ -57,17 +58,19 @@ public GitHubClient(
5758 IRemoteTokenProvider remoteTokenProvider ,
5859 IProcessManager processManager ,
5960 ILogger logger ,
60- IMemoryCache ? cache )
61- : this ( remoteTokenProvider , processManager , logger , null , cache )
61+ IMemoryCache ? cache ,
62+ IRedisCacheClient redisClient )
63+ : this ( remoteTokenProvider , processManager , null , cache , redisClient , logger )
6264 {
6365 }
6466
6567 public GitHubClient (
6668 IRemoteTokenProvider remoteTokenProvider ,
6769 IProcessManager processManager ,
68- ILogger logger ,
6970 string ? temporaryRepositoryPath ,
70- IMemoryCache ? cache )
71+ IMemoryCache ? cache ,
72+ IRedisCacheClient redisClient ,
73+ ILogger logger )
7174 : base ( remoteTokenProvider , processManager , temporaryRepositoryPath , cache , logger )
7275 {
7376 _tokenProvider = remoteTokenProvider ;
@@ -78,6 +81,7 @@ public GitHubClient(
7881 NullValueHandling = NullValueHandling . Ignore
7982 } ;
8083 _gitRefCommitCache = [ ] ;
84+ _cache = redisClient ;
8185 }
8286
8387 public bool AllowRetries { get ; set ; } = true ;
@@ -299,28 +303,18 @@ public async Task<IEnumerable<int>> SearchPullRequestsAsync(
299303 public async Task < Models . PullRequest > GetPullRequestAsync ( string pullRequestUrl )
300304 {
301305 ( string owner , string repo , int id ) = ParsePullRequestUri ( pullRequestUrl ) ;
302- Octokit . PullRequest pr = await GetClient ( owner , repo ) . PullRequest . Get ( owner , repo , id ) ;
303306
304- PrStatus status ;
305- if ( pr . State == ItemState . Closed )
306- {
307- status = pr . Merged == true ? PrStatus . Merged : PrStatus . Closed ;
308- }
309- else
310- {
311- status = PrStatus . Open ;
312- }
307+ IGitHubClient client = GetClient ( owner , repo ) ;
313308
314- return new Models . PullRequest
315- {
316- Title = pr . Title ,
317- Description = pr . Body ,
318- BaseBranch = pr . Base . Ref ,
319- HeadBranch = pr . Head . Ref ,
320- Status = status ,
321- UpdatedAt = pr . UpdatedAt ,
322- TargetBranchCommitSha = pr . Head . Sha ,
323- } ;
309+ var resourceUri = ApiUrls . PullRequest ( owner , repo , id ) ;
310+
311+ Models . PullRequest result = await RequestResourceUsingEtagsAsync < Models . PullRequest , Octokit . PullRequest > (
312+ pullRequestUrl ,
313+ resourceUri ,
314+ client ,
315+ GithubResourceConverters . ConvertPullRequest ) ;
316+
317+ return result ;
324318 }
325319
326320 /// <summary>
@@ -403,7 +397,7 @@ public async Task MergeDependencyPullRequestAsync(string pullRequestUrl, MergePu
403397
404398 IGitHubClient gitHubClient = GetClient ( owner , repo ) ;
405399
406- Octokit . PullRequest pr = await gitHubClient . PullRequest . Get ( owner , repo , id ) ;
400+ Models . PullRequest pr = await GetPullRequestAsync ( pullRequestUrl ) ;
407401
408402 var mergePullRequest = new MergePullRequest
409403 {
@@ -425,11 +419,11 @@ public async Task MergeDependencyPullRequestAsync(string pullRequestUrl, MergePu
425419 {
426420 try
427421 {
428- await gitHubClient . Git . Reference . Delete ( owner , repo , $ "heads/{ pr . Head . Ref } ") ;
422+ await gitHubClient . Git . Reference . Delete ( owner , repo , $ "heads/{ pr . HeadBranch } ") ;
429423 }
430424 catch ( Exception ex )
431425 {
432- _logger . LogInformation ( "Couldn't delete branch {sourceBranch} - {message}" , pr . Head . Ref , ex . Message ) ;
426+ _logger . LogInformation ( "Couldn't delete branch {sourceBranch} - {message}" , pr . HeadBranch , ex . Message ) ;
433427 }
434428 }
435429 }
@@ -449,26 +443,25 @@ public async Task CreateOrUpdatePullRequestMergeStatusInfoAsync(string pullReque
449443 {
450444 ( string owner , string repo , int id ) = ParsePullRequestUri ( pullRequestUrl ) ;
451445 var client = GetClient ( owner , repo ) ;
452- // Get the sha of the latest commit for the current PR
453- string prSha = ( await client . PullRequest . Get ( owner , repo , id ) ) ? . Head ? . Sha
454- ?? throw new InvalidOperationException ( "We cannot find the sha of the pull request" ) ;
446+
447+ Models . PullRequest pr = await GetPullRequestAsync ( pullRequestUrl ) ;
455448
456449 // Get a list of all the merge policies checks runs for the current PR
457450 List < CheckRun > existingChecksRuns =
458- ( await client . Check . Run . GetAllForReference ( owner , repo , prSha ) )
451+ ( await client . Check . Run . GetAllForReference ( owner , repo , pr . HeadBranchCommitSha ) )
459452 . CheckRuns . Where ( e => e . ExternalId . StartsWith ( MergePolicyConstants . MaestroMergePolicyCheckRunPrefix ) ) . ToList ( ) ;
460453
461- var toBeAdded = evaluations . Where ( e => existingChecksRuns . All ( c => c . ExternalId != CheckRunId ( e , prSha ) ) ) ;
462- var toBeUpdated = existingChecksRuns . Where ( c => evaluations . Any ( e => c . ExternalId == CheckRunId ( e , prSha ) ) ) ;
463- var toBeDeleted = existingChecksRuns . Where ( c => evaluations . All ( e => c . ExternalId != CheckRunId ( e , prSha ) ) ) ;
454+ var toBeAdded = evaluations . Where ( e => existingChecksRuns . All ( c => c . ExternalId != CheckRunId ( e , pr . HeadBranchCommitSha ) ) ) ;
455+ var toBeUpdated = existingChecksRuns . Where ( c => evaluations . Any ( e => c . ExternalId == CheckRunId ( e , pr . HeadBranchCommitSha ) ) ) ;
456+ var toBeDeleted = existingChecksRuns . Where ( c => evaluations . All ( e => c . ExternalId != CheckRunId ( e , pr . HeadBranchCommitSha ) ) ) ;
464457
465458 foreach ( var newCheckRunValidation in toBeAdded )
466459 {
467- await client . Check . Run . Create ( owner , repo , CheckRunForAdd ( newCheckRunValidation , prSha ) ) ;
460+ await client . Check . Run . Create ( owner , repo , CheckRunForAdd ( newCheckRunValidation , pr . HeadBranchCommitSha ) ) ;
468461 }
469462 foreach ( var updatedCheckRun in toBeUpdated )
470463 {
471- MergePolicyEvaluationResult eval = evaluations . Last ( e => updatedCheckRun . ExternalId == CheckRunId ( e , prSha ) ) ;
464+ MergePolicyEvaluationResult eval = evaluations . Last ( e => updatedCheckRun . ExternalId == CheckRunId ( e , pr . HeadBranchCommitSha ) ) ;
472465 if ( eval . IsCachedResult )
473466 {
474467 _logger . LogInformation ( "Not updating check run {checkRunId} for PR {pullRequestUrl} because the merge policy was not re-evaluated." ,
@@ -907,13 +900,12 @@ public async Task<IList<Check>> GetPullRequestChecksAsync(string pullRequestUrl)
907900 {
908901 ( string owner , string repo , int id ) = ParsePullRequestUri ( pullRequestUrl ) ;
909902
910- var commits = await GetClient ( owner , repo ) . Repository . PullRequest . Commits ( owner , repo , id ) ;
911- var lastCommitSha = commits [ commits . Count - 1 ] . Sha ;
903+ Models . PullRequest pr = await GetPullRequestAsync ( pullRequestUrl ) ;
912904
913905 return
914906 [
915- .. await GetChecksFromStatusApiAsync ( owner , repo , lastCommitSha ) ,
916- .. await GetChecksFromChecksApiAsync ( owner , repo , lastCommitSha ) ,
907+ .. await GetChecksFromStatusApiAsync ( owner , repo , pr . HeadBranchCommitSha ) ,
908+ .. await GetChecksFromChecksApiAsync ( owner , repo , pr . HeadBranchCommitSha ) ,
917909 ] ;
918910 }
919911
@@ -929,31 +921,22 @@ public async Task<IList<Review>> GetLatestPullRequestReviewsAsync(string pullReq
929921 {
930922 ( string owner , string repo , int id ) = ParsePullRequestUri ( pullRequestUrl ) ;
931923
932- var reviews = await GetClient ( owner , repo ) . Repository . PullRequest . Review . GetAll ( owner , repo , id ) ;
924+ IGitHubClient client = GetClient ( owner , repo ) ;
925+ var pullRequestReviewsUri = ApiUrls . PullRequestReviews ( owner , repo , id ) ;
933926
934- var actionableReviews = reviews
935- . Where ( r => r . State != PullRequestReviewState . Commented ) // filter out reviews that don't affect approval/RFC
936- . GroupBy ( r => r . User . Login )
937- . Select ( g => g . OrderByDescending ( r => r . SubmittedAt ) . First ( ) ) // pick each user's most recent review
938- . Select ( review => new Review ( TranslateReviewState ( review . State . Value ) , pullRequestUrl ) )
939- . ToList ( ) ;
927+ var pullRequestReviews = await RequestResourceUsingEtagsAsync < PullRequestReviews , List < PullRequestReview > > (
928+ pullRequestUrl ,
929+ pullRequestReviewsUri ,
930+ client ,
931+ GithubResourceConverters . ConvertPullRequestReviews ) ;
940932
941- return actionableReviews ;
942- }
933+ var newestActionableReviews = pullRequestReviews . Reviews
934+ . Where ( r => r . Status != ReviewState . Commented ) // filter out reviews that don't affect approval/RFC
935+ . GroupBy ( r => r . User )
936+ . Select ( g => ( Review ) g . OrderByDescending ( r => r . SubmittedAt ) . First ( ) ) // pick each user's most recent review
937+ . ToList ( ) ;
943938
944- private static ReviewState TranslateReviewState ( PullRequestReviewState state )
945- {
946- return state switch
947- {
948- PullRequestReviewState . Approved => ReviewState . Approved ,
949- PullRequestReviewState . ChangesRequested => ReviewState . ChangesRequested ,
950- PullRequestReviewState . Commented => ReviewState . Commented ,
951- // A PR comment could be dismissed by a new push, so this does not count as a rejection.
952- // Change to a comment
953- PullRequestReviewState . Dismissed => ReviewState . Commented ,
954- PullRequestReviewState . Pending => ReviewState . Pending ,
955- _ => throw new NotImplementedException ( $ "Unexpected pull request review state { state } ") ,
956- } ;
939+ return newestActionableReviews ;
957940 }
958941
959942 private async Task < IList < Check > > GetChecksFromStatusApiAsync ( string owner , string repo , string @ref )
@@ -1315,4 +1298,63 @@ public async Task<List<string>> GetPullRequestCommentsAsync(string pullRequestUr
13151298
13161299 return comments . Select ( comment => comment . Body ) . ToList ( ) ;
13171300 }
1301+
1302+ /// <summary>
1303+ /// This method fills a functionality that's currently missing from Octokit: fetching Github resources using eTags.
1304+ /// eTags allow us to cache mutable resources and efficiently check if the resource has changed on Github since the last fetch.
1305+ /// </summary>
1306+ /// <typeparam name="T">The domain class of the resource in our server</typeparam>
1307+ /// <typeparam name="K">The class of the resource in Octokit</typeparam>
1308+ /// <param name="resourceKey">The key used to cache the resource in redis</param>
1309+ /// <param name="resourceUri">The uri used to request the resource from Github</param>
1310+ /// <param name="client">The github client that makes the request</param>
1311+ /// <param name="resourceConverter">Function to convert the resource from Octokit to our domain class</param>
1312+ /// <returns>The resource of type T</returns>
1313+ /// <exception cref="DarcException"></exception>
1314+ protected virtual async Task < T > RequestResourceUsingEtagsAsync < T , K > (
1315+ string resourceKey ,
1316+ Uri resourceUri ,
1317+ IGitHubClient client ,
1318+ Func < K , T > resourceConverter )
1319+ where T : class , IGithubEtagResource
1320+ {
1321+ var cachedResource = await _cache . TryGetAsync < T > ( resourceKey ) ;
1322+ string ? entityTag = cachedResource ? . Etag ;
1323+ var headers = new Dictionary < string , string >
1324+ {
1325+ { "Accept" , "application/vnd.github.v3+json" } ,
1326+ } ;
1327+ if ( entityTag != null )
1328+ {
1329+ headers . Add ( "If-None-Match" , entityTag ) ;
1330+ }
1331+ var response = await client . Connection . Get < K > ( resourceUri , headers ) ;
1332+ if ( response . HttpResponse . StatusCode == HttpStatusCode . NotModified && cachedResource != null )
1333+ {
1334+ // TODO: Add telemetry for cache hits to measure the impact of this optimization.
1335+ return cachedResource ;
1336+ }
1337+ else
1338+ {
1339+ if ( response . HttpResponse . StatusCode == HttpStatusCode . OK )
1340+ {
1341+ string ? etag = response . HttpResponse . Headers
1342+ . FirstOrDefault ( h => h . Key . Equals ( "Etag" , StringComparison . OrdinalIgnoreCase ) )
1343+ . Value ;
1344+
1345+ var resource = resourceConverter ( response . Body ) ;
1346+
1347+ if ( etag != null )
1348+ {
1349+ resource . Etag = etag ;
1350+ await _cache . TrySetAsync < T > ( resourceKey , resource ) ;
1351+ }
1352+ return resource ;
1353+ }
1354+ else
1355+ {
1356+ throw new DarcException ( $ "Failed to get { typeof ( T ) . Name } from GitHub. Status code: { response . HttpResponse . StatusCode } ") ;
1357+ }
1358+ }
1359+ }
13181360}
0 commit comments