M2: the four V1 atoms + Git sync (Gitea / filesystem)
- Author the four V1 skill atoms in skills/ (Git is the source of truth): spec-writing & story-breakdown (product-owner), test-plan-generation & diff-review (qa) — each with risk-tagged actions, golden tests, and a body. - SharedKernel: IGitProvider seam (read-only, provider-agnostic) + GitFile. - Integrations module (its first real code): FileSystemGitProvider (dogfood/local) and a GiteaGitProvider (Gitea REST: recursive tree → SKILL.md blobs → base64 contents); the provider is chosen by GitSource:Provider config. - Skills: SkillSyncService consumes IGitProvider (never Integrations) and indexes each file; POST /api/skills/sync and a POST /api/skills/webhook/gitea (re-sync on push; signature verification + changed-file-only + queue offload come later). Verified: build green; ArchitectureTests 8/8 (Skills & Integrations reference only SharedKernel; the Git seam lives in SharedKernel); IntegrationTests 22/22 incl. a sync that indexes the four real atoms from skills/, published and queryable by role. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeamUp.SharedKernel.Git;
|
||||
|
||||
namespace TeamUp.Modules.Integrations.Git;
|
||||
|
||||
/// <summary>Reads SKILL.md files from a local directory — for dogfood/local dev and tests.</summary>
|
||||
internal sealed class FileSystemGitProvider(IOptions<GitSourceOptions> options) : IGitProvider
|
||||
{
|
||||
private readonly string _root = options.Value.Root;
|
||||
|
||||
public string Name => $"filesystem:{_root}";
|
||||
|
||||
public async Task<IReadOnlyList<GitFile>> ListSkillFilesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!Directory.Exists(_root))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var files = new List<GitFile>();
|
||||
foreach (var path in Directory.EnumerateFiles(_root, "SKILL.md", SearchOption.AllDirectories))
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
files.Add(new GitFile(Path.GetRelativePath(_root, path).Replace('\\', '/'), content));
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace TeamUp.Modules.Integrations.Git;
|
||||
|
||||
internal sealed class GitSourceOptions
|
||||
{
|
||||
public const string SectionName = "GitSource";
|
||||
|
||||
/// <summary>"filesystem" (dogfood/local) or "gitea".</summary>
|
||||
public string Provider { get; set; } = "filesystem";
|
||||
|
||||
/// <summary>Root directory scanned for SKILL.md when Provider is "filesystem".</summary>
|
||||
public string Root { get; set; } = "skills";
|
||||
|
||||
public GiteaOptions Gitea { get; set; } = new();
|
||||
}
|
||||
|
||||
internal sealed class GiteaOptions
|
||||
{
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
public string Owner { get; set; } = string.Empty;
|
||||
public string Repo { get; set; } = string.Empty;
|
||||
public string Branch { get; set; } = "main";
|
||||
public string Token { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeamUp.SharedKernel.Git;
|
||||
|
||||
namespace TeamUp.Modules.Integrations.Git;
|
||||
|
||||
/// <summary>
|
||||
/// Reads SKILL.md files from a Gitea repo over the REST API (read-only, V1). Lists the tree
|
||||
/// recursively, filters SKILL.md blobs, and fetches each via the contents API (base64).
|
||||
/// </summary>
|
||||
internal sealed class GiteaGitProvider(
|
||||
HttpClient http,
|
||||
IOptions<GitSourceOptions> options,
|
||||
ILogger<GiteaGitProvider> logger) : IGitProvider
|
||||
{
|
||||
private readonly GiteaOptions _options = options.Value.Gitea;
|
||||
|
||||
public string Name => $"gitea:{_options.Owner}/{_options.Repo}@{_options.Branch}";
|
||||
|
||||
public async Task<IReadOnlyList<GitFile>> ListSkillFilesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var baseUrl = _options.BaseUrl.TrimEnd('/');
|
||||
if (!string.IsNullOrEmpty(_options.Token))
|
||||
{
|
||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", _options.Token);
|
||||
}
|
||||
|
||||
var treeUrl = $"{baseUrl}/api/v1/repos/{_options.Owner}/{_options.Repo}/git/trees/{_options.Branch}?recursive=true&per_page=1000";
|
||||
var tree = await http.GetFromJsonAsync<GiteaTree>(treeUrl, cancellationToken);
|
||||
if (tree?.Tree is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var files = new List<GitFile>();
|
||||
foreach (var entry in tree.Tree.Where(e =>
|
||||
e.Type == "blob" && e.Path.EndsWith("SKILL.md", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var contentUrl = $"{baseUrl}/api/v1/repos/{_options.Owner}/{_options.Repo}/contents/{entry.Path}?ref={_options.Branch}";
|
||||
var file = await http.GetFromJsonAsync<GiteaContent>(contentUrl, cancellationToken);
|
||||
if (file?.Content is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(file.Content.Replace("\n", string.Empty)));
|
||||
files.Add(new GitFile(entry.Path, decoded));
|
||||
}
|
||||
|
||||
logger.LogInformation("Gitea provider found {Count} SKILL.md file(s).", files.Count);
|
||||
return files;
|
||||
}
|
||||
|
||||
private sealed class GiteaTree
|
||||
{
|
||||
public List<GiteaTreeEntry>? Tree { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GiteaTreeEntry
|
||||
{
|
||||
public string Path { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class GiteaContent
|
||||
{
|
||||
public string? Content { get; set; }
|
||||
public string? Encoding { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user