using ControlPlane.Api.Services; using ControlPlane.Core.Models; using LibGit2Sharp; using System.Net.Http.Headers; using System.Text; using System.Text.Json; namespace ControlPlane.Api.Endpoints; public static class OpcEndpoints { private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web) { WriteIndented = false }; public static IEndpointRouteBuilder MapOpcEndpoints(this IEndpointRouteBuilder app) { var g = app.MapGroup("/api/opc").WithTags("OPC"); // ── OPC CRUD ────────────────────────────────────────────────────────── g.MapGet ("", ListOpcs); g.MapGet ("/next-number", GetNextNumber); g.MapPost ("", CreateOpc); g.MapGet ("/{id:guid}", GetOpc); g.MapPatch ("/{id:guid}", UpdateOpc); g.MapDelete("/{id:guid}", DeleteOpc); // ── Notes ───────────────────────────────────────────────────────────── g.MapGet ("/{id:guid}/notes", ListNotes); g.MapPost ("/{id:guid}/notes", AddNote); // ── Artifacts ───────────────────────────────────────────────────────── g.MapGet ("/{id:guid}/artifacts", ListArtifacts); g.MapPost ("/{id:guid}/artifacts", CreateArtifact); g.MapPatch ("/artifacts/{artifactId:guid}", UpdateArtifact); g.MapDelete("/artifacts/{artifactId:guid}", DeleteArtifact); // ── Pinned commits ──────────────────────────────────────────────────── g.MapGet ("/{id:guid}/pinned-commits", ListPinnedCommits); g.MapPost ("/{id:guid}/pinned-commits", PinCommit); g.MapDelete("/{id:guid}/pinned-commits/{hash}", UnpinCommit); // ── AI assist (proxies to OpenRouter, key stays on server) ──────────── g.MapPost ("/ai-assist", AiAssist); return app; } // ── OPC handlers ────────────────────────────────────────────────────────── private static async Task ListOpcs( OpcService svc, string? type = null, string? status = null, CancellationToken ct = default) { var list = await svc.ListAsync(type, status, ct); return Results.Ok(list); } private static async Task GetNextNumber( OpcService svc, CancellationToken ct) { var number = await svc.NextNumberAsync(ct); return Results.Ok(new { number }); } private static async Task CreateOpc( CreateOpcRequest req, OpcService svc, CancellationToken ct) { var opc = await svc.CreateAsync(req, ct); return Results.Created($"/api/opc/{opc.Id}", opc); } private static async Task GetOpc( Guid id, OpcService svc, CancellationToken ct) { var opc = await svc.GetAsync(id, ct); return opc is null ? Results.NotFound() : Results.Ok(opc); } private static async Task UpdateOpc( Guid id, UpdateOpcRequest req, OpcService svc, CancellationToken ct) { var opc = await svc.UpdateAsync(id, req, ct); return opc is null ? Results.NotFound() : Results.Ok(opc); } private static async Task DeleteOpc( Guid id, OpcService svc, CancellationToken ct) { return await svc.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound(); } // ── Note handlers ───────────────────────────────────────────────────────── private static async Task ListNotes( Guid id, OpcService svc, CancellationToken ct) { var notes = await svc.ListNotesAsync(id, ct); return Results.Ok(notes); } private static async Task AddNote( Guid id, AddNoteRequest req, OpcService svc, CancellationToken ct) { var note = await svc.AddNoteAsync(id, req, ct); return Results.Created($"/api/opc/{id}/notes/{note.Id}", note); } // ── Artifact handlers ───────────────────────────────────────────────────── private static async Task ListArtifacts( Guid id, OpcService svc, string? type = null, CancellationToken ct = default) { var artifacts = await svc.ListArtifactsAsync(id, type, ct); return Results.Ok(artifacts); } private static async Task CreateArtifact( Guid id, UpsertArtifactRequest req, OpcService svc, CancellationToken ct) { var artifact = await svc.UpsertArtifactAsync(id, req, ct); return Results.Created($"/api/opc/{id}/artifacts/{artifact.Id}", artifact); } private static async Task UpdateArtifact( Guid artifactId, UpsertArtifactRequest req, OpcService svc, CancellationToken ct) { var artifact = await svc.UpdateArtifactAsync(artifactId, req, ct); return artifact is null ? Results.NotFound() : Results.Ok(artifact); } private static async Task DeleteArtifact( Guid artifactId, OpcService svc, CancellationToken ct) { return await svc.DeleteArtifactAsync(artifactId, ct) ? Results.NoContent() : Results.NotFound(); } // ── Pinned commit handlers ──────────────────────────────────────────────── private static async Task ListPinnedCommits( Guid id, OpcService svc, CancellationToken ct) { var commits = await svc.ListPinnedCommitsAsync(id, ct); return Results.Ok(commits); } private static async Task PinCommit( Guid id, PinCommitRequest req, OpcService svc, IConfiguration config, CancellationToken ct) { var repoPath = config["Docker:RepoRoot"]; string fullHash = req.Hash; string shortHash = req.Hash.Length >= 7 ? req.Hash[..7] : req.Hash; string subject = string.Empty; string author = string.Empty; if (!string.IsNullOrWhiteSpace(repoPath) && Directory.Exists(repoPath)) { using var repo = new Repository(repoPath); var commit = repo.Lookup(req.Hash); if (commit is null) return Results.NotFound("Commit not found in repository."); fullHash = commit.Sha; shortHash = commit.Sha[..7]; subject = commit.MessageShort; author = commit.Author.Name; } var pinned = await svc.PinCommitAsync(id, fullHash, shortHash, subject, author, req.PinnedBy, ct); return pinned is null ? Results.NotFound() : Results.Created($"/api/opc/{id}/pinned-commits/{fullHash}", pinned); } private static async Task UnpinCommit( Guid id, string hash, OpcService svc, CancellationToken ct) { return await svc.UnpinCommitAsync(id, hash, ct) ? Results.NoContent() : Results.NotFound(); } // ── AI assist ───────────────────────────────────────────────────────────── private static async Task AiAssist( AiAssistRequest req, IConfiguration config, IHttpClientFactory http, CancellationToken ct) { var apiKey = config["OpenRouter:ApiKey"]; if (string.IsNullOrWhiteSpace(apiKey)) return Results.Problem("OpenRouter API key not configured. Add OpenRouter:ApiKey to appsettings."); var systemPrompt = "You are an assistant helping a software engineering team write clear, concise " + "OPC (Online Project Communication) content — requirements, change descriptions, " + "QA test paths, and specifications. Be direct, structured, and professional. " + "Respond with plain text only (no markdown wrapping)."; var messages = new List { new { role = "system", content = systemPrompt }, }; if (!string.IsNullOrWhiteSpace(req.Context)) messages.Add(new { role = "user", content = $"Context:\n{req.Context}" }); messages.Add(new { role = "user", content = req.Prompt }); var body = new { model = "anthropic/claude-3.5-haiku", messages, max_tokens = 1024, }; var client = http.CreateClient("openrouter"); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); client.DefaultRequestHeaders.Add("HTTP-Referer", "https://controlplane.clarity.internal"); client.DefaultRequestHeaders.Add("X-Title", "Clarity ControlPlane OPC"); var response = await client.PostAsync( "https://openrouter.ai/api/v1/chat/completions", new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"), ct); if (!response.IsSuccessStatusCode) { var err = await response.Content.ReadAsStringAsync(ct); return Results.Problem($"OpenRouter error {response.StatusCode}: {err}"); } var json = await response.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(json); var text = doc.RootElement .GetProperty("choices")[0] .GetProperty("message") .GetProperty("content") .GetString() ?? string.Empty; return Results.Ok(new { text }); } }