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:
soroush.asadi
2026-06-09 18:34:53 +03:30
parent 401e3e69af
commit bfcd223374
15 changed files with 452 additions and 5 deletions
@@ -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; }
}
}
@@ -3,20 +3,33 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TeamUp.Modules.Integrations.Git;
using TeamUp.SharedKernel.Git;
using TeamUp.SharedKernel.Modularity;
namespace TeamUp.Modules.Integrations;
/// <summary>BYOK API configs, the Git connection, the encrypted-credential store (M3).</summary>
/// <summary>
/// BYOK API configs, the Git connection, the encrypted-credential store. In M2 it provides the
/// <see cref="IGitProvider"/> (filesystem for dogfood, Gitea over REST). BYOK lands in M3.
/// </summary>
public sealed class IntegrationsModule : IModule
{
public string Name => "integrations";
public void Register(IServiceCollection services, IConfiguration configuration)
{
// Skeleton: no services yet. M3 introduces this module's (internal) DbContext, the
// encrypted ApiConfig store, and the provider-agnostic model-client seam interface.
// The concrete model client (Microsoft.Extensions.AI) is deferred to M3-M4.
services.Configure<GitSourceOptions>(configuration.GetSection(GitSourceOptions.SectionName));
var options = configuration.GetSection(GitSourceOptions.SectionName).Get<GitSourceOptions>() ?? new GitSourceOptions();
if (string.Equals(options.Provider, "gitea", StringComparison.OrdinalIgnoreCase))
{
services.AddHttpClient<IGitProvider, GiteaGitProvider>();
}
else
{
services.AddSingleton<IGitProvider, FileSystemGitProvider>();
}
}
public void MapEndpoints(IEndpointRouteBuilder endpoints)
@@ -23,3 +23,5 @@ internal sealed record SkillDetail(
string Body);
internal sealed record IndexRequest(string Content, string? SourceRepo, string? SourcePath, string? SourceCommit);
internal sealed record SyncResult(int Indexed);
@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.Skills.Domain;
using TeamUp.Modules.Skills.Indexing;
using TeamUp.Modules.Skills.Persistence;
using TeamUp.Modules.Skills.Sync;
using TeamUp.SharedKernel.Modularity;
namespace TeamUp.Modules.Skills.Endpoints;
@@ -19,8 +20,18 @@ internal static class SkillsEndpoints
group.MapGet("/", ListSkills).RequireAuthorization();
group.MapGet("/{key}", GetSkill).RequireAuthorization();
group.MapPost("/index", IndexSkill).RequireAuthorization();
group.MapPost("/sync", Sync).RequireAuthorization();
group.MapPost("/webhook/gitea", Webhook).AllowAnonymous();
}
private static async Task<IResult> Sync(SkillSyncService sync, CancellationToken ct) =>
Results.Ok(new SyncResult(await sync.SyncAsync(ct)));
// Gitea push webhook → re-sync the source. M2 re-indexes the whole source (idempotent);
// signature verification + changed-file-only sync via the job queue land later.
private static async Task<IResult> Webhook(SkillSyncService sync, CancellationToken ct) =>
Results.Ok(new SyncResult(await sync.SyncAsync(ct)));
private static async Task<IResult> ListSkills(
string? role, string? visibility, SkillsDbContext db, CancellationToken ct)
{
@@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using TeamUp.Modules.Skills.Endpoints;
using TeamUp.Modules.Skills.Indexing;
using TeamUp.Modules.Skills.Persistence;
using TeamUp.Modules.Skills.Sync;
using TeamUp.SharedKernel.Modularity;
using TeamUp.SharedKernel.Persistence;
@@ -25,6 +26,7 @@ public sealed class SkillsModule : IModule
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<SkillsDbContext>());
services.AddSingleton<ISkillEmbedder, HashingSkillEmbedder>();
services.AddScoped<SkillIndexer>();
services.AddScoped<SkillSyncService>();
services.TryAddSingleton(TimeProvider.System);
}
@@ -0,0 +1,34 @@
using Microsoft.Extensions.Logging;
using TeamUp.Modules.Skills.Indexing;
using TeamUp.SharedKernel.Git;
namespace TeamUp.Modules.Skills.Sync;
/// <summary>Pulls SKILL.md files from the configured Git source and indexes each one.</summary>
internal sealed class SkillSyncService(
IGitProvider provider,
SkillIndexer indexer,
ILogger<SkillSyncService> logger)
{
public async Task<int> SyncAsync(CancellationToken cancellationToken = default)
{
var files = await provider.ListSkillFilesAsync(cancellationToken);
var indexed = 0;
foreach (var file in files)
{
try
{
await indexer.IndexAsync(file.Content, provider.Name, file.Path, sourceCommit: null, cancellationToken);
indexed++;
}
catch (FormatException ex)
{
logger.LogWarning("Skipping {Path}: {Message}", file.Path, ex.Message);
}
}
logger.LogInformation("Synced {Indexed}/{Total} SKILL.md file(s) from {Source}.", indexed, files.Count, provider.Name);
return indexed;
}
}
@@ -0,0 +1,18 @@
namespace TeamUp.SharedKernel.Git;
/// <summary>A file read from a Git source.</summary>
public sealed record GitFile(string Path, string Content);
/// <summary>
/// Provider-agnostic read access to a Git source (Gitea in V1; GitHub/GitLab/Azure DevOps later).
/// Implemented by the Integrations module; consumed by Skills to sync SKILL.md files. Read-only in
/// V1 — write-back (PR comments, branches) is Phase 2.
/// </summary>
public interface IGitProvider
{
/// <summary>A short identifier for the configured source (used as the skill's provenance).</summary>
string Name { get; }
/// <summary>Returns every <c>SKILL.md</c> in the source, with its repo-relative path and content.</summary>
Task<IReadOnlyList<GitFile>> ListSkillFilesAsync(CancellationToken cancellationToken = default);
}