Forge
csharp3407750f
1using System.Net.Http.Headers;
2using System.Text.Json;
3using System.Text.Json.Serialization;
4using AgentForge.Plugin.Sdk;
5
6namespace AgentForge.Plugin.RepositoryImport.GitHub.Core;
7
8public sealed record GitHubRepositoryInfo(
9 string Name,
10 string CloneUrl,
11 bool IsPrivate,
12 bool IsArchived,
13 IReadOnlyList<string> Topics);
14
15public sealed record GitHubUserInfo(
16 string Id,
17 string Login,
18 string DisplayName);
19
20public sealed record GitHubOrgMemberInfo(
21 string Id,
22 string Login);
23
24public sealed class GitHubRepositoryClient(HttpClient http)
25{
26 private static readonly JsonSerializerOptions JsonOptions = new()
27 {
28 PropertyNameCaseInsensitive = true,
29 };
30
31 public Task<IReadOnlyList<GitHubRepositoryInfo>> ListOrgRepositoriesAsync(
32 string org,
33 string token,
34 bool includeArchived,
35 CancellationToken cancellationToken) =>
36 ListRepositoriesAsync(
37 $"https://api.github.com/orgs/{Uri.EscapeDataString(org)}/repos?type=all&per_page=100&page=",
38 org,
39 token,
40 includeArchived,
41 notFoundMessage: $"GitHub organization '{org}' not found.",
42 cancellationToken);
43
44 public Task<IReadOnlyList<GitHubRepositoryInfo>> ListUserRepositoriesAsync(
45 string login,
46 string token,
47 bool includeArchived,
48 CancellationToken cancellationToken) =>
49 ListRepositoriesAsync(
50 $"https://api.github.com/users/{Uri.EscapeDataString(login)}/repos?type=all&per_page=100&page=",
51 login,
52 token,
53 includeArchived,
54 notFoundMessage: $"GitHub user '{login}' not found.",
55 cancellationToken);
56
57 public async Task<GitHubUserInfo> GetAuthenticatedUserAsync(string token, CancellationToken cancellationToken)
58 {
59 using var request = CreateRequest(HttpMethod.Get, "https://api.github.com/user", token);
60 using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
61 await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);
62 var dto = await DeserializeAsync<GitHubUserDto>(response, cancellationToken).ConfigureAwait(false)
63 ?? throw new ForgeApiException(502, "GitHub user payload was empty.");
64 return ToUserInfo(dto);
65 }
66
67 public async Task<GitHubUserInfo> GetUserAsync(string login, string token, CancellationToken cancellationToken)
68 {
69 using var request = CreateRequest(
70 HttpMethod.Get,
71 $"https://api.github.com/users/{Uri.EscapeDataString(login)}",
72 token);
73 using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
74 if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
75 throw new ForgeApiException(404, $"GitHub user '{login}' not found.");
76 await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);
77 var dto = await DeserializeAsync<GitHubUserDto>(response, cancellationToken).ConfigureAwait(false)
78 ?? throw new ForgeApiException(502, "GitHub user payload was empty.");
79 return ToUserInfo(dto);
80 }
81
82 public async Task<IReadOnlyList<GitHubOrgMemberInfo>> ListOrgMembersAsync(
83 string org,
84 string token,
85 CancellationToken cancellationToken)
86 {
87 var results = new List<GitHubOrgMemberInfo>();
88 for (var page = 1; page <= 20; page++)
89 {
90 using var request = CreateRequest(
91 HttpMethod.Get,
92 $"https://api.github.com/orgs/{Uri.EscapeDataString(org)}/members?filter=all&per_page=100&page={page}",
93 token);
94 using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
95 if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
96 throw new ForgeApiException(404, $"GitHub organization '{org}' not found.");
97 await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);
98 var batch = await DeserializeAsync<List<GitHubMemberDto>>(response, cancellationToken).ConfigureAwait(false) ?? [];
99 if (batch.Count == 0)
100 break;
101
102 foreach (var member in batch)
103 {
104 if (string.IsNullOrWhiteSpace(member.Login) || member.Id == 0)
105 continue;
106 results.Add(new GitHubOrgMemberInfo(member.Id.ToString(), member.Login));
107 }
108
109 if (batch.Count < 100)
110 break;
111 }
112
113 return results;
114 }
115
116 private async Task<IReadOnlyList<GitHubRepositoryInfo>> ListRepositoriesAsync(
117 string urlPrefix,
118 string ownerLabel,
119 string token,
120 bool includeArchived,
121 string notFoundMessage,
122 CancellationToken cancellationToken)
123 {
124 var results = new List<GitHubRepositoryInfo>();
125 for (var page = 1; page <= 20; page++)
126 {
127 using var request = CreateRequest(HttpMethod.Get, $"{urlPrefix}{page}", token);
128 using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
129 if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
130 throw new ForgeApiException(404, notFoundMessage);
131 await EnsureSuccessAsync(response, cancellationToken).ConfigureAwait(false);
132
133 var batch = await DeserializeAsync<List<GitHubRepositoryDto>>(response, cancellationToken).ConfigureAwait(false) ?? [];
134 if (batch.Count == 0)
135 break;
136
137 foreach (var repo in batch)
138 {
139 if (repo.Archived && !includeArchived)
140 continue;
141 if (string.IsNullOrWhiteSpace(repo.Name))
142 continue;
143 results.Add(new GitHubRepositoryInfo(
144 repo.Name,
145 repo.CloneUrl ?? $"https://github.com/{ownerLabel}/{repo.Name}.git",
146 repo.Private,
147 repo.Archived,
148 repo.Topics ?? []));
149 }
150
151 if (batch.Count < 100)
152 break;
153 }
154
155 return results;
156 }
157
158 private static HttpRequestMessage CreateRequest(HttpMethod method, string url, string token)
159 {
160 var request = new HttpRequestMessage(method, url);
161 request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
162 request.Headers.UserAgent.ParseAdd("AgentForge-Import");
163 request.Headers.Accept.ParseAdd("application/vnd.github+json");
164 return request;
165 }
166
167 private static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken)
168 {
169 if (response.IsSuccessStatusCode)
170 return;
171 var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
172 throw new ForgeApiException(502, $"GitHub API error {(int)response.StatusCode}: {body}");
173 }
174
175 private static async Task<T?> DeserializeAsync<T>(HttpResponseMessage response, CancellationToken cancellationToken)
176 {
177 await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
178 return await JsonSerializer.DeserializeAsync<T>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
179 }
180
181 private static GitHubUserInfo ToUserInfo(GitHubUserDto dto) =>
182 new(dto.Id.ToString(), dto.Login ?? "", string.IsNullOrWhiteSpace(dto.Name) ? dto.Login ?? "" : dto.Name!);
183
184 private sealed class GitHubRepositoryDto
185 {
186 public string Name { get; set; } = "";
187
188 [JsonPropertyName("clone_url")]
189 public string? CloneUrl { get; set; }
190
191 public bool Private { get; set; }
192
193 public bool Archived { get; set; }
194
195 public List<string>? Topics { get; set; }
196 }
197
198 private sealed class GitHubUserDto
199 {
200 public long Id { get; set; }
201
202 public string? Login { get; set; }
203
204 public string? Name { get; set; }
205 }
206
207 private sealed class GitHubMemberDto
208 {
209 public long Id { get; set; }
210
211 public string? Login { get; set; }
212 }
213}
214
View only · write via MCP/CIDE