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() }; + _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(() => + _client.SearchRepositoriesAsync("")); + } + + [Fact] + public async Task SearchRepositoriesAsync_WithInvalidPerPage_ThrowsArgumentOutOfRangeException() + { + // Act & Assert + await Assert.ThrowsAsync(() => + _client.SearchRepositoriesAsync("react", perPage: 101)); + } + + [Fact] + public async Task SearchRepositoriesAsync_EscapesQueryString() + { + // Arrange + var searchResponse = new { total_count = 0, items = Array.Empty() }; + _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() }; + _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] diff --git a/PatchNotes.Tests/GitHubSearchApiTests.cs b/PatchNotes.Tests/GitHubSearchApiTests.cs new file mode 100644 index 0000000..0133807 --- /dev/null +++ b/PatchNotes.Tests/GitHubSearchApiTests.cs @@ -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 _mockGitHubClient = null!; + + public async Task InitializeAsync() + { + _mockGitHubClient = new Mock(); + _fixture = new PatchNotesApiFixture(); + _fixture.ConfigureServices(services => + { + services.RemoveAll(); + 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())) + .ReturnsAsync(new List + { + 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(); + 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); + } +} diff --git a/patchnotes-web/openapi.json b/patchnotes-web/openapi.json index 6e92f34..2a4fccc 100644 --- a/patchnotes-web/openapi.json +++ b/patchnotes-web/openapi.json @@ -4,6 +4,11 @@ "title": "PatchNotes.Api | v1", "version": "1.0.0" }, + "servers": [ + { + "url": "http://localhost:5199/" + } + ], "paths": { "/webhooks/stytch": { "post": { @@ -369,6 +374,41 @@ } } }, + "/api/admin/github/search": { + "get": { + "tags": [ + "AdminGitHub" + ], + "operationId": "SearchGitHubRepositories", + "parameters": [ + { + "name": "q", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GitHubRepoSearchResultDto" + } + } + } + } + }, + "400": { + "description": "Bad Request" + } + } + } + }, "/api/releases/{id}": { "get": { "tags": [ @@ -1232,6 +1272,31 @@ } } }, + "GitHubRepoSearchResultDto": { + "required": [ + "owner", + "repo" + ], + "type": "object", + "properties": { + "owner": { + "type": "string" + }, + "repo": { + "type": "string" + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "starCount": { + "type": "integer", + "format": "int32" + } + } + }, "OwnerPackageDto": { "required": [ "id", @@ -1910,6 +1975,9 @@ { "name": "Packages" }, + { + "name": "AdminGitHub" + }, { "name": "Releases" }, @@ -1932,4 +2000,4 @@ "name": "EmailTemplates" } ] -} +} \ No newline at end of file diff --git a/patchnotes-web/src/api/generated/admin-git-hub/admin-git-hub.ts b/patchnotes-web/src/api/generated/admin-git-hub/admin-git-hub.ts new file mode 100644 index 0000000..8ba6cfa --- /dev/null +++ b/patchnotes-web/src/api/generated/admin-git-hub/admin-git-hub.ts @@ -0,0 +1,151 @@ +/** + * Generated by orval v8.3.0 🍺 + * Do not edit manually. + * PatchNotes.Api | v1 + * OpenAPI spec version: 1.0.0 + */ +import { + useQuery +} from '@tanstack/react-query'; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseQueryOptions, + UseQueryResult +} from '@tanstack/react-query'; + +import type { + GitHubRepoSearchResultDto, + SearchGitHubRepositoriesParams +} from '.././model'; + +import { customFetch } from '../../custom-fetch'; + + +type SecondParameter unknown> = Parameters[1]; + + + +export type searchGitHubRepositoriesResponse200 = { + data: GitHubRepoSearchResultDto[] + status: 200 +} + +export type searchGitHubRepositoriesResponse400 = { + data: void + status: 400 +} + +export type searchGitHubRepositoriesResponseSuccess = (searchGitHubRepositoriesResponse200) & { + headers: Headers; +}; +export type searchGitHubRepositoriesResponseError = (searchGitHubRepositoriesResponse400) & { + headers: Headers; +}; + +export type searchGitHubRepositoriesResponse = (searchGitHubRepositoriesResponseSuccess | searchGitHubRepositoriesResponseError) + +export const getSearchGitHubRepositoriesUrl = (params?: SearchGitHubRepositoriesParams,) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : value.toString()) + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 ? `/api/admin/github/search?${stringifiedParams}` : `/api/admin/github/search` +} + +export const searchGitHubRepositories = async (params?: SearchGitHubRepositoriesParams, options?: RequestInit): Promise => { + + return customFetch(getSearchGitHubRepositoriesUrl(params), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getSearchGitHubRepositoriesQueryKey = (params?: SearchGitHubRepositoriesParams,) => { + return [ + `/api/admin/github/search`, ...(params ? [params] : []) + ] as const; + } + + +export const getSearchGitHubRepositoriesQueryOptions = >, TError = void>(params?: SearchGitHubRepositoriesParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getSearchGitHubRepositoriesQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => searchGitHubRepositories(params, { signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type SearchGitHubRepositoriesQueryResult = NonNullable>> +export type SearchGitHubRepositoriesQueryError = void + + +export function useSearchGitHubRepositories>, TError = void>( + params: undefined | SearchGitHubRepositoriesParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useSearchGitHubRepositories>, TError = void>( + params?: SearchGitHubRepositoriesParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useSearchGitHubRepositories>, TError = void>( + params?: SearchGitHubRepositoriesParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } + +export function useSearchGitHubRepositories>, TError = void>( + params?: SearchGitHubRepositoriesParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getSearchGitHubRepositoriesQueryOptions(params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + diff --git a/patchnotes-web/src/api/generated/admin-git-hub/admin-git-hub.zod.ts b/patchnotes-web/src/api/generated/admin-git-hub/admin-git-hub.zod.ts new file mode 100644 index 0000000..12a1974 --- /dev/null +++ b/patchnotes-web/src/api/generated/admin-git-hub/admin-git-hub.zod.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval v8.3.0 🍺 + * Do not edit manually. + * PatchNotes.Api | v1 + * OpenAPI spec version: 1.0.0 + */ +import * as zod from 'zod'; + + +export const SearchGitHubRepositoriesQueryParams = zod.object({ + "q": zod.string().optional() +}) + +export const SearchGitHubRepositoriesResponseItem = zod.object({ + "owner": zod.string(), + "repo": zod.string(), + "description": zod.string().nullish(), + "starCount": zod.number().optional() +}) +export const SearchGitHubRepositoriesResponse = zod.array(SearchGitHubRepositoriesResponseItem) + diff --git a/patchnotes-web/src/api/generated/model/gitHubRepoSearchResultDto.ts b/patchnotes-web/src/api/generated/model/gitHubRepoSearchResultDto.ts new file mode 100644 index 0000000..4eba559 --- /dev/null +++ b/patchnotes-web/src/api/generated/model/gitHubRepoSearchResultDto.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.3.0 🍺 + * Do not edit manually. + * PatchNotes.Api | v1 + * OpenAPI spec version: 1.0.0 + */ + +export interface GitHubRepoSearchResultDto { + owner: string; + repo: string; + /** @nullable */ + description?: string | null; + starCount?: number; +} diff --git a/patchnotes-web/src/api/generated/model/index.ts b/patchnotes-web/src/api/generated/model/index.ts index f707012..26991ca 100644 --- a/patchnotes-web/src/api/generated/model/index.ts +++ b/patchnotes-web/src/api/generated/model/index.ts @@ -20,6 +20,7 @@ export * from './getPackagesParams'; export * from './getPackageSummariesParams'; export * from './getReleasesParams'; export * from './getSummariesParams'; +export * from './gitHubRepoSearchResultDto'; export * from './ownerPackageDto'; export * from './packageDetailDto'; export * from './packageDetailGroupDto'; @@ -34,6 +35,7 @@ export * from './paginatedResponseOfPackageDto'; export * from './releaseDto'; export * from './releasePackageDto'; export * from './releaseSummaryDto'; +export * from './searchGitHubRepositoriesParams'; export * from './setWatchlistRequest'; export * from './subscriptionStatusDto'; export * from './updateEmailPreferencesRequest'; diff --git a/patchnotes-web/src/api/generated/model/searchGitHubRepositoriesParams.ts b/patchnotes-web/src/api/generated/model/searchGitHubRepositoriesParams.ts new file mode 100644 index 0000000..c08bf04 --- /dev/null +++ b/patchnotes-web/src/api/generated/model/searchGitHubRepositoriesParams.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v8.3.0 🍺 + * Do not edit manually. + * PatchNotes.Api | v1 + * OpenAPI spec version: 1.0.0 + */ + +export type SearchGitHubRepositoriesParams = { +q?: string; +};