OPC # 0001: Gitea services

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
amadzarak
2026-04-25 19:35:46 -04:00
parent 76962a6af4
commit 65a6b4afaf
13 changed files with 457 additions and 93 deletions
+43 -32
View File
@@ -17,10 +17,13 @@ public static class GitEndpoints
// GET /api/git/log?grep=OPC+%23+0001&limit=50&repo=all // GET /api/git/log?grep=OPC+%23+0001&limit=50&repo=all
// repo can be "all" (default) or a named key from Git:Repos (e.g. "Clarity", "OPC", "Gateway"). // repo can be "all" (default) or a named key from Git:Repos (e.g. "Clarity", "OPC", "Gateway").
// All responses include a repoKey field so the caller knows which repo each commit came from. // All responses include a repoKey field so the caller knows which repo each commit came from.
// GET /api/git/log?grep=...&limit=25&page=1&repo=all
// page is 1-based. Each repo contributes up to limit*page commits before merge-sort+skip/take.
private static IResult GetLog( private static IResult GetLog(
IConfiguration config, IConfiguration config,
string? grep = null, string? grep = null,
int limit = 50, int limit = 25,
int page = 1,
string repo = "all") string repo = "all")
{ {
var repos = repo == "all" var repos = repo == "all"
@@ -56,12 +59,13 @@ public static class GitEndpoints
if (!string.IsNullOrWhiteSpace(grep)) if (!string.IsNullOrWhiteSpace(grep))
query = query.Where(c => c.Message.Contains(grep, StringComparison.OrdinalIgnoreCase)); query = query.Where(c => c.Message.Contains(grep, StringComparison.OrdinalIgnoreCase));
foreach (var c in query.Take(limit)) foreach (var c in query.Take(limit * page))
bucket.Add((c.Author.When, repoKey, ToGitCommit(r, c))); bucket.Add((c.Author.When, repoKey, ToGitCommit(r, c)));
} }
var commits = bucket var commits = bucket
.OrderByDescending(x => x.When) .OrderByDescending(x => x.When)
.Skip((page - 1) * limit)
.Take(limit) .Take(limit)
.Select(x => new .Select(x => new
{ {
@@ -79,41 +83,48 @@ public static class GitEndpoints
} }
// GET /api/git/commits/{hash} // GET /api/git/commits/{hash}
// Searches all registered repos — hash may belong to any of the three repos.
private static IResult GetCommit(string hash, IConfiguration config) private static IResult GetCommit(string hash, IConfiguration config)
{ {
var repoPath = ResolveRepo(config); var repos = ResolveAllRepos(config);
if (repoPath is null) if (repos.Count == 0)
return Results.Problem("Could not locate a git repository."); return Results.Problem("Could not locate any git repositories.");
using var repo = new Repository(repoPath); foreach (var (_, repoPath) in repos)
var commit = repo.Lookup<Commit>(hash);
if (commit is null) return Results.NotFound();
var parentTree = commit.Parents.FirstOrDefault()?.Tree;
var changes = repo.Diff.Compare<TreeChanges>(parentTree, commit.Tree);
var patch = repo.Diff.Compare<Patch>(parentTree, commit.Tree);
var files = changes.Select(c => new
{ {
path = c.Path, if (!Directory.Exists(repoPath)) continue;
oldPath = c.OldPath, using var repo = new Repository(repoPath);
status = c.Status.ToString(), var commit = repo.Lookup<Commit>(hash);
additions = patch[c.Path]?.LinesAdded ?? 0, if (commit is null) continue;
deletions = patch[c.Path]?.LinesDeleted ?? 0,
patch = patch[c.Path]?.Patch ?? string.Empty,
}).ToList();
return Results.Ok(new var parentTree = commit.Parents.FirstOrDefault()?.Tree;
{ var changes = repo.Diff.Compare<TreeChanges>(parentTree, commit.Tree);
hash = commit.Sha, var patch = repo.Diff.Compare<Patch>(parentTree, commit.Tree);
shortHash = commit.Sha[..7],
author = commit.Author.Name, var files = changes.Select(c => new
email = commit.Author.Email, {
date = commit.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"), path = c.Path,
subject = commit.MessageShort, oldPath = c.OldPath,
body = commit.Message, status = c.Status.ToString(),
files, additions = patch[c.Path]?.LinesAdded ?? 0,
}); deletions = patch[c.Path]?.LinesDeleted ?? 0,
patch = patch[c.Path]?.Patch ?? string.Empty,
}).ToList();
return Results.Ok(new
{
hash = commit.Sha,
shortHash = commit.Sha[..7],
author = commit.Author.Name,
email = commit.Author.Email,
date = commit.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"),
subject = commit.MessageShort,
body = commit.Message,
files,
});
}
return Results.NotFound();
} }
// GET /api/git/branches // GET /api/git/branches
+27 -19
View File
@@ -23,14 +23,22 @@ public static class GiteaEndpoints
return app; return app;
} }
private static async Task<IResult> GetRepo(GiteaService svc, CancellationToken ct) private static async Task<IResult> GetRepo(GiteaService svc, string? repo, CancellationToken ct)
{ {
var repo = await svc.GetRepoAsync(ct); var result = await svc.GetRepoAsync(repo, ct);
return repo is null ? Results.StatusCode(503) : Results.Ok(repo); return result is null ? Results.StatusCode(503) : Results.Ok(result);
} }
private static async Task<IResult> ListBranches(GiteaService svc, CancellationToken ct) => private static async Task<IResult> ListBranches(GiteaService svc, string? repo, CancellationToken ct)
Results.Ok(await svc.ListBranchesAsync(ct)); {
// repo=all returns branches from all registered repos, each tagged with repoKey
if (repo == "all")
{
var all = await svc.ListAllBranchesAsync(ct);
return Results.Ok(all.Select(x => new { repoKey = x.RepoKey, x.Branch.Name, x.Branch.CommitSha, x.Branch.Protected }));
}
return Results.Ok(await svc.ListBranchesAsync(repo, ct));
}
private static async Task<IResult> CreateBranch( private static async Task<IResult> CreateBranch(
CreateBranchRequest req, GiteaService svc, CancellationToken ct) CreateBranchRequest req, GiteaService svc, CancellationToken ct)
@@ -40,40 +48,40 @@ public static class GiteaEndpoints
} }
private static async Task<IResult> ListPulls( private static async Task<IResult> ListPulls(
GiteaService svc, string state = "open", CancellationToken ct = default) => GiteaService svc, string state = "open", string? repo = null, CancellationToken ct = default) =>
Results.Ok(await svc.ListPullRequestsAsync(state, ct)); Results.Ok(await svc.ListPullRequestsAsync(state, repo, ct));
private static async Task<IResult> GetPull( private static async Task<IResult> GetPull(
long number, GiteaService svc, CancellationToken ct) long number, GiteaService svc, string? repo = null, CancellationToken ct = default)
{ {
var pr = await svc.GetPullRequestAsync(number, ct); var pr = await svc.GetPullRequestAsync(number, repo, ct);
return pr is null ? Results.NotFound() : Results.Ok(pr); return pr is null ? Results.NotFound() : Results.Ok(pr);
} }
private static async Task<IResult> CreatePull( private static async Task<IResult> CreatePull(
CreatePullRequestRequest req, GiteaService svc, CancellationToken ct) CreatePullRequestRequest req, GiteaService svc, string? repo = null, CancellationToken ct = default)
{ {
var pr = await svc.CreatePullRequestAsync(req, ct); var pr = await svc.CreatePullRequestAsync(req, repo, ct);
return pr is null ? Results.BadRequest("Failed to create PR in Gitea.") : Results.Ok(pr); return pr is null ? Results.BadRequest("Failed to create PR in Gitea.") : Results.Ok(pr);
} }
private static async Task<IResult> ListTags(GiteaService svc, CancellationToken ct) => private static async Task<IResult> ListTags(GiteaService svc, string? repo = null, CancellationToken ct = default) =>
Results.Ok(await svc.ListTagsAsync(ct)); Results.Ok(await svc.ListTagsAsync(repo, ct));
private static async Task<IResult> CreateTag( private static async Task<IResult> CreateTag(
CreateTagRequest req, GiteaService svc, CancellationToken ct) CreateTagRequest req, GiteaService svc, string? repo = null, CancellationToken ct = default)
{ {
var tag = await svc.CreateTagAsync(req, ct); var tag = await svc.CreateTagAsync(req, repo, ct);
return tag is null ? Results.BadRequest("Failed to create tag in Gitea.") : Results.Ok(tag); return tag is null ? Results.BadRequest("Failed to create tag in Gitea.") : Results.Ok(tag);
} }
private static async Task<IResult> ListWebhooks(GiteaService svc, CancellationToken ct) => private static async Task<IResult> ListWebhooks(GiteaService svc, string? repo = null, CancellationToken ct = default) =>
Results.Ok(await svc.ListWebhooksAsync(ct)); Results.Ok(await svc.ListWebhooksAsync(repo, ct));
private static async Task<IResult> RegisterWebhook( private static async Task<IResult> RegisterWebhook(
CreateWebhookRequest req, GiteaService svc, CancellationToken ct) CreateWebhookRequest req, GiteaService svc, string? repo = null, CancellationToken ct = default)
{ {
var hook = await svc.RegisterWebhookAsync(req, ct); var hook = await svc.RegisterWebhookAsync(req, repo, ct);
return hook is null ? Results.BadRequest("Failed to register webhook in Gitea.") : Results.Ok(hook); return hook is null ? Results.BadRequest("Failed to register webhook in Gitea.") : Results.Ok(hook);
} }
} }
+68 -24
View File
@@ -14,17 +14,19 @@ namespace ControlPlane.Api.Services;
public class GiteaService public class GiteaService
{ {
private readonly HttpClient _http; private readonly HttpClient _http;
private readonly string _owner; private readonly string _defaultOwner;
private readonly string _repo; private readonly string _defaultRepo;
private readonly IConfiguration _cfg;
private readonly ILogger<GiteaService> _log; private readonly ILogger<GiteaService> _log;
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
public GiteaService(IHttpClientFactory factory, IConfiguration cfg, ILogger<GiteaService> log) public GiteaService(IHttpClientFactory factory, IConfiguration cfg, ILogger<GiteaService> log)
{ {
_log = log; _log = log;
_owner = cfg["Gitea:Owner"] ?? "Clarity"; _cfg = cfg;
_repo = cfg["Gitea:Repo"] ?? "Clarity"; _defaultOwner = cfg["Gitea:Owner"] ?? "ClarityStack";
_defaultRepo = cfg["Gitea:Repo"] ?? "OPC";
var baseUrl = cfg["Gitea:BaseUrl"] ?? "https://opc.clarity.test"; var baseUrl = cfg["Gitea:BaseUrl"] ?? "https://opc.clarity.test";
var token = cfg["Gitea:Token"] ?? string.Empty; var token = cfg["Gitea:Token"] ?? string.Empty;
@@ -37,31 +39,66 @@ public class GiteaService
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", token); _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", token);
} }
// ── Repo resolution ───────────────────────────────────────────────────────
/// Returns (owner, repo) for the given key, falling back to the defaults.
private (string Owner, string Repo) ResolveOwnerRepo(string? repoKey = null)
{
if (!string.IsNullOrWhiteSpace(repoKey))
{
var owner = _cfg[$"Gitea:Repos:{repoKey}:Owner"];
var repo = _cfg[$"Gitea:Repos:{repoKey}:Repo"];
if (!string.IsNullOrWhiteSpace(owner) && !string.IsNullOrWhiteSpace(repo))
return (owner, repo);
}
return (_defaultOwner, _defaultRepo);
}
// ── Repos ───────────────────────────────────────────────────────────────── // ── Repos ─────────────────────────────────────────────────────────────────
public async Task<GiteaRepo?> GetRepoAsync(CancellationToken ct = default) public async Task<GiteaRepo?> GetRepoAsync(string? repoKey = null, CancellationToken ct = default)
{ {
var (owner, repo) = ResolveOwnerRepo(repoKey);
try try
{ {
return await _http.GetFromJsonAsync<GiteaRepo>($"repos/{_owner}/{_repo}", JsonOpts, ct); return await _http.GetFromJsonAsync<GiteaRepo>($"repos/{owner}/{repo}", JsonOpts, ct);
} }
catch (Exception ex) { _log.LogWarning(ex, "Gitea GetRepo failed"); return null; } catch (Exception ex) { _log.LogWarning(ex, "Gitea GetRepo failed"); return null; }
} }
// ── Branches ────────────────────────────────────────────────────────────── // ── Branches ──────────────────────────────────────────────────────────────
public async Task<List<GiteaBranch>> ListBranchesAsync(CancellationToken ct = default) public async Task<List<GiteaBranch>> ListBranchesAsync(string? repoKey = null, CancellationToken ct = default)
{ {
var (owner, repo) = ResolveOwnerRepo(repoKey);
try try
{ {
return await _http.GetFromJsonAsync<List<GiteaBranch>>( return await _http.GetFromJsonAsync<List<GiteaBranch>>(
$"repos/{_owner}/{_repo}/branches?limit=50", JsonOpts, ct) ?? []; $"repos/{owner}/{repo}/branches?limit=50", JsonOpts, ct) ?? [];
} }
catch (Exception ex) { _log.LogWarning(ex, "Gitea ListBranches failed"); return []; } catch (Exception ex) { _log.LogWarning(ex, "Gitea ListBranches failed"); return []; }
} }
/// Returns branches from all registered repos, tagged with repoKey.
public async Task<List<(string RepoKey, GiteaBranch Branch)>> ListAllBranchesAsync(CancellationToken ct = default)
{
var result = new List<(string, GiteaBranch)>();
var section = _cfg.GetSection("Gitea:Repos");
var keys = section.GetChildren().Select(c => c.Key).ToList();
if (keys.Count == 0) keys = ["default"];
await Task.WhenAll(keys.Select(async key =>
{
var branches = await ListBranchesAsync(key == "default" ? null : key, ct);
lock (result) result.AddRange(branches.Select(b => (key, b)));
}));
return result;
}
public async Task<GiteaBranch?> CreateBranchAsync(CreateBranchRequest req, CancellationToken ct = default) public async Task<GiteaBranch?> CreateBranchAsync(CreateBranchRequest req, CancellationToken ct = default)
{ {
var (owner, repo) = ResolveOwnerRepo(req.RepoKey);
// Slugify: "OPC # 0032" + title → "feature/OPC-0032-git-workflow-integration" // Slugify: "OPC # 0032" + title → "feature/OPC-0032-git-workflow-integration"
var slug = SlugifyTitle(req.OpcTitle); var slug = SlugifyTitle(req.OpcTitle);
var num = req.OpcNumber.Replace("OPC # ", "OPC-").Replace(" ", ""); var num = req.OpcNumber.Replace("OPC # ", "OPC-").Replace(" ", "");
@@ -74,7 +111,7 @@ public class GiteaService
}, JsonOpts); }, JsonOpts);
var res = await _http.PostAsync( var res = await _http.PostAsync(
$"repos/{_owner}/{_repo}/branches", $"repos/{owner}/{repo}/branches",
new StringContent(body, Encoding.UTF8, "application/json"), ct); new StringContent(body, Encoding.UTF8, "application/json"), ct);
if (!res.IsSuccessStatusCode) if (!res.IsSuccessStatusCode)
@@ -90,29 +127,32 @@ public class GiteaService
// ── Pull Requests ───────────────────────────────────────────────────────── // ── Pull Requests ─────────────────────────────────────────────────────────
public async Task<List<GiteaPullRequest>> ListPullRequestsAsync( public async Task<List<GiteaPullRequest>> ListPullRequestsAsync(
string state = "open", CancellationToken ct = default) string state = "open", string? repoKey = null, CancellationToken ct = default)
{ {
var (owner, repo) = ResolveOwnerRepo(repoKey);
try try
{ {
return await _http.GetFromJsonAsync<List<GiteaPullRequest>>( return await _http.GetFromJsonAsync<List<GiteaPullRequest>>(
$"repos/{_owner}/{_repo}/pulls?state={state}&limit=50", JsonOpts, ct) ?? []; $"repos/{owner}/{repo}/pulls?state={state}&limit=50", JsonOpts, ct) ?? [];
} }
catch (Exception ex) { _log.LogWarning(ex, "Gitea ListPRs failed"); return []; } catch (Exception ex) { _log.LogWarning(ex, "Gitea ListPRs failed"); return []; }
} }
public async Task<GiteaPullRequest?> GetPullRequestAsync(long number, CancellationToken ct = default) public async Task<GiteaPullRequest?> GetPullRequestAsync(long number, string? repoKey = null, CancellationToken ct = default)
{ {
var (owner, repo) = ResolveOwnerRepo(repoKey);
try try
{ {
return await _http.GetFromJsonAsync<GiteaPullRequest>( return await _http.GetFromJsonAsync<GiteaPullRequest>(
$"repos/{_owner}/{_repo}/pulls/{number}", JsonOpts, ct); $"repos/{owner}/{repo}/pulls/{number}", JsonOpts, ct);
} }
catch (Exception ex) { _log.LogWarning(ex, "Gitea GetPR failed"); return null; } catch (Exception ex) { _log.LogWarning(ex, "Gitea GetPR failed"); return null; }
} }
public async Task<GiteaPullRequest?> CreatePullRequestAsync( public async Task<GiteaPullRequest?> CreatePullRequestAsync(
CreatePullRequestRequest req, CancellationToken ct = default) CreatePullRequestRequest req, string? repoKey = null, CancellationToken ct = default)
{ {
var (owner, repo) = ResolveOwnerRepo(repoKey);
var body = JsonSerializer.Serialize(new var body = JsonSerializer.Serialize(new
{ {
title = req.Title, title = req.Title,
@@ -122,7 +162,7 @@ public class GiteaService
}, JsonOpts); }, JsonOpts);
var res = await _http.PostAsync( var res = await _http.PostAsync(
$"repos/{_owner}/{_repo}/pulls", $"repos/{owner}/{repo}/pulls",
new StringContent(body, Encoding.UTF8, "application/json"), ct); new StringContent(body, Encoding.UTF8, "application/json"), ct);
if (!res.IsSuccessStatusCode) if (!res.IsSuccessStatusCode)
@@ -137,18 +177,20 @@ public class GiteaService
// ── Tags ────────────────────────────────────────────────────────────────── // ── Tags ──────────────────────────────────────────────────────────────────
public async Task<List<GiteaTag>> ListTagsAsync(CancellationToken ct = default) public async Task<List<GiteaTag>> ListTagsAsync(string? repoKey = null, CancellationToken ct = default)
{ {
var (owner, repo) = ResolveOwnerRepo(repoKey);
try try
{ {
return await _http.GetFromJsonAsync<List<GiteaTag>>( return await _http.GetFromJsonAsync<List<GiteaTag>>(
$"repos/{_owner}/{_repo}/tags?limit=20", JsonOpts, ct) ?? []; $"repos/{owner}/{repo}/tags?limit=20", JsonOpts, ct) ?? [];
} }
catch (Exception ex) { _log.LogWarning(ex, "Gitea ListTags failed"); return []; } catch (Exception ex) { _log.LogWarning(ex, "Gitea ListTags failed"); return []; }
} }
public async Task<GiteaTag?> CreateTagAsync(CreateTagRequest req, CancellationToken ct = default) public async Task<GiteaTag?> CreateTagAsync(CreateTagRequest req, string? repoKey = null, CancellationToken ct = default)
{ {
var (owner, repo) = ResolveOwnerRepo(repoKey);
var body = JsonSerializer.Serialize(new var body = JsonSerializer.Serialize(new
{ {
tag_name = req.TagName, tag_name = req.TagName,
@@ -157,7 +199,7 @@ public class GiteaService
}, JsonOpts); }, JsonOpts);
var res = await _http.PostAsync( var res = await _http.PostAsync(
$"repos/{_owner}/{_repo}/tags", $"repos/{owner}/{repo}/tags",
new StringContent(body, Encoding.UTF8, "application/json"), ct); new StringContent(body, Encoding.UTF8, "application/json"), ct);
if (!res.IsSuccessStatusCode) if (!res.IsSuccessStatusCode)
@@ -172,19 +214,21 @@ public class GiteaService
// ── Webhooks ────────────────────────────────────────────────────────────── // ── Webhooks ──────────────────────────────────────────────────────────────
public async Task<List<GiteaWebhook>> ListWebhooksAsync(CancellationToken ct = default) public async Task<List<GiteaWebhook>> ListWebhooksAsync(string? repoKey = null, CancellationToken ct = default)
{ {
var (owner, repo) = ResolveOwnerRepo(repoKey);
try try
{ {
return await _http.GetFromJsonAsync<List<GiteaWebhook>>( return await _http.GetFromJsonAsync<List<GiteaWebhook>>(
$"repos/{_owner}/{_repo}/hooks", JsonOpts, ct) ?? []; $"repos/{owner}/{repo}/hooks", JsonOpts, ct) ?? [];
} }
catch (Exception ex) { _log.LogWarning(ex, "Gitea ListWebhooks failed"); return []; } catch (Exception ex) { _log.LogWarning(ex, "Gitea ListWebhooks failed"); return []; }
} }
public async Task<GiteaWebhook?> RegisterWebhookAsync( public async Task<GiteaWebhook?> RegisterWebhookAsync(
CreateWebhookRequest req, CancellationToken ct = default) CreateWebhookRequest req, string? repoKey = null, CancellationToken ct = default)
{ {
var (owner, repo) = ResolveOwnerRepo(repoKey);
var body = JsonSerializer.Serialize(new var body = JsonSerializer.Serialize(new
{ {
type = "gitea", type = "gitea",
@@ -194,7 +238,7 @@ public class GiteaService
}, JsonOpts); }, JsonOpts);
var res = await _http.PostAsync( var res = await _http.PostAsync(
$"repos/{_owner}/{_repo}/hooks", $"repos/{owner}/{repo}/hooks",
new StringContent(body, Encoding.UTF8, "application/json"), ct); new StringContent(body, Encoding.UTF8, "application/json"), ct);
if (!res.IsSuccessStatusCode) if (!res.IsSuccessStatusCode)
+6 -1
View File
@@ -21,6 +21,11 @@
"BaseUrl": "https://opc.clarity.test", "BaseUrl": "https://opc.clarity.test",
"Owner": "ClarityStack", "Owner": "ClarityStack",
"Repo": "OPC", "Repo": "OPC",
"Token": "fcf9f66415754fb639a8343e3904e06b1d78c646" "Token": "fcf9f66415754fb639a8343e3904e06b1d78c646",
"Repos": {
"Clarity": { "Owner": "ClarityStack", "Repo": "Clarity" },
"OPC": { "Owner": "ClarityStack", "Repo": "OPC" },
"Gateway": { "Owner": "ClarityStack", "Repo": "Gateway" }
}
} }
} }
+1 -1
View File
@@ -49,7 +49,7 @@ public record GiteaWebhook(long Id, string Url, bool Active, string[] Events);
// ── Request shapes ──────────────────────────────────────────────────────────── // ── Request shapes ────────────────────────────────────────────────────────────
public record CreateBranchRequest(string OpcNumber, string OpcTitle, string From = "master"); public record CreateBranchRequest(string OpcNumber, string OpcTitle, string RepoKey = "Clarity", string From = "main");
public record CreatePullRequestRequest( public record CreatePullRequestRequest(
string Title, string Title,
+5 -2
View File
@@ -9,6 +9,7 @@ import ImageBuildPage from './pages/ImageBuildPage';
import BranchPage from './pages/BranchPage'; import BranchPage from './pages/BranchPage';
import OpcPage from './opc/OpcPage'; import OpcPage from './opc/OpcPage';
import InfraPage from './pages/InfraPage'; import InfraPage from './pages/InfraPage';
import ChangesetsPage from './pages/ChangesetsPage';
function App() { function App() {
const [activeNav, setActiveNav] = useState('opc'); const [activeNav, setActiveNav] = useState('opc');
@@ -30,8 +31,9 @@ function App() {
<MenuItem icon="build" text="Image Build" active={activeNav === 'image-build'} onClick={() => setActiveNav('image-build')} /> <MenuItem icon="build" text="Image Build" active={activeNav === 'image-build'} onClick={() => setActiveNav('image-build')} />
<MenuItem icon="pulse" text="Build Monitor" active={activeNav === 'build-monitor'} onClick={() => setActiveNav('build-monitor')} /> <MenuItem icon="pulse" text="Build Monitor" active={activeNav === 'build-monitor'} onClick={() => setActiveNav('build-monitor')} />
<MenuDivider /> <MenuDivider />
<MenuItem icon="heat-grid" text="Infrastructure" active={activeNav === 'infra'} onClick={() => setActiveNav('infra')} /> <MenuItem icon="heat-grid" text="Infrastructure" active={activeNav === 'infra'} onClick={() => setActiveNav('infra')} />
<MenuItem icon="clipboard" text="OPC" active={activeNav === 'opc'} onClick={() => setActiveNav('opc')} /> <MenuItem icon="clipboard" text="OPC" active={activeNav === 'opc'} onClick={() => setActiveNav('opc')} />
<MenuItem icon="history" text="Changesets" active={activeNav === 'changesets'} onClick={() => setActiveNav('changesets')} />
<MenuItem icon="people" text="Clients" active={activeNav === 'clients'} onClick={() => setActiveNav('clients')} /> <MenuItem icon="people" text="Clients" active={activeNav === 'clients'} onClick={() => setActiveNav('clients')} />
<MenuItem icon="cog" text="Settings" active={activeNav === 'settings'} onClick={() => setActiveNav('settings')} /> <MenuItem icon="cog" text="Settings" active={activeNav === 'settings'} onClick={() => setActiveNav('settings')} />
</Menu> </Menu>
@@ -57,6 +59,7 @@ function App() {
{activeNav === 'build-monitor' && <BuildMonitorPage />} {activeNav === 'build-monitor' && <BuildMonitorPage />}
{activeNav === 'infra' && <InfraPage />} {activeNav === 'infra' && <InfraPage />}
{activeNav === 'opc' && <OpcPage />} {activeNav === 'opc' && <OpcPage />}
{activeNav === 'changesets' && <ChangesetsPage />}
{activeNav === 'clients' && <PlaceholderPage title="Clients" />} {activeNav === 'clients' && <PlaceholderPage title="Clients" />}
{activeNav === 'settings' && <PlaceholderPage title="Settings" />} {activeNav === 'settings' && <PlaceholderPage title="Settings" />}
</main> </main>
+20 -2
View File
@@ -204,6 +204,22 @@ export async function getBranchCoverageForRepo(repoKey: string, hashes: string[]
return res.json(); return res.json();
} }
// ── Changesets (paginated cross-repo commit log) ───────────────────────────────
// Reuses LinkedCommit shape — repoKey identifies which repo each commit came from.
export async function getChangesets(
page = 1,
limit = 25,
repo = 'all',
grep?: string,
): Promise<LinkedCommit[]> {
const params = new URLSearchParams({ page: String(page), limit: String(limit), repo });
if (grep) params.set('grep', grep);
const res = await fetch(`${BASE_URL}/api/git/log?${params}`);
if (!res.ok) throw new Error(`Failed to load changesets: ${res.statusText}`);
return res.json();
}
// ── Commit detail (full diff) ───────────────────────────────────────────────── // ── Commit detail (full diff) ─────────────────────────────────────────────────
export interface CommitFile { export interface CommitFile {
@@ -296,13 +312,15 @@ function mapArtifact(r: any): OpcArtifact {
// ── Gitea branch integration ─────────────────────────────────────────────────── // ── Gitea branch integration ───────────────────────────────────────────────────
export interface GiteaBranch { export interface GiteaBranch {
repoKey?: string; // present when querying ?repo=all
name: string; name: string;
commitSha: string; commitSha: string;
protected: boolean; protected: boolean;
} }
export async function listGiteaBranches(): Promise<GiteaBranch[]> { export async function listGiteaBranches(repoKey?: string): Promise<GiteaBranch[]> {
const res = await fetch(`${BASE_URL}/api/gitea/branches`); const params = repoKey ? `?repo=${encodeURIComponent(repoKey)}` : '';
const res = await fetch(`${BASE_URL}/api/gitea/branches${params}`);
if (!res.ok) throw new Error(`Failed to load Gitea branches: ${res.statusText}`); if (!res.ok) throw new Error(`Failed to load Gitea branches: ${res.statusText}`);
return res.json(); return res.json();
} }
@@ -14,23 +14,29 @@ const ENVIRONMENTS: { value: TenantEnvironment; label: string; description: stri
]; ];
const TIERS: { value: TenantTier; label: string; description: string; badge: string }[] = [ const TIERS: { value: TenantTier; label: string; description: string; badge: string }[] = [
{
value: 'Trial',
label: 'Trial',
badge: 'Sandbox',
description: 'Ephemeral all-in-one sandbox. Bundled Postgres, shared Keycloak and Vault. No persistent data guarantee.',
},
{ {
value: 'Shared', value: 'Shared',
label: 'Shared', label: 'Shared',
badge: 'Standard', badge: 'Standard',
description: 'Shared Keycloak, Vault, Postgres and MinIO. Isolated by realm, namespace and bucket.', description: 'Shared Keycloak, Vault, Postgres and MinIO. Isolated by realm, namespace and bucket.',
}, },
{
value: 'Isolated',
label: 'Isolated',
badge: 'Professional',
description: 'Shared Keycloak and Vault, but a dedicated Postgres container and MinIO bucket per tenant.',
},
{ {
value: 'Dedicated', value: 'Dedicated',
label: 'Dedicated', label: 'Dedicated',
badge: 'Professional',
description: 'Own sidecar containers per component (Postgres, Keycloak, Vault, MinIO) on the shared host.',
},
{
value: 'Enterprise',
label: 'Enterprise',
badge: 'Enterprise', badge: 'Enterprise',
description: 'Fully dedicated Keycloak, Vault, Postgres and MinIO containers for complete hard isolation.', description: 'Full VM isolation per component. VpsDocker or VpsBareMetal, provisioned via Pulumi.',
}, },
]; ];
@@ -24,7 +24,7 @@ export default function ReviewStep({ data }: Props) {
<tr><td>Container Name</td><td><code>{containerName}</code></td></tr> <tr><td>Container Name</td><td><code>{containerName}</code></td></tr>
<tr><td>Client URL</td><td><code style={{ fontSize: '0.9em' }}>{clientUrl}</code></td></tr> <tr><td>Client URL</td><td><code style={{ fontSize: '0.9em' }}>{clientUrl}</code></td></tr>
<tr><td>Admin Email</td><td>{data.adminEmail}</td></tr> <tr><td>Admin Email</td><td>{data.adminEmail}</td></tr>
<tr><td>Tier</td><td><Tag intent={data.tier === 'Dedicated' ? Intent.DANGER : data.tier === 'Isolated' ? Intent.WARNING : Intent.NONE} round>{data.tier}</Tag></td></tr> <tr><td>Tier</td><td><Tag intent={data.tier === 'Enterprise' ? Intent.DANGER : data.tier === 'Dedicated' ? Intent.WARNING : data.tier === 'Trial' ? Intent.PRIMARY : Intent.NONE} round>{data.tier}</Tag></td></tr>
</tbody> </tbody>
</HTMLTable> </HTMLTable>
+96 -1
View File
@@ -385,8 +385,10 @@ body {
} }
.tier-badge-shared { background: #e5e8eb; color: #5f6b7c; } .tier-badge-shared { background: #e5e8eb; color: #5f6b7c; }
.tier-badge-trial { background: #dce9ff; color: #184a90; }
.tier-badge-isolated { background: #fef3c7; color: #92400e; } .tier-badge-isolated { background: #fef3c7; color: #92400e; }
.tier-badge-dedicated { background: #fee2e2; color: #991b1b; } .tier-badge-dedicated { background: #fde8d8; color: #9e3a06; }
.tier-badge-enterprise { background: #fee2e2; color: #991b1b; }
.tier-card-description { .tier-card-description {
font-size: 0.8rem; font-size: 0.8rem;
@@ -963,3 +965,96 @@ body {
color: #82071e; color: #82071e;
} }
/* ── Changesets Page ───────────────────────────────────────────────────── */
.cs-timeline {
display: flex;
flex-direction: column;
border: 1px solid #dce0e6;
border-radius: 8px;
overflow: hidden;
margin-top: 0.5rem;
}
.cs-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.55rem 1rem;
border-bottom: 1px solid #e5e8eb;
cursor: pointer;
transition: background 0.1s;
}
.cs-row:last-child {
border-bottom: none;
}
.cs-row:hover {
background: #f6f7f9;
}
.cs-row-left {
flex-shrink: 0;
width: 68px;
display: flex;
justify-content: flex-end;
padding-top: 2px;
}
.cs-repo-badge {
font-size: 0.67rem;
min-width: 52px;
text-align: center;
letter-spacing: 0.01em;
}
.cs-row-body {
flex: 1;
min-width: 0;
}
.cs-row-top {
display: flex;
align-items: baseline;
gap: 0.5rem;
flex-wrap: nowrap;
min-width: 0;
}
.cs-subject {
font-size: 0.84rem;
font-weight: 500;
color: #1c2127;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cs-row-meta {
font-size: 0.72rem;
color: #738091;
margin-top: 2px;
}
.cs-file-count {
color: #8f99a8;
}
.cs-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 1rem 0;
border-top: 1px solid #e5e8eb;
margin-top: 0.25rem;
}
.cs-page-label {
font-size: 0.85rem;
color: #5c7080;
font-weight: 500;
min-width: 60px;
text-align: center;
}
+2 -2
View File
@@ -370,7 +370,7 @@ function CommitsTab({ opc, isActive }: { opc: Opc; isActive: boolean }) {
// Load Gitea branch independently — don't block commit rendering // Load Gitea branch independently — don't block commit rendering
const opcTag = opc.number.replace('OPC # ', 'OPC-'); const opcTag = opc.number.replace('OPC # ', 'OPC-');
listGiteaBranches() listGiteaBranches('all')
.then(branches => { .then(branches => {
const found = branches.find(b => b.name.includes(opcTag)); const found = branches.find(b => b.name.includes(opcTag));
setLinkedBranch(found ?? null); setLinkedBranch(found ?? null);
@@ -458,7 +458,7 @@ function CommitsTab({ opc, isActive }: { opc: Opc; isActive: boolean }) {
{linkedBranch.name} {linkedBranch.name}
</Tag> </Tag>
<a <a
href={`https://opc.clarity.test/ClarityStack/Clarity/src/branch/${encodeURIComponent(linkedBranch.name)}`} href={`https://opc.clarity.test/ClarityStack/${linkedBranch.repoKey ?? 'Clarity'}/src/branch/${encodeURIComponent(linkedBranch.name)}`}
target="_blank" rel="noreferrer" target="_blank" rel="noreferrer"
style={{ fontSize: '0.8rem', color: 'var(--bp4-intent-primary)' }}> style={{ fontSize: '0.8rem', color: 'var(--bp4-intent-primary)' }}>
Open in Gitea Open in Gitea
@@ -0,0 +1,174 @@
import { useState, useEffect, useCallback } from 'react';
import { Button, HTMLSelect, InputGroup, Intent, NonIdealState, Spinner, Tag, Tooltip } from '@blueprintjs/core';
import { GitCommitDrawer } from '../components/GitCommitDrawer';
import { getChangesets } from '../api/opcApi';
import type { LinkedCommit } from '../api/opcApi';
const PAGE_SIZE = 25;
const REPO_OPTIONS = [
{ value: 'all', label: 'All Repos' },
{ value: 'Clarity', label: 'Clarity' },
{ value: 'OPC', label: 'OPC' },
{ value: 'Gateway', label: 'Gateway' },
];
const REPO_INTENT: Record<string, Intent> = {
Clarity: Intent.PRIMARY,
OPC: Intent.WARNING,
Gateway: Intent.SUCCESS,
};
export default function ChangesetsPage() {
const [commits, setCommits] = useState<LinkedCommit[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [repoFilter, setRepoFilter] = useState('all');
const [grepFilter, setGrepFilter] = useState('');
const [grepInput, setGrepInput] = useState('');
const [viewingHash, setViewingHash] = useState<string | null>(null);
const load = useCallback(async (p: number, repo: string, grep: string) => {
setLoading(true);
setError(null);
try {
// Fetch one extra to detect whether there's a next page
const rows = await getChangesets(p, PAGE_SIZE + 1, repo, grep || undefined);
setHasMore(rows.length > PAGE_SIZE);
setCommits(rows.slice(0, PAGE_SIZE));
} catch (e) {
setError(String(e));
setCommits([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load(page, repoFilter, grepFilter);
}, [load, page, repoFilter, grepFilter]);
const applySearch = () => {
setPage(1);
setGrepFilter(grepInput);
};
const clearSearch = () => {
setGrepInput('');
setGrepFilter('');
setPage(1);
};
const handleRepoChange = (repo: string) => {
setPage(1);
setRepoFilter(repo);
};
return (
<div>
<div className="page-header">
<div>
<h1>Changesets</h1>
<p>Chronological commit timeline across all three repos.</p>
</div>
<Button icon="refresh" minimal onClick={() => load(page, repoFilter, grepFilter)} />
</div>
{/* Filter bar */}
<div className="opc-filter-bar">
<HTMLSelect
value={repoFilter}
onChange={e => handleRepoChange(e.target.value)}
options={REPO_OPTIONS}
/>
<InputGroup
leftIcon="search"
placeholder="Filter commits by text or OPC number…"
value={grepInput}
onChange={e => setGrepInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') applySearch(); }}
style={{ width: 320 }}
rightElement={
<Button minimal icon="arrow-right" intent={Intent.PRIMARY}
disabled={!grepInput.trim()} onClick={applySearch} />
}
/>
{grepFilter && (
<Button minimal small icon="cross" text="Clear" onClick={clearSearch} />
)}
<span className="opc-count-badge">{loading ? '…' : `${commits.length} shown`}</span>
</div>
{/* Content */}
{loading ? (
<NonIdealState icon={<Spinner />} title="Loading changesets…" />
) : error ? (
<NonIdealState icon="warning-sign" title="Could not load commits"
description={error} intent={Intent.DANGER} />
) : commits.length === 0 ? (
<NonIdealState icon="git-commit" title="No commits found"
description="Try changing the repo filter or clearing the search." />
) : (
<div className="cs-timeline">
{commits.map(c => (
<div
key={`${c.repoKey}-${c.hash}`}
className="cs-row"
onClick={() => setViewingHash(c.hash)}
>
<div className="cs-row-left">
<Tag
intent={REPO_INTENT[c.repoKey] ?? Intent.NONE}
minimal round
className="cs-repo-badge"
>
{c.repoKey || '?'}
</Tag>
</div>
<div className="cs-row-body">
<div className="cs-row-top">
<Tooltip content="View diff" placement="top">
<code className="opc-commit-hash">{c.shortHash}</code>
</Tooltip>
<span className="cs-subject">{c.subject}</span>
</div>
<div className="cs-row-meta">
{c.author} · {c.date}
{c.files.length > 0 && (
<span className="cs-file-count"> · {c.files.length} file{c.files.length !== 1 ? 's' : ''}</span>
)}
</div>
</div>
</div>
))}
</div>
)}
{/* Pagination */}
{!loading && !error && (commits.length > 0 || page > 1) && (
<div className="cs-pagination">
<Button
icon="arrow-left"
minimal
text="Previous"
disabled={page <= 1}
onClick={() => setPage(p => p - 1)}
/>
<span className="cs-page-label">Page {page}</span>
<Button
rightIcon="arrow-right"
minimal
text="Next"
disabled={!hasMore}
onClick={() => setPage(p => p + 1)}
/>
</div>
)}
<GitCommitDrawer hash={viewingHash} onClose={() => setViewingHash(null)} />
</div>
);
}
@@ -1,4 +1,4 @@
export type TenantTier = 'Shared' | 'Isolated' | 'Dedicated'; export type TenantTier = 'Trial' | 'Shared' | 'Dedicated' | 'Enterprise';
export type TenantEnvironment = 'fdev' | 'uat' | 'prod'; export type TenantEnvironment = 'fdev' | 'uat' | 'prod';
export interface ProvisioningRequest { export interface ProvisioningRequest {