diff --git a/PatchNotes.Api/PatchNotes.Api.csproj b/PatchNotes.Api/PatchNotes.Api.csproj
index fd453d1..fe1fb43 100644
--- a/PatchNotes.Api/PatchNotes.Api.csproj
+++ b/PatchNotes.Api/PatchNotes.Api.csproj
@@ -21,6 +21,7 @@
+
diff --git a/PatchNotes.Api/Program.cs b/PatchNotes.Api/Program.cs
index 7fbcf8c..b34c8b8 100644
--- a/PatchNotes.Api/Program.cs
+++ b/PatchNotes.Api/Program.cs
@@ -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);
@@ -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();
diff --git a/PatchNotes.Api/Routes/PackageRoutes.cs b/PatchNotes.Api/Routes/PackageRoutes.cs
index 02753bf..02f1379 100644
--- a/PatchNotes.Api/Routes/PackageRoutes.cs
+++ b/PatchNotes.Api/Routes/PackageRoutes.cs
@@ -1,6 +1,7 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using PatchNotes.Data;
+using PatchNotes.Sync.GitHub;
namespace PatchNotes.Api.Routes;
@@ -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>(StatusCodes.Status200OK)
+ .Produces(StatusCodes.Status400BadRequest)
+ .WithName("SearchGitHubRepositories");
+
return app;
}
}
@@ -624,3 +653,11 @@ public class PaginatedResponse
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; }
+}
diff --git a/PatchNotes.Sync/GitHub/GitHubClient.cs b/PatchNotes.Sync/GitHub/GitHubClient.cs
index b0c46f6..9c30517 100644
--- a/PatchNotes.Sync/GitHub/GitHubClient.cs
+++ b/PatchNotes.Sync/GitHub/GitHubClient.cs
@@ -101,6 +101,28 @@ public async IAsyncEnumerable GetAllReleasesAsync(
return await response.Content.ReadFromJsonAsync(cancellationToken);
}
+ public async Task> 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(cancellationToken);
+
+ return searchResponse?.Items ?? [];
+ }
+
public async Task GetFileContentAsync(
string owner,
string repo,
diff --git a/PatchNotes.Sync/GitHub/IGitHubClient.cs b/PatchNotes.Sync/GitHub/IGitHubClient.cs
index 741a916..671b143 100644
--- a/PatchNotes.Sync/GitHub/IGitHubClient.cs
+++ b/PatchNotes.Sync/GitHub/IGitHubClient.cs
@@ -62,4 +62,16 @@ IAsyncEnumerable GetAllReleasesAsync(
string repo,
string path,
CancellationToken cancellationToken = default);
+
+ ///
+ /// Searches GitHub repositories by query string.
+ ///
+ /// The search query.
+ /// Number of results per page (max 100).
+ /// Cancellation token.
+ /// A list of matching repositories.
+ Task> SearchRepositoriesAsync(
+ string query,
+ int perPage = 10,
+ CancellationToken cancellationToken = default);
}
diff --git a/PatchNotes.Sync/GitHub/Models/GitHubSearchResult.cs b/PatchNotes.Sync/GitHub/Models/GitHubSearchResult.cs
new file mode 100644
index 0000000..5b15cb5
--- /dev/null
+++ b/PatchNotes.Sync/GitHub/Models/GitHubSearchResult.cs
@@ -0,0 +1,45 @@
+using System.Text.Json.Serialization;
+
+namespace PatchNotes.Sync.GitHub.Models;
+
+///
+/// Represents the response from GitHub's repository search API.
+///
+public class GitHubSearchResponse
+{
+ [JsonPropertyName("total_count")]
+ public int TotalCount { get; set; }
+
+ [JsonPropertyName("items")]
+ public List Items { get; set; } = [];
+}
+
+///
+/// Represents a single repository result from GitHub's search API.
+///
+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; }
+}
+
+///
+/// Represents the owner of a repository in search results.
+///
+public class GitHubSearchOwner
+{
+ [JsonPropertyName("login")]
+ public required string Login { get; set; }
+}
diff --git a/PatchNotes.Tests/GitHubClientTests.cs b/PatchNotes.Tests/GitHubClientTests.cs
index 5c277b3..6ec01c2 100644
--- a/PatchNotes.Tests/GitHubClientTests.cs
+++ b/PatchNotes.Tests/GitHubClientTests.cs
@@ -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