From 48c3ac9dac1ad877fdebb15f63945485a11097ae Mon Sep 17 00:00:00 2001 From: logikonline Date: Mon, 19 Jan 2026 11:08:08 -0500 Subject: [PATCH] ci(actions-manager): add build and release workflows Add Gitea Actions workflows for automated CI/CD. Build workflow runs on push/PR to build, test, and push Docker images. Release workflow triggers on version tags to build multi-platform binaries, package NuGet client, create GitHub releases, and publish versioned Docker images. Also add REST API controller for AI services alongside existing gRPC endpoints. --- .gitea/workflows/build.yml | 57 +++ .gitea/workflows/release.yml | 85 ++++ .../Controllers/AIController.cs | 464 ++++++++++++++++++ src/GitCaddy.AI.Service/Program.cs | 19 +- 4 files changed, 614 insertions(+), 11 deletions(-) create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 src/GitCaddy.AI.Service/Controllers/AIController.cs diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..a11f10b --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,57 @@ +name: Build and Test + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Restore dependencies + run: dotnet restore GitCaddy.AI.sln + + - name: Build + run: dotnet build GitCaddy.AI.sln -c Release --no-restore + + - name: Test + run: dotnet test GitCaddy.AI.sln -c Release --no-build --verbosity normal + + docker: + runs-on: ubuntu-latest + needs: build + if: github.ref == 'refs/heads/main' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.REGISTRY_URL }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ secrets.REGISTRY_URL }}/gitcaddy/gitcaddy-ai:latest + ${{ secrets.REGISTRY_URL }}/gitcaddy/gitcaddy-ai:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..a50edca --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,85 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Get version from tag + id: version + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + + - name: Restore dependencies + run: dotnet restore GitCaddy.AI.sln + + - name: Build + run: dotnet build GitCaddy.AI.sln -c Release --no-restore -p:Version=${{ steps.version.outputs.VERSION }} + + - name: Publish Service + run: | + dotnet publish src/GitCaddy.AI.Service/GitCaddy.AI.Service.csproj -c Release -o publish/linux-x64 -r linux-x64 --self-contained -p:Version=${{ steps.version.outputs.VERSION }} + dotnet publish src/GitCaddy.AI.Service/GitCaddy.AI.Service.csproj -c Release -o publish/win-x64 -r win-x64 --self-contained -p:Version=${{ steps.version.outputs.VERSION }} + + - name: Package artifacts + run: | + cd publish + tar -czf gitcaddy-ai-${{ steps.version.outputs.VERSION }}-linux-x64.tar.gz linux-x64/ + zip -r gitcaddy-ai-${{ steps.version.outputs.VERSION }}-win-x64.zip win-x64/ + + - name: Pack NuGet Client + run: dotnet pack src/GitCaddy.AI.Client/GitCaddy.AI.Client.csproj -c Release -o nupkg -p:Version=${{ steps.version.outputs.VERSION }} + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + publish/*.tar.gz + publish/*.zip + nupkg/*.nupkg + generate_release_notes: true + + docker: + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Get version from tag + id: version + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.REGISTRY_URL }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ secrets.REGISTRY_URL }}/gitcaddy/gitcaddy-ai:latest + ${{ secrets.REGISTRY_URL }}/gitcaddy/gitcaddy-ai:${{ steps.version.outputs.VERSION }} + build-args: | + BUILD_CONFIGURATION=Release + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/src/GitCaddy.AI.Service/Controllers/AIController.cs b/src/GitCaddy.AI.Service/Controllers/AIController.cs new file mode 100644 index 0000000..5a159d7 --- /dev/null +++ b/src/GitCaddy.AI.Service/Controllers/AIController.cs @@ -0,0 +1,464 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: BSL-1.1 + +using GitCaddy.AI.Service.Services; +using GitCaddy.AI.Service.Licensing; +using Microsoft.AspNetCore.Mvc; + +namespace GitCaddy.AI.Service.Controllers; + +/// +/// REST API controller for AI services. +/// Provides HTTP endpoints alongside the gRPC service. +/// +[ApiController] +[Route("api/v1")] +public class AIController : ControllerBase +{ + private readonly ICodeReviewService _codeReviewService; + private readonly ICodeIntelligenceService _codeIntelligenceService; + private readonly IIssueService _issueService; + private readonly IDocumentationService _documentationService; + private readonly ILicenseValidator _licenseValidator; + private readonly ILogger _logger; + + public AIController( + ICodeReviewService codeReviewService, + ICodeIntelligenceService codeIntelligenceService, + IIssueService issueService, + IDocumentationService documentationService, + ILicenseValidator licenseValidator, + ILogger logger) + { + _codeReviewService = codeReviewService; + _codeIntelligenceService = codeIntelligenceService; + _issueService = issueService; + _documentationService = documentationService; + _licenseValidator = licenseValidator; + _logger = logger; + } + + /// + /// Health check endpoint + /// + [HttpGet("health")] + public async Task Health() + { + var licenseResult = await _licenseValidator.ValidateAsync(); + return Ok(new + { + healthy = licenseResult.IsValid, + version = typeof(AIController).Assembly.GetName().Version?.ToString() ?? "1.0.0", + provider_status = new Dictionary + { + ["default"] = "operational" + }, + license = licenseResult.License != null ? new + { + tier = licenseResult.License.Tier, + customer = licenseResult.License.Customer, + expires_at = licenseResult.License.ExpiresAt, + features = licenseResult.License.Features, + seat_count = licenseResult.License.SeatCount, + is_trial = licenseResult.License.IsTrial + } : null + }); + } + + /// + /// Review a pull request + /// + [HttpPost("review/pull-request")] + public async Task ReviewPullRequest([FromBody] ReviewPullRequestDto request, CancellationToken cancellationToken) + { + try + { + var protoRequest = new Proto.ReviewPullRequestRequest + { + RepoId = request.RepoId, + PullRequestId = request.PullRequestId, + BaseBranch = request.BaseBranch ?? "", + HeadBranch = request.HeadBranch ?? "", + PrTitle = request.PRTitle ?? "", + PrDescription = request.PRDescription ?? "", + Options = new Proto.ReviewOptions + { + CheckSecurity = request.Options?.CheckSecurity ?? true, + CheckPerformance = request.Options?.CheckPerformance ?? true, + CheckStyle = request.Options?.CheckStyle ?? true, + CheckTests = request.Options?.CheckTests ?? true, + SuggestImprovements = request.Options?.SuggestImprovements ?? true, + FocusAreas = request.Options?.FocusAreas ?? "", + LanguageHints = request.Options?.LanguageHints ?? "" + } + }; + + foreach (var file in request.Files ?? []) + { + protoRequest.Files.Add(new Proto.FileDiff + { + Path = file.Path ?? "", + OldPath = file.OldPath ?? "", + Status = file.Status ?? "modified", + Patch = file.Patch ?? "", + Content = file.Content ?? "", + Language = file.Language ?? "" + }); + } + + var response = await _codeReviewService.ReviewPullRequestAsync(protoRequest, cancellationToken); + return Ok(MapReviewResponse(response)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to review pull request"); + return StatusCode(500, new { error = ex.Message }); + } + } + + /// + /// Triage an issue + /// + [HttpPost("issues/triage")] + public async Task TriageIssue([FromBody] TriageIssueDto request, CancellationToken cancellationToken) + { + try + { + var protoRequest = new Proto.TriageIssueRequest + { + RepoId = request.RepoId, + IssueId = request.IssueId, + Title = request.Title ?? "", + Body = request.Body ?? "" + }; + protoRequest.ExistingLabels.AddRange(request.ExistingLabels ?? []); + protoRequest.AvailableLabels.AddRange(request.AvailableLabels ?? []); + + var response = await _issueService.TriageIssueAsync(protoRequest, cancellationToken); + return Ok(new + { + priority = response.Priority, + category = response.Category, + suggested_labels = response.SuggestedLabels.ToList(), + suggested_assignees = response.SuggestedAssignees.ToList(), + summary = response.Summary, + is_duplicate = response.IsDuplicate, + duplicate_of = response.DuplicateOf + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to triage issue"); + return StatusCode(500, new { error = ex.Message }); + } + } + + /// + /// Suggest labels for an issue + /// + [HttpPost("issues/suggest-labels")] + public async Task SuggestLabels([FromBody] SuggestLabelsDto request, CancellationToken cancellationToken) + { + try + { + var protoRequest = new Proto.SuggestLabelsRequest + { + RepoId = request.RepoId, + Title = request.Title ?? "", + Body = request.Body ?? "" + }; + protoRequest.AvailableLabels.AddRange(request.AvailableLabels ?? []); + + var response = await _issueService.SuggestLabelsAsync(protoRequest, cancellationToken); + return Ok(new + { + suggestions = response.Suggestions.Select(s => new + { + label = s.Label, + confidence = s.Confidence, + reason = s.Reason + }).ToList() + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to suggest labels"); + return StatusCode(500, new { error = ex.Message }); + } + } + + /// + /// Explain code + /// + [HttpPost("code/explain")] + public async Task ExplainCode([FromBody] ExplainCodeDto request, CancellationToken cancellationToken) + { + try + { + var protoRequest = new Proto.ExplainCodeRequest + { + RepoId = request.RepoId, + FilePath = request.FilePath ?? "", + Code = request.Code ?? "", + StartLine = request.StartLine, + EndLine = request.EndLine, + Question = request.Question ?? "" + }; + + var response = await _codeIntelligenceService.ExplainCodeAsync(protoRequest, cancellationToken); + return Ok(new + { + explanation = response.Explanation, + key_concepts = response.KeyConcepts.ToList(), + references = response.References.Select(r => new + { + description = r.Description, + url = r.Url + }).ToList() + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to explain code"); + return StatusCode(500, new { error = ex.Message }); + } + } + + /// + /// Summarize changes + /// + [HttpPost("code/summarize")] + public async Task SummarizeChanges([FromBody] SummarizeChangesDto request, CancellationToken cancellationToken) + { + try + { + var protoRequest = new Proto.SummarizeChangesRequest + { + RepoId = request.RepoId, + Context = request.Context ?? "" + }; + + foreach (var file in request.Files ?? []) + { + protoRequest.Files.Add(new Proto.FileDiff + { + Path = file.Path ?? "", + OldPath = file.OldPath ?? "", + Status = file.Status ?? "modified", + Patch = file.Patch ?? "", + Content = file.Content ?? "", + Language = file.Language ?? "" + }); + } + + var response = await _codeIntelligenceService.SummarizeChangesAsync(protoRequest, cancellationToken); + return Ok(new + { + summary = response.Summary, + bullet_points = response.BulletPoints.ToList(), + impact_assessment = response.ImpactAssessment + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to summarize changes"); + return StatusCode(500, new { error = ex.Message }); + } + } + + /// + /// Generate documentation + /// + [HttpPost("docs/generate")] + public async Task GenerateDocumentation([FromBody] GenerateDocumentationDto request, CancellationToken cancellationToken) + { + try + { + var protoRequest = new Proto.GenerateDocumentationRequest + { + RepoId = request.RepoId, + FilePath = request.FilePath ?? "", + Code = request.Code ?? "", + DocType = request.DocType ?? "function", + Language = request.Language ?? "", + Style = request.Style ?? "markdown" + }; + + var response = await _documentationService.GenerateDocumentationAsync(protoRequest, cancellationToken); + return Ok(new + { + documentation = response.Documentation, + sections = response.Sections.Select(s => new + { + title = s.Title, + content = s.Content + }).ToList() + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to generate documentation"); + return StatusCode(500, new { error = ex.Message }); + } + } + + /// + /// Generate commit message + /// + [HttpPost("docs/commit-message")] + public async Task GenerateCommitMessage([FromBody] GenerateCommitMessageDto request, CancellationToken cancellationToken) + { + try + { + var protoRequest = new Proto.GenerateCommitMessageRequest + { + RepoId = request.RepoId, + Style = request.Style ?? "conventional" + }; + + foreach (var file in request.Files ?? []) + { + protoRequest.Files.Add(new Proto.FileDiff + { + Path = file.Path ?? "", + OldPath = file.OldPath ?? "", + Status = file.Status ?? "modified", + Patch = file.Patch ?? "" + }); + } + + var response = await _documentationService.GenerateCommitMessageAsync(protoRequest, cancellationToken); + return Ok(new + { + message = response.Message, + alternatives = response.Alternatives.ToList() + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to generate commit message"); + return StatusCode(500, new { error = ex.Message }); + } + } + + private static object MapReviewResponse(Proto.ReviewPullRequestResponse response) + { + return new + { + summary = response.Summary, + comments = response.Comments.Select(c => new + { + path = c.Path, + line = c.Line, + end_line = c.EndLine, + body = c.Body, + severity = c.Severity.ToString().ToLower().Replace("comment_severity_", ""), + category = c.Category, + suggested_fix = c.SuggestedFix + }).ToList(), + verdict = response.Verdict.ToString().ToLower().Replace("review_verdict_", ""), + suggestions = response.Suggestions.ToList(), + security = new + { + issues = response.Security?.Issues.Select(i => new + { + path = i.Path, + line = i.Line, + issue_type = i.IssueType, + description = i.Description, + severity = i.Severity, + remediation = i.Remediation + }).ToList() ?? [], + risk_score = response.Security?.RiskScore ?? 0, + summary = response.Security?.Summary ?? "" + }, + estimated_review_minutes = response.EstimatedReviewMinutes + }; + } +} + +// DTO classes for REST API +public class ReviewPullRequestDto +{ + public long RepoId { get; set; } + public long PullRequestId { get; set; } + public string? BaseBranch { get; set; } + public string? HeadBranch { get; set; } + public List? Files { get; set; } + public string? PRTitle { get; set; } + public string? PRDescription { get; set; } + public ReviewOptionsDto? Options { get; set; } +} + +public class FileDiffDto +{ + public string? Path { get; set; } + public string? OldPath { get; set; } + public string? Status { get; set; } + public string? Patch { get; set; } + public string? Content { get; set; } + public string? Language { get; set; } +} + +public class ReviewOptionsDto +{ + public bool CheckSecurity { get; set; } = true; + public bool CheckPerformance { get; set; } = true; + public bool CheckStyle { get; set; } = true; + public bool CheckTests { get; set; } = true; + public bool SuggestImprovements { get; set; } = true; + public string? FocusAreas { get; set; } + public string? LanguageHints { get; set; } +} + +public class TriageIssueDto +{ + public long RepoId { get; set; } + public long IssueId { get; set; } + public string? Title { get; set; } + public string? Body { get; set; } + public List? ExistingLabels { get; set; } + public List? AvailableLabels { get; set; } +} + +public class SuggestLabelsDto +{ + public long RepoId { get; set; } + public string? Title { get; set; } + public string? Body { get; set; } + public List? AvailableLabels { get; set; } +} + +public class ExplainCodeDto +{ + public long RepoId { get; set; } + public string? FilePath { get; set; } + public string? Code { get; set; } + public int StartLine { get; set; } + public int EndLine { get; set; } + public string? Question { get; set; } +} + +public class SummarizeChangesDto +{ + public long RepoId { get; set; } + public List? Files { get; set; } + public string? Context { get; set; } +} + +public class GenerateDocumentationDto +{ + public long RepoId { get; set; } + public string? FilePath { get; set; } + public string? Code { get; set; } + public string? DocType { get; set; } + public string? Language { get; set; } + public string? Style { get; set; } +} + +public class GenerateCommitMessageDto +{ + public long RepoId { get; set; } + public List? Files { get; set; } + public string? Style { get; set; } +} diff --git a/src/GitCaddy.AI.Service/Program.cs b/src/GitCaddy.AI.Service/Program.cs index ff51f58..d0b918f 100644 --- a/src/GitCaddy.AI.Service/Program.cs +++ b/src/GitCaddy.AI.Service/Program.cs @@ -41,6 +41,10 @@ builder.Services.AddGrpc(options => options.MaxSendMessageSize = 50 * 1024 * 1024; }); +// Add REST API controllers +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); + // Add health checks builder.Services.AddHealthChecks(); @@ -63,18 +67,11 @@ else app.MapGrpcService(); app.MapHealthChecks("/healthz"); +// Map REST API controllers +app.MapControllers(); + // HTTP endpoint for basic health check -app.MapGet("/", () => "GitCaddy AI Service is running. Use gRPC to connect."); -app.MapGet("/health", async (ILicenseValidator validator) => -{ - var result = await validator.ValidateAsync(); - return Results.Json(new - { - healthy = result.IsValid, - version = typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0", - license = result.License - }); -}); +app.MapGet("/", () => "GitCaddy AI Service is running. Use gRPC or REST API to connect."); Log.Information("GitCaddy AI Service starting on {Urls}", string.Join(", ", app.Urls));