Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions PatchNotes.Api/PatchNotes.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

<ItemGroup>
<ProjectReference Include="..\PatchNotes.Data\PatchNotes.Data.csproj" />
<ProjectReference Include="..\PatchNotes.Sync\PatchNotes.Sync.csproj" />
</ItemGroup>

</Project>
9 changes: 9 additions & 0 deletions PatchNotes.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using PatchNotes.Api.Stytch;
using PatchNotes.Api.Routes;
using PatchNotes.Api.Webhooks;
using PatchNotes.Sync.GitHub;
using Stripe;

var builder = WebApplication.CreateBuilder(args);
Expand Down Expand Up @@ -68,6 +69,14 @@
});
builder.Services.AddPatchNotesDbContext(builder.Configuration);
builder.Services.AddHttpClient();
builder.Services.AddGitHubClient(options =>
{
var token = builder.Configuration["GitHub:Token"];
if (!string.IsNullOrEmpty(token))
{
options.Token = token;
}
});

builder.Services.AddSingleton<IStytchClient, StytchClient>();

Expand Down
37 changes: 37 additions & 0 deletions PatchNotes.Api/Routes/PackageRoutes.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using PatchNotes.Data;
using PatchNotes.Sync.GitHub;

namespace PatchNotes.Api.Routes;

Expand Down Expand Up @@ -507,6 +508,34 @@ public static WebApplication MapPackageRoutes(this WebApplication app)
.Produces(StatusCodes.Status400BadRequest)
.WithName("BulkCreatePackages");

// GET /api/admin/github/search?q={query} - Search GitHub repositories (admin only)
var adminGitHub = app.MapGroup("/api/admin/github").WithTags("AdminGitHub");

adminGitHub.MapGet("/search", async (string? q, IGitHubClient gitHubClient) =>
{
if (string.IsNullOrWhiteSpace(q) || q.Trim().Length < 2)
{
return Results.BadRequest(new ApiError("Query parameter 'q' is required and must be at least 2 characters"));
}

var results = await gitHubClient.SearchRepositoriesAsync(q.Trim(), perPage: 10);

var dtos = results.Select(r => new GitHubRepoSearchResultDto
{
Owner = r.Owner.Login,
Repo = r.Name,
Description = r.Description,
StarCount = r.StargazersCount,
}).ToList();

return Results.Ok(dtos);
})
.AddEndpointFilterFactory(requireAuth)
.AddEndpointFilterFactory(requireAdmin)
.Produces<List<GitHubRepoSearchResultDto>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.WithName("SearchGitHubRepositories");

return app;
}
}
Expand Down Expand Up @@ -624,3 +653,11 @@ public class PaginatedResponse<T>
public int Limit { get; set; }
public int Offset { get; set; }
}

public class GitHubRepoSearchResultDto
{
public required string Owner { get; set; }
public required string Repo { get; set; }
public string? Description { get; set; }
public int StarCount { get; set; }
}
22 changes: 22 additions & 0 deletions PatchNotes.Sync/GitHub/GitHubClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,28 @@ public async IAsyncEnumerable<GitHubRelease> GetAllReleasesAsync(
return await response.Content.ReadFromJsonAsync<GitHubRelease>(cancellationToken);
}

public async Task<IReadOnlyList<GitHubSearchResult>> SearchRepositoriesAsync(
string query,
int perPage = 10,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(query);
ArgumentOutOfRangeException.ThrowIfLessThan(perPage, 1);
ArgumentOutOfRangeException.ThrowIfGreaterThan(perPage, 100);

var url = $"search/repositories?q={Uri.EscapeDataString(query)}&per_page={perPage}";

using var response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();

var rateLimitInfo = RateLimitHelper.ParseHeaders(response.Headers);
RateLimitHelper.LogStatus(_logger, rateLimitInfo, "search");

var searchResponse = await response.Content.ReadFromJsonAsync<GitHubSearchResponse>(cancellationToken);

return searchResponse?.Items ?? [];
}

public async Task<string?> GetFileContentAsync(
string owner,
string repo,
Expand Down
12 changes: 12 additions & 0 deletions PatchNotes.Sync/GitHub/IGitHubClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,16 @@ IAsyncEnumerable<GitHubRelease> GetAllReleasesAsync(
string repo,
string path,
CancellationToken cancellationToken = default);

/// <summary>
/// Searches GitHub repositories by query string.
/// </summary>
/// <param name="query">The search query.</param>
/// <param name="perPage">Number of results per page (max 100).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of matching repositories.</returns>
Task<IReadOnlyList<GitHubSearchResult>> SearchRepositoriesAsync(
string query,
int perPage = 10,
CancellationToken cancellationToken = default);
}
45 changes: 45 additions & 0 deletions PatchNotes.Sync/GitHub/Models/GitHubSearchResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System.Text.Json.Serialization;

namespace PatchNotes.Sync.GitHub.Models;

/// <summary>
/// Represents the response from GitHub's repository search API.
/// </summary>
public class GitHubSearchResponse
{
[JsonPropertyName("total_count")]
public int TotalCount { get; set; }

[JsonPropertyName("items")]
public List<GitHubSearchResult> Items { get; set; } = [];
}

/// <summary>
/// Represents a single repository result from GitHub's search API.
/// </summary>
public class GitHubSearchResult
{
[JsonPropertyName("full_name")]
public required string FullName { get; set; }

[JsonPropertyName("owner")]
public required GitHubSearchOwner Owner { get; set; }

[JsonPropertyName("name")]
public required string Name { get; set; }

[JsonPropertyName("description")]
public string? Description { get; set; }

[JsonPropertyName("stargazers_count")]
public int StargazersCount { get; set; }
}

/// <summary>
/// Represents the owner of a repository in search results.
/// </summary>
public class GitHubSearchOwner
{
[JsonPropertyName("login")]
public required string Login { get; set; }
}
88 changes: 88 additions & 0 deletions PatchNotes.Tests/GitHubClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,94 @@ public async Task GetReleasesAsync_LogsWarningWhenApproachingRateLimit()

#endregion

#region SearchRepositoriesAsync Tests

[Fact]
public async Task SearchRepositoriesAsync_ReturnsResults()
{
// Arrange
var searchResponse = new
{
total_count = 2,
items = new[]
{
new { full_name = "facebook/react", owner = new { login = "facebook" }, name = "react", description = "A JavaScript library for building user interfaces", stargazers_count = 200000 },
new { full_name = "facebook/react-native", owner = new { login = "facebook" }, name = "react-native", description = "React Native", stargazers_count = 100000 }
}
};
_mockHandler.SetupResponse("search/repositories?q=react&per_page=10", searchResponse);

// Act
var result = await _client.SearchRepositoriesAsync("react");

// Assert
result.Should().HaveCount(2);
result[0].Owner.Login.Should().Be("facebook");
result[0].Name.Should().Be("react");
result[0].Description.Should().Be("A JavaScript library for building user interfaces");
result[0].StargazersCount.Should().Be(200000);
}

[Fact]
public async Task SearchRepositoriesAsync_WithCustomPerPage_UsesCorrectParameter()
{
// Arrange
var searchResponse = new { total_count = 0, items = Array.Empty<object>() };
_mockHandler.SetupResponse("search/repositories?q=test&per_page=5", searchResponse);

// Act
await _client.SearchRepositoriesAsync("test", perPage: 5);

// Assert
_mockHandler.LastRequestUri.Should().Contain("per_page=5");
}

[Fact]
public async Task SearchRepositoriesAsync_WithEmptyQuery_ThrowsArgumentException()
{
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() =>
_client.SearchRepositoriesAsync(""));
}

[Fact]
public async Task SearchRepositoriesAsync_WithInvalidPerPage_ThrowsArgumentOutOfRangeException()
{
// Act & Assert
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
_client.SearchRepositoriesAsync("react", perPage: 101));
}

[Fact]
public async Task SearchRepositoriesAsync_EscapesQueryString()
{
// Arrange
var searchResponse = new { total_count = 0, items = Array.Empty<object>() };
_mockHandler.SetupResponse("search/repositories?q=c%23%20library&per_page=10", searchResponse);

// Act
await _client.SearchRepositoriesAsync("c# library");

// Assert
_mockHandler.LastRequestUri.Should().Contain("q=c%23%20library");
}

[Fact]
public async Task SearchRepositoriesAsync_WithNoResults_ReturnsEmptyList()
{
// Arrange
var searchResponse = new { total_count = 0, items = Array.Empty<object>() };
_mockHandler.SetupResponse("search/repositories?q=xyznonexistent&per_page=10", searchResponse);

// Act
var result = await _client.SearchRepositoriesAsync("xyznonexistent");

// Assert
result.Should().BeEmpty();
}

#endregion

#region Error Handling Tests

[Fact]
Expand Down
103 changes: 103 additions & 0 deletions PatchNotes.Tests/GitHubSearchApiTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Moq;
using PatchNotes.Sync.GitHub;
using PatchNotes.Sync.GitHub.Models;

namespace PatchNotes.Tests;

public class GitHubSearchApiTests : IAsyncLifetime
{
private PatchNotesApiFixture _fixture = null!;
private HttpClient _authClient = null!;
private HttpClient _unauthClient = null!;
private HttpClient _nonAdminClient = null!;
private Mock<IGitHubClient> _mockGitHubClient = null!;

public async Task InitializeAsync()
{
_mockGitHubClient = new Mock<IGitHubClient>();
_fixture = new PatchNotesApiFixture();
_fixture.ConfigureServices(services =>
{
services.RemoveAll<IGitHubClient>();
services.AddSingleton(_mockGitHubClient.Object);
});
await _fixture.InitializeAsync();
_authClient = _fixture.CreateAuthenticatedClient();
_unauthClient = _fixture.CreateClient();
_nonAdminClient = _fixture.CreateNonAdminClient();
}

public async Task DisposeAsync()
{
_authClient.Dispose();
_unauthClient.Dispose();
_nonAdminClient.Dispose();
await _fixture.DisposeAsync();
_fixture.Dispose();
}

[Fact]
public async Task SearchGitHub_ReturnsResults_ForAdmin()
{
// Arrange
_mockGitHubClient
.Setup(c => c.SearchRepositoriesAsync("react", 10, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<GitHubSearchResult>
{
new()
{
FullName = "facebook/react",
Owner = new GitHubSearchOwner { Login = "facebook" },
Name = "react",
Description = "A JavaScript library",
StargazersCount = 200000
}
});

// Act
var response = await _authClient.GetAsync("/api/admin/github/search?q=react");

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var results = await response.Content.ReadFromJsonAsync<JsonElement>();
results.GetArrayLength().Should().Be(1);
results[0].GetProperty("owner").GetString().Should().Be("facebook");
results[0].GetProperty("repo").GetString().Should().Be("react");
results[0].GetProperty("description").GetString().Should().Be("A JavaScript library");
results[0].GetProperty("starCount").GetInt32().Should().Be(200000);
}

[Fact]
public async Task SearchGitHub_Returns400_WhenQueryMissing()
{
var response = await _authClient.GetAsync("/api/admin/github/search");
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}

[Fact]
public async Task SearchGitHub_Returns400_WhenQueryTooShort()
{
var response = await _authClient.GetAsync("/api/admin/github/search?q=a");
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}

[Fact]
public async Task SearchGitHub_Returns401_WhenUnauthenticated()
{
var response = await _unauthClient.GetAsync("/api/admin/github/search?q=react");
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}

[Fact]
public async Task SearchGitHub_Returns403_WhenNotAdmin()
{
var response = await _nonAdminClient.GetAsync("/api/admin/github/search?q=react");
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}
}
Loading
Loading