| 1 | using System.Text; |
| 2 | using AgentForge.Contracts; |
| 3 | using AgentForge.Data; |
| 4 | using AgentForge.Data.Entities; |
| 5 | using AgentForge.Models; |
| 6 | using AgentForge.Plugin.MergeRequest.Pages; |
| 7 | using AgentForge.Plugin.Sdk; |
| 8 | using AgentForge.Abstractions; |
| 9 | using AgentForge.Plugin.View; |
| 10 | using AgentForge.Plugin.View.Pages; |
| 11 | using AgentForge.Services; |
| 12 | using Microsoft.AspNetCore.Builder; |
| 13 | using Microsoft.Extensions.DependencyInjection; |
| 14 | |
| 15 | namespace AgentForge.Plugin.MergeRequest; |
| 16 | |
| 17 | public static class ForgeMergeRequestViewEndpoints |
| 18 | { |
| 19 | public static void Map(WebApplication app) |
| 20 | { |
| 21 | app.MapGet($"/view/repos/{ForgeRepoRouting.Pattern}/merge-requests/{{number:int}}/head.json", RenderMergeRequestHeadJson); |
| 22 | app.MapGet($"/view/repos/{ForgeRepoRouting.Pattern}/merge-requests/{{number:int}}/events", StreamMergeRequestEvents); |
| 23 | app.MapGet($"/view/repos/{ForgeRepoRouting.Pattern}/merge-requests/{{number:int}}", RenderMergeRequestView); |
| 24 | app.MapPost($"/view/repos/{ForgeRepoRouting.Pattern}/merge-requests/{{number:int}}/accept", AcceptFromView); |
| 25 | app.MapPost($"/view/repos/{ForgeRepoRouting.Pattern}/merge-requests/{{number:int}}/reject", RejectFromView); |
| 26 | app.MapPost($"/view/repos/{ForgeRepoRouting.Pattern}/merge-requests/{{number:int}}/handoff", HandoffFromView); |
| 27 | app.MapPost($"/view/repos/{ForgeRepoRouting.Pattern}/merge-requests/{{number:int}}/comment", CommentOnMergeRequestFromView); |
| 28 | } |
| 29 | |
| 30 | private static IResult RenderMergeRequestHeadJson( |
| 31 | string name, |
| 32 | int number, |
| 33 | HttpContext http, |
| 34 | ForgeRepository repository, |
| 35 | GitRepoService git) |
| 36 | { |
| 37 | var repo = ForgePluginEndpoints.RequireRepo(repository, name, http); |
| 38 | var mergeRequest = repository.GetMergeRequestByNumber(repo.Id, number) |
| 39 | ?? throw new ForgeApiException(404, $"Merge request #{number} not found."); |
| 40 | var sourceTip = git.BareRepoExists(repo.Name) |
| 41 | ? git.GetBranchTipCommit(repo.Name, mergeRequest.SourceBranch) |
| 42 | : null; |
| 43 | return Results.Json(new |
| 44 | { |
| 45 | sourceTip, |
| 46 | status = mergeRequest.Status.ToSlug(), |
| 47 | number = mergeRequest.Number, |
| 48 | }); |
| 49 | } |
| 50 | |
| 51 | private static async Task StreamMergeRequestEvents( |
| 52 | string name, |
| 53 | int number, |
| 54 | ForgeRepository repository, |
| 55 | GitRepoService git, |
| 56 | HttpContext http, |
| 57 | CancellationToken cancellationToken) |
| 58 | { |
| 59 | http.Response.Headers.CacheControl = "no-cache"; |
| 60 | http.Response.Headers.Connection = "keep-alive"; |
| 61 | http.Response.ContentType = "text/event-stream"; |
| 62 | |
| 63 | var repo = ForgePluginEndpoints.RequireRepo(repository, name, http); |
| 64 | var mergeRequest = repository.GetMergeRequestByNumber(repo.Id, number) |
| 65 | ?? throw new ForgeApiException(404, $"Merge request #{number} not found."); |
| 66 | |
| 67 | string? lastTip = git.BareRepoExists(repo.Name) |
| 68 | ? git.GetBranchTipCommit(repo.Name, mergeRequest.SourceBranch) |
| 69 | : null; |
| 70 | |
| 71 | await http.Response.WriteAsync($"event: head\ndata: {{\"sourceTip\":{JsonEncode(lastTip)}}}\n\n", cancellationToken) |
| 72 | .ConfigureAwait(false); |
| 73 | await http.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false); |
| 74 | |
| 75 | while (!cancellationToken.IsCancellationRequested) |
| 76 | { |
| 77 | await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false); |
| 78 | |
| 79 | mergeRequest = repository.GetMergeRequestByNumber(repo.Id, number); |
| 80 | if (mergeRequest is null) |
| 81 | break; |
| 82 | |
| 83 | var tip = git.BareRepoExists(repo.Name) |
| 84 | ? git.GetBranchTipCommit(repo.Name, mergeRequest.SourceBranch) |
| 85 | : null; |
| 86 | |
| 87 | if (!string.Equals(tip, lastTip, StringComparison.Ordinal)) |
| 88 | { |
| 89 | lastTip = tip; |
| 90 | await http.Response.WriteAsync( |
| 91 | $"event: head\ndata: {{\"sourceTip\":{JsonEncode(tip)},\"status\":\"{mergeRequest.Status.ToSlug()}\"}}\n\n", |
| 92 | cancellationToken).ConfigureAwait(false); |
| 93 | await http.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false); |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | private static string JsonEncode(string? value) => |
| 99 | value is null ? "null" : $"\"{value.Replace("\\", "\\\\").Replace("\"", "\\\"")}\""; |
| 100 | |
| 101 | private static IResult RenderMergeRequestView( |
| 102 | string name, |
| 103 | int number, |
| 104 | string? iop, |
| 105 | ForgeRepository repository, |
| 106 | ForgeUrls urls, |
| 107 | GitRepoService git, |
| 108 | MergeRequestService mergeRequests, |
| 109 | HttpContext http) |
| 110 | { |
| 111 | var repo = ForgePluginEndpoints.RequireRepo(repository, name, http); |
| 112 | var mergeRequest = repository.GetMergeRequestByNumber(repo.Id, number) |
| 113 | ?? throw new ForgeApiException(404, $"Merge request #{number} not found."); |
| 114 | |
| 115 | var iopTab = ForgeIopViewRenderer.NormalizeIopTab(iop); |
| 116 | var tabLinks = ForgeIopViewRenderer.MrPageTabLinks(name, number); |
| 117 | var defaultBranch = git.BareRepoExists(repo.Name) ? git.GetDefaultBranch(repo.Name) : null; |
| 118 | var diff = mergeRequests.GetDiff(repo, number); |
| 119 | var isOpen = mergeRequest.Status == MergeRequestStatus.Open; |
| 120 | var comments = repository.ListMergeRequestComments(mergeRequest.Id) |
| 121 | .Select(c => (c.Author, c.Body, c.CreatedAt)) |
| 122 | .ToList(); |
| 123 | var ciRun = repository.GetLatestCiRun(repo.Id, number); |
| 124 | var sourceTip = git.BareRepoExists(repo.Name) |
| 125 | ? git.GetBranchTipCommit(repo.Name, mergeRequest.SourceBranch) |
| 126 | : null; |
| 127 | var handoffTargets = isOpen && repo.DriveMode == DriveMode.AgentDriven |
| 128 | ? ForgeGitViewHelpers.ListHandoffTargetRepoNames(repository, repo.Name) |
| 129 | : []; |
| 130 | |
| 131 | var iopPanel = new StringBuilder(); |
| 132 | ForgeIopViewRenderer.AppendMrPanel( |
| 133 | iopPanel, |
| 134 | name, |
| 135 | mergeRequest, |
| 136 | repo, |
| 137 | comments, |
| 138 | ciRun, |
| 139 | isOpen, |
| 140 | iopTab, |
| 141 | tabLinks, |
| 142 | diff, |
| 143 | sourceBranchForFiles: mergeRequest.SourceBranch, |
| 144 | handoffTargets: handoffTargets); |
| 145 | |
| 146 | var body = ForgeMergeRequestPage.Render( |
| 147 | name, |
| 148 | number, |
| 149 | mergeRequest, |
| 150 | repo, |
| 151 | diff, |
| 152 | new ForgeDiffRenderContext(name, mergeRequest.SourceBranch), |
| 153 | defaultBranch, |
| 154 | urls, |
| 155 | iopPanel.ToString(), |
| 156 | mergeRequest.Anchors, |
| 157 | sourceTip); |
| 158 | |
| 159 | return ForgeViewPresenter.RepoPage($"MR !{number}", repo, "merge-requests", repository, body, http); |
| 160 | } |
| 161 | |
| 162 | private static async Task<IResult> AcceptFromView( |
| 163 | string name, |
| 164 | int number, |
| 165 | HttpContext http, |
| 166 | ForgeRepository repository, |
| 167 | MergeRequestService mergeRequests, |
| 168 | CancellationToken cancellationToken) |
| 169 | { |
| 170 | var repo = ForgePluginEndpoints.RequireRepo(repository, name, http); |
| 171 | await mergeRequests.AcceptAsync(repo, number, token: null, comment: null, cancellationToken).ConfigureAwait(false); |
| 172 | return Results.Redirect($"/view/repos/{name}/merge-requests/{number}"); |
| 173 | } |
| 174 | |
| 175 | private static async Task<IResult> RejectFromView( |
| 176 | string name, |
| 177 | int number, |
| 178 | HttpContext http, |
| 179 | ForgeRepository repository, |
| 180 | MergeRequestService mergeRequests, |
| 181 | CancellationToken cancellationToken) |
| 182 | { |
| 183 | var repo = ForgePluginEndpoints.RequireRepo(repository, name, http); |
| 184 | await mergeRequests.RejectAsync(repo, number, token: null, comment: null, cancellationToken).ConfigureAwait(false); |
| 185 | return Results.Redirect($"/view/repos/{name}/merge-requests/{number}"); |
| 186 | } |
| 187 | |
| 188 | private static IResult HandoffFromView( |
| 189 | string name, |
| 190 | int number, |
| 191 | HttpContext http, |
| 192 | ForgeRepository repository, |
| 193 | MergeRequestService mergeRequests, |
| 194 | ForgeUrls urls) |
| 195 | { |
| 196 | var sourceRepo = ForgePluginEndpoints.RequireRepo(repository, name, http); |
| 197 | var targetName = http.Request.Form["targetRepo"].ToString().Trim(); |
| 198 | if (targetName.Length == 0) |
| 199 | throw new ForgeApiException(400, "targetRepo is required."); |
| 200 | |
| 201 | var targetRepo = ForgePluginEndpoints.RequireRepo(repository, targetName, http); |
| 202 | |
| 203 | var token = ForgeViewSession.TryGetToken(http, http.RequestServices.GetRequiredService<ForgeAuthService>()); |
| 204 | var createdBy = token is not null ? ForgeActorResolver.NormalizeAgentIdentity(token.Name) : null; |
| 205 | var response = mergeRequests.HandoffToRepo(sourceRepo, number, targetRepo, createdBy, urls); |
| 206 | return Results.Redirect($"/view/repos/{targetRepo.Name}/merge-requests/{response.Number}"); |
| 207 | } |
| 208 | |
| 209 | private static async Task<IResult> CommentOnMergeRequestFromView( |
| 210 | string name, |
| 211 | int number, |
| 212 | HttpContext http, |
| 213 | ForgeRepository repository, |
| 214 | CiWebhookService ci, |
| 215 | CancellationToken cancellationToken) |
| 216 | { |
| 217 | var repo = ForgePluginEndpoints.RequireRepo(repository, name, http); |
| 218 | var body = http.Request.Form["body"].ToString().Trim(); |
| 219 | if (body.Length == 0) |
| 220 | return Results.Redirect($"/view/repos/{Uri.EscapeDataString(name)}/merge-requests/{number}"); |
| 221 | |
| 222 | var token = ForgeViewSession.TryGetToken(http, http.RequestServices.GetRequiredService<ForgeAuthService>()); |
| 223 | var author = token is not null ? ForgeActorResolver.NormalizeAgentIdentity(token.Name) : "human"; |
| 224 | var comment = repository.InsertMergeRequestComment(repo.Id, number, body, author); |
| 225 | await ci.EmitIopAsync( |
| 226 | ForgeIopEvent.ForMr("mr.comment", repo.Name, number, ForgePluginResponses.ToCommentResponse(comment), author), |
| 227 | cancellationToken).ConfigureAwait(false); |
| 228 | return Results.Redirect($"/view/repos/{Uri.EscapeDataString(name)}/merge-requests/{number}"); |
| 229 | } |
| 230 | } |
| 231 | |