Skip to content

Commit 5e4e719

Browse files
authored
Resolve installation ID when missing (#4427)
#4426
1 parent 831cf88 commit 5e4e719

File tree

6 files changed

+263
-102
lines changed

6 files changed

+263
-102
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.DotNet.GitHub.Authentication;
5+
using Octokit;
6+
7+
namespace ProductConstructionService.Api.Api;
8+
9+
public interface IGitHubInstallationIdResolver
10+
{
11+
Task<long?> GetInstallationIdForRepository(string repoUri);
12+
}
13+
14+
public class GitHubInstallationIdResolver : IGitHubInstallationIdResolver
15+
{
16+
private readonly IGitHubTokenProvider _gitHubTokenProvider;
17+
private readonly ILogger<GitHubInstallationIdResolver> _logger;
18+
19+
public GitHubInstallationIdResolver(
20+
IGitHubTokenProvider gitHubTokenProvider,
21+
ILogger<GitHubInstallationIdResolver> logger)
22+
{
23+
_gitHubTokenProvider = gitHubTokenProvider;
24+
_logger = logger;
25+
}
26+
27+
public async Task<long?> GetInstallationIdForRepository(string repoUri)
28+
{
29+
_logger.LogInformation("Getting installation ID for {repoUri}", repoUri);
30+
31+
var (owner, repo) = Microsoft.DotNet.DarcLib.GitHubClient.ParseRepoUri(repoUri);
32+
var token = _gitHubTokenProvider.GetTokenForApp();
33+
var client = new GitHubClient(new ProductHeaderValue(nameof(ProductConstructionService)))
34+
{
35+
Credentials = new Credentials(token, AuthenticationType.Bearer)
36+
};
37+
38+
try
39+
{
40+
var installation = await client.GitHubApps.GetRepositoryInstallationForCurrent(owner, repo);
41+
42+
if (installation == null)
43+
{
44+
_logger.LogInformation("Failed to get installation id for {owner}/{repo}", owner, repo);
45+
return null;
46+
}
47+
48+
_logger.LogInformation("Installation id for {owner}/{repo} is {installationId}", owner, repo, installation.Id);
49+
return installation.Id;
50+
}
51+
catch (ApiException e)
52+
{
53+
_logger.LogInformation("Failed to get installation id for {owner}/{repo} - {statusCode}.", owner, repo, e.StatusCode);
54+
return null;
55+
}
56+
}
57+
}

src/ProductConstructionService/ProductConstructionService.Api/Api/v2018_07_16/Controllers/SubscriptionsController.cs

+60
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,18 @@ public class SubscriptionsController : ControllerBase
2626
{
2727
private readonly BuildAssetRegistryContext _context;
2828
private readonly IWorkItemProducerFactory _workItemProducerFactory;
29+
private readonly IGitHubInstallationIdResolver _installationIdResolver;
2930
private readonly ILogger<SubscriptionsController> _logger;
3031

3132
public SubscriptionsController(
3233
BuildAssetRegistryContext context,
3334
IWorkItemProducerFactory workItemProducerFactory,
35+
IGitHubInstallationIdResolver installationIdResolver,
3436
ILogger<SubscriptionsController> logger)
3537
{
3638
_context = context;
3739
_workItemProducerFactory = workItemProducerFactory;
40+
_installationIdResolver = installationIdResolver;
3841
_logger = logger;
3942
}
4043

@@ -452,6 +455,63 @@ public virtual async Task<IActionResult> Create([FromBody, Required] Subscriptio
452455
new Subscription(subscriptionModel));
453456
}
454457

458+
/// <summary>
459+
/// Verifies that the repository is registered in the database (and has a valid installation ID).
460+
/// </summary>
461+
protected async Task<bool> EnsureRepositoryRegistration(string repoUri)
462+
{
463+
Maestro.Data.Models.Repository? repo = await _context.Repositories.FindAsync(repoUri);
464+
465+
// If we have no repository information or an invalid installation ID, we need to register the repository
466+
if (repoUri.Contains("github.com"))
467+
{
468+
if (repo?.InstallationId > 0)
469+
{
470+
return true;
471+
}
472+
473+
var installationId = await _installationIdResolver.GetInstallationIdForRepository(repoUri);
474+
475+
if (!installationId.HasValue)
476+
{
477+
return false;
478+
}
479+
480+
if (repo == null)
481+
{
482+
_context.Repositories.Add(
483+
new Maestro.Data.Models.Repository
484+
{
485+
RepositoryName = repoUri,
486+
InstallationId = installationId.Value
487+
});
488+
}
489+
else
490+
{
491+
repo.InstallationId = installationId.Value;
492+
}
493+
return true;
494+
}
495+
496+
if (repoUri.Contains("dev.azure.com") && repo == null)
497+
{
498+
// In the case of a dev.azure.com repository, we don't have an app installation,
499+
// but we should add an entry in the repositories table, as this is required when
500+
// adding a new subscription policy.
501+
// NOTE:
502+
// There is a good chance here that we will need to also handle <account>.visualstudio.com
503+
// but leaving it out for now as it would be preferred to use the new format
504+
_context.Repositories.Add(
505+
new Maestro.Data.Models.Repository
506+
{
507+
RepositoryName = repoUri,
508+
InstallationId = default
509+
});
510+
}
511+
512+
return true;
513+
}
514+
455515
/// <summary>
456516
/// Find an existing subscription in the database with the same key data as the subscription we are adding/updating
457517
///

src/ProductConstructionService/ProductConstructionService.Api/Api/v2019_01_16/Controllers/SubscriptionsController.cs

+8-39
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,16 @@ public class SubscriptionsController : v2018_07_16.Controllers.SubscriptionsCont
2525
public SubscriptionsController(
2626
BuildAssetRegistryContext context,
2727
IWorkItemProducerFactory workItemProducerFactory,
28+
IGitHubInstallationIdResolver gitHubInstallationRetriever,
2829
ILogger<SubscriptionsController> logger)
29-
: base(context, workItemProducerFactory, logger)
30+
: base(context, workItemProducerFactory, gitHubInstallationRetriever, logger)
3031
{
3132
_context = context;
3233
}
3334

3435
/// <summary>
3536
/// Gets a list of all <see cref="Subscription"/>s that match the given search criteria.
3637
/// </summary>
37-
/// <param name="sourceRepository"></param>
38-
/// <param name="targetRepository"></param>
39-
/// <param name="channelId"></param>
40-
/// <param name="enabled"></param>
4138
[HttpGet]
4239
[SwaggerApiResponse(HttpStatusCode.OK, Type = typeof(List<Subscription>), Description = "The list of Subscriptions")]
4340
[ValidateModelState]
@@ -233,41 +230,13 @@ public override async Task<IActionResult> Create([FromBody, Required] ProductCon
233230
new[] { $"The channel '{subscription.ChannelName}' could not be found." }));
234231
}
235232

236-
Maestro.Data.Models.Repository? repo = await _context.Repositories.FindAsync(subscription.TargetRepository);
237-
238-
if (subscription.TargetRepository.Contains("github.com"))
239-
{
240-
// If we have no repository information or an invalid installation id
241-
// then we will fail when trying to update things, so we fail early.
242-
if (repo == null || repo.InstallationId <= 0)
243-
{
244-
return BadRequest(
245-
new ApiError(
246-
"the request is invalid",
247-
new[]
248-
{
249-
$"The repository '{subscription.TargetRepository}' does not have an associated github installation. " +
250-
"The Maestro github application must be installed by the repository's owner and given access to the repository."
251-
}));
252-
}
253-
}
254-
// In the case of a dev.azure.com repository, we don't have an app installation,
255-
// but we should add an entry in the repositories table, as this is required when
256-
// adding a new subscription policy.
257-
// NOTE:
258-
// There is a good chance here that we will need to also handle <account>.visualstudio.com
259-
// but leaving it out for now as it would be preferred to use the new format
260-
else if (subscription.TargetRepository.Contains("dev.azure.com"))
233+
if (!await EnsureRepositoryRegistration(subscription.TargetRepository))
261234
{
262-
if (repo == null)
263-
{
264-
_context.Repositories.Add(
265-
new Maestro.Data.Models.Repository
266-
{
267-
RepositoryName = subscription.TargetRepository,
268-
InstallationId = default
269-
});
270-
}
235+
return BadRequest(new ApiError("The request is invalid",
236+
[
237+
$"No Maestro GitHub application installation found for repository '{subscription.TargetRepository}'. " +
238+
"The Maestro github application must be installed by the repository's owner and given access to the repository."
239+
]));
271240
}
272241

273242
Maestro.Data.Models.Subscription subscriptionModel = subscription.ToDb();

src/ProductConstructionService/ProductConstructionService.Api/Api/v2020_02_20/Controllers/SubscriptionsController.cs

+18-47
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ public class SubscriptionsController : v2019_01_16.Controllers.SubscriptionsCont
2929
public SubscriptionsController(
3030
BuildAssetRegistryContext context,
3131
IGitHubClientFactory gitHubClientFactory,
32+
IGitHubInstallationIdResolver gitHubInstallationRetriever,
3233
IWorkItemProducerFactory workItemProducerFactory,
3334
ILogger<SubscriptionsController> logger)
34-
: base(context, workItemProducerFactory, logger)
35+
: base(context, workItemProducerFactory, gitHubInstallationRetriever, logger)
3536
{
3637
_context = context;
3738
_gitHubClientFactory = gitHubClientFactory;
@@ -378,33 +379,22 @@ public override Task<IActionResult> Create([FromBody, Required] ProductConstruct
378379
[ValidateModelState]
379380
public async Task<IActionResult> Create([FromBody, Required] SubscriptionData subscription)
380381
{
381-
Maestro.Data.Models.Channel? channel = await _context.Channels.Where(c => c.Name == subscription.ChannelName)
382+
Maestro.Data.Models.Channel? channel = await _context.Channels
383+
.Where(c => c.Name == subscription.ChannelName)
382384
.FirstOrDefaultAsync();
385+
383386
if (channel == null)
384387
{
385-
return BadRequest(
386-
new ApiError(
387-
"the request is invalid",
388-
new[] { $"The channel '{subscription.ChannelName}' could not be found." }));
388+
return BadRequest(new ApiError("The request is invalid", [$"The channel '{subscription.ChannelName}' could not be found."]));
389389
}
390390

391-
Maestro.Data.Models.Repository? repo = await _context.Repositories.FindAsync(subscription.TargetRepository);
392-
393-
if (subscription.TargetRepository.Contains("github.com"))
391+
if (!await EnsureRepositoryRegistration(subscription.TargetRepository))
394392
{
395-
// If we have no repository information or an invalid installation id
396-
// then we will fail when trying to update things, so we fail early.
397-
if (repo == null || repo.InstallationId <= 0)
398-
{
399-
return BadRequest(
400-
new ApiError(
401-
"the request is invalid",
402-
new[]
403-
{
404-
$"The repository '{subscription.TargetRepository}' does not have an associated github installation. " +
405-
"The Maestro github application must be installed by the repository's owner and given access to the repository."
406-
}));
407-
}
393+
return BadRequest(new ApiError("The request is invalid",
394+
[
395+
$"No Maestro GitHub application installation found for repository '{subscription.TargetRepository}'. " +
396+
"The Maestro github application must be installed by the repository's owner and given access to the repository."
397+
]));
408398
}
409399

410400
if (subscription.SourceEnabled.HasValue)
@@ -430,25 +420,6 @@ public async Task<IActionResult> Create([FromBody, Required] SubscriptionData su
430420
}
431421
}
432422

433-
// In the case of a dev.azure.com repository, we don't have an app installation,
434-
// but we should add an entry in the repositories table, as this is required when
435-
// adding a new subscription policy.
436-
// NOTE:
437-
// There is a good chance here that we will need to also handle <account>.visualstudio.com
438-
// but leaving it out for now as it would be preferred to use the new format
439-
else if (subscription.TargetRepository.Contains("dev.azure.com"))
440-
{
441-
if (repo == null)
442-
{
443-
_context.Repositories.Add(
444-
new Maestro.Data.Models.Repository
445-
{
446-
RepositoryName = subscription.TargetRepository,
447-
InstallationId = default
448-
});
449-
}
450-
}
451-
452423
Maestro.Data.Models.Subscription subscriptionModel = subscription.ToDb();
453424
subscriptionModel.Channel = channel;
454425
subscriptionModel.Id = Guid.NewGuid();
@@ -459,19 +430,19 @@ public async Task<IActionResult> Create([FromBody, Required] SubscriptionData su
459430
{
460431
return BadRequest(
461432
new ApiError(
462-
"the request is invalid",
433+
"The request is invalid",
463434
new[]
464435
{
465436
$"The subscription '{equivalentSubscription.Id}' already performs the same update."
466437
}));
467438
}
468439

469-
if (!string.IsNullOrEmpty(subscriptionModel.PullRequestFailureNotificationTags))
440+
if (!string.IsNullOrEmpty(subscriptionModel.PullRequestFailureNotificationTags)
441+
&& !await AllNotificationTagsValid(subscriptionModel.PullRequestFailureNotificationTags))
470442
{
471-
if (!await AllNotificationTagsValid(subscriptionModel.PullRequestFailureNotificationTags))
472-
{
473-
return BadRequest(new ApiError("Invalid value(s) provided in Pull Request Failure Notification Tags; is everyone listed publicly a member of the Microsoft github org?"));
474-
}
443+
return BadRequest(new ApiError(
444+
"Invalid value(s) provided in Pull Request Failure Notification Tags; " +
445+
"is everyone listed publicly a member of the Microsoft github org?"));
475446
}
476447

477448
await _context.Subscriptions.AddAsync(subscriptionModel);

src/ProductConstructionService/ProductConstructionService.Api/PcsStartup.cs

+1
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ internal static async Task ConfigurePcs(
187187
builder.Configuration[ConfigurationKeys.GitHubClientSecret]);
188188
builder.Services.AddGitHubTokenProvider();
189189
builder.Services.AddScoped<IRemoteFactory, RemoteFactory>();
190+
builder.Services.AddTransient<IGitHubInstallationIdResolver, GitHubInstallationIdResolver>();
190191
builder.Services.AddSingleton<Microsoft.Extensions.Internal.ISystemClock, Microsoft.Extensions.Internal.SystemClock>();
191192
builder.Services.AddSingleton<ExponentialRetry>();
192193
builder.Services.Configure<ExponentialRetryOptions>(_ => { });

0 commit comments

Comments
 (0)