Forge
csharpdeeb25a2
1#nullable enable
2
3using System.Net.Http.Json;
4using System.Text.Json;
5using CascadeIDE.Features.Workspace;
6using CascadeIDE.Features.Workspace.DataAcquisition;
7
8namespace CascadeIDE.Features.Forge.Lens;
9
10/// <summary>Forge Lens write: create issue / MR via HTTP API (ADR 0158, release gate B).</summary>
11public static class ForgeLensWriteClient
12{
13 private static readonly JsonSerializerOptions JsonOptions = new()
14 {
15 PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
16 DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
17 };
18
19 public static async Task<(bool Ok, string Message)> CreateIssueAsync(
20 string baseUrl,
21 string repo,
22 string? apiToken,
23 string title,
24 string? body,
25 IReadOnlyList<ForgeLensAnchorPayload>? anchors,
26 CancellationToken cancellationToken)
27 {
28 var payload = new Dictionary<string, object?> { ["title"] = title };
29 if (!string.IsNullOrWhiteSpace(body))
30 payload["body"] = body;
31 if (anchors is { Count: > 0 })
32 payload["anchors"] = anchors;
33
34 return await PostAsync(baseUrl, repo, apiToken, "issues", payload, cancellationToken).ConfigureAwait(false);
35 }
36
37 public static async Task<(bool Ok, string Message)> CreateMergeRequestAsync(
38 string baseUrl,
39 string repo,
40 string? apiToken,
41 string title,
42 string sourceBranch,
43 string? targetBranch,
44 IReadOnlyList<ForgeLensAnchorPayload>? anchors,
45 CancellationToken cancellationToken)
46 {
47 var payload = new Dictionary<string, object?>
48 {
49 ["title"] = title,
50 ["sourceBranch"] = sourceBranch,
51 };
52 if (!string.IsNullOrWhiteSpace(targetBranch))
53 payload["targetBranch"] = targetBranch;
54 if (anchors is { Count: > 0 })
55 payload["anchors"] = anchors;
56
57 return await PostAsync(baseUrl, repo, apiToken, "merge-requests", payload, cancellationToken).ConfigureAwait(false);
58 }
59
60 public static ForgeLensWriteContext? TryResolveContext(string? workspaceRoot, string? baseUrlArg, string? repoArg)
61 {
62 var toml = RepositoryWorkspaceTomlLoader.TryLoad(workspaceRoot);
63 var forge = toml?.Workspace?.Forge;
64 var baseUrl = (baseUrlArg ?? forge?.BaseUrl ?? "").Trim().TrimEnd('/');
65 var repo = (repoArg ?? forge?.Repo ?? "").Trim();
66 if (baseUrl.Length == 0 || repo.Length == 0)
67 return null;
68
69 var token = ForgeLensCredentialResolver.ResolveApiToken(baseUrl, forge?.ApiTokenEnv);
70 return new ForgeLensWriteContext(baseUrl, repo, token);
71 }
72
73 private static async Task<(bool Ok, string Message)> PostAsync(
74 string baseUrl,
75 string repo,
76 string? apiToken,
77 string resource,
78 Dictionary<string, object?> payload,
79 CancellationToken cancellationToken)
80 {
81 using var http = new HttpClient { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(15) };
82 if (!string.IsNullOrEmpty(apiToken))
83 http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiToken);
84
85 var path = $"/api/v1/repos/{Uri.EscapeDataString(repo)}/{resource}";
86 using var response = await http.PostAsJsonAsync(path, payload, JsonOptions, cancellationToken).ConfigureAwait(false);
87 var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
88 if (response.IsSuccessStatusCode)
89 return (true, FormatSuccess(resource, body));
90
91 return (false, FormatError(response.StatusCode, body));
92 }
93
94 private static string FormatSuccess(string resource, string json)
95 {
96 try
97 {
98 using var doc = JsonDocument.Parse(json);
99 var root = doc.RootElement;
100 if (resource == "issues" && root.TryGetProperty("number", out var num) && root.TryGetProperty("issueUrl", out var url))
101 return $"Issue #{num.GetInt32()} → {url.GetString()}";
102 if (resource == "merge-requests" && root.TryGetProperty("number", out var mrNum) && root.TryGetProperty("mrUrl", out var mrUrl))
103 return $"MR !{mrNum.GetInt32()} → {mrUrl.GetString()}";
104 }
105 catch
106 {
107 // fall through
108 }
109
110 return json;
111 }
112
113 private static string FormatError(System.Net.HttpStatusCode status, string body)
114 {
115 try
116 {
117 using var doc = JsonDocument.Parse(body);
118 if (doc.RootElement.TryGetProperty("detail", out var detail))
119 return $"{(int)status}: {detail.GetString()}";
120 }
121 catch
122 {
123 // fall through
124 }
125
126 return $"{(int)status}: {body}";
127 }
128}
129
130public sealed record ForgeLensWriteContext(string BaseUrl, string Repo, string? ApiToken);
131
132public sealed record ForgeLensAnchorPayload(string File, int LineStart, int? LineEnd = null, string? MemberKey = null);
133
View only · write via MCP/CIDE