Forge
csharp3407750f
1using System.Text;
2using AgentForge.Contracts;
3using AgentForge.Data;
4using AgentForge.Data.Entities;
5using AgentForge.Models;
6using AgentForge.Plugin.MergeRequest.Pages;
7using AgentForge.Plugin.Sdk;
8using AgentForge.Abstractions;
9using AgentForge.Plugin.View;
10using AgentForge.Plugin.View.Pages;
11using AgentForge.Services;
12using Microsoft.AspNetCore.Builder;
13using Microsoft.Extensions.DependencyInjection;
14
15namespace AgentForge.Plugin.MergeRequest;
16
17public 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
View only · write via MCP/CIDE