diff --git a/docs/server.md b/docs/server.md
index 616acc77..7916a2b8 100644
--- a/docs/server.md
+++ b/docs/server.md
@@ -51,18 +51,10 @@ The deployment exposes the following parameters that can be tuned to suit the se
|Parameter Name|Remarks|Required|Default|
|--|--|--|--|
-|projectUrl|The URL of the Azure DevOps project or collection. For example `https://dev.azure.com/fabrikam/DefaultCollection`. This URL must be accessible from the network that the deployment is done in. You can modify the deployment to be done in an private network but you are on your own there.|Yes|**none**|
-|projectToken|Personal Access Token (PAT) for accessing the Azure DevOps project. The required permissions are:
- Code (Full)
- Pull Requests Threads (Read & Write).
- Notifications (Read, Write & Manage).
See the [documentation](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page#create-a-pat) to know more about creating a Personal Access Token|Yes|**none**|
|location|Location to deploy the resources.|No|<resource-group-location>|
|name|The name of all resources.|No|`dependabot`|
-|synchronizeOnStartup|Whether to synchronize repositories on startup. This option is useful for initial deployments since the server synchronizes every 6 hours. Leaving it on has no harm, it actually helps you find out if the token works based on the logs.|No|false|
-|createOrUpdateWebhooksOnStartup|Whether to create or update Azure DevOps subscriptions on startup. This is required if you want configuration files to be picked up automatically and other event driven functionality.
When this is set to `true`, ensure the value provided for `projectToken` has permissions for service hooks and the owner is a Project Administrator. Leaving this on has no harm because the server will only create new subscription if there are no existing ones based on the URL.|No|false|
+|projectSetups|A JSON array string representing the projects to be setup on startup. This is useful when running your own setup. Example: `[{\"url\":\"https://dev.azure.com/tingle/dependabot\",\"token\":\"dummy\",\"AutoComplete\":true}]`|
|githubToken|Access token for authenticating requests to GitHub. Required for vulnerability checks and to avoid rate limiting on free requests|No|<empty>|
-|autoComplete|Whether to set auto complete on created pull requests.|No|true|
-|autoCompleteIgnoreConfigs|Identifiers of configs to be ignored in auto complete. E.g 3,4,10|No|<empty>|
-|autoCompleteMergeStrategy|Merge strategy to use when setting auto complete on created pull requests. Allowed values: `NoFastForward`, `Rebase`, `RebaseMerge`, or `Squash`|No|`Squash`|
-|autoApprove|Whether to automatically approve created pull requests.|No|false|
-|notificationsPassword|The password used to authenticate incoming requests from Azure DevOps|No|<auto-generated>|
|imageTag|The image tag to use when pulling the docker containers. A tag also defines the version. You should avoid using `latest`. Example: `1.1.0`|No|<version-downloaded>|
|minReplicas|The minimum number of replicas to required for the deployment. Given that scheduling runs in process, this value cannot be less than `1`. This may change in the future.|No|1|
|maxReplicas|The maximum number of replicas when automatic scaling engages. In most cases, you do not need more than 1.|No|1|
@@ -78,8 +70,6 @@ For a one time deployment, it is similar to how you deploy other resources on Az
```bash
az deployment group create --resource-group DEPENDABOT \
--template-file main.bicep \
- --parameters projectUrl= \
- --parameters projectToken= \
--parameters githubToken= \
--confirm-with-what-if
```
@@ -104,15 +94,6 @@ The parameters file (`dependabot.parameters.json`):
"$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#",
"contentVersion": "1.0.0.0",
"parameters": {
- "projectUrl": {
- "value": "#{System_TeamFoundationCollectionUri}##{System_TeamProject}#"
- },
- "projectToken": {
- "value": "#{DependabotProjectToken}#"
- },
- "autoComplete": {
- "value": true
- },
"githubToken": {
"value": "#{DependabotGithubToken}#"
},
diff --git a/server/Tingle.Dependabot.Tests/PeriodicTasks/MissedTriggerCheckerTaskTests.cs b/server/Tingle.Dependabot.Tests/PeriodicTasks/MissedTriggerCheckerTaskTests.cs
index d4810ec7..df2285f2 100644
--- a/server/Tingle.Dependabot.Tests/PeriodicTasks/MissedTriggerCheckerTaskTests.cs
+++ b/server/Tingle.Dependabot.Tests/PeriodicTasks/MissedTriggerCheckerTaskTests.cs
@@ -16,6 +16,7 @@ namespace Tingle.Dependabot.Tests.PeriodicTasks;
public class MissedTriggerCheckerTaskTests
{
+ private const string ProjectId = "prj_1234567890";
private const string RepositoryId = "repo_1234567890";
private const int UpdateId1 = 1;
@@ -103,9 +104,20 @@ private async Task TestAsync(DateTimeOffset? lastUpdate0, DateTimeOffset? lastUp
var context = provider.GetRequiredService();
await context.Database.EnsureCreatedAsync();
+ await context.Projects.AddAsync(new Project
+ {
+ Id = ProjectId,
+ Url = "https://dev.azure.com/dependabot/dependabot",
+ Token = "token",
+ Name = "dependabot",
+ ProviderId = "6ce954b1-ce1f-45d1-b94d-e6bf2464ba2c",
+ Password = "burp-bump",
+ });
await context.Repositories.AddAsync(new Repository
{
Id = RepositoryId,
+ ProjectId = ProjectId,
+ ProviderId = Guid.NewGuid().ToString(),
Name = "test-repo",
ConfigFileContents = "",
Updates = new List
diff --git a/server/Tingle.Dependabot.Tests/PeriodicTasks/SynchronizationTaskTests.cs b/server/Tingle.Dependabot.Tests/PeriodicTasks/SynchronizationTaskTests.cs
index 37a256e3..105f57c5 100644
--- a/server/Tingle.Dependabot.Tests/PeriodicTasks/SynchronizationTaskTests.cs
+++ b/server/Tingle.Dependabot.Tests/PeriodicTasks/SynchronizationTaskTests.cs
@@ -1,7 +1,10 @@
-using Microsoft.Extensions.DependencyInjection;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Tingle.Dependabot.Events;
+using Tingle.Dependabot.Models;
+using Tingle.Dependabot.Models.Management;
using Tingle.Dependabot.Workflow;
using Tingle.EventBus;
using Tingle.EventBus.Transports.InMemory;
@@ -12,6 +15,8 @@ namespace Tingle.Dependabot.Tests.PeriodicTasks;
public class SynchronizationTaskTests
{
+ private const string ProjectId = "prj_1234567890";
+
private readonly ITestOutputHelper outputHelper;
public SynchronizationTaskTests(ITestOutputHelper outputHelper)
@@ -42,6 +47,12 @@ private async Task TestAsync(Func builder.AddXUnit(outputHelper))
.ConfigureServices((context, services) =>
{
+ var dbName = Guid.NewGuid().ToString();
+ services.AddDbContext(options =>
+ {
+ options.UseInMemoryDatabase(dbName, o => o.EnableNullChecks());
+ options.EnableDetailedErrors();
+ });
services.AddEventBus(builder => builder.AddInMemoryTransport().AddInMemoryTestHarness());
})
.Build();
@@ -49,6 +60,20 @@ private async Task TestAsync(Func();
+ await context.Database.EnsureCreatedAsync();
+
+ await context.Projects.AddAsync(new Project
+ {
+ Id = ProjectId,
+ Url = "https://dev.azure.com/dependabot/dependabot",
+ Token = "token",
+ Name = "dependabot",
+ ProviderId = "6ce954b1-ce1f-45d1-b94d-e6bf2464ba2c",
+ Password = "burp-bump",
+ });
+ await context.SaveChangesAsync();
+
var harness = provider.GetRequiredService();
await harness.StartAsync();
diff --git a/server/Tingle.Dependabot.Tests/PeriodicTasks/UpdateJobsCleanerTaskTests.cs b/server/Tingle.Dependabot.Tests/PeriodicTasks/UpdateJobsCleanerTaskTests.cs
index 997dafcf..241ac77f 100644
--- a/server/Tingle.Dependabot.Tests/PeriodicTasks/UpdateJobsCleanerTaskTests.cs
+++ b/server/Tingle.Dependabot.Tests/PeriodicTasks/UpdateJobsCleanerTaskTests.cs
@@ -16,6 +16,7 @@ namespace Tingle.Dependabot.Tests.PeriodicTasks;
public class UpdateJobsCleanerTaskTests
{
+ private const string ProjectId = "prj_1234567890";
private const string RepositoryId = "repo_1234567890";
private readonly ITestOutputHelper outputHelper;
@@ -34,6 +35,7 @@ await TestAsync(async (harness, context, pt) =>
await context.UpdateJobs.AddAsync(new UpdateJob
{
Id = Guid.NewGuid().ToString(),
+ ProjectId = ProjectId,
RepositoryId = RepositoryId,
RepositorySlug = "test-repo",
Created = DateTimeOffset.UtcNow.AddMinutes(-19),
@@ -46,6 +48,7 @@ await context.UpdateJobs.AddAsync(new UpdateJob
await context.UpdateJobs.AddAsync(new UpdateJob
{
Id = Guid.NewGuid().ToString(),
+ ProjectId = ProjectId,
RepositoryId = RepositoryId,
RepositorySlug = "test-repo",
Created = DateTimeOffset.UtcNow.AddHours(-100),
@@ -58,6 +61,7 @@ await context.UpdateJobs.AddAsync(new UpdateJob
await context.UpdateJobs.AddAsync(new UpdateJob
{
Id = targetId,
+ ProjectId = ProjectId,
RepositoryId = RepositoryId,
RepositorySlug = "test-repo",
Created = DateTimeOffset.UtcNow.AddMinutes(-30),
@@ -87,6 +91,7 @@ await TestAsync(async (harness, context, pt) =>
await context.UpdateJobs.AddAsync(new UpdateJob
{
Id = Guid.NewGuid().ToString(),
+ ProjectId = ProjectId,
RepositoryId = RepositoryId,
RepositorySlug = "test-repo",
Created = DateTimeOffset.UtcNow.AddDays(-80),
@@ -98,6 +103,7 @@ await context.UpdateJobs.AddAsync(new UpdateJob
await context.UpdateJobs.AddAsync(new UpdateJob
{
Id = Guid.NewGuid().ToString(),
+ ProjectId = ProjectId,
RepositoryId = RepositoryId,
RepositorySlug = "test-repo",
Created = DateTimeOffset.UtcNow.AddDays(-100),
@@ -109,6 +115,7 @@ await context.UpdateJobs.AddAsync(new UpdateJob
await context.UpdateJobs.AddAsync(new UpdateJob
{
Id = Guid.NewGuid().ToString(),
+ ProjectId = ProjectId,
RepositoryId = RepositoryId,
RepositorySlug = "test-repo",
Created = DateTimeOffset.UtcNow.AddDays(-120),
@@ -146,9 +153,20 @@ private async Task TestAsync(Func();
await context.Database.EnsureCreatedAsync();
+ await context.Projects.AddAsync(new Project
+ {
+ Id = ProjectId,
+ Url = "https://dev.azure.com/dependabot/dependabot",
+ Token = "token",
+ Name = "dependabot",
+ ProviderId = "6ce954b1-ce1f-45d1-b94d-e6bf2464ba2c",
+ Password = "burp-bump",
+ });
await context.Repositories.AddAsync(new Repository
{
Id = RepositoryId,
+ ProjectId = ProjectId,
+ ProviderId = Guid.NewGuid().ToString(),
Name = "test-repo",
ConfigFileContents = "",
Updates = new List
diff --git a/server/Tingle.Dependabot.Tests/WebhooksControllerIntegrationTests.cs b/server/Tingle.Dependabot.Tests/WebhooksControllerIntegrationTests.cs
index 4570f07f..640c6a65 100644
--- a/server/Tingle.Dependabot.Tests/WebhooksControllerIntegrationTests.cs
+++ b/server/Tingle.Dependabot.Tests/WebhooksControllerIntegrationTests.cs
@@ -3,7 +3,6 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System.Net;
@@ -20,6 +19,8 @@ namespace Tingle.Dependabot.Tests;
public class WebhooksControllerIntegrationTests
{
+ private const string ProjectId = "prj_1234567890";
+
private readonly ITestOutputHelper outputHelper;
public WebhooksControllerIntegrationTests(ITestOutputHelper outputHelper)
@@ -41,7 +42,7 @@ await TestAsync(async (harness, client) =>
// password does not match what is on record
request = new HttpRequestMessage(HttpMethod.Post, "/webhooks/azure");
- request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes("vsts:burp-bump5")));
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{ProjectId}:burp-bump5")));
response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Empty(await response.Content.ReadAsStringAsync());
@@ -55,7 +56,7 @@ public async Task Returns_BadRequest_NoBody()
await TestAsync(async (harness, client) =>
{
var request = new HttpRequestMessage(HttpMethod.Post, "/webhooks/azure");
- request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes("vsts:burp-bump")));
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{ProjectId}:burp-bump")));
request.Content = new StringContent("", Encoding.UTF8, "application/json");
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
@@ -74,7 +75,7 @@ public async Task Returns_BadRequest_MissingValues()
await TestAsync(async (harness, client) =>
{
var request = new HttpRequestMessage(HttpMethod.Post, "/webhooks/azure");
- request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes("vsts:burp-bump")));
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{ProjectId}:burp-bump")));
request.Content = new StringContent("{}", Encoding.UTF8, "application/json");
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
@@ -96,7 +97,7 @@ await TestAsync(async (harness, client) =>
{
var stream = TestSamples.GetAzureDevOpsPullRequestUpdated1();
var request = new HttpRequestMessage(HttpMethod.Post, "/webhooks/azure");
- request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes("vsts:burp-bump")));
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{ProjectId}:burp-bump")));
request.Content = new StreamContent(stream);
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
@@ -115,7 +116,7 @@ await TestAsync(async (harness, client) =>
{
var stream = TestSamples.GetAzureDevOpsGitPush1();
var request = new HttpRequestMessage(HttpMethod.Post, "/webhooks/azure");
- request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes("vsts:burp-bump")));
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{ProjectId}:burp-bump")));
request.Content = new StreamContent(stream);
request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json", "utf-8");
var response = await client.SendAsync(request);
@@ -139,7 +140,7 @@ await TestAsync(async (harness, client) =>
{
var stream = TestSamples.GetAzureDevOpsPullRequestUpdated1();
var request = new HttpRequestMessage(HttpMethod.Post, "/webhooks/azure");
- request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes("vsts:burp-bump")));
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{ProjectId}:burp-bump")));
request.Content = new StreamContent(stream);
request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json", "utf-8");
var response = await client.SendAsync(request);
@@ -156,7 +157,7 @@ await TestAsync(async (harness, client) =>
{
var stream = TestSamples.GetAzureDevOpsPullRequestMerged1();
var request = new HttpRequestMessage(HttpMethod.Post, "/webhooks/azure");
- request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes("vsts:burp-bump")));
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{ProjectId}:burp-bump")));
request.Content = new StreamContent(stream);
request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json", "utf-8");
var response = await client.SendAsync(request);
@@ -173,7 +174,7 @@ await TestAsync(async (harness, client) =>
{
var stream = TestSamples.GetAzureDevOpsPullRequestCommentEvent1();
var request = new HttpRequestMessage(HttpMethod.Post, "/webhooks/azure");
- request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes("vsts:burp-bump")));
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{ProjectId}:burp-bump")));
request.Content = new StreamContent(stream);
request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json", "utf-8");
var response = await client.SendAsync(request);
@@ -188,13 +189,6 @@ private async Task TestAsync(Func execute
// Arrange
var builder = new WebHostBuilder()
.ConfigureLogging(builder => builder.AddXUnit(outputHelper))
- .ConfigureAppConfiguration(builder =>
- {
- builder.AddInMemoryCollection(new Dictionary
- {
- ["Authentication:Schemes:ServiceHooks:Credentials:vsts"] = "burp-bump",
- });
- })
.ConfigureServices((context, services) =>
{
services.AddControllers()
@@ -247,6 +241,17 @@ private async Task TestAsync(Func execute
var context = provider.GetRequiredService();
await context.Database.EnsureCreatedAsync();
+ await context.Projects.AddAsync(new Dependabot.Models.Management.Project
+ {
+ Id = ProjectId,
+ Url = "https://dev.azure.com/dependabot/dependabot",
+ Token = "token",
+ Name = "dependabot",
+ ProviderId = "6ce954b1-ce1f-45d1-b94d-e6bf2464ba2c",
+ Password = "burp-bump",
+ });
+ await context.SaveChangesAsync();
+
var harness = provider.GetRequiredService();
await harness.StartAsync();
diff --git a/server/Tingle.Dependabot/AppSetup.cs b/server/Tingle.Dependabot/AppSetup.cs
index 0e2f51d5..a1ed866b 100644
--- a/server/Tingle.Dependabot/AppSetup.cs
+++ b/server/Tingle.Dependabot/AppSetup.cs
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Options;
+using System.Security.Cryptography;
+using System.Text.Json;
using Tingle.Dependabot.Models;
using Tingle.Dependabot.Workflow;
@@ -7,6 +8,19 @@ namespace Tingle.Dependabot;
internal static class AppSetup
{
+ private class ProjectSetupInfo
+ {
+ public required Uri Url { get; set; }
+ public required string Token { get; set; }
+ public bool AutoComplete { get; set; }
+ public List? AutoCompleteIgnoreConfigs { get; set; }
+ public MergeStrategy? AutoCompleteMergeStrategy { get; set; }
+ public bool AutoApprove { get; set; }
+ public Dictionary Secrets { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+ }
+
+ private static readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web);
+
public static async Task SetupAsync(WebApplication app, CancellationToken cancellationToken = default)
{
using var scope = app.Services.CreateScope();
@@ -22,30 +36,91 @@ public static async Task SetupAsync(WebApplication app, CancellationToken cancel
}
}
- var options = provider.GetRequiredService>().Value;
- if (options.SynchronizeOnStartup)
+ // parse projects to be setup
+ var setupsJson = app.Configuration.GetValue("PROJECT_SETUPS");
+ var setups = new List();
+ if (!string.IsNullOrWhiteSpace(setupsJson))
{
- var synchronizer = provider.GetRequiredService();
- await synchronizer.SynchronizeAsync(false, cancellationToken); /* database sync should not trigger, just in case it's too many */
+ setups = JsonSerializer.Deserialize>(setupsJson, serializerOptions)!;
+ }
+
+ // add projects if there are projects to be added
+ var adoProvider = provider.GetRequiredService();
+ var context = provider.GetRequiredService();
+ var projects = await context.Projects.ToListAsync(cancellationToken);
+ foreach (var setup in setups)
+ {
+ var url = (AzureDevOpsProjectUrl)setup.Url;
+ var project = projects.SingleOrDefault(p => new Uri(p.Url!) == setup.Url);
+ if (project is null)
+ {
+ project = new Models.Management.Project
+ {
+ Id = Guid.NewGuid().ToString("n"),
+ Created = DateTimeOffset.UtcNow,
+ Password = GeneratePassword(32),
+ Url = setup.Url.ToString(),
+ Type = Models.Management.ProjectType.Azure,
+ };
+ await context.Projects.AddAsync(project, cancellationToken);
+ }
+
+ // update project using values from the setup
+ project.Token = setup.Token;
+ project.AutoComplete.Enabled = setup.AutoComplete;
+ project.AutoComplete.IgnoreConfigs = setup.AutoCompleteIgnoreConfigs;
+ project.AutoComplete.MergeStrategy = setup.AutoCompleteMergeStrategy;
+ project.AutoApprove.Enabled = setup.AutoApprove;
+ project.Secrets = setup.Secrets;
+
+ // update values from the project
+ var tp = await adoProvider.GetProjectAsync(project, cancellationToken);
+ project.ProviderId = tp.Id.ToString();
+ project.Name = tp.Name;
+ project.Private = tp.Visibility is not Models.Azure.AzdoProjectVisibility.Public;
+
+ // if there are changes, set the Updated field
+ if (context.ChangeTracker.HasChanges())
+ {
+ project.Updated = DateTimeOffset.UtcNow;
+ }
+ }
+
+ // update database and list of projects
+ var updated = await context.SaveChangesAsync(cancellationToken);
+ projects = updated > 0 ? await context.Projects.ToListAsync(cancellationToken) : projects;
+
+ // synchronize and create/update subscriptions if we have setups
+ var synchronizer = provider.GetRequiredService();
+ if (setups.Count > 0)
+ {
+ foreach (var project in projects)
+ {
+ // synchronize project
+ await synchronizer.SynchronizeAsync(project, false, cancellationToken); /* database sync should not trigger, just in case it's too many */
+
+ // create or update webhooks/subscriptions
+ await adoProvider.CreateOrUpdateSubscriptionsAsync(project, cancellationToken);
+ }
}
// skip loading schedules if told to
if (!app.Configuration.GetValue("SKIP_LOAD_SCHEDULES"))
{
- var dbContext = provider.GetRequiredService();
- var repositories = await dbContext.Repositories.ToListAsync(cancellationToken);
+ var repositories = await context.Repositories.ToListAsync(cancellationToken);
var scheduler = provider.GetRequiredService();
foreach (var repository in repositories)
{
await scheduler.CreateOrUpdateAsync(repository, cancellationToken);
}
}
+ }
- // create or update webhooks/subscriptions if asked to
- if (options.CreateOrUpdateWebhooksOnStartup)
- {
- var adoProvider = provider.GetRequiredService();
- await adoProvider.CreateOrUpdateSubscriptionsAsync(cancellationToken);
- }
+ private static string GeneratePassword(int length = 32)
+ {
+ var data = new byte[length];
+ using var rng = RandomNumberGenerator.Create();
+ rng.GetBytes(data);
+ return Convert.ToBase64String(data);
}
}
diff --git a/server/Tingle.Dependabot/ApplicationInsights/DependabotTelemetryInitializer.cs b/server/Tingle.Dependabot/ApplicationInsights/DependabotTelemetryInitializer.cs
new file mode 100644
index 00000000..1512131d
--- /dev/null
+++ b/server/Tingle.Dependabot/ApplicationInsights/DependabotTelemetryInitializer.cs
@@ -0,0 +1,27 @@
+using Microsoft.ApplicationInsights.Channel;
+using Microsoft.ApplicationInsights.DataContracts;
+using Microsoft.ApplicationInsights.Extensibility;
+
+namespace Tingle.Dependabot.ApplicationInsights;
+
+internal class DependabotTelemetryInitializer : ITelemetryInitializer
+{
+ private const string KeyProjectId = "ProjectId";
+
+ private readonly IHttpContextAccessor httpContextAccessor;
+
+ public DependabotTelemetryInitializer(IHttpContextAccessor httpContextAccessor)
+ {
+ this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
+ }
+
+ public void Initialize(ITelemetry telemetry)
+ {
+ var context = httpContextAccessor.HttpContext;
+ if (context is null || telemetry is not RequestTelemetry rt) return; // ensure we have a context and the telemetry is for a request
+
+ // add properties
+ var props = rt.Properties;
+ props.TryAddIfNotDefault(KeyProjectId, context.GetProjectId());
+ }
+}
diff --git a/server/Tingle.Dependabot/ApplicationInsights/InsightsFilteringProcessor.cs b/server/Tingle.Dependabot/ApplicationInsights/InsightsFilteringProcessor.cs
new file mode 100644
index 00000000..58eb4630
--- /dev/null
+++ b/server/Tingle.Dependabot/ApplicationInsights/InsightsFilteringProcessor.cs
@@ -0,0 +1,58 @@
+using Microsoft.ApplicationInsights.Channel;
+using Microsoft.ApplicationInsights.DataContracts;
+using Microsoft.ApplicationInsights.Extensibility;
+
+namespace Tingle.Dependabot.ApplicationInsights;
+
+///
+/// Implementation of that filters out unneeded telemetry.
+///
+internal class InsightsFilteringProcessor : ITelemetryProcessor
+{
+ private static readonly string[] excludedRequestNames =
+ {
+ "ServiceBusReceiver.Receive",
+ "ServiceBusProcessor.ProcessMessage",
+ };
+
+ private readonly ITelemetryProcessor next;
+
+ public InsightsFilteringProcessor(ITelemetryProcessor next)
+ {
+ this.next = next;
+ }
+
+ ///
+ public void Process(ITelemetry item)
+ {
+ // Skip unneeded RequestTelemetry
+ if (item is RequestTelemetry rt)
+ {
+ // Skip known request names
+ if (rt.Name is not null && excludedRequestNames.Contains(rt.Name, StringComparer.OrdinalIgnoreCase))
+ {
+ return; // terminate the processor pipeline
+ }
+
+ // Skip requests for /health and /liveness because they are better diagnosed via logs
+ var path = rt.Url?.AbsolutePath;
+ if (string.Equals(path, "/health", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(path, "/liveness", StringComparison.OrdinalIgnoreCase))
+ {
+ return; // terminate the processor pipeline
+ }
+ }
+
+ // Skip requests sent to the orchestrator to find the details of a pod/host
+ // Sometimes they fail, like when the service is starting up
+ if (item is DependencyTelemetry dt
+ && string.Equals("http", dt.Type, StringComparison.OrdinalIgnoreCase)
+ && string.Equals("10.0.0.1", dt.Target, StringComparison.OrdinalIgnoreCase))
+ {
+ return; // terminate the processor pipeline
+ }
+
+ // process all the others
+ next.Process(item);
+ }
+}
diff --git a/server/Tingle.Dependabot/ApplicationInsights/InsightsShutdownFlushService.cs b/server/Tingle.Dependabot/ApplicationInsights/InsightsShutdownFlushService.cs
new file mode 100644
index 00000000..6b1de3be
--- /dev/null
+++ b/server/Tingle.Dependabot/ApplicationInsights/InsightsShutdownFlushService.cs
@@ -0,0 +1,30 @@
+using Microsoft.ApplicationInsights;
+
+namespace Tingle.Dependabot.ApplicationInsights;
+
+// from https://medium.com/@asimmon/prevent-net-application-insights-telemetry-loss-d82a06c3673f
+internal class InsightsShutdownFlushService : IHostedService
+{
+ private readonly TelemetryClient telemetryClient;
+
+ public InsightsShutdownFlushService(TelemetryClient telemetryClient)
+ {
+ this.telemetryClient = telemetryClient ?? throw new ArgumentNullException(nameof(telemetryClient));
+ }
+
+ public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ // Flush the remaining telemetry data when application shutdown is requested.
+ // Using "CancellationToken.None" ensures that the application doesn't stop until the telemetry data is flushed.
+ //
+ // If you want to use the "cancellationToken" argument, make sure to configure "HostOptions.ShutdownTimeout" with a sufficiently large duration,
+ // and silence the eventual "OperationCanceledException" exception. Otherwise, you will still be at risk of loosing telemetry data.
+ var successfullyFlushed = await telemetryClient.FlushAsync(CancellationToken.None);
+ if (!successfullyFlushed)
+ {
+ // Here you can handle th case where transfer of telemetry data to the server has failed with HTTP status that cannot be retried.
+ }
+ }
+}
diff --git a/server/Tingle.Dependabot/BasicUserValidationService.cs b/server/Tingle.Dependabot/BasicUserValidationService.cs
index ca50b7ad..81ace82a 100644
--- a/server/Tingle.Dependabot/BasicUserValidationService.cs
+++ b/server/Tingle.Dependabot/BasicUserValidationService.cs
@@ -1,19 +1,21 @@
using AspNetCore.Authentication.Basic;
+using Microsoft.EntityFrameworkCore;
+using Tingle.Dependabot.Models;
namespace Tingle.Dependabot;
internal class BasicUserValidationService : IBasicUserValidationService
{
- private readonly IConfiguration configuration;
+ private readonly MainDbContext dbContext;
- public BasicUserValidationService(IConfiguration configuration)
+ public BasicUserValidationService(MainDbContext dbContext)
{
- this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
+ this.dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
}
- public Task IsValidAsync(string username, string password)
+ public async Task IsValidAsync(string username, string password)
{
- var expected = configuration.GetValue($"Authentication:Schemes:ServiceHooks:Credentials:{username}");
- return Task.FromResult(string.Equals(expected, password, StringComparison.Ordinal));
+ var project = await dbContext.Projects.SingleOrDefaultAsync(p => p.Id == username);
+ return project is not null && string.Equals(project.Password, password, StringComparison.Ordinal);
}
}
diff --git a/server/Tingle.Dependabot/Constants.cs b/server/Tingle.Dependabot/Constants.cs
index 82377473..6b34147e 100644
--- a/server/Tingle.Dependabot/Constants.cs
+++ b/server/Tingle.Dependabot/Constants.cs
@@ -15,4 +15,15 @@ internal static class AuthConstants
internal static class ErrorCodes
{
internal const string FeaturesDisabled = "features_disabled";
+ internal const string ProjectNotFound = "project_not_found";
+ internal const string RepositoryNotFound = "repository_not_found";
+ internal const string RepositoryUpdateNotFound = "repository_update_not_found";
+}
+
+internal static class FeatureNames
+{
+ internal const string DebugAllJobs = "DebugAllJobs"; // Whether to debug all jobs (controls environment variable value).
+ internal const string DebugJobs = "DebugJobs"; // Whether to debug jobs (controls value in job definition).
+ internal const string DeterministicUpdates = "DeterministicUpdates"; // Whether updates should be created in the same order.
+ internal const string UpdaterV2 = "UpdaterV2"; // Whether to use V2 updater
}
diff --git a/server/Tingle.Dependabot/Consumers/ProcessSynchronizationConsumer.cs b/server/Tingle.Dependabot/Consumers/ProcessSynchronizationConsumer.cs
index 4676e446..2f397cd8 100644
--- a/server/Tingle.Dependabot/Consumers/ProcessSynchronizationConsumer.cs
+++ b/server/Tingle.Dependabot/Consumers/ProcessSynchronizationConsumer.cs
@@ -22,29 +22,37 @@ public ProcessSynchronizationConsumer(MainDbContext dbContext, Synchronizer sync
public async Task ConsumeAsync(EventContext context, CancellationToken cancellationToken = default)
{
var evt = context.Event;
-
var trigger = evt.Trigger;
+ // ensure project exists
+ var projectId = evt.ProjectId ?? throw new InvalidOperationException($"'{nameof(evt.ProjectId)}' cannot be null");
+ var project = await dbContext.Projects.SingleOrDefaultAsync(r => r.Id == projectId, cancellationToken);
+ if (project is null)
+ {
+ logger.LogWarning("Skipping trigger for update because project '{Project}' does not exist.", projectId);
+ return;
+ }
+
if (evt.RepositoryId is not null)
{
// ensure repository exists
var repositoryId = evt.RepositoryId ?? throw new InvalidOperationException($"'{nameof(evt.RepositoryId)}' cannot be null");
- var repository = await dbContext.Repositories.SingleOrDefaultAsync(r => r.Id == repositoryId, cancellationToken);
+ var repository = await dbContext.Repositories.SingleOrDefaultAsync(r => r.ProjectId == project.Id && r.Id == repositoryId, cancellationToken);
if (repository is null)
{
logger.LogWarning("Skipping synchronization because repository '{Repository}' does not exist.", repositoryId);
return;
}
- await synchronizer.SynchronizeAsync(repository, trigger, cancellationToken);
+ await synchronizer.SynchronizeAsync(project, repository, trigger, cancellationToken);
}
else if (evt.RepositoryProviderId is not null)
{
- await synchronizer.SynchronizeAsync(repositoryProviderId: evt.RepositoryProviderId, trigger, cancellationToken);
+ await synchronizer.SynchronizeAsync(project, repositoryProviderId: evt.RepositoryProviderId, trigger, cancellationToken);
}
else
{
- await synchronizer.SynchronizeAsync(evt.Trigger, cancellationToken);
+ await synchronizer.SynchronizeAsync(project, evt.Trigger, cancellationToken);
}
}
}
diff --git a/server/Tingle.Dependabot/Consumers/TriggerUpdateJobsEventConsumer.cs b/server/Tingle.Dependabot/Consumers/TriggerUpdateJobsEventConsumer.cs
index a2a923ae..dec830fd 100644
--- a/server/Tingle.Dependabot/Consumers/TriggerUpdateJobsEventConsumer.cs
+++ b/server/Tingle.Dependabot/Consumers/TriggerUpdateJobsEventConsumer.cs
@@ -24,6 +24,15 @@ public async Task ConsumeAsync(EventContext context, Can
{
var evt = context.Event;
+ // ensure project exists
+ var projectId = evt.ProjectId ?? throw new InvalidOperationException($"'{nameof(evt.ProjectId)}' cannot be null");
+ var project = await dbContext.Projects.SingleOrDefaultAsync(r => r.Id == projectId, cancellationToken);
+ if (project is null)
+ {
+ logger.LogWarning("Skipping trigger for update because project '{Project}' does not exist.", projectId);
+ return;
+ }
+
// ensure repository exists
var repositoryId = evt.RepositoryId ?? throw new InvalidOperationException($"'{nameof(evt.RepositoryId)}' cannot be null");
var repository = await dbContext.Repositories.SingleOrDefaultAsync(r => r.Id == repositoryId, cancellationToken);
@@ -80,6 +89,7 @@ public async Task ConsumeAsync(EventContext context, Can
Status = UpdateJobStatus.Scheduled,
Trigger = evt.Trigger,
+ ProjectId = project.Id,
RepositoryId = repository.Id,
RepositorySlug = repository.Slug,
EventBusId = eventBusId,
@@ -94,6 +104,7 @@ public async Task ConsumeAsync(EventContext context, Can
End = null,
Duration = null,
Log = null,
+ Error = null,
};
await dbContext.UpdateJobs.AddAsync(job, cancellationToken);
@@ -107,7 +118,7 @@ public async Task ConsumeAsync(EventContext context, Can
}
// call the update runner to run the update
- await updateRunner.CreateAsync(repository, update, job, cancellationToken);
+ await updateRunner.CreateAsync(project, repository, update, job, cancellationToken);
// save changes that may have been made by the updateRunner
update.LatestJobStatus = job.Status;
diff --git a/server/Tingle.Dependabot/Controllers/ManagementController.cs b/server/Tingle.Dependabot/Controllers/ManagementController.cs
index 513013a2..b25ffb31 100644
--- a/server/Tingle.Dependabot/Controllers/ManagementController.cs
+++ b/server/Tingle.Dependabot/Controllers/ManagementController.cs
@@ -29,8 +29,13 @@ public ManagementController(MainDbContext dbContext, IEventPublisher publisher,
[HttpPost("sync")]
public async Task SyncAsync([FromBody] SynchronizationRequest model)
{
+ // ensure project exists
+ var projectId = HttpContext.GetProjectId() ?? throw new InvalidOperationException("Project identifier must be provided");
+ var project = await dbContext.Projects.SingleOrDefaultAsync(p => p.Id == projectId);
+ if (project is null) return Problem(title: ErrorCodes.ProjectNotFound, statusCode: 400);
+
// request synchronization of the project
- var evt = new ProcessSynchronization(model.Trigger);
+ var evt = new ProcessSynchronization(projectId, model.Trigger);
await publisher.PublishAsync(evt);
return Ok();
@@ -39,33 +44,50 @@ public async Task SyncAsync([FromBody] SynchronizationRequest mod
[HttpPost("/webhooks/register")]
public async Task WebhooksRegisterAsync()
{
- await adoProvider.CreateOrUpdateSubscriptionsAsync();
+ // ensure project exists
+ var projectId = HttpContext.GetProjectId() ?? throw new InvalidOperationException("Project identifier must be provided");
+ var project = await dbContext.Projects.SingleOrDefaultAsync(p => p.Id == projectId);
+ if (project is null) return Problem(title: ErrorCodes.ProjectNotFound, statusCode: 400);
+
+ await adoProvider.CreateOrUpdateSubscriptionsAsync(project);
return Ok();
}
[HttpGet("repos")]
public async Task GetReposAsync()
{
- var repos = await dbContext.Repositories.ToListAsync();
+ // ensure project exists
+ var projectId = HttpContext.GetProjectId() ?? throw new InvalidOperationException("Project identifier must be provided");
+ var project = await dbContext.Projects.SingleOrDefaultAsync(p => p.Id == projectId);
+ if (project is null) return Problem(title: ErrorCodes.ProjectNotFound, statusCode: 400);
+
+ var repos = await dbContext.Repositories.Where(r => r.ProjectId == project.Id).ToListAsync();
return Ok(repos);
}
[HttpGet("repos/{id}")]
public async Task GetRepoAsync([FromRoute, Required] string id)
{
- var repository = await dbContext.Repositories.SingleOrDefaultAsync(r => r.Id == id);
+ // ensure project exists
+ var projectId = HttpContext.GetProjectId() ?? throw new InvalidOperationException("Project identifier must be provided");
+ var project = await dbContext.Projects.SingleOrDefaultAsync(p => p.Id == projectId);
+ if (project is null) return Problem(title: ErrorCodes.ProjectNotFound, statusCode: 400);
+
+ var repository = await dbContext.Repositories.SingleOrDefaultAsync(r => r.ProjectId == project.Id && r.Id == id);
return Ok(repository);
}
[HttpGet("repos/{id}/jobs/{jobId}")]
public async Task GetJobAsync([FromRoute, Required] string id, [FromRoute, Required] string jobId)
{
+ // ensure project exists
+ var projectId = HttpContext.GetProjectId() ?? throw new InvalidOperationException("Project identifier must be provided");
+ var project = await dbContext.Projects.SingleOrDefaultAsync(p => p.Id == projectId);
+ if (project is null) return Problem(title: ErrorCodes.ProjectNotFound, statusCode: 400);
+
// ensure repository exists
- var repository = await dbContext.Repositories.SingleOrDefaultAsync(r => r.Id == id);
- if (repository is null)
- {
- return Problem(title: "repository_not_found", statusCode: 400);
- }
+ var repository = await dbContext.Repositories.SingleOrDefaultAsync(r => r.ProjectId == project.Id && r.Id == id);
+ if (repository is null) return Problem(title: ErrorCodes.RepositoryNotFound, statusCode: 400);
// find the job
var job = dbContext.UpdateJobs.Where(j => j.RepositoryId == repository.Id && j.Id == jobId).SingleOrDefaultAsync();
@@ -75,15 +97,17 @@ public async Task GetJobAsync([FromRoute, Required] string id, [F
[HttpPost("repos/{id}/sync")]
public async Task SyncRepoAsync([FromRoute, Required] string id, [FromBody] SynchronizationRequest model)
{
+ // ensure project exists
+ var projectId = HttpContext.GetProjectId() ?? throw new InvalidOperationException("Project identifier must be provided");
+ var project = await dbContext.Projects.SingleOrDefaultAsync(p => p.Id == projectId);
+ if (project is null) return Problem(title: ErrorCodes.ProjectNotFound, statusCode: 400);
+
// ensure repository exists
- var repository = await dbContext.Repositories.SingleOrDefaultAsync(r => r.Id == id);
- if (repository is null)
- {
- return Problem(title: "repository_not_found", statusCode: 400);
- }
+ var repository = await dbContext.Repositories.SingleOrDefaultAsync(r => r.ProjectId == project.Id && r.Id == id);
+ if (repository is null) return Problem(title: ErrorCodes.RepositoryNotFound, statusCode: 400);
// request synchronization of the repository
- var evt = new ProcessSynchronization(model.Trigger, repositoryId: repository.Id, null);
+ var evt = new ProcessSynchronization(projectId, model.Trigger, repositoryId: repository.Id, null);
await publisher.PublishAsync(evt);
return Ok(repository);
@@ -92,23 +116,23 @@ public async Task SyncRepoAsync([FromRoute, Required] string id,
[HttpPost("repos/{id}/trigger")]
public async Task TriggerAsync([FromRoute, Required] string id, [FromBody] TriggerUpdateRequest model)
{
+ // ensure project exists
+ var projectId = HttpContext.GetProjectId() ?? throw new InvalidOperationException("Project identifier must be provided");
+ var project = await dbContext.Projects.SingleOrDefaultAsync(p => p.Id == projectId);
+ if (project is null) return Problem(title: ErrorCodes.ProjectNotFound, statusCode: 400);
+
// ensure repository exists
- var repository = await dbContext.Repositories.SingleOrDefaultAsync(r => r.Id == id);
- if (repository is null)
- {
- return Problem(title: "repository_not_found", statusCode: 400);
- }
+ var repository = await dbContext.Repositories.SingleOrDefaultAsync(r => r.ProjectId == project.Id && r.Id == id);
+ if (repository is null) return Problem(title: ErrorCodes.RepositoryNotFound, statusCode: 400);
// ensure the repository update exists
var update = repository.Updates.ElementAtOrDefault(model.Id!.Value);
- if (update is null)
- {
- return Problem(title: "repository_update_not_found", statusCode: 400);
- }
+ if (update is null) return Problem(title: ErrorCodes.RepositoryUpdateNotFound, statusCode: 400);
// trigger update for specific update
var evt = new TriggerUpdateJobsEvent
{
+ ProjectId = project.Id,
RepositoryId = repository.Id,
RepositoryUpdateId = model.Id.Value,
Trigger = UpdateJobTrigger.Manual,
diff --git a/server/Tingle.Dependabot/Controllers/UpdateJobsController.cs b/server/Tingle.Dependabot/Controllers/UpdateJobsController.cs
index 60ef8e3a..af4aab2c 100644
--- a/server/Tingle.Dependabot/Controllers/UpdateJobsController.cs
+++ b/server/Tingle.Dependabot/Controllers/UpdateJobsController.cs
@@ -33,7 +33,10 @@ public UpdateJobsController(MainDbContext dbContext, IEventPublisher publisher,
[HttpPost("{id}/create_pull_request")]
public async Task CreatePullRequestAsync([FromRoute, Required] string id, [FromBody] PayloadWithData model)
{
- var job = await dbContext.UpdateJobs.SingleAsync(p => p.Id == id);
+ var job = await dbContext.UpdateJobs.SingleAsync(j => j.Id == id);
+ var repository = await dbContext.Repositories.SingleAsync(r => r.Id == job.RepositoryId);
+ var project = await dbContext.Projects.SingleAsync(p => p.Id == job.ProjectId);
+
logger.LogInformation("Received request to create a pull request from job {JobId} but we did nothing.\r\n{ModelJson}", id, JsonSerializer.Serialize(model));
return Ok();
}
@@ -41,7 +44,10 @@ public async Task CreatePullRequestAsync([FromRoute, Required] st
[HttpPost("{id}/update_pull_request")]
public async Task UpdatePullRequestAsync([FromRoute, Required] string id, [FromBody] PayloadWithData model)
{
- var job = await dbContext.UpdateJobs.SingleAsync(p => p.Id == id);
+ var job = await dbContext.UpdateJobs.SingleAsync(j => j.Id == id);
+ var repository = await dbContext.Repositories.SingleAsync(r => r.Id == job.RepositoryId);
+ var project = await dbContext.Projects.SingleAsync(p => p.Id == job.ProjectId);
+
logger.LogInformation("Received request to update a pull request from job {JobId} but we did nothing.\r\n{ModelJson}", id, JsonSerializer.Serialize(model));
return Ok();
}
@@ -49,7 +55,10 @@ public async Task UpdatePullRequestAsync([FromRoute, Required] st
[HttpPost("{id}/close_pull_request")]
public async Task ClosePullRequestAsync([FromRoute, Required] string id, [FromBody] PayloadWithData model)
{
- var job = await dbContext.UpdateJobs.SingleAsync(p => p.Id == id);
+ var job = await dbContext.UpdateJobs.SingleAsync(j => j.Id == id);
+ var repository = await dbContext.Repositories.SingleAsync(r => r.Id == job.RepositoryId);
+ var project = await dbContext.Projects.SingleAsync(p => p.Id == job.ProjectId);
+
logger.LogInformation("Received request to close a pull request from job {JobId} but we did nothing.\r\n{ModelJson}", id, JsonSerializer.Serialize(model));
return Ok();
}
@@ -57,7 +66,7 @@ public async Task ClosePullRequestAsync([FromRoute, Required] str
[HttpPost("{id}/record_update_job_error")]
public async Task RecordErrorAsync([FromRoute, Required] string id, [FromBody] PayloadWithData model)
{
- var job = await dbContext.UpdateJobs.SingleAsync(p => p.Id == id);
+ var job = await dbContext.UpdateJobs.SingleAsync(j => j.Id == id);
job.Error = new UpdateJobError
{
@@ -73,7 +82,7 @@ public async Task RecordErrorAsync([FromRoute, Required] string i
[HttpPatch("{id}/mark_as_processed")]
public async Task MarkAsProcessedAsync([FromRoute, Required] string id, [FromBody] PayloadWithData model)
{
- var job = await dbContext.UpdateJobs.SingleAsync(p => p.Id == id);
+ var job = await dbContext.UpdateJobs.SingleAsync(j => j.Id == id);
// publish event that will run update the job and collect logs
var evt = new UpdateJobCheckStateEvent { JobId = id, };
@@ -85,7 +94,7 @@ public async Task MarkAsProcessedAsync([FromRoute, Required] stri
[HttpPost("{id}/update_dependency_list")]
public async Task UpdateDependencyListAsync([FromRoute, Required] string id, [FromBody] PayloadWithData model)
{
- var job = await dbContext.UpdateJobs.SingleAsync(p => p.Id == id);
+ var job = await dbContext.UpdateJobs.SingleAsync(j => j.Id == id);
var repository = await dbContext.Repositories.SingleAsync(r => r.Id == job.RepositoryId);
// update the database
@@ -102,7 +111,7 @@ public async Task UpdateDependencyListAsync([FromRoute, Required]
[HttpPost("{id}/record_ecosystem_versions")]
public async Task RecordEcosystemVersionsAsync([FromRoute, Required] string id, [FromBody] JsonNode model)
{
- var job = await dbContext.UpdateJobs.SingleAsync(p => p.Id == id);
+ var job = await dbContext.UpdateJobs.SingleAsync(j => j.Id == id);
logger.LogInformation("Received request to record ecosystem version from job {JobId} but we did nothing.\r\n{ModelJson}", id, model.ToJsonString());
return Ok();
}
@@ -110,7 +119,7 @@ public async Task RecordEcosystemVersionsAsync([FromRoute, Requir
[HttpPost("{id}/increment_metric")]
public async Task IncrementMetricAsync([FromRoute, Required] string id, [FromBody] JsonNode model)
{
- var job = await dbContext.UpdateJobs.SingleAsync(p => p.Id == id);
+ var job = await dbContext.UpdateJobs.SingleAsync(j => j.Id == id);
logger.LogInformation("Received metrics from job {JobId} but we did nothing with them.\r\n{ModelJson}", id, model.ToJsonString());
return Ok();
}
diff --git a/server/Tingle.Dependabot/Controllers/WebhooksController.cs b/server/Tingle.Dependabot/Controllers/WebhooksController.cs
index 667046a8..da4e637f 100644
--- a/server/Tingle.Dependabot/Controllers/WebhooksController.cs
+++ b/server/Tingle.Dependabot/Controllers/WebhooksController.cs
@@ -1,7 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using Tingle.Dependabot.Events;
+using Tingle.Dependabot.Models;
using Tingle.Dependabot.Models.Azure;
using Tingle.EventBus;
@@ -12,11 +14,13 @@ namespace Tingle.Dependabot.Controllers;
[Authorize(AuthConstants.PolicyNameServiceHooks)]
public class WebhooksController : ControllerBase // TODO: unit test this
{
+ private readonly MainDbContext dbContext;
private readonly IEventPublisher publisher;
private readonly ILogger logger;
- public WebhooksController(IEventPublisher publisher, ILogger logger)
+ public WebhooksController(MainDbContext dbContext, IEventPublisher publisher, ILogger logger)
{
+ this.dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
this.publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -37,12 +41,17 @@ public async Task PostAsync([FromBody] AzureDevOpsEvent model)
var adoRepositoryId = adoRepository.Id;
var defaultBranch = adoRepository.DefaultBranch;
+ // ensure project exists
+ var adoProjectId = adoRepository.Project!.Id;
+ var project = await dbContext.Projects.SingleOrDefaultAsync(p => p.ProviderId == adoProjectId);
+ if (project is null) return Problem(title: ErrorCodes.ProjectNotFound, statusCode: 400);
+
// if the updates are not the default branch, then we ignore them
var updatedReferences = resource.RefUpdates!.Select(ru => ru.Name).ToList();
if (updatedReferences.Contains(defaultBranch, StringComparer.OrdinalIgnoreCase))
{
// request synchronization of the repository
- var evt = new ProcessSynchronization(true, repositoryProviderId: adoRepositoryId);
+ var evt = new ProcessSynchronization(project.Id!, trigger: true, repositoryProviderId: adoRepositoryId);
await publisher.PublishAsync(evt);
}
}
@@ -53,6 +62,11 @@ public async Task PostAsync([FromBody] AzureDevOpsEvent model)
var prId = resource.PullRequestId;
var status = resource.Status;
+ // ensure project exists
+ var adoProjectId = adoRepository.Project!.Id;
+ var project = await dbContext.Projects.SingleOrDefaultAsync(p => p.ProviderId == adoProjectId);
+ if (project is null) return Problem(title: ErrorCodes.ProjectNotFound, statusCode: 400);
+
if (type is AzureDevOpsEventType.GitPullRequestUpdated)
{
logger.LogInformation("PR {PullRequestId} in {RepositoryUrl} status updated to {PullRequestStatus}",
@@ -83,6 +97,11 @@ public async Task PostAsync([FromBody] AzureDevOpsEvent model)
var prId = pr.PullRequestId;
var status = pr.Status;
+ // ensure project exists
+ var adoProjectId = adoRepository.Project!.Id;
+ var project = await dbContext.Projects.SingleOrDefaultAsync(p => p.ProviderId == adoProjectId);
+ if (project is null) return Problem(title: ErrorCodes.ProjectNotFound, statusCode: 400);
+
// ensure the comment starts with @dependabot
var content = comment.Content?.Trim();
if (content is not null && content.StartsWith("@dependabot"))
diff --git a/server/Tingle.Dependabot/Events/RepositoryCreatedEvent.cs b/server/Tingle.Dependabot/Events/Events.cs
similarity index 66%
rename from server/Tingle.Dependabot/Events/RepositoryCreatedEvent.cs
rename to server/Tingle.Dependabot/Events/Events.cs
index f43fac45..6c4e94e3 100644
--- a/server/Tingle.Dependabot/Events/RepositoryCreatedEvent.cs
+++ b/server/Tingle.Dependabot/Events/Events.cs
@@ -2,10 +2,12 @@
namespace Tingle.Dependabot.Events;
-public record RepositoryCreatedEvent : AbstractRepositoryEvent { }
+public record ProjectCreatedEvent : AbstractProjectEvent { }
+public record ProjectUpdatedEvent : AbstractProjectEvent { }
+public record ProjectDeletedEvent : AbstractProjectEvent { }
+public record RepositoryCreatedEvent : AbstractRepositoryEvent { }
public record RepositoryUpdatedEvent : AbstractRepositoryEvent { }
-
public record RepositoryDeletedEvent : AbstractRepositoryEvent { }
public record TriggerUpdateJobsEvent : AbstractRepositoryEvent
@@ -20,8 +22,14 @@ public record TriggerUpdateJobsEvent : AbstractRepositoryEvent
public required UpdateJobTrigger Trigger { get; set; }
}
-public abstract record AbstractRepositoryEvent
+public abstract record AbstractRepositoryEvent : AbstractProjectEvent
{
/// Identifier of the repository.
public required string? RepositoryId { get; set; }
}
+
+public abstract record AbstractProjectEvent
+{
+ /// Identifier of the project.
+ public required string? ProjectId { get; set; }
+}
diff --git a/server/Tingle.Dependabot/Events/ProcessSynchronization.cs b/server/Tingle.Dependabot/Events/ProcessSynchronization.cs
index 001ffd6c..dea671c8 100644
--- a/server/Tingle.Dependabot/Events/ProcessSynchronization.cs
+++ b/server/Tingle.Dependabot/Events/ProcessSynchronization.cs
@@ -4,13 +4,17 @@ public record ProcessSynchronization
{
public ProcessSynchronization() { } // required for deserialization
- public ProcessSynchronization(bool trigger, string? repositoryId = null, string? repositoryProviderId = null)
+ public ProcessSynchronization(string projectId, bool trigger, string? repositoryId = null, string? repositoryProviderId = null)
{
+ ProjectId = projectId ?? throw new ArgumentNullException(nameof(projectId));
Trigger = trigger;
RepositoryId = repositoryId;
RepositoryProviderId = repositoryProviderId;
}
+ /// Identifier of the project.
+ public string? ProjectId { get; set; }
+
///
/// Indicates whether we should trigger the update jobs where changes have been detected.
///
diff --git a/server/Tingle.Dependabot/Extensions/CollectionExtensions.cs b/server/Tingle.Dependabot/Extensions/CollectionExtensions.cs
new file mode 100644
index 00000000..46d91f93
--- /dev/null
+++ b/server/Tingle.Dependabot/Extensions/CollectionExtensions.cs
@@ -0,0 +1,50 @@
+namespace System.Collections.Generic;
+
+internal static class CollectionExtensions
+{
+ ///
+ /// Adds an element with the provided key and value,
+ /// provided the value is not equal to the type's default value (or empty for strings).
+ ///
+ /// The type of keys in the dictionary.
+ /// The type of values in the dictionary.
+ /// The dictionary to use
+ /// The object to use as the key of the element to add.
+ /// The object to use as the value of the element to add.
+ /// key is null.
+ /// The dictionary is read-only.
+ ///
+ public static IDictionary AddIfNotDefault(this IDictionary dictionary, TKey key, TValue? value)
+ where TKey : notnull
+ {
+ if (value is not null || value is string s && !string.IsNullOrWhiteSpace(s))
+ {
+ dictionary[key] = value;
+ }
+
+ return dictionary;
+ }
+
+
+ ///
+ /// Tries to add an element with the provided key and value to the ,
+ /// provided the value is not equal to the type's default value (or empty for strings).
+ ///
+ /// The type of keys in the dictionary.
+ /// The type of values in the dictionary.
+ /// The dictionary to use
+ /// The object to use as the key of the element to add.
+ /// The object to use as the value of the element to add.
+ /// key is null.
+ ///
+ public static bool TryAddIfNotDefault(this IDictionary dictionary, TKey key, TValue? value)
+ where TKey : notnull
+ {
+ if (value is not null || (value is string s && !string.IsNullOrWhiteSpace(s)))
+ {
+ return dictionary.TryAdd(key, value);
+ }
+
+ return false;
+ }
+}
diff --git a/server/Tingle.Dependabot/Extensions/HttpContextExtensions.cs b/server/Tingle.Dependabot/Extensions/HttpContextExtensions.cs
new file mode 100644
index 00000000..c649bdbf
--- /dev/null
+++ b/server/Tingle.Dependabot/Extensions/HttpContextExtensions.cs
@@ -0,0 +1,9 @@
+namespace Microsoft.AspNetCore.Http;
+
+internal static class HttpContextExtensions
+{
+ public const string XProjectId = "X-Project-Id";
+
+ public static string? GetProjectId(this HttpContext httpContext)
+ => httpContext.Request.Headers.TryGetValue(XProjectId, out var values) ? values.Single() : null;
+}
diff --git a/server/Tingle.Dependabot/Extensions/IServiceCollectionExtensions.cs b/server/Tingle.Dependabot/Extensions/IServiceCollectionExtensions.cs
index 6cfd8f74..1e3d6e5a 100644
--- a/server/Tingle.Dependabot/Extensions/IServiceCollectionExtensions.cs
+++ b/server/Tingle.Dependabot/Extensions/IServiceCollectionExtensions.cs
@@ -1,6 +1,9 @@
using Medallion.Threading;
using Medallion.Threading.FileSystem;
+using Microsoft.ApplicationInsights.Extensibility;
using Microsoft.FeatureManagement;
+using Tingle.Dependabot.ApplicationInsights;
+using Tingle.Dependabot.FeatureManagement;
using Tingle.Dependabot.Workflow;
namespace Microsoft.Extensions.DependencyInjection;
@@ -8,6 +11,34 @@ namespace Microsoft.Extensions.DependencyInjection;
/// Extensions on .
public static class IServiceCollectionExtensions
{
+ /// Add standard Application Insights services.
+ /// The instance to add to.
+ /// The root configuration instance from which to pull settings.
+ public static IServiceCollection AddStandardApplicationInsights(this IServiceCollection services, IConfiguration configuration)
+ {
+ // Add the core services
+ services.AddApplicationInsightsTelemetry(configuration);
+
+ // Add background service to flush telemetry on shutdown
+ services.AddHostedService();
+
+ // Add processors
+ services.AddApplicationInsightsTelemetryProcessor();
+
+ // Enrich the telemetry with various sources of information
+ services.AddHttpContextAccessor(); // Required to resolve the request from the HttpContext
+ // according to docs link below, this registration should be singleton
+ // https://docs.microsoft.com/en-us/azure/azure-monitor/app/asp-net-core#adding-telemetryinitializers
+ services.AddSingleton();
+ // services.AddApplicationInsightsTelemetryExtras(); // Add other extras
+
+ // services.AddActivitySourceDependencyCollector(new[] {
+ // "Tingle.EventBus",
+ // });
+
+ return services;
+ }
+
public static IServiceCollection AddDistributedLockProvider(this IServiceCollection services, IHostEnvironment environment, IConfiguration configuration)
{
var configKey = ConfigurationPath.Combine("DistributedLocking", "FilePath");
@@ -36,10 +67,9 @@ public static IServiceCollection AddStandardFeatureManagement(this IServiceColle
builder.AddFeatureFilter();
builder.AddFeatureFilter();
-
- // In some scenarios (such as AspNetCore, the TargetingFilter together with an ITargetingContextAccessor
- // should be used in place of ContextualTargetingFilter.
builder.AddFeatureFilter();
+
+ builder.Services.AddSingleton();
builder.AddFeatureFilter(); // requires ITargetingContextAccessor
builder.Services.Configure(o => o.IgnoreCase = true);
@@ -53,7 +83,7 @@ public static IServiceCollection AddWorkflowServices(this IServiceCollection ser
services.Configure(configuration);
services.ConfigureOptions();
- services.AddSingleton();
+ services.AddScoped();
services.AddSingleton();
services.AddScoped();
diff --git a/server/Tingle.Dependabot/Extensions/PropertyBuilderExtensions.cs b/server/Tingle.Dependabot/Extensions/PropertyBuilderExtensions.cs
new file mode 100644
index 00000000..fb798fee
--- /dev/null
+++ b/server/Tingle.Dependabot/Extensions/PropertyBuilderExtensions.cs
@@ -0,0 +1,138 @@
+using Microsoft.EntityFrameworkCore.ChangeTracking;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+
+namespace Microsoft.EntityFrameworkCore;
+
+///
+/// Extensions for .
+///
+public static class PropertyBuilderExtensions
+{
+ ///
+ /// Attach conversion of property to/from stored in the database as concatenated string of strings.
+ ///
+ ///
+ /// The to extend.
+ /// The separator to use.
+ ///
+ public static PropertyBuilder> HasArrayConversion(this PropertyBuilder> propertyBuilder, string separator = ",") where T : IConvertible
+ => propertyBuilder.HasArrayConversion(separator: separator, serializerOptions: null);
+
+ ///
+ /// Attach conversion of property to/from stored in the database as concatenated string of strings.
+ ///
+ ///
+ /// The to extend.
+ /// The to use for enums.
+ ///
+ public static PropertyBuilder> HasArrayConversion(this PropertyBuilder> propertyBuilder, JsonSerializerOptions? serializerOptions = null) where T : IConvertible
+ => propertyBuilder.HasArrayConversion(separator: ",", serializerOptions: serializerOptions);
+
+ ///
+ /// Attach conversion of property to/from stored in the database as concatenated string of strings.
+ ///
+ ///
+ /// The to extend.
+ /// The separator to use.
+ /// The to use for enums.
+ ///
+ public static PropertyBuilder> HasArrayConversion(this PropertyBuilder> propertyBuilder, string separator, JsonSerializerOptions? serializerOptions)
+ where T : IConvertible
+ {
+ ArgumentNullException.ThrowIfNull(propertyBuilder);
+
+ var converter = new ValueConverter, string?>(
+ convertToProviderExpression: v => ConvertToString(v, separator, serializerOptions),
+ convertFromProviderExpression: v => ConvertFromString(v, separator, serializerOptions));
+
+ var comparer = new ValueComparer>(
+ equalsExpression: (l, r) => ConvertToString(l, separator, serializerOptions) == ConvertToString(r, separator, serializerOptions),
+ hashCodeExpression: v => v == null ? 0 : ConvertToString(v, separator, serializerOptions).GetHashCode(),
+ snapshotExpression: v => ConvertFromString(ConvertToString(v, separator, serializerOptions), separator, serializerOptions));
+
+ propertyBuilder.HasConversion(converter);
+ propertyBuilder.Metadata.SetValueConverter(converter);
+ propertyBuilder.Metadata.SetValueComparer(comparer);
+
+ return propertyBuilder;
+ }
+
+ ///
+ /// Attach conversion of property to/from JSON stored in the database as a string.
+ ///
+ ///
+ /// The to extend.
+ /// The to use.
+ ///
+ public static PropertyBuilder HasJsonConversion(this PropertyBuilder propertyBuilder, JsonSerializerOptions? serializerOptions = null)
+ {
+ ArgumentNullException.ThrowIfNull(propertyBuilder);
+
+#pragma warning disable CS8603 // Possible null reference return.
+ var converter = new ValueConverter(
+ convertToProviderExpression: v => ConvertToJson(v, serializerOptions),
+ convertFromProviderExpression: v => ConvertFromJson(v, serializerOptions));
+
+ var comparer = new ValueComparer(
+ equalsExpression: (l, r) => ConvertToJson(l, serializerOptions) == ConvertToJson(r, serializerOptions),
+ hashCodeExpression: v => v == null ? 0 : ConvertToJson(v, serializerOptions).GetHashCode(),
+ snapshotExpression: v => ConvertFromJson(ConvertToJson(v, serializerOptions), serializerOptions));
+#pragma warning restore CS8603 // Possible null reference return.
+
+ propertyBuilder.HasConversion(converter);
+ propertyBuilder.Metadata.SetValueConverter(converter);
+ propertyBuilder.Metadata.SetValueComparer(comparer);
+
+ return propertyBuilder;
+ }
+
+ [return: NotNullIfNotNull(nameof(value))]
+ private static string? ConvertToString(List? value, string separator, JsonSerializerOptions? serializerOptions) where T : IConvertible
+ {
+ if (value is null) return null;
+ if (string.IsNullOrWhiteSpace(separator))
+ {
+ throw new ArgumentException($"'{nameof(separator)}' cannot be null or whitespace.", nameof(separator));
+ }
+
+ return typeof(T).IsEnum
+ ? string.Join(separator, value.Select(t => EnumToString(t, serializerOptions)))
+ : string.Join(separator, value);
+ }
+
+ private static List ConvertFromString(string? value, string separator, JsonSerializerOptions? serializerOptions) where T : IConvertible
+ {
+ if (string.IsNullOrWhiteSpace(value)) return new List();
+ if (string.IsNullOrWhiteSpace(separator))
+ {
+ throw new ArgumentException($"'{nameof(separator)}' cannot be null or whitespace.", nameof(separator));
+ }
+
+ var split = value.Split(separator, StringSplitOptions.RemoveEmptyEntries);
+ return typeof(T).IsEnum
+ ? split.Select(v => EnumFromString(v, serializerOptions)).ToList()
+ : split.Select(v => (T)Convert.ChangeType(v, typeof(T))).ToList();
+ }
+
+
+ private static T EnumFromString(string value, JsonSerializerOptions? serializerOptions) where T : IConvertible
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new ArgumentException($"'{nameof(value)}' cannot be null or whitespace.", nameof(value));
+ }
+
+ return JsonSerializer.Deserialize($"\"{value}\"", serializerOptions)!;
+ }
+
+ private static string EnumToString(T value, JsonSerializerOptions? serializerOptions)
+ => JsonSerializer.Serialize(value, serializerOptions).Trim('"');
+
+
+ private static string ConvertToJson(T value, JsonSerializerOptions? serializerOptions) => JsonSerializer.Serialize(value, serializerOptions);
+
+ private static T? ConvertFromJson(string? value, JsonSerializerOptions? serializerOptions) => value is null ? default : JsonSerializer.Deserialize(value, serializerOptions);
+}
diff --git a/server/Tingle.Dependabot/Extensions/SystemExtensions.cs b/server/Tingle.Dependabot/Extensions/SystemExtensions.cs
deleted file mode 100644
index 5d8951cd..00000000
--- a/server/Tingle.Dependabot/Extensions/SystemExtensions.cs
+++ /dev/null
@@ -1,64 +0,0 @@
-using Microsoft.EntityFrameworkCore.ChangeTracking;
-using Microsoft.EntityFrameworkCore.Metadata.Builders;
-using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
-using System.Text.Json;
-
-namespace System;
-
-internal static class SystemExtensions
-{
- ///
- /// Adds an element with the provided key and value,
- /// provided the value is not equal to the type's default value (or empty for strings).
- ///
- /// The type of keys in the dictionary.
- /// The type of values in the dictionary.
- /// The dictionary to use
- /// The object to use as the key of the element to add.
- /// The object to use as the value of the element to add.
- /// key is null.
- /// The dictionary is read-only.
- ///
- public static IDictionary AddIfNotDefault(this IDictionary dictionary, TKey key, TValue? value)
- where TKey : notnull
- {
- if (value is not null || value is string s && !string.IsNullOrWhiteSpace(s))
- {
- dictionary[key] = value;
- }
-
- return dictionary;
- }
-
- ///
- /// Attach conversion of property to/from JSON stored in the database as a string.
- ///
- ///
- /// The to extend.
- /// The to use.
- ///
- public static PropertyBuilder HasJsonConversion(this PropertyBuilder propertyBuilder, JsonSerializerOptions? serializerOptions = null)
- {
- ArgumentNullException.ThrowIfNull(propertyBuilder);
-
-#pragma warning disable CS8603 // Possible null reference return.
- var converter = new ValueConverter(
- convertToProviderExpression: v => ConvertToJson(v, serializerOptions),
- convertFromProviderExpression: v => ConvertFromJson(v, serializerOptions));
-
- var comparer = new ValueComparer(
- equalsExpression: (l, r) => ConvertToJson(l, serializerOptions) == ConvertToJson(r, serializerOptions),
- hashCodeExpression: v => v == null ? 0 : ConvertToJson(v, serializerOptions).GetHashCode(),
- snapshotExpression: v => ConvertFromJson(ConvertToJson(v, serializerOptions), serializerOptions));
-#pragma warning restore CS8603 // Possible null reference return.
-
- propertyBuilder.HasConversion(converter);
- propertyBuilder.Metadata.SetValueConverter(converter);
- propertyBuilder.Metadata.SetValueComparer(comparer);
-
- return propertyBuilder;
- }
-
- private static string ConvertToJson(T value, JsonSerializerOptions? serializerOptions) => JsonSerializer.Serialize(value, serializerOptions);
- private static T? ConvertFromJson(string? value, JsonSerializerOptions? serializerOptions) => value is null ? default : JsonSerializer.Deserialize(value, serializerOptions);
-}
diff --git a/server/Tingle.Dependabot/CustomDisabledFeaturesHandler.cs b/server/Tingle.Dependabot/FeatureManagement/CustomDisabledFeaturesHandler.cs
similarity index 92%
rename from server/Tingle.Dependabot/CustomDisabledFeaturesHandler.cs
rename to server/Tingle.Dependabot/FeatureManagement/CustomDisabledFeaturesHandler.cs
index 8d94e4ac..a3226f90 100644
--- a/server/Tingle.Dependabot/CustomDisabledFeaturesHandler.cs
+++ b/server/Tingle.Dependabot/FeatureManagement/CustomDisabledFeaturesHandler.cs
@@ -1,5 +1,5 @@
-using Microsoft.AspNetCore.Mvc.Filters;
-using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.FeatureManagement.Mvc;
namespace Tingle.Dependabot;
diff --git a/server/Tingle.Dependabot/FeatureManagement/ProjectTargetingContextAccessor.cs b/server/Tingle.Dependabot/FeatureManagement/ProjectTargetingContextAccessor.cs
new file mode 100644
index 00000000..82af1ca6
--- /dev/null
+++ b/server/Tingle.Dependabot/FeatureManagement/ProjectTargetingContextAccessor.cs
@@ -0,0 +1,29 @@
+using Microsoft.FeatureManagement.FeatureFilters;
+
+namespace Tingle.Dependabot.FeatureManagement;
+
+///
+/// An implementation of
+/// that creates a using the current .
+///
+internal class ProjectTargetingContextAccessor : ITargetingContextAccessor
+{
+ private readonly IHttpContextAccessor contextAccessor;
+
+ public ProjectTargetingContextAccessor(IHttpContextAccessor contextAccessor)
+ {
+ this.contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor));
+ }
+
+ ///
+ public ValueTask GetContextAsync()
+ {
+ var httpContext = contextAccessor.HttpContext!;
+
+ // Build targeting context based off project info
+ var projectId = httpContext.GetProjectId();
+ var targetingContext = new TargetingContext { UserId = projectId, };
+
+ return new ValueTask(targetingContext);
+ }
+}
diff --git a/server/Tingle.Dependabot/Migrations/20230824083425_InitialCreate.Designer.cs b/server/Tingle.Dependabot/Migrations/20230824083425_InitialCreate.Designer.cs
index d7c11726..6ee3f156 100644
--- a/server/Tingle.Dependabot/Migrations/20230824083425_InitialCreate.Designer.cs
+++ b/server/Tingle.Dependabot/Migrations/20230824083425_InitialCreate.Designer.cs
@@ -44,7 +44,77 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.ToTable("DataProtectionKeys");
});
- modelBuilder.Entity("Tingle.Dependabot.Models.Repository", b =>
+ modelBuilder.Entity("Tingle.Dependabot.Models.Management.Project", b =>
+ {
+ b.Property("Id")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("Created")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("Etag")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.Property("GithubToken")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Location")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Password")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Private")
+ .HasColumnType("bit");
+
+ b.Property("ProviderId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Secrets")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Synchronized")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Type")
+ .HasColumnType("int");
+
+ b.Property("Updated")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("Url")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Created")
+ .IsDescending();
+
+ b.HasIndex("Password")
+ .IsUnique();
+
+ b.HasIndex("ProviderId")
+ .IsUnique();
+
+ b.ToTable("Projects");
+ });
+
+ modelBuilder.Entity("Tingle.Dependabot.Models.Management.Repository", b =>
{
b.Property("Id")
.HasMaxLength(50)
@@ -69,7 +139,12 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.Property("Name")
.HasColumnType("nvarchar(max)");
+ b.Property("ProjectId")
+ .IsRequired()
+ .HasColumnType("nvarchar(50)");
+
b.Property("ProviderId")
+ .IsRequired()
.HasColumnType("nvarchar(450)");
b.Property("Registries")
@@ -94,14 +169,15 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.HasIndex("Created")
.IsDescending();
+ b.HasIndex("ProjectId");
+
b.HasIndex("ProviderId")
- .IsUnique()
- .HasFilter("[ProviderId] IS NOT NULL");
+ .IsUnique();
b.ToTable("Repositories");
});
- modelBuilder.Entity("Tingle.Dependabot.Models.UpdateJob", b =>
+ modelBuilder.Entity("Tingle.Dependabot.Models.Management.UpdateJob", b =>
{
b.Property("Id")
.HasMaxLength(50)
@@ -128,9 +204,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.Property("End")
.HasColumnType("datetimeoffset");
- b.Property("Error")
- .HasColumnType("nvarchar(max)");
-
b.Property("Etag")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
@@ -146,6 +219,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
.IsRequired()
.HasColumnType("nvarchar(450)");
+ b.Property("ProjectId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
b.Property("RepositoryId")
.IsRequired()
.HasColumnType("nvarchar(450)");
@@ -171,6 +248,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.HasIndex("Created")
.IsDescending();
+ b.HasIndex("ProjectId");
+
b.HasIndex("RepositoryId");
b.HasIndex("PackageEcosystem", "Directory");
@@ -182,9 +261,87 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.ToTable("UpdateJobs");
});
- modelBuilder.Entity("Tingle.Dependabot.Models.UpdateJob", b =>
+ modelBuilder.Entity("Tingle.Dependabot.Models.Management.Project", b =>
{
- b.OwnsOne("Tingle.Dependabot.Models.UpdateJobResources", "Resources", b1 =>
+ b.OwnsOne("Tingle.Dependabot.Models.Management.ProjectAutoApprove", "AutoApprove", b1 =>
+ {
+ b1.Property("ProjectId")
+ .HasColumnType("nvarchar(50)");
+
+ b1.Property("Enabled")
+ .HasColumnType("bit");
+
+ b1.HasKey("ProjectId");
+
+ b1.ToTable("Projects");
+
+ b1.WithOwner()
+ .HasForeignKey("ProjectId");
+ });
+
+ b.OwnsOne("Tingle.Dependabot.Models.Management.ProjectAutoComplete", "AutoComplete", b1 =>
+ {
+ b1.Property("ProjectId")
+ .HasColumnType("nvarchar(50)");
+
+ b1.Property("Enabled")
+ .HasColumnType("bit");
+
+ b1.Property("IgnoreConfigs")
+ .HasColumnType("nvarchar(max)");
+
+ b1.Property("MergeStrategy")
+ .HasColumnType("int");
+
+ b1.HasKey("ProjectId");
+
+ b1.ToTable("Projects");
+
+ b1.WithOwner()
+ .HasForeignKey("ProjectId");
+ });
+
+ b.Navigation("AutoApprove")
+ .IsRequired();
+
+ b.Navigation("AutoComplete")
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Tingle.Dependabot.Models.Management.Repository", b =>
+ {
+ b.HasOne("Tingle.Dependabot.Models.Management.Project", null)
+ .WithMany("Repositories")
+ .HasForeignKey("ProjectId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Tingle.Dependabot.Models.Management.UpdateJob", b =>
+ {
+ b.OwnsOne("Tingle.Dependabot.Models.Management.UpdateJobError", "Error", b1 =>
+ {
+ b1.Property("UpdateJobId")
+ .HasColumnType("nvarchar(50)");
+
+ b1.Property("Detail")
+ .HasColumnType("nvarchar(max)");
+
+ b1.Property("Type")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b1.HasKey("UpdateJobId");
+
+ b1.HasIndex("Type");
+
+ b1.ToTable("UpdateJobs");
+
+ b1.WithOwner()
+ .HasForeignKey("UpdateJobId");
+ });
+
+ b.OwnsOne("Tingle.Dependabot.Models.Management.UpdateJobResources", "Resources", b1 =>
{
b1.Property("UpdateJobId")
.HasColumnType("nvarchar(50)");
@@ -203,9 +360,16 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
.HasForeignKey("UpdateJobId");
});
+ b.Navigation("Error");
+
b.Navigation("Resources")
.IsRequired();
});
+
+ modelBuilder.Entity("Tingle.Dependabot.Models.Management.Project", b =>
+ {
+ b.Navigation("Repositories");
+ });
#pragma warning restore 612, 618
}
}
diff --git a/server/Tingle.Dependabot/Migrations/20230824083425_InitialCreate.cs b/server/Tingle.Dependabot/Migrations/20230824083425_InitialCreate.cs
index 58210021..b863c427 100644
--- a/server/Tingle.Dependabot/Migrations/20230824083425_InitialCreate.cs
+++ b/server/Tingle.Dependabot/Migrations/20230824083425_InitialCreate.cs
@@ -25,25 +25,32 @@ protected override void Up(MigrationBuilder migrationBuilder)
});
migrationBuilder.CreateTable(
- name: "Repositories",
+ name: "Projects",
columns: table => new
{
Id = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false),
Created = table.Column(type: "datetimeoffset", nullable: false),
Updated = table.Column(type: "datetimeoffset", nullable: false),
- Name = table.Column(type: "nvarchar(max)", nullable: true),
- Slug = table.Column(type: "nvarchar(max)", nullable: true),
- ProviderId = table.Column(type: "nvarchar(450)", nullable: true),
- LatestCommit = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true),
- ConfigFileContents = table.Column(type: "nvarchar(max)", nullable: false),
- SyncException = table.Column(type: "nvarchar(max)", nullable: true),
- Updates = table.Column(type: "nvarchar(max)", nullable: false),
- Registries = table.Column(type: "nvarchar(max)", nullable: false),
+ Type = table.Column(type: "int", nullable: false),
+ Name = table.Column(type: "nvarchar(max)", nullable: false),
+ ProviderId = table.Column(type: "nvarchar(450)", nullable: false),
+ Url = table.Column(type: "nvarchar(max)", nullable: false),
+ Token = table.Column(type: "nvarchar(max)", nullable: false),
+ Private = table.Column(type: "bit", nullable: false),
+ AutoComplete_Enabled = table.Column(type: "bit", nullable: false),
+ AutoComplete_IgnoreConfigs = table.Column(type: "nvarchar(max)", nullable: true),
+ AutoComplete_MergeStrategy = table.Column(type: "int", nullable: true),
+ AutoApprove_Enabled = table.Column(type: "bit", nullable: false),
+ Password = table.Column(type: "nvarchar(450)", nullable: false),
+ Secrets = table.Column(type: "nvarchar(max)", nullable: false),
+ GithubToken = table.Column(type: "nvarchar(max)", nullable: true),
+ Location = table.Column(type: "nvarchar(max)", nullable: true),
+ Synchronized = table.Column(type: "datetimeoffset", nullable: true),
Etag = table.Column(type: "rowversion", rowVersion: true, nullable: true)
},
constraints: table =>
{
- table.PrimaryKey("PK_Repositories", x => x.Id);
+ table.PrimaryKey("PK_Projects", x => x.Id);
});
migrationBuilder.CreateTable(
@@ -54,6 +61,7 @@ protected override void Up(MigrationBuilder migrationBuilder)
Created = table.Column(type: "datetimeoffset", nullable: false),
Status = table.Column(type: "int", nullable: false),
Trigger = table.Column(type: "int", nullable: false),
+ ProjectId = table.Column(type: "nvarchar(450)", nullable: false),
RepositoryId = table.Column(type: "nvarchar(450)", nullable: false),
RepositorySlug = table.Column(type: "nvarchar(max)", nullable: false),
EventBusId = table.Column(type: "nvarchar(450)", nullable: true),
@@ -67,7 +75,8 @@ protected override void Up(MigrationBuilder migrationBuilder)
End = table.Column(type: "datetimeoffset", nullable: true),
Duration = table.Column(type: "bigint", nullable: true),
Log = table.Column(type: "nvarchar(max)", nullable: true),
- Error = table.Column(type: "nvarchar(max)", nullable: true),
+ Error_Type = table.Column(type: "nvarchar(450)", nullable: true),
+ Error_Detail = table.Column(type: "nvarchar(max)", nullable: true),
Etag = table.Column(type: "rowversion", rowVersion: true, nullable: true)
},
constraints: table =>
@@ -75,18 +84,69 @@ protected override void Up(MigrationBuilder migrationBuilder)
table.PrimaryKey("PK_UpdateJobs", x => x.Id);
});
+ migrationBuilder.CreateTable(
+ name: "Repositories",
+ columns: table => new
+ {
+ Id = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false),
+ Created = table.Column(type: "datetimeoffset", nullable: false),
+ Updated = table.Column(type: "datetimeoffset", nullable: false),
+ ProjectId = table.Column(type: "nvarchar(50)", nullable: false),
+ Name = table.Column(type: "nvarchar(max)", nullable: true),
+ Slug = table.Column(type: "nvarchar(max)", nullable: true),
+ ProviderId = table.Column(type: "nvarchar(450)", nullable: false),
+ LatestCommit = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true),
+ ConfigFileContents = table.Column(type: "nvarchar(max)", nullable: false),
+ SyncException = table.Column(type: "nvarchar(max)", nullable: true),
+ Updates = table.Column(type: "nvarchar(max)", nullable: false),
+ Registries = table.Column(type: "nvarchar(max)", nullable: false),
+ Etag = table.Column(type: "rowversion", rowVersion: true, nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Repositories", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Repositories_Projects_ProjectId",
+ column: x => x.ProjectId,
+ principalTable: "Projects",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Projects_Created",
+ table: "Projects",
+ column: "Created",
+ descending: new bool[0]);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Projects_Password",
+ table: "Projects",
+ column: "Password",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Projects_ProviderId",
+ table: "Projects",
+ column: "ProviderId",
+ unique: true);
+
migrationBuilder.CreateIndex(
name: "IX_Repositories_Created",
table: "Repositories",
column: "Created",
descending: new bool[0]);
+ migrationBuilder.CreateIndex(
+ name: "IX_Repositories_ProjectId",
+ table: "Repositories",
+ column: "ProjectId");
+
migrationBuilder.CreateIndex(
name: "IX_Repositories_ProviderId",
table: "Repositories",
column: "ProviderId",
- unique: true,
- filter: "[ProviderId] IS NOT NULL");
+ unique: true);
migrationBuilder.CreateIndex(
name: "IX_UpdateJobs_AuthKey",
@@ -100,6 +160,11 @@ protected override void Up(MigrationBuilder migrationBuilder)
column: "Created",
descending: new bool[0]);
+ migrationBuilder.CreateIndex(
+ name: "IX_UpdateJobs_Error_Type",
+ table: "UpdateJobs",
+ column: "Error_Type");
+
migrationBuilder.CreateIndex(
name: "IX_UpdateJobs_PackageEcosystem_Directory",
table: "UpdateJobs",
@@ -112,6 +177,11 @@ protected override void Up(MigrationBuilder migrationBuilder)
unique: true,
filter: "[EventBusId] IS NOT NULL");
+ migrationBuilder.CreateIndex(
+ name: "IX_UpdateJobs_ProjectId",
+ table: "UpdateJobs",
+ column: "ProjectId");
+
migrationBuilder.CreateIndex(
name: "IX_UpdateJobs_RepositoryId",
table: "UpdateJobs",
@@ -129,5 +199,8 @@ protected override void Down(MigrationBuilder migrationBuilder)
migrationBuilder.DropTable(
name: "UpdateJobs");
+
+ migrationBuilder.DropTable(
+ name: "Projects");
}
}
diff --git a/server/Tingle.Dependabot/Migrations/MainDbContextModelSnapshot.cs b/server/Tingle.Dependabot/Migrations/MainDbContextModelSnapshot.cs
index c6fc03f1..ce9bdeb6 100644
--- a/server/Tingle.Dependabot/Migrations/MainDbContextModelSnapshot.cs
+++ b/server/Tingle.Dependabot/Migrations/MainDbContextModelSnapshot.cs
@@ -41,7 +41,77 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("DataProtectionKeys");
});
- modelBuilder.Entity("Tingle.Dependabot.Models.Repository", b =>
+ modelBuilder.Entity("Tingle.Dependabot.Models.Management.Project", b =>
+ {
+ b.Property("Id")
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("Created")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("Etag")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("rowversion");
+
+ b.Property("GithubToken")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Location")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Password")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Private")
+ .HasColumnType("bit");
+
+ b.Property("ProviderId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Secrets")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Synchronized")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Type")
+ .HasColumnType("int");
+
+ b.Property("Updated")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("Url")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Created")
+ .IsDescending();
+
+ b.HasIndex("Password")
+ .IsUnique();
+
+ b.HasIndex("ProviderId")
+ .IsUnique();
+
+ b.ToTable("Projects");
+ });
+
+ modelBuilder.Entity("Tingle.Dependabot.Models.Management.Repository", b =>
{
b.Property("Id")
.HasMaxLength(50)
@@ -66,7 +136,12 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property("Name")
.HasColumnType("nvarchar(max)");
+ b.Property("ProjectId")
+ .IsRequired()
+ .HasColumnType("nvarchar(50)");
+
b.Property("ProviderId")
+ .IsRequired()
.HasColumnType("nvarchar(450)");
b.Property("Registries")
@@ -91,14 +166,15 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasIndex("Created")
.IsDescending();
+ b.HasIndex("ProjectId");
+
b.HasIndex("ProviderId")
- .IsUnique()
- .HasFilter("[ProviderId] IS NOT NULL");
+ .IsUnique();
b.ToTable("Repositories");
});
- modelBuilder.Entity("Tingle.Dependabot.Models.UpdateJob", b =>
+ modelBuilder.Entity("Tingle.Dependabot.Models.Management.UpdateJob", b =>
{
b.Property("Id")
.HasMaxLength(50)
@@ -125,9 +201,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property("End")
.HasColumnType("datetimeoffset");
- b.Property("Error")
- .HasColumnType("nvarchar(max)");
-
b.Property