diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f6a83e1..3087e43 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -9,7 +9,7 @@ This is a **multi-project .NET 10.0 solution** for AI-powered static code analys - **Shared** (`Lintellect.Shared`): Data contracts (`AnalysisRequest`, `GitInfo`, `AnalyzerFindings`) shared between CLI and API - **AppHost** (`Lintellect.AppHost`) & **ServiceDefaults** (`Lintellect.ServiceDefaults`): .NET Aspire orchestration for local development with OpenTelemetry, health checks, and service discovery -**Key Data Flow**: CI/CD runs CLI → Roslyn analyzes code → CLI detects Git context → Results posted to API → API processes with AI (Claude/Semantic Kernel) → DevOps integration +**Key Data Flow**: CI/CD runs CLI → Roslyn analyzes code → CLI detects Git context → Results posted to API → API processes with AI (Claude / Azure OpenAI via Microsoft Agent Framework) → DevOps integration ## Solution Structure @@ -24,6 +24,7 @@ This is a **multi-project .NET 10.0 solution** for AI-powered static code analys ### Test Projects - **Lintellect.Api.FunctionalTests**: Functional tests using Testcontainers, Respawn, and Shouldly +- **Lintellect.Api.IntegrationTests**: End-to-end tests against real AI providers (Azure OpenAI, Anthropic). Self-skip when credentials are missing. - **Lintellect.Api.UnitTests**: Unit tests using NUnit, NSubstitute, and Shouldly - **Lintellect.Cli.UnitTests**: Unit tests for the CLI project using NUnit and Shouldly @@ -33,7 +34,7 @@ This is a **multi-project .NET 10.0 solution** for AI-powered static code analys - **C# Version**: 14.0 (latest) - **Key Technologies**: - **Roslyn** (Microsoft.CodeAnalysis.CSharp.Workspaces) for code analysis - - **AI Integration**: Anthropic Claude API and Microsoft Semantic Kernel + - **AI Integration**: Anthropic Claude API and Azure OpenAI via Microsoft Agent Framework - **Database**: PostgreSQL with Entity Framework Core - **Messaging**: Channel-based job queue (no Azure Service Bus currently) - **CLI**: System.CommandLine for command-line interface @@ -114,7 +115,7 @@ This is a **multi-project .NET 10.0 solution** for AI-powered static code analys - **Unit Tests**: Fast, isolated tests with mocks - **Integration Tests**: Test with real dependencies (CLI with real solution files) - **Functional Tests**: End-to-end API tests with test database -- **InternalsVisibleTo**: The CLI project exposes internals to the test project +- **InternalsVisibleTo**: The CLI project exposes internals to its unit tests; the API project exposes internals to `Lintellect.Api.UnitTests` and `Lintellect.Api.IntegrationTests` - **Test Data Builders**: Use fluent builders for creating test data (see `TestDataBuilder`) ## CI/CD Integration @@ -143,7 +144,7 @@ When working on the API project: 1. **Architecture**: Follow Clean Architecture with Domain → Application → Infrastructure layers 2. **CQRS**: Use Mediator pattern for commands and queries -3. **AI Integration**: Support both Anthropic Claude and Microsoft Semantic Kernel +3. **AI Integration**: Support both Anthropic Claude and Azure OpenAI via Microsoft Agent Framework 4. **Background Processing**: Use Channel-based job queue for async analysis processing 5. **Database**: PostgreSQL with Entity Framework Core and JSONB for flexible data storage 6. **API Design**: Use minimal APIs and controllers as appropriate @@ -175,7 +176,7 @@ When working with code analysis: 4. **Docker**: API project supports Docker with Linux target OS 5. **User Secrets**: Both API and AppHost have user secrets configured 6. **Package Management**: Centralized package version management via `Directory.Packages.props` -7. **AI Providers**: Support for both Anthropic Claude and Microsoft Semantic Kernel +7. **AI Providers**: Support for both Anthropic Claude and Azure OpenAI via Microsoft Agent Framework 8. **Database**: PostgreSQL with JSONB for flexible data storage 9. **Background Processing**: Channel-based job queue instead of Azure Service Bus 10. **Testing**: Comprehensive test coverage with unit, integration, and functional tests @@ -232,7 +233,8 @@ When working with code analysis: - [Microsoft.CodeAnalysis Documentation](https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/) - [System.CommandLine Documentation](https://learn.microsoft.com/en-us/dotnet/standard/commandline/) -- [Semantic Kernel Documentation](https://learn.microsoft.com/en-us/semantic-kernel/) +- [Microsoft Agent Framework Documentation](https://learn.microsoft.com/en-us/agent-framework/) +- [Microsoft.Extensions.AI Documentation](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.ai) - [Anthropic Claude API Documentation](https://docs.anthropic.com/) - [.NET Aspire Documentation](https://learn.microsoft.com/en-us/dotnet/aspire/) - [Entity Framework Core Documentation](https://learn.microsoft.com/en-us/ef/core/) diff --git a/.gitignore b/.gitignore index c6883dc..39fe52b 100644 --- a/.gitignore +++ b/.gitignore @@ -361,4 +361,5 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd -*.cursor \ No newline at end of file +*.cursor +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md index 5a11868..41712f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ Lintellect is an AI-powered PR code review assistant. There are two runtime comp **CLI** (`Lintellect.Cli`) — stateless, runs in CI/CD pipelines. Reads PR context from CI environment variables, performs Roslyn-based C# analysis, and POSTs an `AnalysisRequest` to the API. -**API** (`Lintellect.Api`) — ASP.NET Core service. Receives the request, persists it as an `AnalysisJob`, enqueues it onto a channel-based `AnalysisJobQueue`, and a background service processes it: calls Claude or Semantic Kernel, then posts review comments back to GitHub/Azure DevOps. +**API** (`Lintellect.Api`) — ASP.NET Core service. Receives the request, persists it as an `AnalysisJob`, enqueues it onto a channel-based `AnalysisJobQueue`, and a background service processes it: calls Claude or Azure OpenAI (via Microsoft Agent Framework), then posts review comments back to GitHub/Azure DevOps. **Data flow:** @@ -87,7 +87,7 @@ Lintellect is an AI-powered PR code review assistant. There are two runtime comp CI/CD → CLI (Roslyn analysis + Git context extraction) → POST /analysis to API → AnalysisJob persisted in PostgreSQL - → Background service dequeues + calls AI (Claude / Semantic Kernel) + → Background service dequeues + calls AI (Claude / Azure OpenAI via Microsoft Agent Framework) → Results stored (Summary, DetailedAnalysis, InlineSuggestions) → Comments posted to PR via Octokit / TFS client ``` @@ -109,11 +109,13 @@ Apis/ → Minimal API endpoints, API key auth filter | Interface | Purpose | | --------------------- | -------------------------------------------------------------------- | -| `IAnalyzerService` | AI service contract (ClaudeAnalyzerService, SemanticAnalyzerService) | +| `IAnalyzerService` | AI service contract (ClaudeAnalyzerService, AzureOpenAIAnalyzerService) | | `IGitInfoExtractor` | Extract PR context from CI env vars | | `IGitClientFactory` | Create GitHub/Azure DevOps clients dynamically | | `IPullRequestService` | Fetch diffs, post comments | | `IMcpServiceResolver` | Resolve MCP servers for AI context | +| `IWorkItemService` | Resolve linked work items / issues for a PR (per-provider) | +| `IWorkItemSummarizer` | AI-condense linked work items into a tight GOAL + CONTEXT block | Factories (`GitInfoExtractorFactory`, `GitClientFactory`) select implementations based on `EGitProvider` at runtime. @@ -121,6 +123,18 @@ Factories (`GitInfoExtractorFactory`, `GitClientFactory`) select implementations `PromptBuilder` assembles prompts from templates in `Infrastructure/Services/AI/Prompts/Templates/{Language}/`. `TokenAwareChunker` splits large diffs to stay within model token limits; `TokenEstimator` estimates token counts without calling the API. +### Work-item context (on by default) + +When `AnalysisRequest.EnableWorkItemContext` is true (CLI flag `--enable-work-item-context` / `-ewi`, defaults to true; pass `--enable-work-item-context false` to disable), the orchestrator resolves linked work items via `IWorkItemService` and runs a single `IWorkItemSummarizer` pass that produces a structured response: + +``` +GOAL: +CONTEXT: +<2-3 short paragraphs> +``` + +The full block is injected into the Summary and Detailed-Analysis prompts via `{{workItemContext}}`; only the `GOAL` line is injected into the per-file Inline-Suggestion prompts (per-file calls multiply tokens by file count, so the inline cost stays bounded). Failures during fetch or summarization log + continue with no context. Azure DevOps work items are resolved server-side via the WIT REST API; GitHub uses PR-body parsing for `Closes/Fixes/Resolves #N` keywords. + ### Configuration Settings fall back to environment variables via `PostConfigure<>()`: @@ -129,7 +143,10 @@ Settings fall back to environment variables via `PostConfigure<>()`: | ----------------------------------- | ---------------------- | | `ApiKey` | — | | `ConnectionStrings:postgresdb` | — | -| `ClaudeAnalyzer:ApiKey` | — | +| `ClaudeAnalyzer:ApiKey` | `CLAUDE_API_KEY` | +| `AzureOpenAIAnalyzer:ApiKey` | `AZURE_OPENAI_API_KEY` | +| `AzureOpenAIAnalyzer:Endpoint` | `AZURE_OPENAI_ENDPOINT` | +| `AzureOpenAIAnalyzer:DeploymentName`| `AZURE_OPENAI_DEPLOYMENT_NAME` | | `GitCredentials:GitHub:Token` | `GITHUB_TOKEN` | | `GitCredentials:AzureDevOps:Pat` | `AZURE_DEVOPS_PAT` | | `GitCredentials:AzureDevOps:OrgUrl` | `AZURE_DEVOPS_ORG_URL` | diff --git a/Directory.Packages.props b/Directory.Packages.props index 43b73be..71bfca7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,17 +14,15 @@ - - - + + + + - - - diff --git a/Lintellect.slnx b/Lintellect.slnx index 4dc5192..8d101d0 100644 --- a/Lintellect.slnx +++ b/Lintellect.slnx @@ -5,6 +5,7 @@ + diff --git a/README.md b/README.md index 32e4548..fc81682 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,15 @@ Lintellect analyze \ --enable-inline-suggestions \ --enable-azure-devops-code-owners +# Linked work items / issues are used as PR context by default. +# Azure DevOps: linked work items resolved server-side via the WIT REST API. +# GitHub: PR body parsed for "Closes/Fixes/Resolves #N" keywords. +# To opt out: +Lintellect analyze \ + --language "csharp" \ + --enable-summary-comment \ + --enable-work-item-context false + # Python analysis with Semgrep Lintellect analyze \ --language "python" \ @@ -330,10 +339,10 @@ API-Key: your-api-key ```json { - "SemanticAnalyzer": { + "AzureOpenAIAnalyzer": { "ApiKey": "your-azure-ai-key", "Endpoint": "https://your-resource.openai.azure.com/", - "Model": "gpt-4o" + "DeploymentName": "gpt-4o" } } ``` diff --git a/src/Lintellect.Api/Application/Interfaces/IAnalyzerService.cs b/src/Lintellect.Api/Application/Interfaces/IAnalyzerService.cs index eaf475d..094f590 100644 --- a/src/Lintellect.Api/Application/Interfaces/IAnalyzerService.cs +++ b/src/Lintellect.Api/Application/Interfaces/IAnalyzerService.cs @@ -65,6 +65,23 @@ Task> GenerateInlineSuggestionsAsync( /// Cancellation token /// Answer to the question in Markdown format Task AnswerQuestionAsync(AnalyzerServiceModel analysisResult, string threadContext, string question, CancellationToken cancellationToken = default); + + /// + /// Runs a tightly-scoped, single-shot AI call used for ancillary context summarization + /// (e.g. condensing linked work items before they are fed into the main review prompts). + /// Implementations must not require an ; the caller supplies a + /// fully-rendered system + user prompt and an output token cap. + /// + /// Fully-rendered system prompt. + /// Fully-rendered user prompt. + /// Hard cap on the response token count. + /// Cancellation token. + /// The model's text response, or an empty string if the model produced nothing. + Task SummarizeContextAsync( + string systemPrompt, + string userPrompt, + int maxOutputTokens, + CancellationToken cancellationToken = default); } /// diff --git a/src/Lintellect.Api/Application/Interfaces/IGitClient.cs b/src/Lintellect.Api/Application/Interfaces/IGitClient.cs index 42d41f3..d0f4578 100644 --- a/src/Lintellect.Api/Application/Interfaces/IGitClient.cs +++ b/src/Lintellect.Api/Application/Interfaces/IGitClient.cs @@ -149,4 +149,22 @@ Task AddCodeOwnersToPr( /// Task GetPullRequestThreadContextAsync(string projectName, string repositoryName, int pullRequestId, int prCommentId); + /// + /// Retrieves work items / issues linked to a pull request. + /// Implementations resolve linked items using the most natural mechanism for the provider: + /// Azure DevOps reads PR work-item refs from the WIT API; GitHub parses the PR body for closing + /// keywords and fetches the matching issues. Caller-supplied (e.g. ids + /// already extracted CLI-side) are taken as-is and resolved into rich references. + /// + /// Project / owner name. + /// Repository name. + /// Pull request ID / number. + /// Optional work-item ids the caller pre-extracted (CLI body parsing, etc.). + /// Resolved work-item references, possibly empty. + Task> GetLinkedWorkItemsAsync( + string projectName, + string repositoryName, + int pullRequestId, + IReadOnlyList? hints = null); + } diff --git a/src/Lintellect.Api/Application/Interfaces/IWorkItemService.cs b/src/Lintellect.Api/Application/Interfaces/IWorkItemService.cs new file mode 100644 index 0000000..8304db5 --- /dev/null +++ b/src/Lintellect.Api/Application/Interfaces/IWorkItemService.cs @@ -0,0 +1,13 @@ +using Lintellect.Shared.Models; + +namespace Lintellect.Api.Application.Interfaces; + +/// +/// Resolves work items / issues linked to a pull request, regardless of provider. +/// Wraps + so the +/// orchestrator can stay provider-agnostic. +/// +public interface IWorkItemService +{ + Task> ResolveAsync(AnalysisRequest analysisRequest, CancellationToken cancellationToken = default); +} diff --git a/src/Lintellect.Api/Application/Interfaces/IWorkItemSummarizer.cs b/src/Lintellect.Api/Application/Interfaces/IWorkItemSummarizer.cs new file mode 100644 index 0000000..b94a377 --- /dev/null +++ b/src/Lintellect.Api/Application/Interfaces/IWorkItemSummarizer.cs @@ -0,0 +1,24 @@ +using Lintellect.Shared.Models; + +namespace Lintellect.Api.Application.Interfaces; + +/// +/// Result of summarizing a set of linked work items into a compact AI-ready context block. +/// contains the full GOAL + CONTEXT block injected into Summary and +/// Detailed-Analysis prompts. is the single GOAL line injected into the +/// per-file Inline-Suggestion prompts (kept tight to avoid per-file token blow-up). +/// +public sealed record WorkItemSummary(string FullContext, string Goal) +{ + public static WorkItemSummary Empty { get; } = new(string.Empty, string.Empty); +} + +/// +/// Condenses a set of s into a tight context block suitable for +/// injection into the main code-review prompts. Implementations call the configured +/// with a hard token cap. +/// +public interface IWorkItemSummarizer +{ + Task SummarizeAsync(IReadOnlyList workItems, CancellationToken cancellationToken = default); +} diff --git a/src/Lintellect.Api/Application/Messages/Commands/Analysis/ProcessAnalysisJobCommand.cs b/src/Lintellect.Api/Application/Messages/Commands/Analysis/ProcessAnalysisJobCommand.cs index 74083d9..0b106f6 100644 --- a/src/Lintellect.Api/Application/Messages/Commands/Analysis/ProcessAnalysisJobCommand.cs +++ b/src/Lintellect.Api/Application/Messages/Commands/Analysis/ProcessAnalysisJobCommand.cs @@ -22,7 +22,10 @@ public sealed record ProcessAnalysisJobCommand( public sealed class ProcessAnalysisJobCommandHandler( IApplicationDbContext context, PullRequestService prService, - IAnalyzerService analyzerService) : IRequestHandler + IAnalyzerService analyzerService, + IWorkItemService workItemService, + IWorkItemSummarizer workItemSummarizer, + ILogger logger) : IRequestHandler { public async ValueTask Handle(ProcessAnalysisJobCommand request, CancellationToken cancellationToken) { @@ -68,7 +71,15 @@ public async ValueTask Handle(ProcessAnalysisJob // Step 2: Prepare analyzer and custom instructions var customInstructions = await prService.GetCustomInstructionsAsync(analysisRequest); - var aiAnalyzerModel = new AnalyzerServiceModel(analysisRequest, customInstructions ?? string.Empty); + + // Step 2b: Resolve and summarize linked work items (graceful degradation on failure) + var workItemSummary = await ResolveWorkItemContextAsync(analysisRequest, cancellationToken); + + var aiAnalyzerModel = new AnalyzerServiceModel( + analysisRequest, + customInstructions ?? string.Empty, + WorkItemContext: workItemSummary.FullContext, + WorkItemGoal: workItemSummary.Goal); // Step 3: Execute analysis tasks in parallel var analysisResults = await ExecuteAnalysisTasksAsync(analyzerService, aiAnalyzerModel, diffFull, analysisRequest, cancellationToken); @@ -325,6 +336,34 @@ private static DiffStatistics BuildDiffStatistics(Dictionary dif }; } + private async Task ResolveWorkItemContextAsync(AnalysisRequest analysisRequest, CancellationToken cancellationToken) + { + if (!analysisRequest.EnableWorkItemContext) + { + return WorkItemSummary.Empty; + } + + try + { + var items = await workItemService.ResolveAsync(analysisRequest, cancellationToken); + if (items.Count == 0) + { + logger.LogInformation("Work item context enabled but no linked items found for PR #{PullRequestId}", + analysisRequest.GitInfo?.PullRequestId); + return WorkItemSummary.Empty; + } + + return await workItemSummarizer.SummarizeAsync(items, cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, + "Work item context resolution failed for PR #{PullRequestId}; continuing without context", + analysisRequest.GitInfo?.PullRequestId); + return WorkItemSummary.Empty; + } + } + private async Task CheckForDuplicateAnalysisAsync(AnalysisRequest analysisRequest, CancellationToken cancellationToken) { diff --git a/src/Lintellect.Api/Application/Models/AnalyzerServiceModel.cs b/src/Lintellect.Api/Application/Models/AnalyzerServiceModel.cs index 8d36877..bed42d9 100644 --- a/src/Lintellect.Api/Application/Models/AnalyzerServiceModel.cs +++ b/src/Lintellect.Api/Application/Models/AnalyzerServiceModel.cs @@ -4,5 +4,7 @@ namespace Lintellect.Api.Application.Models; public record AnalyzerServiceModel( AnalysisRequest AnalysisResult, - string CopilotInstructionsPrompt + string CopilotInstructionsPrompt, + string WorkItemContext = "", + string WorkItemGoal = "" ); diff --git a/src/Lintellect.Api/Application/Models/AnalyzerServiceOptions.cs b/src/Lintellect.Api/Application/Models/AnalyzerServiceOptions.cs index 4c43fce..607de62 100644 --- a/src/Lintellect.Api/Application/Models/AnalyzerServiceOptions.cs +++ b/src/Lintellect.Api/Application/Models/AnalyzerServiceOptions.cs @@ -38,9 +38,9 @@ public sealed class ClaudeAnalyzerOptions } /// -/// Configuration options for the Semantic Kernel (AIFoundry) analyzer service. +/// Configuration options for the Azure OpenAI analyzer service (Microsoft Agent Framework). /// -public sealed class SemanticAnalyzerOptions +public sealed class AzureOpenAIAnalyzerOptions { /// /// The API key for authenticating with the AI service. diff --git a/src/Lintellect.Api/ConfigureServices.cs b/src/Lintellect.Api/ConfigureServices.cs index cee9a0d..c0aee02 100644 --- a/src/Lintellect.Api/ConfigureServices.cs +++ b/src/Lintellect.Api/ConfigureServices.cs @@ -10,9 +10,9 @@ using Lintellect.Api.Infrastructure.Services.Analysis; using Lintellect.Api.Infrastructure.Services.Git; using Lintellect.Api.Infrastructure.Services.Webhooks; +using Lintellect.Api.Infrastructure.Services.WorkItems; using Lintellect.Api.Infrastructure.Telemetry; using Lintellect.Shared.Models; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace Lintellect.Api; @@ -38,6 +38,10 @@ public static IServiceCollection AddGitClients(this IServiceCollection services, // Register the diff service services.AddScoped(); + // Register work-item context services (toggled per-job via AnalysisRequest.EnableWorkItemContext) + services.AddScoped(); + services.AddScoped(); + return services; } @@ -45,7 +49,7 @@ public static IServiceCollection AddAnalyzerServices( this IServiceCollection services, IConfiguration configuration, Action? configureClaudeOptions = null, - Action? configureSemanticOptions = null) + Action? configureAzureOpenAIOptions = null) { // Only register Claude if configured var claudeApiKey = configuration.GetValue("CLAUDE_API_KEY") ?? @@ -65,40 +69,42 @@ public static IServiceCollection AddAnalyzerServices( { var options = sp.GetRequiredService>().Value; var mcpServiceResolver = sp.GetRequiredService(); + var httpClientFactory = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); - return new ClaudeAnalyzerService(options, mcpServiceResolver); + return new ClaudeAnalyzerService(options, mcpServiceResolver, httpClientFactory, logger); }); } else { - var semanticApiKey = configuration.GetValue("SEMANTIC_API_KEY") ?? - configuration.GetSection("SemanticAnalyzer:ApiKey").Value; + var azureOpenAIApiKey = configuration.GetValue("AZURE_OPENAI_API_KEY") ?? + configuration.GetSection("AzureOpenAIAnalyzer:ApiKey").Value; - var semanticEndpoint = configuration.GetValue("SEMANTIC_ENDPOINT") ?? - configuration.GetSection("SemanticAnalyzer:Endpoint").Value; + var azureOpenAIEndpoint = configuration.GetValue("AZURE_OPENAI_ENDPOINT") ?? + configuration.GetSection("AzureOpenAIAnalyzer:Endpoint").Value; - var semanticDeploymentName = configuration.GetValue("SEMANTIC_DEPLOYMENT_NAME") ?? - configuration.GetSection("SemanticAnalyzer:DeploymentName").Value; + var azureOpenAIDeploymentName = configuration.GetValue("AZURE_OPENAI_DEPLOYMENT_NAME") ?? + configuration.GetSection("AzureOpenAIAnalyzer:DeploymentName").Value; - services.Configure(options => + services.Configure(options => { - configuration.GetSection("SemanticAnalyzer").Bind(options); - configureSemanticOptions?.Invoke(options); + configuration.GetSection("AzureOpenAIAnalyzer").Bind(options); + configureAzureOpenAIOptions?.Invoke(options); - options.ApiKey ??= semanticApiKey; - options.Endpoint ??= semanticEndpoint; + options.ApiKey ??= azureOpenAIApiKey; + options.Endpoint ??= azureOpenAIEndpoint; - options.DeploymentName ??= semanticDeploymentName; + options.DeploymentName ??= azureOpenAIDeploymentName; options.DeploymentName ??= "gpt-4o"; //fallback }); - services.AddScoped( + services.AddScoped( (sp) => { - var options = sp.GetRequiredService>().Value; + var options = sp.GetRequiredService>().Value; var mcpResolver = sp.GetRequiredService(); - var logger = sp.GetRequiredService>(); - return new SemanticAnalyzerService(options, mcpResolver, logger); + var logger = sp.GetRequiredService>(); + return new AzureOpenAIAnalyzerService(options, mcpResolver, logger); }); } diff --git a/src/Lintellect.Api/Domain/Entities/AnalysisJob.cs b/src/Lintellect.Api/Domain/Entities/AnalysisJob.cs index bf6770c..8c388dd 100644 --- a/src/Lintellect.Api/Domain/Entities/AnalysisJob.cs +++ b/src/Lintellect.Api/Domain/Entities/AnalysisJob.cs @@ -122,6 +122,8 @@ private static AnalysisRequest CloneAnalysisRequest(AnalysisRequest request) EnableInlineSuggestions = request.EnableInlineSuggestions, EnableDescriptionSummary = request.EnableDescriptionSummary, EnableAzureDevopsCodeOwners = request.EnableAzureDevopsCodeOwners, + EnableWorkItemContext = request.EnableWorkItemContext, + WorkItems = request.WorkItems is null ? [] : [.. request.WorkItems], McpServer = request.McpServer is null ? [] : [.. request.McpServer], }; } diff --git a/src/Lintellect.Api/Infrastructure/Persistence/Configurations/AnalysisJobConfiguration.cs b/src/Lintellect.Api/Infrastructure/Persistence/Configurations/AnalysisJobConfiguration.cs index 9c534e2..1b48343 100644 --- a/src/Lintellect.Api/Infrastructure/Persistence/Configurations/AnalysisJobConfiguration.cs +++ b/src/Lintellect.Api/Infrastructure/Persistence/Configurations/AnalysisJobConfiguration.cs @@ -40,6 +40,8 @@ public void Configure(EntityTypeBuilder builder) ar.OwnsOne(a => a.GitInfo); // Findings is part of the JSON, ignore it as a navigation property ar.Ignore(a => a.Findings); + // WorkItems are CLI-provided hints, in-memory only during processing + ar.Ignore(a => a.WorkItems); }); // Configure indexes diff --git a/src/Lintellect.Api/Infrastructure/Services/AI/SemanticAnalyzerService.cs b/src/Lintellect.Api/Infrastructure/Services/AI/AzureOpenAIAnalyzerService.cs similarity index 55% rename from src/Lintellect.Api/Infrastructure/Services/AI/SemanticAnalyzerService.cs rename to src/Lintellect.Api/Infrastructure/Services/AI/AzureOpenAIAnalyzerService.cs index 382d374..04efe3a 100644 --- a/src/Lintellect.Api/Infrastructure/Services/AI/SemanticAnalyzerService.cs +++ b/src/Lintellect.Api/Infrastructure/Services/AI/AzureOpenAIAnalyzerService.cs @@ -1,27 +1,26 @@ +using System.ClientModel; using System.Text.Json; +using Azure.AI.OpenAI; using Lintellect.Api.Application.Interfaces; using Lintellect.Api.Application.Models; using Lintellect.Api.Infrastructure.Extensions; using Lintellect.Api.Infrastructure.Services.AI.Prompts; using Lintellect.Shared.Models; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.ChatCompletion; -using Microsoft.SemanticKernel.Connectors.AzureOpenAI; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; using ModelContextProtocol.Client; namespace Lintellect.Api.Infrastructure.Services.AI; /// -/// Analyzer service using Semantic Kernel (AIFoundry) for code analysis. +/// Analyzer service using the Microsoft Agent Framework over Azure OpenAI for code analysis. /// -public sealed class SemanticAnalyzerService(SemanticAnalyzerOptions options, IMcpServiceResolver resolver, ILogger logger) : IAnalyzerService +public sealed class AzureOpenAIAnalyzerService(AzureOpenAIAnalyzerOptions options, IMcpServiceResolver resolver, ILogger logger) : IAnalyzerService { - private readonly SemanticAnalyzerOptions _options = options ?? throw new ArgumentNullException(nameof(options)); + private readonly AzureOpenAIAnalyzerOptions _options = options ?? throw new ArgumentNullException(nameof(options)); private readonly PromptTemplateService _templateService = new(); private readonly PromptBuilder _promptBuilder = new(); - private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - - private static FunctionChoiceBehavior FunctionChoiceBehavior => FunctionChoiceBehavior.Auto(options: new() { AllowParallelCalls = true, AllowConcurrentInvocation = true }); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); // public async Task GetDetailedAnalysisAsync( @@ -34,38 +33,29 @@ public async Task GetDetailedAnalysisAsync( diffs.Count, analysisResult.AnalysisResult.McpServer?.Count ?? 0); - var kernel = await CreateKernelAsync(_options, analysisResult.AnalysisResult.McpServer); - var chatCompletionService = kernel.GetRequiredService(); - var systemPrompt = _templateService.RenderLanguageTemplate( LanguagePromptTemplates.DetailedAnalysisSystemPrompt, analysisResult.AnalysisResult.Language, new Dictionary { - ["customInstructions"] = analysisResult.CopilotInstructionsPrompt + ["customInstructions"] = analysisResult.CopilotInstructionsPrompt, + ["workItemContext"] = analysisResult.WorkItemContext }); - var chatHistory = new ChatHistory(systemPrompt); - chatHistory.AddUserMessage(_promptBuilder.BuildAnalysisPrompt(analysisResult.AnalysisResult, diffs)); + var agent = await CreateAgentAsync(_options, systemPrompt, analysisResult.AnalysisResult.McpServer); -#pragma warning disable SKEXP0010 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var executionSettings = new AzureOpenAIPromptExecutionSettings + var runOptions = new ChatClientAgentRunOptions(new ChatOptions { - MaxTokens = _options.MaxTokens, - Temperature = _options.Temperature, - FunctionChoiceBehavior = FunctionChoiceBehavior, - SetNewMaxCompletionTokensEnabled = true, - ResponseFormat = "text" // Use text for detailed markdown analysis - }; -#pragma warning restore SKEXP0010 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - - var response = await chatCompletionService.GetChatMessageContentAsync( - chatHistory, - executionSettings: executionSettings, - kernel: kernel, - cancellationToken: cancellationToken); - - var content = response.Content ?? "No analysis generated."; + MaxOutputTokens = _options.MaxTokens, + Temperature = (float)_options.Temperature, + AllowMultipleToolCalls = true, + ResponseFormat = ChatResponseFormat.Text + }); + + var userPrompt = _promptBuilder.BuildAnalysisPrompt(analysisResult.AnalysisResult, diffs); + var response = await agent.RunAsync(userPrompt, options: runOptions, cancellationToken: cancellationToken); + + var content = string.IsNullOrEmpty(response.Text) ? "No analysis generated." : response.Text; _logger.LogInformation("Detailed analysis generated. ContentLength={ContentLength}", content.Length); return content; } @@ -76,17 +66,20 @@ public async Task GetDetailedAnalysisAsync( List changedFilePaths, CancellationToken cancellationToken = default) { - _logger.LogInformation("Generating CODEOWNERS suggestions. ChangedFiles={ChangedFiles}", changedFilePaths.Count); - var kernel = await CreateKernelAsync(_options); - var chatCompletionService = kernel.GetRequiredService(); - var systemPrompt = _templateService.RenderTemplate(AvailablePrompts.GeneralPrompts[GeneralPromptTemplates.CodeOwnerSystemPrompt]); - var chatHistory = new ChatHistory(systemPrompt); + var agent = await CreateAgentAsync(_options, systemPrompt); + + var runOptions = new ChatClientAgentRunOptions(new ChatOptions + { + MaxOutputTokens = _options.MaxTokens, + Temperature = (float)_options.Temperature, + AllowMultipleToolCalls = true, + ResponseFormat = ChatResponseFormat.Json + }); - // Create a message that includes both the CODEOWNERS content and the changed file paths var userMessage = $""" CODEOWNERS file content: {codeOwnerFileContent} @@ -95,32 +88,15 @@ public async Task GetDetailedAnalysisAsync( {string.Join("\n", changedFilePaths.Select(path => $"- {path}"))} """; - chatHistory.AddUserMessage(userMessage); + var response = await agent.RunAsync(userMessage, options: runOptions, cancellationToken: cancellationToken); -#pragma warning disable SKEXP0010 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var executionSettings = new AzureOpenAIPromptExecutionSettings - { - MaxTokens = _options.MaxTokens, - Temperature = _options.Temperature, // Lower temperature for more precise suggestions - FunctionChoiceBehavior = FunctionChoiceBehavior, - SetNewMaxCompletionTokensEnabled = true, - ResponseFormat = "json_object" // Request JSON output for structured parsing - }; -#pragma warning restore SKEXP0010 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - - var response = await chatCompletionService.GetChatMessageContentAsync( - chatHistory, - executionSettings: executionSettings, - kernel: kernel, - cancellationToken: cancellationToken); - - if (string.IsNullOrWhiteSpace(response.Content)) + if (string.IsNullOrWhiteSpace(response.Text)) { _logger.LogWarning("CODEOWNERS suggestions response was empty"); return null; } - var result = JsonSerializer.Deserialize(response.Content, JsonExtensions.JsonSerializerOptions); + var result = JsonSerializer.Deserialize(response.Text, JsonExtensions.JsonSerializerOptions); if (result is null) { _logger.LogWarning("Failed to deserialize CODEOWNERS suggestions"); @@ -129,7 +105,7 @@ public async Task GetDetailedAnalysisAsync( { _logger.LogInformation("CODEOWNERS suggestions deserialized successfully"); } - return result is null ? null : result; + return result; } // @@ -142,33 +118,28 @@ public async Task GenerateSummaryAsync( analysisResult.AnalysisResult.Language, diffs.Count); - var kernel = await CreateKernelAsync(_options, analysisResult.AnalysisResult.McpServer); - var chatCompletionService = kernel.GetRequiredService(); - var systemPrompt = _templateService.RenderLanguageTemplate( LanguagePromptTemplates.SummarySystemPrompt, - analysisResult.AnalysisResult.Language); + analysisResult.AnalysisResult.Language, + new Dictionary + { + ["customInstructions"] = analysisResult.CopilotInstructionsPrompt, + ["workItemContext"] = analysisResult.WorkItemContext + }); - var chatHistory = new ChatHistory(systemPrompt); - chatHistory.AddUserMessage(PromptBuilder.BuildSummaryPrompt(analysisResult.AnalysisResult, diffs)); + var agent = await CreateAgentAsync(_options, systemPrompt, analysisResult.AnalysisResult.McpServer); -#pragma warning disable SKEXP0010 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var executionSettings = new AzureOpenAIPromptExecutionSettings + var runOptions = new ChatClientAgentRunOptions(new ChatOptions { - MaxTokens = 500, // Keep summaries concise - Temperature = _options.Temperature, // Lower temperature for more focused summaries - SetNewMaxCompletionTokensEnabled = true, - FunctionChoiceBehavior = FunctionChoiceBehavior - }; -#pragma warning restore SKEXP0010 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - - var response = await chatCompletionService.GetChatMessageContentAsync( - chatHistory, - executionSettings: executionSettings, - kernel: kernel, - cancellationToken: cancellationToken); - - var content = response.Content ?? "No summary generated."; + MaxOutputTokens = 500, + Temperature = (float)_options.Temperature, + AllowMultipleToolCalls = true + }); + + var userPrompt = PromptBuilder.BuildSummaryPrompt(analysisResult.AnalysisResult, diffs); + var response = await agent.RunAsync(userPrompt, options: runOptions, cancellationToken: cancellationToken); + + var content = string.IsNullOrEmpty(response.Text) ? "No summary generated." : response.Text; _logger.LogInformation("Summary generated. ContentLength={ContentLength}", content.Length); return content; } @@ -183,9 +154,6 @@ public async Task AnswerQuestionAsync( _logger.LogInformation("Answering question for PR. McpServers={McpServers}", analysisResult.AnalysisResult.McpServer?.Count ?? 0); - var kernel = await CreateKernelAsync(_options, analysisResult.AnalysisResult.McpServer); - var chatCompletionService = kernel.GetRequiredService(); - var systemPrompt = _templateService.RenderTemplate( AvailablePrompts.GeneralPrompts[GeneralPromptTemplates.QuestionAnsweringPrompt], new Dictionary @@ -194,35 +162,52 @@ public async Task AnswerQuestionAsync( ["threadContext"] = threadContext }); - var chatHistory = new ChatHistory(systemPrompt); - chatHistory.AddUserMessage($""" + var agent = await CreateAgentAsync(_options, systemPrompt, analysisResult.AnalysisResult.McpServer); + + var runOptions = new ChatClientAgentRunOptions(new ChatOptions + { + MaxOutputTokens = _options.MaxTokens, + Temperature = (float)_options.Temperature, + AllowMultipleToolCalls = true, + ResponseFormat = ChatResponseFormat.Text + }); + + var userPrompt = $""" this is my question: {question} - """); + """; + var response = await agent.RunAsync(userPrompt, options: runOptions, cancellationToken: cancellationToken); -#pragma warning disable SKEXP0010 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var executionSettings = new AzureOpenAIPromptExecutionSettings - { - MaxTokens = _options.MaxTokens, - Temperature = _options.Temperature, - FunctionChoiceBehavior = FunctionChoiceBehavior, - SetNewMaxCompletionTokensEnabled = true, - ResponseFormat = "text" // Use text for markdown answers - }; -#pragma warning restore SKEXP0010 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - - var response = await chatCompletionService.GetChatMessageContentAsync( - chatHistory, - executionSettings: executionSettings, - kernel: kernel, - cancellationToken: cancellationToken); - - var content = response.Content ?? "No answer generated."; + var content = string.IsNullOrEmpty(response.Text) ? "No answer generated." : response.Text; _logger.LogInformation("Question answered. ContentLength={ContentLength}", content.Length); return content; } + // + public async Task SummarizeContextAsync( + string systemPrompt, + string userPrompt, + int maxOutputTokens, + CancellationToken cancellationToken = default) + { + _logger.LogInformation("Running context summarization. SystemLength={SystemLength} UserLength={UserLength} MaxTokens={MaxTokens}", + systemPrompt.Length, userPrompt.Length, maxOutputTokens); + + var agent = await CreateAgentAsync(_options, systemPrompt); + + var runOptions = new ChatClientAgentRunOptions(new ChatOptions + { + MaxOutputTokens = maxOutputTokens, + Temperature = (float)_options.Temperature, + AllowMultipleToolCalls = false, + ResponseFormat = ChatResponseFormat.Text + }); + + var response = await agent.RunAsync(userPrompt, options: runOptions, cancellationToken: cancellationToken); + return response.Text ?? string.Empty; + } + // public async Task> GenerateInlineSuggestionsAsync( AnalyzerServiceModel analysisResult, @@ -240,9 +225,6 @@ public async Task> GenerateInlineSuggestionsAsync( return []; } - var kernel = await CreateKernelAsync(_options, analysisResult.AnalysisResult.McpServer); - var chatCompletionService = kernel.GetRequiredService(); - var systemPrompt = _templateService.RenderLanguageTemplate( LanguagePromptTemplates.InlineSuggestionsSystemPrompt, analysisResult.AnalysisResult.Language, @@ -252,10 +234,13 @@ public async Task> GenerateInlineSuggestionsAsync( ["customInstructions"] = analysisResult.CopilotInstructionsPrompt, ["mcpServers"] = analysisResult.AnalysisResult.McpServer is null ? "none" : string.Join(",", analysisResult.AnalysisResult.McpServer.Select(s => s.ToString())), ["totalFilesInPR"] = diffs.Count.ToString(), - ["maxSuggestionsPerFile"] = ComputeMaxSuggestionsPerFile(diffs.Count, _options.MaxInlineSuggestions).ToString() + ["maxSuggestionsPerFile"] = ComputeMaxSuggestionsPerFile(diffs.Count, _options.MaxInlineSuggestions).ToString(), + ["workItemContext"] = analysisResult.WorkItemGoal }, enableGlobalInstructions: true); + var agent = await CreateAgentAsync(_options, systemPrompt, analysisResult.AnalysisResult.McpServer); + // Process files in parallel with concurrency limit to avoid rate limits // Azure OpenAI typically has rate limits (e.g., requests per minute), so we limit concurrency const int maxConcurrency = 5; @@ -272,9 +257,7 @@ await Parallel.ForEachAsync( { var (filePath, diff) = kvp; var suggestions = await ProcessFileForInlineSuggestionsAsync( - chatCompletionService, - kernel, - systemPrompt, + agent, analysisResult.AnalysisResult, filePath, diff, @@ -337,9 +320,7 @@ internal static int ComputeMaxSuggestionsPerFile(int fileCount, int globalMax) /// Processes a single file to generate inline suggestions. /// private async Task?> ProcessFileForInlineSuggestionsAsync( - IChatCompletionService chatCompletionService, - Kernel kernel, - string systemPrompt, + AIAgent agent, AnalysisRequest analysisResult, string filePath, string diff, @@ -349,37 +330,27 @@ internal static int ComputeMaxSuggestionsPerFile(int fileCount, int globalMax) { _logger.LogDebug("Processing inline suggestions for file {FilePath}", filePath); - var chatHistory = new ChatHistory(); var userPrompt = _promptBuilder.BuildInlineSuggestionsPrompt( analysisResult, new Dictionary { [filePath] = diff }); - chatHistory.AddUserMessage(userPrompt); -#pragma warning disable SKEXP0010 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var executionSettings = new AzureOpenAIPromptExecutionSettings + var runOptions = new ChatClientAgentRunOptions(new ChatOptions { - MaxTokens = _options.MaxTokens, - SetNewMaxCompletionTokensEnabled = true, - Temperature = _options.Temperature, - FunctionChoiceBehavior = FunctionChoiceBehavior, - ChatSystemPrompt = systemPrompt, - ResponseFormat = typeof(InlineSuggestionsResponse) // Request JSON output for structured parsing - }; -#pragma warning restore SKEXP0010 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - - var response = await chatCompletionService.GetChatMessageContentAsync( - chatHistory, - executionSettings: executionSettings, - kernel: kernel, - cancellationToken: cancellationToken); - - if (string.IsNullOrWhiteSpace(response.Content)) + MaxOutputTokens = _options.MaxTokens, + Temperature = (float)_options.Temperature, + AllowMultipleToolCalls = true, + ResponseFormat = ChatResponseFormat.ForJsonSchema(JsonExtensions.JsonSerializerOptions) + }); + + var response = await agent.RunAsync(userPrompt, options: runOptions, cancellationToken: cancellationToken); + + if (string.IsNullOrWhiteSpace(response.Text)) { _logger.LogWarning("Inline suggestions response was empty for file {FilePath}", filePath); return null; } - var result = JsonSerializer.Deserialize(response.Content, JsonExtensions.JsonSerializerOptions); + var result = JsonSerializer.Deserialize(response.Text, JsonExtensions.JsonSerializerOptions); if (result is null) { _logger.LogWarning("Failed to deserialize inline suggestions for file {FilePath}", filePath); @@ -392,7 +363,6 @@ internal static int ComputeMaxSuggestionsPerFile(int fileCount, int globalMax) if (string.IsNullOrWhiteSpace(suggestion.FilePath) || !suggestion.FilePath.Equals(filePath, StringComparison.OrdinalIgnoreCase)) { _logger.LogDebug("Correcting file path for suggestion from '{OriginalPath}' to '{CorrectPath}'", suggestion.FilePath, filePath); - // Create a new suggestion with the correct file path return suggestion with { FilePath = filePath }; } return suggestion; @@ -409,53 +379,52 @@ internal static int ComputeMaxSuggestionsPerFile(int fileCount, int globalMax) } /// - /// Creates a new Kernel instance with the specified options. - /// This allows for per-request configuration in the future. + /// Creates an Azure OpenAI–backed AIAgent with the given system prompt and (optional) MCP tools. /// - private async Task CreateKernelAsync(SemanticAnalyzerOptions options, List? mcpServers = null) + private async Task CreateAgentAsync(AzureOpenAIAnalyzerOptions options, string instructions, List? mcpServers = null) { - _logger.LogDebug("Creating Kernel for deployment {Deployment} at {Endpoint}", options.DeploymentName, options.Endpoint); - var builder = Kernel.CreateBuilder(); + _logger.LogDebug("Creating AIAgent for deployment {Deployment} at {Endpoint}", options.DeploymentName, options.Endpoint); if (options.Endpoint is null) { - throw new InvalidOperationException("Endpoint must be provided for SemanticAnalyzerService."); + throw new InvalidOperationException("Endpoint must be provided for AzureOpenAIAnalyzerService."); } + AzureOpenAIClient azureClient; if (!string.IsNullOrWhiteSpace(options.ApiKey)) { - - - _logger.LogDebug("Using ApiKey authentication for Azure OpenAI chat completion"); - builder.AddAzureOpenAIChatCompletion( - deploymentName: options.DeploymentName!, - endpoint: options.Endpoint, - apiKey: options.ApiKey); + _logger.LogDebug("Using ApiKey authentication for Azure OpenAI"); + azureClient = new AzureOpenAIClient(new Uri(options.Endpoint), new ApiKeyCredential(options.ApiKey)); } else { if (options.TokenCredential is null) { - throw new InvalidOperationException("Either ApiKey and Endpoint or TokenCredential must be provided for SemanticAnalyzerService."); + throw new InvalidOperationException("Either ApiKey and Endpoint or TokenCredential must be provided for AzureOpenAIAnalyzerService."); } - _logger.LogDebug("Using TokenCredential authentication for Azure OpenAI chat completion"); - builder.AddAzureOpenAIChatCompletion( - deploymentName: options.DeploymentName!, - endpoint: options.Endpoint!, - options.TokenCredential); + _logger.LogDebug("Using TokenCredential authentication for Azure OpenAI"); + azureClient = new AzureOpenAIClient(new Uri(options.Endpoint), options.TokenCredential); } - var chatCompletionService = builder.Build(); + var tools = await CollectMcpToolsAsync(mcpServers); + return azureClient + .GetChatClient(options.DeploymentName!) + .AsIChatClient() + .AsAIAgent(instructions: instructions, tools: tools); + } + + private async Task> CollectMcpToolsAsync(List? mcpServers) + { + var tools = new List(); if (mcpServers is null || mcpServers.Count == 0) { - _logger.LogDebug("No MCP servers configured. Returning Kernel without tools."); - return chatCompletionService; + _logger.LogDebug("No MCP servers configured. Returning agent without tools."); + return tools; } _logger.LogInformation("Configuring {Count} MCP server(s) for tool calling", mcpServers.Count); - var totalTools = 0; foreach (var mcpServer in mcpServers) { _logger.LogDebug("Resolving MCP server config for {Server}", mcpServer); @@ -467,22 +436,17 @@ private async Task CreateKernelAsync(SemanticAnalyzerOptions options, Li continue; } - var tools = await McpFunctionToolsAsync(config); - - chatCompletionService.Plugins.AddFromFunctions(config.Name, tools); - _logger.LogInformation("Added {ToolCount} tool(s) from MCP server {ServerName}", tools.Count(), config.Name); - totalTools += tools.Count(); + var serverTools = await McpToolsAsync(config); + tools.AddRange(serverTools); + _logger.LogInformation("Added {ToolCount} tool(s) from MCP server {ServerName}", serverTools.Count, config.Name); } - _logger.LogInformation("Total MCP tools registered: {TotalTools}", totalTools); - return chatCompletionService; + _logger.LogInformation("Total MCP tools registered: {TotalTools}", tools.Count); + return tools; } - - private static async Task> McpFunctionToolsAsync(McpConfig config) + private static async Task> McpToolsAsync(McpConfig config) { - List tools = []; - var httpTransport = new HttpClientTransport(new HttpClientTransportOptions() { Endpoint = new Uri(config.Url), @@ -492,11 +456,6 @@ private static async Task> McpFunctionToolsAsync(Mcp }); var client = await McpClient.CreateAsync(httpTransport); var toolsList = await client.ListToolsAsync(); - foreach (var tool in toolsList) - { - tools.Add(tool.AsKernelFunction()); - } - - return tools; + return [.. toolsList.Cast()]; } } diff --git a/src/Lintellect.Api/Infrastructure/Services/AI/ClaudeAnalyzerService.cs b/src/Lintellect.Api/Infrastructure/Services/AI/ClaudeAnalyzerService.cs index 0ddb630..3a0c2b5 100644 --- a/src/Lintellect.Api/Infrastructure/Services/AI/ClaudeAnalyzerService.cs +++ b/src/Lintellect.Api/Infrastructure/Services/AI/ClaudeAnalyzerService.cs @@ -6,7 +6,6 @@ using Lintellect.Api.Application.Models; using Lintellect.Api.Infrastructure.Services.AI.Prompts; using Lintellect.Shared.Models; -using Polly; namespace Lintellect.Api.Infrastructure.Services.AI; @@ -20,25 +19,25 @@ internal sealed class ClaudeAnalyzerService : IBatchAnalyzerService private readonly PromptTemplateService _templateService; private readonly PromptBuilder _promptBuilder; private readonly AnthropicClient _client; - private readonly IAsyncPolicy _retryPolicy; private readonly IMcpServiceResolver _mcpServiceResolver; + private readonly ILogger _logger; - public ClaudeAnalyzerService(ClaudeAnalyzerOptions options, IMcpServiceResolver mcpServiceResolver) + public ClaudeAnalyzerService( + ClaudeAnalyzerOptions options, + IMcpServiceResolver mcpServiceResolver, + IHttpClientFactory httpClientFactory, + ILogger logger) { _options = options ?? throw new ArgumentNullException(nameof(options)); _mcpServiceResolver = mcpServiceResolver ?? throw new ArgumentNullException(nameof(mcpServiceResolver)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _templateService = new PromptTemplateService(); _promptBuilder = new PromptBuilder(); - _client = new AnthropicClient(_options.ApiKey!); - - // Configure retry policy for Claude API calls - _retryPolicy = Policy - .Handle() - .Or() - .WaitAndRetryAsync( - retryCount: 3, - sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), - onRetry: (outcome, timespan, retryCount, context) => Console.WriteLine($"Claude API retry {retryCount} in {timespan} seconds due to: {outcome?.Message}")); + + // The "ClaudeApi" HttpClient is registered in ConfigureServices.AddResiliencePolicies + // with retry + 5-minute timeout via Microsoft.Extensions.Http.Polly. + var httpClient = httpClientFactory.CreateClient("ClaudeApi"); + _client = new AnthropicClient(new APIAuthentication(_options.ApiKey!), httpClient, requestInterceptor: null); } /// @@ -49,21 +48,23 @@ public async Task GetDetailedAnalysisAsync( Dictionary diffs, CancellationToken cancellationToken = default) { - return await _retryPolicy.ExecuteAsync(async () => - { - var systemPrompt = _templateService.RenderLanguageTemplate( - LanguagePromptTemplates.DetailedAnalysisSystemPrompt, - analysisResult.AnalysisResult.Language, - new Dictionary - { - { "customInstructions", analysisResult.CopilotInstructionsPrompt } - }); + _logger.LogInformation("Starting detailed analysis for language {Language}. DiffCount={DiffCount}", + analysisResult.AnalysisResult.Language, diffs.Count); + + var systemPrompt = _templateService.RenderLanguageTemplate( + LanguagePromptTemplates.DetailedAnalysisSystemPrompt, + analysisResult.AnalysisResult.Language, + new Dictionary + { + { "customInstructions", analysisResult.CopilotInstructionsPrompt }, + { "workItemContext", analysisResult.WorkItemContext } + }); - // Use optimized prompt builder with truncation and prioritization - var userPrompt = _promptBuilder.BuildAnalysisPrompt(analysisResult.AnalysisResult, diffs); + var userPrompt = _promptBuilder.BuildAnalysisPrompt(analysisResult.AnalysisResult, diffs); - return await SendClaudeMessageAsync(systemPrompt, userPrompt, cancellationToken); - }); + var content = await SendClaudeMessageAsync(systemPrompt, userPrompt, cancellationToken); + _logger.LogInformation("Detailed analysis generated. ContentLength={ContentLength}", content.Length); + return content; } /// @@ -74,33 +75,34 @@ public async Task> GenerateInlineSuggestionsAsync( Dictionary diffs, CancellationToken cancellationToken = default) { - return await _retryPolicy.ExecuteAsync(async () => - { - var systemPrompt = _templateService.RenderLanguageTemplate( - LanguagePromptTemplates.InlineSuggestionsSystemPrompt, - analysisResult.AnalysisResult.Language, - new Dictionary - { - { "customInstructions", analysisResult.CopilotInstructionsPrompt }, - { "totalFilesInPR", diffs.Count.ToString() }, - { "maxSuggestionsPerFile", SemanticAnalyzerService.ComputeMaxSuggestionsPerFile(diffs.Count, _options.MaxInlineSuggestions).ToString() } - }); + _logger.LogInformation("Starting inline suggestions for language {Language}. DiffCount={DiffCount}", + analysisResult.AnalysisResult.Language, diffs.Count); - // Use optimized prompt builder with truncation, prioritization, and filtered findings - var userPrompt = _promptBuilder.BuildInlineSuggestionsPrompt(analysisResult.AnalysisResult, diffs); + var systemPrompt = _templateService.RenderLanguageTemplate( + LanguagePromptTemplates.InlineSuggestionsSystemPrompt, + analysisResult.AnalysisResult.Language, + new Dictionary + { + { "customInstructions", analysisResult.CopilotInstructionsPrompt }, + { "totalFilesInPR", diffs.Count.ToString() }, + { "maxSuggestionsPerFile", AzureOpenAIAnalyzerService.ComputeMaxSuggestionsPerFile(diffs.Count, _options.MaxInlineSuggestions).ToString() }, + { "workItemContext", analysisResult.WorkItemGoal } + }); - var response = await SendClaudeMessageAsync(systemPrompt, userPrompt, cancellationToken); + var userPrompt = _promptBuilder.BuildInlineSuggestionsPrompt(analysisResult.AnalysisResult, diffs); - try - { - var suggestions = JsonSerializer.Deserialize>(response); - return SemanticAnalyzerService.ApplyGlobalCap(suggestions ?? [], _options.MaxInlineSuggestions); - } - catch - { - return []; - } - }); + var response = await SendClaudeMessageAsync(systemPrompt, userPrompt, cancellationToken); + + try + { + var suggestions = JsonSerializer.Deserialize>(response); + return AzureOpenAIAnalyzerService.ApplyGlobalCap(suggestions ?? [], _options.MaxInlineSuggestions, _logger); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to deserialize inline suggestions response from Claude. ResponseLength={ResponseLength}", response.Length); + return []; + } } /// @@ -111,23 +113,23 @@ public async Task GenerateSummaryAsync( Dictionary diffs, CancellationToken cancellationToken = default) { - return await _retryPolicy.ExecuteAsync(async () => - { - var systemPrompt = _templateService.RenderLanguageTemplate( - LanguagePromptTemplates.SummarySystemPrompt, - analysisResult.AnalysisResult.Language, - new Dictionary - { - { "customInstructions", analysisResult.CopilotInstructionsPrompt } - }); - + _logger.LogInformation("Starting summary generation for language {Language}. DiffCount={DiffCount}", + analysisResult.AnalysisResult.Language, diffs.Count); - var userPrompt = PromptBuilder.BuildSummaryPrompt(analysisResult.AnalysisResult, diffs); + var systemPrompt = _templateService.RenderLanguageTemplate( + LanguagePromptTemplates.SummarySystemPrompt, + analysisResult.AnalysisResult.Language, + new Dictionary + { + { "customInstructions", analysisResult.CopilotInstructionsPrompt }, + { "workItemContext", analysisResult.WorkItemContext } + }); - var response = await SendClaudeMessageAsync(systemPrompt, userPrompt, cancellationToken); + var userPrompt = PromptBuilder.BuildSummaryPrompt(analysisResult.AnalysisResult, diffs); - return response; - }); + var content = await SendClaudeMessageAsync(systemPrompt, userPrompt, cancellationToken); + _logger.LogInformation("Summary generated. ContentLength={ContentLength}", content.Length); + return content; } /// @@ -139,23 +141,42 @@ public async Task AnswerQuestionAsync( string question, CancellationToken cancellationToken = default) { - return await _retryPolicy.ExecuteAsync(async () => - { - var systemPrompt = _templateService.RenderTemplate( - AvailablePrompts.GeneralPrompts[GeneralPromptTemplates.QuestionAnsweringPrompt], - new Dictionary - { - { "customInstructions", analysisResult.CopilotInstructionsPrompt }, - { "threadContext", threadContext } - }); + _logger.LogInformation("Answering question for PR."); + var systemPrompt = _templateService.RenderTemplate( + AvailablePrompts.GeneralPrompts[GeneralPromptTemplates.QuestionAnsweringPrompt], + new Dictionary + { + { "customInstructions", analysisResult.CopilotInstructionsPrompt }, + { "threadContext", threadContext } + }); - return await SendClaudeMessageAsync(systemPrompt, $""" + var content = await SendClaudeMessageAsync(systemPrompt, $""" this is my question: {question} """, cancellationToken); - }); + + _logger.LogInformation("Question answered. ContentLength={ContentLength}", content.Length); + return content; + } + + /// + public async Task SummarizeContextAsync( + string systemPrompt, + string userPrompt, + int maxOutputTokens, + CancellationToken cancellationToken = default) + { + _logger.LogInformation("Running context summarization. SystemLength={SystemLength} UserLength={UserLength} MaxTokens={MaxTokens}", + systemPrompt.Length, userPrompt.Length, maxOutputTokens); + + var parameter = CreateMessageParameters(systemPrompt, userPrompt); + parameter.PromptCaching = PromptCacheType.None; + parameter.MaxTokens = maxOutputTokens; + + var message = await _client.Messages.GetClaudeMessageAsync(parameter, cancellationToken); + return message.ContentBlock?.Text ?? string.Empty; } /// @@ -163,23 +184,22 @@ public async Task AnswerQuestionAsync( /// public async Task GetCodeOwnersAsync(string codeOwnerFileContent, List changedFilePaths, CancellationToken cancellationToken = default) { - return await _retryPolicy.ExecuteAsync(async () => - { - var systemPrompt = _templateService.RenderTemplate("CodeOwnerSystemPrompt"); - var userPrompt = $"CODEOWNERS file content:\n{codeOwnerFileContent}\n\nChanged files:\n{string.Join("\n", changedFilePaths)}"; + _logger.LogInformation("Generating CODEOWNERS suggestions. ChangedFiles={ChangedFiles}", changedFilePaths.Count); - var response = await SendClaudeMessageAsync(systemPrompt, userPrompt, cancellationToken); + var systemPrompt = _templateService.RenderTemplate("CodeOwnerSystemPrompt"); + var userPrompt = $"CODEOWNERS file content:\n{codeOwnerFileContent}\n\nChanged files:\n{string.Join("\n", changedFilePaths)}"; - try - { - var result = JsonSerializer.Deserialize(response); - return result; - } - catch - { - return null; - } - }); + var response = await SendClaudeMessageAsync(systemPrompt, userPrompt, cancellationToken); + + try + { + return JsonSerializer.Deserialize(response); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to deserialize CODEOWNERS response from Claude. ResponseLength={ResponseLength}", response.Length); + return null; + } } /// @@ -193,150 +213,152 @@ public async Task RunBatchedAnalysisAsync( List changedFilePaths, CancellationToken cancellationToken = default) { - return await _retryPolicy.ExecuteAsync(async () => - { - // Build message parameters for each operation - var detailedSystem = _templateService.RenderLanguageTemplate( - LanguagePromptTemplates.DetailedAnalysisSystemPrompt, - analysisResult.AnalysisResult.Language, - new Dictionary - { - { "customInstructions", analysisResult.CopilotInstructionsPrompt } - }); + _logger.LogInformation("Starting batched analysis for language {Language}. DiffCount={DiffCount}", + analysisResult.AnalysisResult.Language, diffs.Count); - var inlineSystem = _templateService.RenderLanguageTemplate( - LanguagePromptTemplates.InlineSuggestionsSystemPrompt, - analysisResult.AnalysisResult.Language, - new Dictionary - { - { "customInstructions", analysisResult.CopilotInstructionsPrompt }, - { "mcpServers", string.Join(",", analysisResult.AnalysisResult.McpServer ?? []) }, - { "totalFilesInPR", diffs.Count.ToString() }, - { "maxSuggestionsPerFile", SemanticAnalyzerService.ComputeMaxSuggestionsPerFile(diffs.Count, _options.MaxInlineSuggestions).ToString() } - }, true); + var detailedSystem = _templateService.RenderLanguageTemplate( + LanguagePromptTemplates.DetailedAnalysisSystemPrompt, + analysisResult.AnalysisResult.Language, + new Dictionary + { + { "customInstructions", analysisResult.CopilotInstructionsPrompt }, + { "workItemContext", analysisResult.WorkItemContext } + }); - var summarySystem = _templateService.RenderLanguageTemplate( - LanguagePromptTemplates.SummarySystemPrompt, - analysisResult.AnalysisResult.Language, - new Dictionary { { "customInstructions", analysisResult.CopilotInstructionsPrompt } }); + var inlineSystem = _templateService.RenderLanguageTemplate( + LanguagePromptTemplates.InlineSuggestionsSystemPrompt, + analysisResult.AnalysisResult.Language, + new Dictionary + { + { "customInstructions", analysisResult.CopilotInstructionsPrompt }, + { "mcpServers", string.Join(",", analysisResult.AnalysisResult.McpServer ?? []) }, + { "totalFilesInPR", diffs.Count.ToString() }, + { "maxSuggestionsPerFile", AzureOpenAIAnalyzerService.ComputeMaxSuggestionsPerFile(diffs.Count, _options.MaxInlineSuggestions).ToString() }, + { "workItemContext", analysisResult.WorkItemGoal } + }, true); + + var summarySystem = _templateService.RenderLanguageTemplate( + LanguagePromptTemplates.SummarySystemPrompt, + analysisResult.AnalysisResult.Language, + new Dictionary + { + { "customInstructions", analysisResult.CopilotInstructionsPrompt }, + { "workItemContext", analysisResult.WorkItemContext } + }); - // Use optimized prompt builders with truncation and prioritization - var analysisUser = _promptBuilder.BuildAnalysisPrompt(analysisResult.AnalysisResult, diffs); - var inlineUser = _promptBuilder.BuildInlineSuggestionsPrompt(analysisResult.AnalysisResult, diffs); - var summaryUser = PromptBuilder.BuildSummaryPrompt(analysisResult.AnalysisResult, diffs); + var analysisUser = _promptBuilder.BuildAnalysisPrompt(analysisResult.AnalysisResult, diffs); + var inlineUser = _promptBuilder.BuildInlineSuggestionsPrompt(analysisResult.AnalysisResult, diffs); + var summaryUser = PromptBuilder.BuildSummaryPrompt(analysisResult.AnalysisResult, diffs); - var codeownersSystem = _templateService.RenderTemplate(AvailablePrompts.GeneralPrompts[GeneralPromptTemplates.CodeOwnerSystemPrompt]); - // Optimize CODEOWNERS prompt - more concise format - var codeownersUser = $"CODEOWNERS:\n{codeOwnerFileContent}\n\nChanged files: {string.Join(", ", changedFilePaths)}"; + var codeownersSystem = _templateService.RenderTemplate(AvailablePrompts.GeneralPrompts[GeneralPromptTemplates.CodeOwnerSystemPrompt]); + var codeownersUser = $"CODEOWNERS:\n{codeOwnerFileContent}\n\nChanged files: {string.Join(", ", changedFilePaths)}"; - var commonMcp = BuildMcpServerConfigs(analysisResult.AnalysisResult.McpServer ?? []); + var commonMcp = BuildMcpServerConfigs(analysisResult.AnalysisResult.McpServer ?? []); - var requests = new List(); + var requests = new List(); - // Create batch requests based on enabled features - var idDescriptionSummary = BuildDescriptionSummaryRequest(detailedSystem, analysisUser, commonMcp); - var idInlineSuggestions = BuildInlineRequest(inlineSystem, inlineUser, commonMcp); - var idSummary = BuildSummaryCommentRequest(summarySystem, summaryUser, commonMcp); - var idCodeOwners = BuildCodeOwnersRequest(codeownersSystem, codeownersUser, commonMcp); + var idDescriptionSummary = BuildDescriptionSummaryRequest(detailedSystem, analysisUser, commonMcp); + var idInlineSuggestions = BuildInlineRequest(inlineSystem, inlineUser, commonMcp); + var idSummary = BuildSummaryCommentRequest(summarySystem, summaryUser, commonMcp); + var idCodeOwners = BuildCodeOwnersRequest(codeownersSystem, codeownersUser, commonMcp); + //await _client.Messages.CountMessageTokensAsync(new() + //{ + // Messages = idDescriptionSummary.MessageParameters.Messages, + // Tools = idDescriptionSummary.MessageParameters.Tools, + // Model = idDescriptionSummary.MessageParameters.Model, + // System = idDescriptionSummary.MessageParameters.System + //}); - var tokenCount = await _client.Messages.CountMessageTokensAsync(new() - { - Messages = idDescriptionSummary.MessageParameters.Messages, - Tools = idDescriptionSummary.MessageParameters.Tools, - Model = idDescriptionSummary.MessageParameters.Model, - System = idDescriptionSummary.MessageParameters.System - }); + // EnableSummaryComment = detailed analysis comment on PR + if (analysisResult.AnalysisResult.EnableSummaryComment) + { + requests.Add(idDescriptionSummary); + } - // EnableSummaryComment = detailed analysis comment on PR - if (analysisResult.AnalysisResult.EnableSummaryComment) - { - requests.Add(idDescriptionSummary); - } + if (analysisResult.AnalysisResult.EnableInlineSuggestions) + { + requests.Add(idInlineSuggestions); + } - if (analysisResult.AnalysisResult.EnableInlineSuggestions) - { - requests.Add(idInlineSuggestions); - } + // EnableDescriptionSummary = summary appended to PR description + if (analysisResult.AnalysisResult.EnableDescriptionSummary) + { + requests.Add(idSummary); + } - // EnableDescriptionSummary = summary appended to PR description - if (analysisResult.AnalysisResult.EnableDescriptionSummary) - { - requests.Add(idSummary); - } + if (analysisResult.AnalysisResult.EnableAzureDevopsCodeOwners && !string.IsNullOrWhiteSpace(codeOwnerFileContent)) + { + requests.Add(idCodeOwners); + } - if (analysisResult.AnalysisResult.EnableAzureDevopsCodeOwners && !string.IsNullOrWhiteSpace(codeOwnerFileContent)) - { - requests.Add(idCodeOwners); - } + if (requests.Count == 0) + { + _logger.LogInformation("Batched analysis skipped: no operations enabled"); + return new BatchedAnalysisResult(); + } - // If no requests, return empty result - if (requests.Count == 0) - { - return new BatchedAnalysisResult(); - } + _logger.LogInformation("Submitting Claude batch with {RequestCount} request(s)", requests.Count); + var created = await _client.Batches.CreateBatchAsync(requests); - // Create batch - var created = await _client.Batches.CreateBatchAsync(requests); + BatchResponse currentStatus; + do + { + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + currentStatus = await _client.Batches.RetrieveBatchStatusAsync(created.Id, cancellationToken); + } while (currentStatus.ProcessingStatus is "in_progress"); - // Poll until completed - BatchResponse currentStatus; - do - { - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); - currentStatus = await _client.Batches.RetrieveBatchStatusAsync(created.Id, cancellationToken); - } while (currentStatus.ProcessingStatus is "in_progress"); + _logger.LogInformation("Batch {BatchId} completed with status {Status}", created.Id, currentStatus.ProcessingStatus); - // Retrieve results - var detailed = string.Empty; - var inlineRaw = string.Empty; - var summary = string.Empty; - var codeownersRaw = string.Empty; + var detailed = string.Empty; + var inlineRaw = string.Empty; + var summary = string.Empty; + var codeownersRaw = string.Empty; - await foreach (var result in _client.Batches.RetrieveBatchResultsAsync(created.Id, cancellationToken)) + await foreach (var result in _client.Batches.RetrieveBatchResultsAsync(created.Id, cancellationToken)) + { + if (result.Result.Type is "errored") { - if (result.Result.Type is "errored") - { - continue; - } - - detailed = result.CustomId == idDescriptionSummary.CustomId ? result.Result.Message.FirstMessage.Text ?? string.Empty : detailed; - inlineRaw = result.CustomId == idInlineSuggestions.CustomId ? result.Result.Message.FirstMessage.Text ?? string.Empty : inlineRaw; - summary = result.CustomId == idSummary.CustomId ? result.Result.Message.FirstMessage.Text ?? string.Empty : summary; - codeownersRaw = result.CustomId == idCodeOwners.CustomId ? result.Result.Message.FirstMessage.Text ?? string.Empty : codeownersRaw; + _logger.LogWarning("Batch result {CustomId} errored — skipping", result.CustomId); + continue; } - // Parse inline suggestions and apply global cap - List inline; - try - { - var parsed = string.IsNullOrWhiteSpace(inlineRaw) ? [] : (JsonSerializer.Deserialize>(inlineRaw) ?? []); - inline = SemanticAnalyzerService.ApplyGlobalCap(parsed, _options.MaxInlineSuggestions); - } - catch - { - inline = []; - } + detailed = result.CustomId == idDescriptionSummary.CustomId ? result.Result.Message.FirstMessage.Text ?? string.Empty : detailed; + inlineRaw = result.CustomId == idInlineSuggestions.CustomId ? result.Result.Message.FirstMessage.Text ?? string.Empty : inlineRaw; + summary = result.CustomId == idSummary.CustomId ? result.Result.Message.FirstMessage.Text ?? string.Empty : summary; + codeownersRaw = result.CustomId == idCodeOwners.CustomId ? result.Result.Message.FirstMessage.Text ?? string.Empty : codeownersRaw; + } - // Parse code owners - CodeOwnersResult? codeowners; - try - { - codeowners = string.IsNullOrWhiteSpace(codeownersRaw) ? null : JsonSerializer.Deserialize(codeownersRaw); - } - catch - { - codeowners = null; - } + List inline; + try + { + var parsed = string.IsNullOrWhiteSpace(inlineRaw) ? [] : (JsonSerializer.Deserialize>(inlineRaw) ?? []); + inline = AzureOpenAIAnalyzerService.ApplyGlobalCap(parsed, _options.MaxInlineSuggestions, _logger); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to deserialize inline suggestions from batch result. RawLength={RawLength}", inlineRaw.Length); + inline = []; + } - return new BatchedAnalysisResult - { - DetailedAnalysis = detailed, - InlineSuggestions = inline, - Summary = summary, - CodeOwners = codeowners - }; - }); + CodeOwnersResult? codeowners; + try + { + codeowners = string.IsNullOrWhiteSpace(codeownersRaw) ? null : JsonSerializer.Deserialize(codeownersRaw); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to deserialize CODEOWNERS from batch result. RawLength={RawLength}", codeownersRaw.Length); + codeowners = null; + } + + return new BatchedAnalysisResult + { + DetailedAnalysis = detailed, + InlineSuggestions = inline, + Summary = summary, + CodeOwners = codeowners + }; } /// @@ -453,8 +475,4 @@ private List BuildMcpServerConfigs(List mcpServers) return servers; } - public Task GetDetailedAnalysis(AnalyzerServiceModel analysisResult, Dictionary diffs, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } } diff --git a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/AvailablePrompts.cs b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/AvailablePrompts.cs index fe80964..0177e1c 100644 --- a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/AvailablePrompts.cs +++ b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/AvailablePrompts.cs @@ -11,7 +11,8 @@ internal enum GeneralPromptTemplates { CodeOwnerSystemPrompt, GlobalInstructionsPrompt, - QuestionAnsweringPrompt + QuestionAnsweringPrompt, + WorkItemSummarizerSystemPrompt } internal static class AvailablePrompts @@ -28,6 +29,7 @@ internal static class AvailablePrompts { { GeneralPromptTemplates.CodeOwnerSystemPrompt, "CodeOwnerSystemPrompt" }, { GeneralPromptTemplates.GlobalInstructionsPrompt, "GlobalInstructionsPrompt" }, - { GeneralPromptTemplates.QuestionAnsweringPrompt, "QuestionAnsweringPrompt" } + { GeneralPromptTemplates.QuestionAnsweringPrompt, "QuestionAnsweringPrompt" }, + { GeneralPromptTemplates.WorkItemSummarizerSystemPrompt, "WorkItemSummarizerSystemPrompt" } }; } diff --git a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/CSharp/DetailedAnalysisSystemPrompt.md b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/CSharp/DetailedAnalysisSystemPrompt.md index d7eaa23..1e1f58a 100644 --- a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/CSharp/DetailedAnalysisSystemPrompt.md +++ b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/CSharp/DetailedAnalysisSystemPrompt.md @@ -12,3 +12,5 @@ You are an expert C# code reviewer analyzing static analysis findings and code d ## Custom Project Instructions for this Analysis: {{customInstructions}} + +{{workItemContext}} diff --git a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/CSharp/InlineSuggestionsSystemPrompt.md b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/CSharp/InlineSuggestionsSystemPrompt.md index 08eab55..1708e2f 100644 --- a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/CSharp/InlineSuggestionsSystemPrompt.md +++ b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/CSharp/InlineSuggestionsSystemPrompt.md @@ -178,3 +178,5 @@ For the hunk `@@ -1,15 +1,15 @@`: ## Custom Project Instructions for this Analysis: {{customInstructions}} + +{{workItemContext}} diff --git a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/CSharp/SummarySystemPrompt.md b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/CSharp/SummarySystemPrompt.md index f5e0752..ea42d63 100644 --- a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/CSharp/SummarySystemPrompt.md +++ b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/CSharp/SummarySystemPrompt.md @@ -44,3 +44,5 @@ Keep it concise (under 150 words) and actionable for DevOps teams. ## Custom Project Instructions for this Analysis: {{customInstructions}} + +{{workItemContext}} diff --git a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Java/DetailedAnalysisSystemPrompt.md b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Java/DetailedAnalysisSystemPrompt.md index 66a797b..568cbec 100644 --- a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Java/DetailedAnalysisSystemPrompt.md +++ b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Java/DetailedAnalysisSystemPrompt.md @@ -12,3 +12,5 @@ You are an expert Java code reviewer analyzing static analysis findings and code ## Custom Project Instructions for this Analysis: {{customInstructions}} + +{{workItemContext}} diff --git a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Java/InlineSuggestionsSystemPrompt.md b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Java/InlineSuggestionsSystemPrompt.md index 49360e3..a6955d1 100644 --- a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Java/InlineSuggestionsSystemPrompt.md +++ b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Java/InlineSuggestionsSystemPrompt.md @@ -174,3 +174,5 @@ For the hunk `@@ -1,10 +1,10 @@`: ## Custom Project Instructions for this Analysis: {{customInstructions}} + +{{workItemContext}} diff --git a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Java/SummarySystemPrompt.md b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Java/SummarySystemPrompt.md index bc7cb37..a2e041f 100644 --- a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Java/SummarySystemPrompt.md +++ b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Java/SummarySystemPrompt.md @@ -41,3 +41,5 @@ Structure your response as a concise PR summary in Markdown: Keep it concise (under 150 words) and actionable for DevOps teams. + +{{workItemContext}} diff --git a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/JavaScript/DetailedAnalysisSystemPrompt.md b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/JavaScript/DetailedAnalysisSystemPrompt.md index 7ea37d6..8b4608b 100644 --- a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/JavaScript/DetailedAnalysisSystemPrompt.md +++ b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/JavaScript/DetailedAnalysisSystemPrompt.md @@ -12,3 +12,5 @@ You are an expert JavaScript code reviewer analyzing static analysis findings an ## Custom Project Instructions for this Analysis: {{customInstructions}} + +{{workItemContext}} diff --git a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/JavaScript/InlineSuggestionsSystemPrompt.md b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/JavaScript/InlineSuggestionsSystemPrompt.md index 8da3688..e2d34ca 100644 --- a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/JavaScript/InlineSuggestionsSystemPrompt.md +++ b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/JavaScript/InlineSuggestionsSystemPrompt.md @@ -171,3 +171,5 @@ For the hunk `@@ -1,10 +1,10 @@`: ## Custom Project Instructions for this Analysis: {{customInstructions}} + +{{workItemContext}} diff --git a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/JavaScript/SummarySystemPrompt.md b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/JavaScript/SummarySystemPrompt.md index 91a634b..8e8e9b7 100644 --- a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/JavaScript/SummarySystemPrompt.md +++ b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/JavaScript/SummarySystemPrompt.md @@ -41,3 +41,5 @@ Structure your response as a concise PR summary in Markdown: Keep it concise (under 150 words) and actionable for DevOps teams. + +{{workItemContext}} diff --git a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Python/DetailedAnalysisSystemPrompt.md b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Python/DetailedAnalysisSystemPrompt.md index 091960e..8803d6f 100644 --- a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Python/DetailedAnalysisSystemPrompt.md +++ b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Python/DetailedAnalysisSystemPrompt.md @@ -14,3 +14,5 @@ You are an expert Python code reviewer analyzing static analysis findings and co ## Custom Project Instructions for this Analysis: {{customInstructions}} + +{{workItemContext}} diff --git a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Python/InlineSuggestionsSystemPrompt.md b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Python/InlineSuggestionsSystemPrompt.md index 19852aa..8dfc0ec 100644 --- a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Python/InlineSuggestionsSystemPrompt.md +++ b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Python/InlineSuggestionsSystemPrompt.md @@ -166,3 +166,5 @@ For the hunk `@@ -1,10 +1,10 @@`: ## Custom Project Instructions for this Analysis: {{customInstructions}} + +{{workItemContext}} diff --git a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Python/SummarySystemPrompt.md b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Python/SummarySystemPrompt.md index c5ef1d7..af2b82b 100644 --- a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Python/SummarySystemPrompt.md +++ b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/Python/SummarySystemPrompt.md @@ -40,3 +40,5 @@ Structure your response as a concise PR summary in Markdown: - Follow-up actions needed Keep it concise (under 150 words) and actionable for DevOps teams. + +{{workItemContext}} diff --git a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/TypeScript/DetailedAnalysisSystemPrompt.md b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/TypeScript/DetailedAnalysisSystemPrompt.md index acb0fe5..1c45c6c 100644 --- a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/TypeScript/DetailedAnalysisSystemPrompt.md +++ b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/TypeScript/DetailedAnalysisSystemPrompt.md @@ -9,3 +9,5 @@ You are an expert TypeScript code reviewer analyzing static analysis findings an - 🔧 Quality: Best practice improvements - ✨ Positives: Good practices found + +{{workItemContext}} diff --git a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/TypeScript/InlineSuggestionsSystemPrompt.md b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/TypeScript/InlineSuggestionsSystemPrompt.md index 9b78885..78fd959 100644 --- a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/TypeScript/InlineSuggestionsSystemPrompt.md +++ b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/TypeScript/InlineSuggestionsSystemPrompt.md @@ -168,3 +168,5 @@ For the hunk `@@ -1,10 +1,10 @@`: ## Custom Project Instructions for this Analysis: {{customInstructions}} + +{{workItemContext}} diff --git a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/TypeScript/SummarySystemPrompt.md b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/TypeScript/SummarySystemPrompt.md index 9ddd6e9..ff69adf 100644 --- a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/TypeScript/SummarySystemPrompt.md +++ b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/TypeScript/SummarySystemPrompt.md @@ -40,3 +40,5 @@ Structure your response as a concise PR summary in Markdown: - Follow-up actions needed Keep it concise (under 150 words) and actionable for DevOps teams. + +{{workItemContext}} diff --git a/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/WorkItemSummarizerSystemPrompt.md b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/WorkItemSummarizerSystemPrompt.md new file mode 100644 index 0000000..36fc416 --- /dev/null +++ b/src/Lintellect.Api/Infrastructure/Services/AI/Prompts/Templates/WorkItemSummarizerSystemPrompt.md @@ -0,0 +1,23 @@ +You are an assistant that produces tight, structured summaries of work items / issues that are linked to a pull request. + +Your output is **fed back as context** into a separate AI code-review pipeline. It must be compact (the entire response under ~400 tokens), faithful to the source, and free of speculation. + +## Output Format (strict) + +Return exactly two sections in this shape, with no preamble: + +``` +GOAL: + +CONTEXT: +<2-3 short paragraphs covering: intent / acceptance criteria, explicit non-goals or constraints, and any technical hints the author left. Cite work item ids inline like [#123] when multiple items are involved. Do not invent details that are not in the source.> +``` + +## Rules + +- Do **not** add markdown headings other than the two labels above. +- Do **not** wrap the output in code fences. +- If multiple work items are provided, write a single combined GOAL line and a single CONTEXT section that references each item by id. +- If the work item descriptions are sparse or unclear, prefer brevity over filler. It is acceptable for CONTEXT to be a single paragraph. +- Never include credentials, URLs, or HTML markup; strip those from the source before paraphrasing. +- Output must be plain UTF-8 text suitable for direct injection into another prompt. diff --git a/src/Lintellect.Api/Infrastructure/Services/Git/AzureDevops/AzureDevopsClientService.cs b/src/Lintellect.Api/Infrastructure/Services/Git/AzureDevops/AzureDevopsClientService.cs index a0378c1..4ab4a3c 100644 --- a/src/Lintellect.Api/Infrastructure/Services/Git/AzureDevops/AzureDevopsClientService.cs +++ b/src/Lintellect.Api/Infrastructure/Services/Git/AzureDevops/AzureDevopsClientService.cs @@ -6,12 +6,16 @@ using Lintellect.Shared.Models; using Microsoft.TeamFoundation.Core.WebApi; using Microsoft.TeamFoundation.SourceControl.WebApi; +using Microsoft.TeamFoundation.WorkItemTracking.WebApi; using Microsoft.VisualStudio.Services.Common; using Microsoft.VisualStudio.Services.Identity; using Microsoft.VisualStudio.Services.Identity.Client; using Microsoft.VisualStudio.Services.OAuth; using Microsoft.VisualStudio.Services.Security.Client; using Microsoft.VisualStudio.Services.WebApi; +using AdoWorkItem = Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models.WorkItem; +using AdoWorkItemExpand = Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models.WorkItemExpand; +using SharedWorkItemReference = Lintellect.Shared.Models.WorkItemReference; namespace Lintellect.Api.Infrastructure.Services.Git.AzureDevops; @@ -31,6 +35,7 @@ public class AzureDevopsClientService : IGitClient private readonly Lazy> _projectClient; private readonly Lazy> _identityClient; private readonly Lazy> _securityClient; + private readonly Lazy> _witClient; public AzureDevopsClientService(string devopsPat, Uri orgUri) { @@ -41,6 +46,64 @@ public AzureDevopsClientService(string devopsPat, Uri orgUri) _projectClient = new Lazy>(() => _connection.GetClientAsync()); _identityClient = new Lazy>(() => _connection.GetClientAsync()); _securityClient = new Lazy>(() => _connection.GetClientAsync()); + _witClient = new Lazy>(() => _connection.GetClientAsync()); + } + + /// + public async Task> GetLinkedWorkItemsAsync( + string projectName, + string repositoryName, + int pullRequestId, + IReadOnlyList? hints = null) + { + var gitClient = await GetHttpGitClient(); + var refs = await gitClient.GetPullRequestWorkItemRefsAsync(projectName, repositoryName, pullRequestId); + + var ids = new List(); + foreach (var r in refs ?? []) + { + if (int.TryParse(r.Id, out var id)) + { + ids.Add(id); + } + } + + if (ids.Count == 0) + { + return []; + } + + var witClient = await _witClient.Value; + var fields = new[] { "System.Title", "System.Description", "System.WorkItemType", "System.State" }; + var items = await witClient.GetWorkItemsAsync(ids, fields: fields, expand: AdoWorkItemExpand.None); + + return [.. items.Select(MapWorkItem)]; + } + + private static SharedWorkItemReference MapWorkItem(AdoWorkItem item) + { + var fields = item.Fields ?? new Dictionary(); + string? GetField(string key) => fields.TryGetValue(key, out var v) ? v?.ToString() : null; + + return new SharedWorkItemReference( + Id: item.Id?.ToString() ?? string.Empty, + Url: item.Url, + Title: GetField("System.Title"), + Body: StripHtml(GetField("System.Description")), + State: GetField("System.State"), + Type: GetField("System.WorkItemType")); + } + + private static string? StripHtml(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return value; + } + + var noTags = System.Text.RegularExpressions.Regex.Replace(value, "<[^>]+>", " "); + var collapsed = System.Text.RegularExpressions.Regex.Replace(noTags, @"\s+", " ").Trim(); + return collapsed; } public Task GetHttpGitClient() @@ -205,8 +268,7 @@ public async Task GetFileContentAsync(string projectName, string reposit { Version = commitId, VersionType = GitVersionType.Commit - }) - ; + }); } /// @@ -295,8 +357,7 @@ public async Task> GetPullRequestFileDiffsAsync(strin { Version = pullRequest.SourceRefName?.Replace("refs/heads/", string.Empty), VersionType = GitVersionType.Branch - }) - ; + }); var fileDiffs = new Dictionary(); @@ -633,6 +694,18 @@ public async Task> HasSufficientPermissionsAsync(Ana results.Add(new CheckPermissionResult(Success, Success ? null : $"Pull Request Edit: {Reason}")); } + // Work item read permission (required for work item context) + if (analysisRequest.EnableWorkItemContext) + { + var (Success, Reason) = await TestPermissionAsync(async () => + { + // Exercises the exact API path used by GetLinkedWorkItemsAsync — validates + // that the PAT has "Work Items (Read)" in addition to "Code (Read)". + _ = await gitClient.GetPullRequestWorkItemRefsAsync(project, repoName, pullRequestId); + }); + results.Add(new CheckPermissionResult(Success, Success ? null : $"Work Item Read: {Reason}")); + } + // Identity read permission (required for code owners) if (analysisRequest.EnableAzureDevopsCodeOwners) { diff --git a/src/Lintellect.Api/Infrastructure/Services/Git/GitHub/GitHubClientService.cs b/src/Lintellect.Api/Infrastructure/Services/Git/GitHub/GitHubClientService.cs index ecdef56..352151d 100644 --- a/src/Lintellect.Api/Infrastructure/Services/Git/GitHub/GitHubClientService.cs +++ b/src/Lintellect.Api/Infrastructure/Services/Git/GitHub/GitHubClientService.cs @@ -516,6 +516,13 @@ public async Task> HasSufficientPermissionsAsync(Ana results.Add(new CheckPermissionResult(hasEditScope, hasEditScope ? null : "Pull Request Edit: Missing 'repo' or 'public_repo' scope")); } + // Issue read permission (required for work item context — GitHub linked issues) + if (analysisRequest.EnableWorkItemContext) + { + var hasIssueScope = scopes.Contains("repo") || scopes.Contains("public_repo"); + results.Add(new CheckPermissionResult(hasIssueScope, hasIssueScope ? null : "Issue Read (Work Item Context): Missing 'repo' or 'public_repo' scope")); + } + return results; } catch (Octokit.AuthorizationException) @@ -569,6 +576,93 @@ public async Task> HasSufficientPermissionsAsync(Ana /// /// Maps a GitHub PullRequestState to the generic PullRequestStatus enum. /// + /// + public async Task> GetLinkedWorkItemsAsync( + string projectName, + string repositoryName, + int pullRequestId, + IReadOnlyList? hints = null) + { + try + { + var ids = new HashSet(); + + foreach (var hint in hints ?? []) + { + if (int.TryParse(hint.Id, out var hintId)) + { + ids.Add(hintId); + } + } + + if (ids.Count == 0) + { + var pr = await _client.PullRequest.Get(projectName, repositoryName, pullRequestId); + foreach (var id in ParseLinkedIssueIds(pr.Body)) + { + ids.Add(id); + } + } + + if (ids.Count == 0) + { + return []; + } + + var results = new List(); + foreach (var id in ids) + { + try + { + var issue = await _client.Issue.Get(projectName, repositoryName, id); + if (issue.PullRequest is not null) + { + continue; + } + + results.Add(new WorkItemReference( + Id: id.ToString(), + Url: issue.HtmlUrl, + Title: issue.Title, + Body: issue.Body, + State: issue.State.StringValue, + Type: "Issue")); + } + catch (NotFoundException) + { + _logger.LogDebug("Linked issue #{IssueId} not found in {Owner}/{Repo}", id, projectName, repositoryName); + } + } + + return results; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve linked work items for GitHub PR #{PullRequestId}", pullRequestId); + throw; + } + } + + private static readonly System.Text.RegularExpressions.Regex LinkedIssueRegex = + new(@"\b(?:close[sd]?|fix(?:es|ed)?|resolve[sd]?)\s+#(\d+)", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled); + + internal static IEnumerable ParseLinkedIssueIds(string? body) + { + if (string.IsNullOrWhiteSpace(body)) + { + yield break; + } + + foreach (System.Text.RegularExpressions.Match match in LinkedIssueRegex.Matches(body)) + { + if (int.TryParse(match.Groups[1].Value, out var id)) + { + yield return id; + } + } + } + private static Lintellect.Api.Application.Models.Git.PullRequestStatus MapPullRequestStatus(StringEnum state) { return state.Value switch diff --git a/src/Lintellect.Api/Infrastructure/Services/WorkItems/WorkItemService.cs b/src/Lintellect.Api/Infrastructure/Services/WorkItems/WorkItemService.cs new file mode 100644 index 0000000..46a600b --- /dev/null +++ b/src/Lintellect.Api/Infrastructure/Services/WorkItems/WorkItemService.cs @@ -0,0 +1,31 @@ +using Lintellect.Api.Application.Interfaces; +using Lintellect.Shared.Models; + +namespace Lintellect.Api.Infrastructure.Services.WorkItems; + +internal sealed class WorkItemService(IGitClientFactory gitClientFactory, ILogger logger) : IWorkItemService +{ + public async Task> ResolveAsync(AnalysisRequest analysisRequest, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(analysisRequest); + + if (analysisRequest.GitInfo is null) + { + return []; + } + + var client = gitClientFactory.CreateClient(analysisRequest); + var hints = analysisRequest.WorkItems is { Count: > 0 } ? analysisRequest.WorkItems : null; + + var items = await client.GetLinkedWorkItemsAsync( + analysisRequest.GitInfo.ProjectName ?? analysisRequest.GitInfo.RepositoryName, + analysisRequest.GitInfo.RepositoryName, + analysisRequest.GitInfo.PullRequestId, + hints).ConfigureAwait(false); + + logger.LogInformation("Resolved {Count} linked work item(s) for PR #{PullRequestId}", + items.Count, analysisRequest.GitInfo.PullRequestId); + + return items; + } +} diff --git a/src/Lintellect.Api/Infrastructure/Services/WorkItems/WorkItemSummarizer.cs b/src/Lintellect.Api/Infrastructure/Services/WorkItems/WorkItemSummarizer.cs new file mode 100644 index 0000000..52d548b --- /dev/null +++ b/src/Lintellect.Api/Infrastructure/Services/WorkItems/WorkItemSummarizer.cs @@ -0,0 +1,105 @@ +using System.Text; +using Lintellect.Api.Application.Interfaces; +using Lintellect.Api.Infrastructure.Services.AI.Prompts; +using Lintellect.Shared.Models; + +namespace Lintellect.Api.Infrastructure.Services.WorkItems; + +internal sealed class WorkItemSummarizer(IAnalyzerService analyzerService, ILogger logger) : IWorkItemSummarizer +{ + private const int MaxOutputTokens = 800; + private const int MaxBodyChars = 4000; + + private readonly PromptTemplateService _templates = new(); + + public async Task SummarizeAsync(IReadOnlyList workItems, CancellationToken cancellationToken = default) + { + if (workItems is null || workItems.Count == 0) + { + return WorkItemSummary.Empty; + } + + var systemPrompt = _templates.RenderTemplate( + AvailablePrompts.GeneralPrompts[GeneralPromptTemplates.WorkItemSummarizerSystemPrompt]); + var userPrompt = BuildUserPrompt(workItems); + + var response = await analyzerService + .SummarizeContextAsync(systemPrompt, userPrompt, MaxOutputTokens, cancellationToken) + .ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(response)) + { + logger.LogWarning("Work item summarizer returned an empty response for {Count} item(s)", workItems.Count); + return WorkItemSummary.Empty; + } + + var (goal, _) = SplitGoalAndContext(response); + return new WorkItemSummary(FullContext: response.Trim(), Goal: goal); + } + + private static string BuildUserPrompt(IReadOnlyList workItems) + { + var builder = new StringBuilder(); + builder.AppendLine($"There are {workItems.Count} linked work item(s). Treat each as a distinct item."); + builder.AppendLine(); + + for (var i = 0; i < workItems.Count; i++) + { + var item = workItems[i]; + builder.AppendLine($"--- Work Item {i + 1} of {workItems.Count} ---"); + builder.AppendLine($"Id: {item.Id}"); + if (!string.IsNullOrWhiteSpace(item.Type)) + { + builder.AppendLine($"Type: {item.Type}"); + } + if (!string.IsNullOrWhiteSpace(item.State)) + { + builder.AppendLine($"State: {item.State}"); + } + if (!string.IsNullOrWhiteSpace(item.Title)) + { + builder.AppendLine($"Title: {item.Title}"); + } + if (!string.IsNullOrWhiteSpace(item.Body)) + { + builder.AppendLine("Body:"); + builder.AppendLine(Truncate(item.Body, MaxBodyChars)); + } + builder.AppendLine(); + } + + return builder.ToString(); + } + + private static string Truncate(string value, int maxLength) + { + return value.Length <= maxLength ? value : value[..maxLength] + "... (truncated)"; + } + + internal static (string Goal, string Context) SplitGoalAndContext(string response) + { + var lines = response.Split('\n'); + string? goalLine = null; + var contextStartIndex = -1; + + for (var i = 0; i < lines.Length; i++) + { + var trimmed = lines[i].TrimStart(); + if (goalLine is null && trimmed.StartsWith("GOAL:", StringComparison.OrdinalIgnoreCase)) + { + goalLine = trimmed["GOAL:".Length..].Trim(); + } + else if (trimmed.StartsWith("CONTEXT:", StringComparison.OrdinalIgnoreCase)) + { + contextStartIndex = i + 1; + break; + } + } + + var contextText = contextStartIndex >= 0 && contextStartIndex < lines.Length + ? string.Join('\n', lines[contextStartIndex..]).Trim() + : string.Empty; + + return (goalLine ?? string.Empty, contextText); + } +} diff --git a/src/Lintellect.Api/Lintellect.Api.csproj b/src/Lintellect.Api/Lintellect.Api.csproj index 4ee09df..0a03b32 100644 --- a/src/Lintellect.Api/Lintellect.Api.csproj +++ b/src/Lintellect.Api/Lintellect.Api.csproj @@ -68,8 +68,9 @@ runtime; build; native; contentfiles; analyzers - - + + + @@ -78,9 +79,6 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - @@ -109,6 +107,7 @@ + diff --git a/src/Lintellect.AppHost/AppHost.cs b/src/Lintellect.AppHost/AppHost.cs index 65f2e61..4989180 100644 --- a/src/Lintellect.AppHost/AppHost.cs +++ b/src/Lintellect.AppHost/AppHost.cs @@ -22,8 +22,9 @@ .WithEnvironment("LINTELLECT_API_KEY", apiKey) .WithEnvironment("AZURE_DEVOPS_PAT", builder.Configuration.GetValue("AZURE_DEVOPS_PAT")) .WithEnvironment("AZURE_DEVOPS_ORG_URL", builder.Configuration.GetValue("AZURE_DEVOPS_ORG_URL")) - .WithEnvironment("SEMANTIC_API_KEY", builder.Configuration.GetValue("SEMANTIC_API_KEY")) - .WithEnvironment("SEMANTIC_ENDPOINT", builder.Configuration.GetValue("SEMANTIC_ENDPOINT")) + .WithEnvironment("AZURE_OPENAI_API_KEY", builder.Configuration.GetValue("AZURE_OPENAI_API_KEY")) + .WithEnvironment("AZURE_OPENAI_ENDPOINT", builder.Configuration.GetValue("AZURE_OPENAI_ENDPOINT")) + .WithEnvironment("AZURE_OPENAI_DEPLOYMENT_NAME", builder.Configuration.GetValue("AZURE_OPENAI_DEPLOYMENT_NAME")) .WithReference(postgresDb) .WaitFor(postgres) .WithComputeEnvironment(compose); diff --git a/src/Lintellect.Cli/Commands/StaticAnalysisCommand.cs b/src/Lintellect.Cli/Commands/StaticAnalysisCommand.cs index b91d698..6340ba5 100644 --- a/src/Lintellect.Cli/Commands/StaticAnalysisCommand.cs +++ b/src/Lintellect.Cli/Commands/StaticAnalysisCommand.cs @@ -1,5 +1,6 @@ using System.CommandLine; using Lintellect.Cli.Services; +using Lintellect.Cli.Services.Git; using Lintellect.Shared.Models; namespace Lintellect.Cli.Commands; @@ -95,6 +96,13 @@ public StaticAnalysisCommand() : base("analyze", "Run static analysis on code") Aliases = { "-eac" } }; + var enableWorkItemContext = new Option("--enable-work-item-context") + { + Description = "Fetch linked work items / issues and feed an AI-condensed summary into the review prompts (default: true)", + DefaultValueFactory = _ => true, + Aliases = { "-ewi" } + }; + // Semgrep analysis options var enableSemgrep = new Option("--enable-semgrep") { @@ -122,6 +130,7 @@ public StaticAnalysisCommand() : base("analyze", "Run static analysis on code") Options.Add(enableInlineSuggestions); Options.Add(enableDescriptionSummary); Options.Add(enableCodeOwners); + Options.Add(enableWorkItemContext); Options.Add(enableSemgrep); @@ -163,6 +172,12 @@ public StaticAnalysisCommand() : base("analyze", "Run static analysis on code") analysisResult.EnableSummaryComment = parseResult.GetValue(enableSummaryComment); analysisResult.FileExclusions = [.. exclusionPatterns]; analysisResult.EnableAzureDevopsCodeOwners = parseResult.GetValue(enableCodeOwners); + analysisResult.EnableWorkItemContext = parseResult.GetValue(enableWorkItemContext); + + if (analysisResult.EnableWorkItemContext) + { + analysisResult.WorkItems = [.. WorkItemReferenceExtractor.ExtractFromEnvironment()]; + } var mcpServers = mcpServerValue ?? []; analysisResult.McpServer = [.. mcpServers]; diff --git a/src/Lintellect.Cli/Services/Git/WorkItemReferenceExtractor.cs b/src/Lintellect.Cli/Services/Git/WorkItemReferenceExtractor.cs new file mode 100644 index 0000000..663f5c2 --- /dev/null +++ b/src/Lintellect.Cli/Services/Git/WorkItemReferenceExtractor.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Lintellect.Shared.Models; + +namespace Lintellect.Cli.Services.Git; + +/// +/// Extracts cheap work-item / issue id hints from CI environment variables. +/// Rich resolution (titles, descriptions) happens API-side because the CLI typically lacks PATs. +/// +internal static partial class WorkItemReferenceExtractor +{ + [GeneratedRegex(@"\b(?:close[sd]?|fix(?:es|ed)?|resolve[sd]?)\s+#(\d+)", + RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex LinkedIssueRegex(); + + public static IEnumerable ExtractFromEnvironment() + { + // GitHub Actions: the PR body lives inside the event payload JSON. + // Azure DevOps does not surface linked work-item ids through env vars; the API resolves + // those server-side via the WIT REST API using the configured PAT. + var eventPath = Environment.GetEnvironmentVariable("GITHUB_EVENT_PATH"); + if (string.IsNullOrWhiteSpace(eventPath) || !File.Exists(eventPath)) + { + yield break; + } + + string body; + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(eventPath)); + if (!doc.RootElement.TryGetProperty("pull_request", out var pr) || + !pr.TryGetProperty("body", out var bodyElement) || + bodyElement.ValueKind != JsonValueKind.String) + { + yield break; + } + + body = bodyElement.GetString() ?? string.Empty; + } + catch (IOException) + { + yield break; + } + catch (JsonException) + { + yield break; + } + catch (UnauthorizedAccessException) + { + yield break; + } + + foreach (var id in ParseLinkedIssueIds(body)) + { + yield return new WorkItemReference(Id: id.ToString()); + } + } + + internal static IEnumerable ParseLinkedIssueIds(string? body) + { + if (string.IsNullOrWhiteSpace(body)) + { + yield break; + } + + var seen = new HashSet(); + foreach (Match match in LinkedIssueRegex().Matches(body)) + { + if (int.TryParse(match.Groups[1].Value, out var id) && seen.Add(id)) + { + yield return id; + } + } + } +} diff --git a/src/Lintellect.Shared/Models/AnalysisRequest.cs b/src/Lintellect.Shared/Models/AnalysisRequest.cs index 2a4dac9..bad4db4 100644 --- a/src/Lintellect.Shared/Models/AnalysisRequest.cs +++ b/src/Lintellect.Shared/Models/AnalysisRequest.cs @@ -18,5 +18,9 @@ public class AnalysisRequest public bool EnableAzureDevopsCodeOwners { get; set; } = false; + public bool EnableWorkItemContext { get; set; } = true; + + public List WorkItems { get; set; } = []; + public List McpServer { get; set; } = []; } diff --git a/src/Lintellect.Shared/Models/WorkItemReference.cs b/src/Lintellect.Shared/Models/WorkItemReference.cs new file mode 100644 index 0000000..594da7f --- /dev/null +++ b/src/Lintellect.Shared/Models/WorkItemReference.cs @@ -0,0 +1,13 @@ +namespace Lintellect.Shared.Models; + +/// +/// Reference to a work item / issue linked to a pull request. +/// Carries enough information for the AI to use it as PR context. +/// +public sealed record WorkItemReference( + string Id, + string? Url = null, + string? Title = null, + string? Body = null, + string? State = null, + string? Type = null); diff --git a/tests/Lintellect.Api.FunctionalTests/CommandTests/Analysis/ProcessAnalysisJobWorkItemContextTests.cs b/tests/Lintellect.Api.FunctionalTests/CommandTests/Analysis/ProcessAnalysisJobWorkItemContextTests.cs new file mode 100644 index 0000000..98d08f2 --- /dev/null +++ b/tests/Lintellect.Api.FunctionalTests/CommandTests/Analysis/ProcessAnalysisJobWorkItemContextTests.cs @@ -0,0 +1,47 @@ +using Lintellect.Api.Application.Messages.Commands.Analysis; +using Lintellect.Api.FunctionalTests.Mocks.AI; +using static Lintellect.Api.FunctionalTests.Testing; + +namespace Lintellect.Api.FunctionalTests.CommandTests.Analysis; + +public class ProcessAnalysisJobWorkItemContextTests : BaseTestFixture +{ + [Test] + public async Task Handle_WhenWorkItemContextDisabled_DoesNotCallSummarizer() + { + var request = TestDataBuilder.ValidRequest(); + request.EnableWorkItemContext = false; + + var mockAnalyzer = await GetMockAnalyzerAsync(); + + await SendAsync(new ProcessAnalysisJobCommand(Guid.NewGuid(), request)); + + mockAnalyzer.SummarizeContextCallCount.ShouldBe(0); + mockAnalyzer.LastSummaryWorkItemContext.ShouldBeEmpty(); + mockAnalyzer.LastDetailedWorkItemContext.ShouldBeEmpty(); + mockAnalyzer.LastInlineWorkItemGoal.ShouldBeEmpty(); + } + + [Test] + public async Task Handle_WhenWorkItemContextEnabled_FeedsContextIntoPrompts() + { + var request = TestDataBuilder.ValidRequest(); + request.EnableWorkItemContext = true; + + var mockAnalyzer = await GetMockAnalyzerAsync(); + + await SendAsync(new ProcessAnalysisJobCommand(Guid.NewGuid(), request)); + + mockAnalyzer.SummarizeContextCallCount.ShouldBe(1); + mockAnalyzer.LastSummaryWorkItemContext.ShouldNotBeNull().ShouldContain("GOAL:"); + mockAnalyzer.LastDetailedWorkItemContext.ShouldNotBeNull().ShouldContain("GOAL:"); + mockAnalyzer.LastInlineWorkItemGoal.ShouldBe("Implement the linked work item."); + } + + private static async Task GetMockAnalyzerAsync() + { + var analyzer = await GetService(); + return analyzer as MockAnalyzerService + ?? throw new InvalidOperationException("Test fixture must register MockAnalyzerService."); + } +} diff --git a/tests/Lintellect.Api.FunctionalTests/Mocks/AI/MockAnalyzerService.cs b/tests/Lintellect.Api.FunctionalTests/Mocks/AI/MockAnalyzerService.cs index 8ee0e83..541c980 100644 --- a/tests/Lintellect.Api.FunctionalTests/Mocks/AI/MockAnalyzerService.cs +++ b/tests/Lintellect.Api.FunctionalTests/Mocks/AI/MockAnalyzerService.cs @@ -6,18 +6,32 @@ /// public sealed class MockAnalyzerService : IAnalyzerService { + public string? LastDetailedWorkItemContext { get; private set; } + public string? LastSummaryWorkItemContext { get; private set; } + public string? LastInlineWorkItemGoal { get; private set; } + public int SummarizeContextCallCount { get; private set; } + public Task GetDetailedAnalysisAsync(AnalyzerServiceModel analysisResult, Dictionary diffs, CancellationToken cancellationToken = default) { + LastDetailedWorkItemContext = analysisResult.WorkItemContext; return Task.FromResult("Mock detailed analysis"); } public Task GenerateSummaryAsync(AnalyzerServiceModel analysisResult, Dictionary diffs, CancellationToken cancellationToken = default) { + LastSummaryWorkItemContext = analysisResult.WorkItemContext; return Task.FromResult("Mock summary"); } + public Task SummarizeContextAsync(string systemPrompt, string userPrompt, int maxOutputTokens, CancellationToken cancellationToken = default) + { + SummarizeContextCallCount++; + return Task.FromResult("GOAL: Implement the linked work item.\n\nCONTEXT:\nThe linked item asks for X to be done."); + } + public Task> GenerateInlineSuggestionsAsync(AnalyzerServiceModel analysisResult, Dictionary diffs, CancellationToken cancellationToken = default) { + LastInlineWorkItemGoal = analysisResult.WorkItemGoal; return Task.FromResult(new List { new() diff --git a/tests/Lintellect.Api.FunctionalTests/Mocks/Git/MockGitHubClient.cs b/tests/Lintellect.Api.FunctionalTests/Mocks/Git/MockGitHubClient.cs index c73433f..740f6c8 100644 --- a/tests/Lintellect.Api.FunctionalTests/Mocks/Git/MockGitHubClient.cs +++ b/tests/Lintellect.Api.FunctionalTests/Mocks/Git/MockGitHubClient.cs @@ -7,6 +7,20 @@ namespace Lintellect.Api.FunctionalTests.Mocks.Git; /// public sealed class MockGitClient : IGitClient { + public List WorkItemsToReturn { get; set; } = + [ + new("100", Title: "Add foo support", Body: "Implement foo per spec.", Type: "User Story", State: "Active") + ]; + + public Task> GetLinkedWorkItemsAsync( + string projectName, + string repositoryName, + int pullRequestId, + IReadOnlyList? hints = null) + { + return Task.FromResult(WorkItemsToReturn); + } + public Task> HasSufficientPermissionsAsync(AnalysisRequest request) { return Task.FromResult(new List diff --git a/tests/Lintellect.Api.FunctionalTests/Setup/LintellectApiFixture.cs b/tests/Lintellect.Api.FunctionalTests/Setup/LintellectApiFixture.cs index b3213b6..d9f3fb4 100644 --- a/tests/Lintellect.Api.FunctionalTests/Setup/LintellectApiFixture.cs +++ b/tests/Lintellect.Api.FunctionalTests/Setup/LintellectApiFixture.cs @@ -66,6 +66,16 @@ protected override IHost CreateHost(IHostBuilder builder) // Replace external services with mocks services.AddScoped(); + // Replace analyzer with deterministic mock so prompt-injection assertions are stable. + // Singleton so tests can resolve the same instance via GetService() + // and observe state mutated by the orchestrator's scoped resolution. + var analyzerDescriptors = services.Where(s => s.ServiceType == typeof(IAnalyzerService)).ToList(); + foreach (var d in analyzerDescriptors) + { + services.Remove(d); + } + services.AddSingleton(); + // Remove background services for testing to avoid race conditions var analysisBackgroundService = services.FirstOrDefault(s => s.ImplementationType == typeof(AnalysisBackgroundService)); if (analysisBackgroundService != null) diff --git a/tests/Lintellect.Api.FunctionalTests/Testing.cs b/tests/Lintellect.Api.FunctionalTests/Testing.cs index 0a6d56d..785fd72 100644 --- a/tests/Lintellect.Api.FunctionalTests/Testing.cs +++ b/tests/Lintellect.Api.FunctionalTests/Testing.cs @@ -1,7 +1,6 @@ using Lintellect.Api.FunctionalTests.Setup; using Lintellect.Api.Infrastructure.Persistence; using Mediator; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace Lintellect.Api.FunctionalTests; diff --git a/tests/Lintellect.Api.IntegrationTests/AnalyzerServiceIntegrationTests.cs b/tests/Lintellect.Api.IntegrationTests/AnalyzerServiceIntegrationTests.cs new file mode 100644 index 0000000..8232993 --- /dev/null +++ b/tests/Lintellect.Api.IntegrationTests/AnalyzerServiceIntegrationTests.cs @@ -0,0 +1,151 @@ +namespace Lintellect.Api.IntegrationTests; + +/// +/// End-to-end tests against the real AI providers. Each test self-skips with +/// Assert.Inconclusive when the required credentials are not present in +/// environment variables, so a default dotnet test run on a machine with +/// no creds is a no-op rather than a failure. +/// +/// To run: +/// - Azure OpenAI: set AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, AZURE_OPENAI_DEPLOYMENT_NAME +/// - Anthropic: set CLAUDE_API_KEY +/// - then: dotnet test tests/Lintellect.Api.IntegrationTests/ +/// +/// These tests cost real money on every run; keep diffs and MaxTokens small. +/// +[TestFixture] +[Category("Integration")] +public sealed class AnalyzerServiceIntegrationTests +{ + private static readonly Dictionary SmallDiff = new() + { + ["TestFile.cs"] = """ + @@ -1,5 +1,6 @@ + public class Greeter + { + + public string Hello(string name) => "Hi " + name; + } + """ + }; + + private static AnalyzerServiceModel SmallAnalysisModel() + { + var request = new AnalysisRequest + { + GitProvider = EGitProvider.GitHub, + Language = EProgrammingLanguage.CSharp, + GitInfo = new GitInfo(1, "abc1234", "TestRepo", EGitInfoType.PullRequest, "TestProject"), + EnableSummaryComment = true, + EnableInlineSuggestions = true, + EnableDescriptionSummary = true, + Findings = + [ + new() + { + RuleId = "CS8602", + Message = "Dereference of a possibly null reference.", + FilePath = "TestFile.cs", + Line = 3, + Severity = "Warning" + } + ] + }; + return new AnalyzerServiceModel(request, CopilotInstructionsPrompt: string.Empty); + } + + private static AzureOpenAIAnalyzerOptions? TryReadAzureOpenAIOptions() + { + var apiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); + var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT"); + var deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME"); + + if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(deployment)) + { + return null; + } + + return new AzureOpenAIAnalyzerOptions + { + ApiKey = apiKey, + Endpoint = endpoint, + DeploymentName = deployment, + MaxTokens = 500, + Temperature = 0, + MaxInlineSuggestions = 3, + }; + } + + [Test] + public async Task AzureOpenAIAnalyzer_GenerateSummary_returns_non_empty_text() + { + var options = TryReadAzureOpenAIOptions(); + if (options is null) + { + Assert.Inconclusive("AZURE_OPENAI_API_KEY / AZURE_OPENAI_ENDPOINT / AZURE_OPENAI_DEPLOYMENT_NAME not set; skipping."); + return; + } + + var resolver = Substitute.For(); + var logger = Substitute.For>(); + var sut = new AzureOpenAIAnalyzerService(options, resolver, logger); + + var result = await sut.GenerateSummaryAsync(SmallAnalysisModel(), SmallDiff); + + result.ShouldNotBeNullOrWhiteSpace(); + } + + /// + /// Exercises the structured-output path (ChatResponseFormat.ForJsonSchema<InlineSuggestionsResponse>()), + /// which is the most fragile part of the SK → Agent Framework migration. A successful return + /// (regardless of suggestion count) confirms the schema round-trips through the model + JSON + /// deserialization without exceptions. + /// + [Test] + public async Task AzureOpenAIAnalyzer_GenerateInlineSuggestions_deserializes_structured_output() + { + var options = TryReadAzureOpenAIOptions(); + if (options is null) + { + Assert.Inconclusive("AZURE_OPENAI_API_KEY / AZURE_OPENAI_ENDPOINT / AZURE_OPENAI_DEPLOYMENT_NAME not set; skipping."); + return; + } + + var resolver = Substitute.For(); + var logger = Substitute.For>(); + var sut = new AzureOpenAIAnalyzerService(options, resolver, logger); + + var suggestions = await sut.GenerateInlineSuggestionsAsync(SmallAnalysisModel(), SmallDiff); + + suggestions.ShouldNotBeNull(); + } + + [Test] + public async Task ClaudeAnalyzer_GenerateSummary_returns_non_empty_text() + { + var apiKey = Environment.GetEnvironmentVariable("CLAUDE_API_KEY"); + if (string.IsNullOrWhiteSpace(apiKey)) + { + Assert.Inconclusive("CLAUDE_API_KEY not set; skipping."); + return; + } + + var options = new ClaudeAnalyzerOptions + { + ApiKey = apiKey, + MaxTokens = 500, + Temperature = 0, + MaxInlineSuggestions = 3, + }; + + var resolver = Substitute.For(); + var logger = Substitute.For>(); + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()).Returns(_ => new HttpClient()); + + var sut = new ClaudeAnalyzerService(options, resolver, httpClientFactory, logger); + + var result = await sut.GenerateSummaryAsync(SmallAnalysisModel(), SmallDiff); + + result.ShouldNotBeNullOrWhiteSpace(); + } +} diff --git a/tests/Lintellect.Api.IntegrationTests/GlobalUsings.cs b/tests/Lintellect.Api.IntegrationTests/GlobalUsings.cs new file mode 100644 index 0000000..950f2b2 --- /dev/null +++ b/tests/Lintellect.Api.IntegrationTests/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using Lintellect.Api.Application.Interfaces; +global using Lintellect.Api.Application.Models; +global using Lintellect.Api.Infrastructure.Services.AI; +global using Lintellect.Shared.Models; +global using Microsoft.Extensions.Logging; diff --git a/tests/Lintellect.Api.IntegrationTests/Lintellect.Api.IntegrationTests.csproj b/tests/Lintellect.Api.IntegrationTests/Lintellect.Api.IntegrationTests.csproj new file mode 100644 index 0000000..e722733 --- /dev/null +++ b/tests/Lintellect.Api.IntegrationTests/Lintellect.Api.IntegrationTests.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + Lintellect.Api.IntegrationTests + latest + enable + enable + false + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + diff --git a/tests/Lintellect.Api.UnitTests/Infrastructure/Services/WorkItems/WorkItemSummarizerTests.cs b/tests/Lintellect.Api.UnitTests/Infrastructure/Services/WorkItems/WorkItemSummarizerTests.cs new file mode 100644 index 0000000..9cfb486 --- /dev/null +++ b/tests/Lintellect.Api.UnitTests/Infrastructure/Services/WorkItems/WorkItemSummarizerTests.cs @@ -0,0 +1,90 @@ +using Lintellect.Api.Application.Interfaces; +using Lintellect.Api.Infrastructure.Services.WorkItems; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Lintellect.Api.UnitTests.Infrastructure.Services.WorkItems; + +[TestFixture] +public class WorkItemSummarizerTests +{ + [Test] + public void SplitGoalAndContext_ExtractsBothSections() + { + var response = """ + GOAL: Migrate auth middleware to comply with the new session token policy. + + CONTEXT: + The legacy middleware stores raw session tokens in the response cache. + Compliance requires hashed tokens with a 30-minute TTL. + """; + + var (goal, context) = WorkItemSummarizer.SplitGoalAndContext(response); + + goal.ShouldBe("Migrate auth middleware to comply with the new session token policy."); + context.ShouldContain("Compliance requires hashed tokens"); + } + + [Test] + public void SplitGoalAndContext_WithoutContextSection_ReturnsEmptyContext() + { + var response = "GOAL: Tighten the rate limiter."; + + var (goal, context) = WorkItemSummarizer.SplitGoalAndContext(response); + + goal.ShouldBe("Tighten the rate limiter."); + context.ShouldBeEmpty(); + } + + [Test] + public async Task SummarizeAsync_WithEmptyList_ReturnsEmpty() + { + var analyzer = Substitute.For(); + var summarizer = new WorkItemSummarizer(analyzer, NullLogger.Instance); + + var result = await summarizer.SummarizeAsync([]); + + result.ShouldBe(WorkItemSummary.Empty); + await analyzer.DidNotReceive().SummarizeContextAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Test] + public async Task SummarizeAsync_PassesItemDelimitersInPrompt() + { + var analyzer = Substitute.For(); + analyzer.SummarizeContextAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("GOAL: Do X.\n\nCONTEXT:\nBecause Y."); + + var summarizer = new WorkItemSummarizer(analyzer, NullLogger.Instance); + var items = new List + { + new("11", Title: "First", Body: "First body"), + new("22", Title: "Second", Body: "Second body") + }; + + var result = await summarizer.SummarizeAsync(items); + + result.Goal.ShouldBe("Do X."); + result.FullContext.ShouldContain("GOAL:"); + + await analyzer.Received(1).SummarizeContextAsync( + Arg.Any(), + Arg.Is(p => p.Contains("Work Item 1 of 2") && p.Contains("Work Item 2 of 2")), + Arg.Any(), + Arg.Any()); + } + + [Test] + public async Task SummarizeAsync_WithEmptyResponse_ReturnsEmpty() + { + var analyzer = Substitute.For(); + analyzer.SummarizeContextAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(string.Empty); + + var summarizer = new WorkItemSummarizer(analyzer, NullLogger.Instance); + + var result = await summarizer.SummarizeAsync([new WorkItemReference("1", Title: "T")]); + + result.ShouldBe(WorkItemSummary.Empty); + } +} diff --git a/tests/Lintellect.Cli.UnitTests/Tests/WorkItemReferenceExtractorTests.cs b/tests/Lintellect.Cli.UnitTests/Tests/WorkItemReferenceExtractorTests.cs new file mode 100644 index 0000000..d97d59c --- /dev/null +++ b/tests/Lintellect.Cli.UnitTests/Tests/WorkItemReferenceExtractorTests.cs @@ -0,0 +1,90 @@ +using Lintellect.Cli.Services.Git; +using Shouldly; + +namespace Lintellect.Cli.UnitTests.Tests; + +[TestFixture] +public class WorkItemReferenceExtractorTests +{ + [TestCase("Closes #42", new[] { 42 })] + [TestCase("closes #42", new[] { 42 })] + [TestCase("Fixes #1 and resolves #2", new[] { 1, 2 })] + [TestCase("This PR fixed #99.", new[] { 99 })] + [TestCase("resolved #7", new[] { 7 })] + [TestCase("Closes #1\nFixes #1", new[] { 1 })] // dedup + public void ParseLinkedIssueIds_ReturnsExpected(string body, int[] expected) + { + var result = WorkItemReferenceExtractor.ParseLinkedIssueIds(body).ToArray(); + result.ShouldBe(expected); + } + + [TestCase("")] + [TestCase(null)] + [TestCase("Just talking about #1234 without a keyword")] + [TestCase("see issue 42")] + public void ParseLinkedIssueIds_WithNoMatch_ReturnsEmpty(string? body) + { + WorkItemReferenceExtractor.ParseLinkedIssueIds(body).ShouldBeEmpty(); + } + + [Test] + public void ExtractFromEnvironment_WithoutEventPath_ReturnsEmpty() + { + using var env = TestHelpers.SetEnvironmentVariables(new Dictionary + { + ["GITHUB_EVENT_PATH"] = null + }); + + WorkItemReferenceExtractor.ExtractFromEnvironment().ShouldBeEmpty(); + } + + [Test] + public void ExtractFromEnvironment_WithGitHubEventPayload_YieldsParsedIds() + { + var payload = """ + { + "pull_request": { + "body": "This PR closes #11 and fixes #22." + } + } + """; + var path = Path.Combine(Path.GetTempPath(), $"lintellect-event-{Guid.NewGuid():N}.json"); + File.WriteAllText(path, payload); + + try + { + using var env = TestHelpers.SetEnvironmentVariables(new Dictionary + { + ["GITHUB_EVENT_PATH"] = path + }); + + var result = WorkItemReferenceExtractor.ExtractFromEnvironment().Select(r => r.Id).ToArray(); + result.ShouldBe(new[] { "11", "22" }); + } + finally + { + File.Delete(path); + } + } + + [Test] + public void ExtractFromEnvironment_WithMissingPullRequest_ReturnsEmpty() + { + var path = Path.Combine(Path.GetTempPath(), $"lintellect-event-{Guid.NewGuid():N}.json"); + File.WriteAllText(path, "{}"); + + try + { + using var env = TestHelpers.SetEnvironmentVariables(new Dictionary + { + ["GITHUB_EVENT_PATH"] = path + }); + + WorkItemReferenceExtractor.ExtractFromEnvironment().ShouldBeEmpty(); + } + finally + { + File.Delete(path); + } + } +}