OPC # 0001: Gitea services
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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,15 +83,19 @@ 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.");
|
||||||
|
|
||||||
|
foreach (var (_, repoPath) in repos)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(repoPath)) continue;
|
||||||
using var repo = new Repository(repoPath);
|
using var repo = new Repository(repoPath);
|
||||||
var commit = repo.Lookup<Commit>(hash);
|
var commit = repo.Lookup<Commit>(hash);
|
||||||
if (commit is null) return Results.NotFound();
|
if (commit is null) continue;
|
||||||
|
|
||||||
var parentTree = commit.Parents.FirstOrDefault()?.Tree;
|
var parentTree = commit.Parents.FirstOrDefault()?.Tree;
|
||||||
var changes = repo.Diff.Compare<TreeChanges>(parentTree, commit.Tree);
|
var changes = repo.Diff.Compare<TreeChanges>(parentTree, commit.Tree);
|
||||||
@@ -116,6 +124,9 @@ public static class GitEndpoints
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/git/branches
|
// GET /api/git/branches
|
||||||
private static IResult GetBranches(IConfiguration config)
|
private static IResult GetBranches(IConfiguration config)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ 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);
|
||||||
@@ -23,8 +24,9 @@ public class GiteaService
|
|||||||
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)
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -32,6 +33,7 @@ function App() {
|
|||||||
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user