Forge
csharp3407750f
1using System.Net.Http.Json;
2using System.Text.Json;
3using System.Text.Json.Serialization;
4using AgentForge.Abstractions;
5
6namespace AgentForge.Mcp.Core;
7
8public sealed class ForgeApiClient(HttpClient http)
9{
10 private static readonly JsonSerializerOptions JsonOptions = new()
11 {
12 PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
13 DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
14 };
15
16 internal async Task<ApiResult> GetCapabilitiesAsync(CancellationToken cancellationToken)
17 {
18 using var response = await http.GetAsync("/api/v1/capabilities", cancellationToken).ConfigureAwait(false);
19 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
20 }
21
22 internal async Task<ApiResult> ListReposAsync(string? group, string? org, CancellationToken cancellationToken)
23 {
24 var query = new List<string>();
25 if (!string.IsNullOrWhiteSpace(group))
26 query.Add($"group={Uri.EscapeDataString(group.Trim())}");
27 if (!string.IsNullOrWhiteSpace(org))
28 query.Add($"org={Uri.EscapeDataString(org.Trim())}");
29 var path = query.Count == 0 ? "/api/v1/repos" : $"/api/v1/repos?{string.Join('&', query)}";
30 using var response = await http.GetAsync(path, cancellationToken).ConfigureAwait(false);
31 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
32 }
33
34 internal async Task<ApiResult> ListOrgsAsync(CancellationToken cancellationToken)
35 {
36 using var response = await http.GetAsync("/api/v1/orgs", cancellationToken).ConfigureAwait(false);
37 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
38 }
39
40 internal async Task<ApiResult> CreateOrgAsync(CreateOrgPayload payload, CancellationToken cancellationToken)
41 {
42 using var response = await http.PostAsJsonAsync("/api/v1/orgs", payload, JsonOptions, cancellationToken)
43 .ConfigureAwait(false);
44 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
45 }
46
47 internal async Task<ApiResult> GetOrgAsync(string slug, CancellationToken cancellationToken)
48 {
49 var encoded = Uri.EscapeDataString(slug.Trim());
50 using var response = await http.GetAsync($"/api/v1/orgs/{encoded}", cancellationToken).ConfigureAwait(false);
51 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
52 }
53
54 internal async Task<ApiResult> AddOrgMemberAsync(string orgSlug, AddOrgMemberPayload payload, CancellationToken cancellationToken)
55 {
56 var encoded = Uri.EscapeDataString(orgSlug.Trim());
57 using var response = await http.PostAsJsonAsync($"/api/v1/orgs/{encoded}/members", payload, JsonOptions, cancellationToken)
58 .ConfigureAwait(false);
59 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
60 }
61
62 internal async Task<ApiResult> ListOrgMembersAsync(string orgSlug, CancellationToken cancellationToken)
63 {
64 var encoded = Uri.EscapeDataString(orgSlug.Trim());
65 using var response = await http.GetAsync($"/api/v1/orgs/{encoded}/members", cancellationToken).ConfigureAwait(false);
66 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
67 }
68
69 internal async Task<ApiResult> AddInstanceAdminAsync(AddInstanceAdminPayload payload, CancellationToken cancellationToken)
70 {
71 using var response = await http.PostAsJsonAsync("/api/v1/instance/admins", payload, JsonOptions, cancellationToken)
72 .ConfigureAwait(false);
73 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
74 }
75
76 internal async Task<ApiResult> ListInstanceAdminsAsync(CancellationToken cancellationToken)
77 {
78 using var response = await http.GetAsync("/api/v1/instance/admins", cancellationToken).ConfigureAwait(false);
79 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
80 }
81
82 internal async Task<ApiResult> ListRepoGroupsAsync(string orgSlug, CancellationToken cancellationToken)
83 {
84 var encoded = Uri.EscapeDataString(orgSlug.Trim());
85 using var response = await http.GetAsync($"/api/v1/orgs/{encoded}/repo-groups", cancellationToken).ConfigureAwait(false);
86 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
87 }
88
89 internal async Task<ApiResult> CreateRepoGroupAsync(string orgSlug, CreateRepoGroupPayload payload, CancellationToken cancellationToken)
90 {
91 var encoded = Uri.EscapeDataString(orgSlug.Trim());
92 using var response = await http.PostAsJsonAsync($"/api/v1/orgs/{encoded}/repo-groups", payload, JsonOptions, cancellationToken)
93 .ConfigureAwait(false);
94 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
95 }
96
97 internal async Task<ApiResult> GetRepoGroupAsync(string orgSlug, string groupPath, CancellationToken cancellationToken)
98 {
99 var org = Uri.EscapeDataString(orgSlug.Trim());
100 var group = Uri.EscapeDataString(ForgeRepoGroupPath.ToUrlPath(groupPath));
101 using var response = await http.GetAsync($"/api/v1/orgs/{org}/repo-groups/{group}", cancellationToken).ConfigureAwait(false);
102 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
103 }
104
105 internal async Task<ApiResult> CreateRepoAsync(CreateRepoPayload payload, CancellationToken cancellationToken)
106 {
107 using var response = await http.PostAsJsonAsync("/api/v1/repos", payload, JsonOptions, cancellationToken)
108 .ConfigureAwait(false);
109 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
110 }
111
112 internal async Task<ApiResult> GetRepoAsync(string name, CancellationToken cancellationToken)
113 {
114 var encoded = Uri.EscapeDataString(name.Trim());
115 using var response = await http.GetAsync($"/api/v1/repos/{encoded}", cancellationToken).ConfigureAwait(false);
116 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
117 }
118
119 internal async Task<ApiResult> ListIssuesAsync(string repo, string? query, CancellationToken cancellationToken)
120 {
121 var encoded = ForgeRepoNames.ToUrlPath(repo);
122 var path = string.IsNullOrWhiteSpace(query)
123 ? $"/api/v1/repos/{encoded}/issues"
124 : $"/api/v1/repos/{encoded}/issues?q={Uri.EscapeDataString(query.Trim())}";
125 using var response = await http.GetAsync(path, cancellationToken).ConfigureAwait(false);
126 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
127 }
128
129 internal async Task<ApiResult> GetIssueAsync(string repo, int number, CancellationToken cancellationToken)
130 {
131 var encoded = ForgeRepoNames.ToUrlPath(repo);
132 using var response = await http.GetAsync($"/api/v1/repos/{encoded}/issues/{number}", cancellationToken)
133 .ConfigureAwait(false);
134 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
135 }
136
137 internal async Task<ApiResult> CreateIssueAsync(string repo, CreateIssuePayload payload, CancellationToken cancellationToken)
138 {
139 var encoded = ForgeRepoNames.ToUrlPath(repo);
140 using var response = await http.PostAsJsonAsync($"/api/v1/repos/{encoded}/issues", payload, JsonOptions, cancellationToken)
141 .ConfigureAwait(false);
142 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
143 }
144
145 internal async Task<ApiResult> UpdateIssueAsync(
146 string repo,
147 int number,
148 UpdateIssuePayload payload,
149 CancellationToken cancellationToken)
150 {
151 var encoded = ForgeRepoNames.ToUrlPath(repo);
152 using var response = await http.PatchAsJsonAsync(
153 $"/api/v1/repos/{encoded}/issues/{number}",
154 payload,
155 JsonOptions,
156 cancellationToken)
157 .ConfigureAwait(false);
158 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
159 }
160
161 internal async Task<ApiResult> AddIssueCommentAsync(
162 string repo,
163 int number,
164 AddIssueCommentPayload payload,
165 CancellationToken cancellationToken)
166 {
167 var encoded = ForgeRepoNames.ToUrlPath(repo);
168 using var response = await http.PostAsJsonAsync(
169 $"/api/v1/repos/{encoded}/issues/{number}/comments",
170 payload,
171 JsonOptions,
172 cancellationToken)
173 .ConfigureAwait(false);
174 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
175 }
176
177 internal async Task<ApiResult> LinkIssueAnchorsAsync(
178 string repo,
179 int number,
180 LinkIssueAnchorsPayload payload,
181 CancellationToken cancellationToken)
182 {
183 var encoded = ForgeRepoNames.ToUrlPath(repo);
184 using var response = await http.PostAsJsonAsync(
185 $"/api/v1/repos/{encoded}/issues/{number}/anchors",
186 payload,
187 JsonOptions,
188 cancellationToken)
189 .ConfigureAwait(false);
190 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
191 }
192
193 internal async Task<ApiResult> GetForgeLensAsync(string repo, string? file, CancellationToken cancellationToken)
194 {
195 var encoded = ForgeRepoNames.ToUrlPath(repo);
196 var path = string.IsNullOrWhiteSpace(file)
197 ? $"/api/v1/repos/{encoded}/lens"
198 : $"/api/v1/repos/{encoded}/lens?file={Uri.EscapeDataString(file.Trim())}";
199 using var response = await http.GetAsync(path, cancellationToken).ConfigureAwait(false);
200 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
201 }
202
203 internal async Task<ApiResult> ListMergeRequestsAsync(string repo, CancellationToken cancellationToken)
204 {
205 var encoded = ForgeRepoNames.ToUrlPath(repo);
206 using var response = await http.GetAsync($"/api/v1/repos/{encoded}/merge-requests", cancellationToken)
207 .ConfigureAwait(false);
208 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
209 }
210
211 internal async Task<ApiResult> GetMergeRequestAsync(string repo, int number, CancellationToken cancellationToken)
212 {
213 var encoded = ForgeRepoNames.ToUrlPath(repo);
214 using var response = await http.GetAsync($"/api/v1/repos/{encoded}/merge-requests/{number}", cancellationToken)
215 .ConfigureAwait(false);
216 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
217 }
218
219 internal async Task<ApiResult> CreateMergeRequestAsync(
220 string repo,
221 CreateMergeRequestPayload payload,
222 CancellationToken cancellationToken)
223 {
224 var encoded = ForgeRepoNames.ToUrlPath(repo);
225 using var response = await http.PostAsJsonAsync(
226 $"/api/v1/repos/{encoded}/merge-requests",
227 payload,
228 JsonOptions,
229 cancellationToken)
230 .ConfigureAwait(false);
231 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
232 }
233
234 internal async Task<ApiResult> GetCiStatusAsync(string repo, int number, CancellationToken cancellationToken)
235 {
236 var encoded = ForgeRepoNames.ToUrlPath(repo);
237 using var response = await http.GetAsync($"/api/v1/repos/{encoded}/merge-requests/{number}/ci", cancellationToken)
238 .ConfigureAwait(false);
239 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
240 }
241
242 internal async Task<ApiResult> GetMergeRequestDiffAsync(string repo, int number, CancellationToken cancellationToken)
243 {
244 var encoded = ForgeRepoNames.ToUrlPath(repo);
245 using var response = await http.GetAsync($"/api/v1/repos/{encoded}/merge-requests/{number}/diff", cancellationToken)
246 .ConfigureAwait(false);
247 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
248 }
249
250 internal async Task<ApiResult> AcceptMergeRequestAsync(string repo, int number, CancellationToken cancellationToken)
251 {
252 var encoded = ForgeRepoNames.ToUrlPath(repo);
253 using var response = await http.PostAsJsonAsync(
254 $"/api/v1/repos/{encoded}/merge-requests/{number}/accept",
255 new { },
256 JsonOptions,
257 cancellationToken)
258 .ConfigureAwait(false);
259 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
260 }
261
262 internal async Task<ApiResult> RejectMergeRequestAsync(string repo, int number, CancellationToken cancellationToken)
263 {
264 var encoded = ForgeRepoNames.ToUrlPath(repo);
265 using var response = await http.PostAsJsonAsync(
266 $"/api/v1/repos/{encoded}/merge-requests/{number}/reject",
267 new { },
268 JsonOptions,
269 cancellationToken)
270 .ConfigureAwait(false);
271 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
272 }
273
274 internal async Task<ApiResult> AddMergeRequestCommentAsync(
275 string repo,
276 int number,
277 AddIssueCommentPayload payload,
278 CancellationToken cancellationToken)
279 {
280 var encoded = ForgeRepoNames.ToUrlPath(repo);
281 using var response = await http.PostAsJsonAsync(
282 $"/api/v1/repos/{encoded}/merge-requests/{number}/comments",
283 payload,
284 JsonOptions,
285 cancellationToken)
286 .ConfigureAwait(false);
287 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
288 }
289
290 internal async Task<ApiResult> HandoffMergeRequestAsync(
291 string repo,
292 int number,
293 string targetRepo,
294 CancellationToken cancellationToken)
295 {
296 var encoded = ForgeRepoNames.ToUrlPath(repo);
297 using var response = await http.PostAsJsonAsync(
298 $"/api/v1/repos/{encoded}/merge-requests/{number}/handoff",
299 new { targetRepo },
300 JsonOptions,
301 cancellationToken)
302 .ConfigureAwait(false);
303 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
304 }
305
306 internal async Task<ApiResult> RecordCommitProvenanceAsync(
307 string repo,
308 RecordCommitProvenancePayload payload,
309 CancellationToken cancellationToken)
310 {
311 var encoded = ForgeRepoNames.ToUrlPath(repo);
312 using var response = await http.PostAsJsonAsync(
313 $"/api/v1/repos/{encoded}/commits/provenance",
314 payload,
315 JsonOptions,
316 cancellationToken)
317 .ConfigureAwait(false);
318 return await ToResultAsync(response, cancellationToken).ConfigureAwait(false);
319 }
320
321 private static async Task<ApiResult> ToResultAsync(HttpResponseMessage response, CancellationToken cancellationToken)
322 {
323 var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
324 if (response.IsSuccessStatusCode)
325 return new ApiResult(true, string.IsNullOrWhiteSpace(body) ? "{}" : body);
326
327 var status = (int)response.StatusCode;
328 if (string.IsNullOrWhiteSpace(body))
329 return new ApiResult(false, $"{{\"detail\":\"HTTP {status}\"}}");
330
331 return new ApiResult(false, body.StartsWith('{') || body.StartsWith('[') ? body : JsonSerializer.Serialize(new { detail = body }));
332 }
333}
334
335public sealed record ApiResult(bool IsSuccess, string Body);
336
337internal sealed record CreateRepoPayload(
338 string Name,
339 string? Description = null,
340 string? DriveMode = null,
341 string? Group = null,
342 string? Org = null,
343 string? Visibility = null);
344
345internal sealed record CreateOrgPayload(
346 string Slug,
347 string? DisplayName = null,
348 string? Description = null,
349 string? DefaultVisibility = null);
350
351internal sealed record AddOrgMemberPayload(string Provider, string Login, string? Role = null);
352
353internal sealed record AddInstanceAdminPayload(string Provider, string Login);
354
355internal sealed record CreateRepoGroupPayload(
356 string Slug,
357 string? DisplayName = null,
358 string? Description = null,
359 string? DefaultVisibility = null,
360 string? Parent = null);
361
362internal sealed record CreateIssuePayload(
363 string Title,
364 string? Body = null,
365 IReadOnlyList<AnchorPayload>? Anchors = null,
366 IReadOnlyList<string>? AnchorBrackets = null,
367 string? Author = null);
368
369internal sealed record UpdateIssuePayload(string? Title = null, string? Body = null, string? Status = null);
370
371internal sealed record AddIssueCommentPayload(string Body, string? Author = null);
372
373internal sealed record LinkIssueAnchorsPayload(
374 IReadOnlyList<AnchorPayload>? Anchors = null,
375 IReadOnlyList<string>? AnchorBrackets = null);
376
377internal sealed record CreateMergeRequestPayload(
378 string Title,
379 string SourceBranch,
380 string? TargetBranch = null,
381 IReadOnlyList<AnchorPayload>? Anchors = null,
382 string? Author = null);
383
384internal sealed record RecordCommitProvenancePayload(string Sha, CommitProvenancePayload Provenance);
385
386internal sealed record CommitProvenancePayload(
387 string Actor,
388 string Branch,
389 IReadOnlyList<int>? IssueNumbers = null,
390 int? MrNumber = null,
391 string? RunId = null,
392 IReadOnlyList<AnchorPayload>? Anchors = null,
393 string? LogicalLineId = null);
394
View only · write via MCP/CIDE