From 35891185a61297731b32526e962c5cd43c14291d Mon Sep 17 00:00:00 2001 From: Samuel Glauser Date: Wed, 15 Apr 2026 16:05:12 +0200 Subject: [PATCH] updates to github feature generation --- .../Memex.Portal.Shared/MemexConfiguration.cs | 8 + memex/aspire/Memex.AppHost/Program.cs | 6 + src/MeshWeaver.AI/AIExtensions.cs | 17 + src/MeshWeaver.AI/Data/Agent/SpecWriter.md | 69 +++ src/MeshWeaver.AI/Plugins/GitHubPlugin.cs | 326 ++++++++++ src/MeshWeaver.Documentation/Data/AI.md | 18 + .../Data/AI/SpecWriter.md | 42 ++ .../Data/AI/Tools/GitHubPlugin.md | 159 +++++ test/MeshWeaver.AI.Test/GitHubPluginTest.cs | 576 ++++++++++++++++++ .../MeshWeaver.AI.Test/SpecWriterAgentTest.cs | 311 ++++++++++ .../MeshPluginAccessContextTest.cs | 1 + 11 files changed, 1533 insertions(+) create mode 100644 src/MeshWeaver.AI/Data/Agent/SpecWriter.md create mode 100644 src/MeshWeaver.AI/Plugins/GitHubPlugin.cs create mode 100644 src/MeshWeaver.Documentation/Data/AI/SpecWriter.md create mode 100644 src/MeshWeaver.Documentation/Data/AI/Tools/GitHubPlugin.md create mode 100644 test/MeshWeaver.AI.Test/GitHubPluginTest.cs create mode 100644 test/MeshWeaver.AI.Test/SpecWriterAgentTest.cs diff --git a/memex/Memex.Portal.Shared/MemexConfiguration.cs b/memex/Memex.Portal.Shared/MemexConfiguration.cs index f99f75a41..2b1f5c673 100644 --- a/memex/Memex.Portal.Shared/MemexConfiguration.cs +++ b/memex/Memex.Portal.Shared/MemexConfiguration.cs @@ -105,6 +105,14 @@ public static void ConfigureMemexServices(this WebApplicationBuilder builder) services.AddWebSearchPlugin(config => builder.Configuration.GetSection("WebSearch").Bind(config)); + // Register GitHub plugin (agents declare it in frontmatter; requires GITHUB_TOKEN env var or GitHub:PersonalAccessToken) + services.AddGitHubPlugin(config => + { + builder.Configuration.GetSection("GitHub").Bind(config); + config.DefaultOwner ??= "Systemorph"; + config.DefaultRepo ??= "MeshWeaver"; + }); + // Configure GoogleMaps services.Configure(builder.Configuration.GetSection("GoogleMaps")); diff --git a/memex/aspire/Memex.AppHost/Program.cs b/memex/aspire/Memex.AppHost/Program.cs index eeef90722..9dee9e948 100644 --- a/memex/aspire/Memex.AppHost/Program.cs +++ b/memex/aspire/Memex.AppHost/Program.cs @@ -27,6 +27,7 @@ // Parameters:embedding-model // Parameters:microsoft-client-id // Parameters:microsoft-client-secret +// Parameters:github-token // // For local-test/local-prod, also set the connection string to the Azure PostgreSQL: // ConnectionStrings:memex (Azure PostgreSQL, bypassing provisioning) @@ -60,6 +61,9 @@ var googleClientId = builder.AddParameter("google-client-id", secret: false); var googleClientSecret = builder.AddParameter("google-client-secret", secret: true); +// GitHub plugin (SpecWriter agent → issue creation) +var githubToken = builder.AddParameter("github-token", secret: true); + // --- Custom domain (for deployed modes) --- var customDomain = builder.AddParameter("custom-domain", secret: false); var certificateName = builder.AddParameter("certificate-name", secret: false); @@ -153,6 +157,8 @@ .WithEnvironment("Authentication__Microsoft__ClientSecret", microsoftClientSecret) .WithEnvironment("Authentication__Google__ClientId", googleClientId) .WithEnvironment("Authentication__Google__ClientSecret", googleClientSecret) + // GitHub + .WithEnvironment("GitHub__PersonalAccessToken", githubToken) // Wait for dependencies .WaitFor(orleansTables) .WaitForCompletion(dbMigration) diff --git a/src/MeshWeaver.AI/AIExtensions.cs b/src/MeshWeaver.AI/AIExtensions.cs index 8c021acc7..ee12b883c 100644 --- a/src/MeshWeaver.AI/AIExtensions.cs +++ b/src/MeshWeaver.AI/AIExtensions.cs @@ -2,6 +2,7 @@ using MeshWeaver.Data; using MeshWeaver.Domain; using MeshWeaver.Layout; +using MeshWeaver.Mesh; using MeshWeaver.Messaging; using Microsoft.Extensions.DependencyInjection; @@ -128,5 +129,21 @@ public IServiceCollection AddWebSearchPlugin(Action? con services.AddSingleton(); return services; } + + /// + /// Registers the GitHub plugin, making CreateIssue, GetIssue, ListIssues, and UpdateIssue tools + /// available to agents that declare "GitHub" in their plugins frontmatter. + /// + public IServiceCollection AddGitHubPlugin(Action? configure = null) + { + if (configure != null) + services.Configure(configure); + else + services.AddOptions(); + + services.AddHttpClient(); + services.AddSingleton(); + return services; + } } } diff --git a/src/MeshWeaver.AI/Data/Agent/SpecWriter.md b/src/MeshWeaver.AI/Data/Agent/SpecWriter.md new file mode 100644 index 000000000..a0da1b1af --- /dev/null +++ b/src/MeshWeaver.AI/Data/Agent/SpecWriter.md @@ -0,0 +1,69 @@ +--- +nodeType: Agent +name: SpecWriter +description: Generates implementation specifications from Markdown nodes and publishes them as GitHub issues +icon: DocumentText +category: Agents +exposedInNavigator: true +preferredModel: claude-opus-4-6 +delegations: + - agentPath: Agent/Research + instructions: Gather context about related features, existing code patterns, and domain knowledge +plugins: + - Mesh:Get,Search,Create,Update + - GitHub +--- + +You are **SpecWriter**, the specification generation agent. Your job is to take a user's description of a bug or feature (typically stored as a Markdown MeshNode), structure it into a proper spec, and publish it as a GitHub issue. + +# Tools Reference + +@@Agent/ToolsReference + +# Workflow + +When the user asks you to create a spec or GitHub issue from a Markdown node: + +1. **Read the source node**: Use `Get` to retrieve the Markdown node the user references. The content will be a markdown string describing the bug or feature. +2. **Gather context**: Use `Search` to find related nodes, existing patterns, and relevant documentation. Delegate to **Research** for broader context if needed. +3. **Generate the structured spec**: Produce a Markdown spec following the output format below. +4. **Save the spec**: Use `Create` to save the structured spec as a new Markdown node in the appropriate project or shared namespace. Example: `Create('{"id": "my-spec", "namespace": "ACME/Specs", "name": "My Spec", "nodeType": "Markdown", "content": "..."}')` +5. **Check for duplicate issues**: Use `ListIssues` to search for existing GitHub issues with a similar title or keywords. If a matching open issue exists, link to it instead of creating a new one. +6. **Create a GitHub issue**: Use `CreateIssue` to publish the spec as a GitHub issue. Use the node name as the issue title and the full spec as the body. Apply appropriate labels (e.g., `feature-spec`, `bug`). +7. **Report back**: Tell the user the node path and the GitHub issue URL. + +# Spec Output Format + +Your output must follow this structure: + +```markdown +## Summary +[2-3 sentence overview of what the feature does and why] + +## Motivation +[Why this feature matters — customer-facing language] + +## Detailed Design +[Technical approach, architecture decisions, key implementation details] + +## Acceptance Criteria +- [ ] Criterion 1 +- [ ] Criterion 2 +- [ ] Criterion 3 + +## Dependencies +[Related features, prerequisites, or external requirements] + +## Open Questions +[Unresolved design decisions that need input] +``` + +# Guidelines + +- Always discover the node schema dynamically via `Get('@ACME/schema:')` — never assume field names +- Use `Search` to find related nodes, prior specs, and relevant documentation +- Delegate to **Research** for broad context gathering (codebase patterns, web references) +- Write acceptance criteria that are specific, measurable, and testable +- Include attribution: note the node path and any key context sources used +- When creating a GitHub issue, include a link back to the node path in the issue body for traceability +- If the GitHub plugin is not configured (no PAT), complete steps 1-4 and inform the user that GitHub integration requires configuration diff --git a/src/MeshWeaver.AI/Plugins/GitHubPlugin.cs b/src/MeshWeaver.AI/Plugins/GitHubPlugin.cs new file mode 100644 index 000000000..493995834 --- /dev/null +++ b/src/MeshWeaver.AI/Plugins/GitHubPlugin.cs @@ -0,0 +1,326 @@ +using System.ComponentModel; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace MeshWeaver.AI.Plugins; + +/// +/// Configuration for the GitHub plugin. +/// +public class GitHubConfiguration +{ + /// + /// GitHub Personal Access Token for API authentication. + /// If not set, all tools return an error prompting configuration. + /// + public string? PersonalAccessToken { get; set; } + + /// + /// Default repository owner (organization or user). + /// + public string? DefaultOwner { get; set; } + + /// + /// Default repository name. + /// + public string? DefaultRepo { get; set; } + + /// + /// Returns if set, otherwise falls back + /// to the GITHUB_TOKEN environment variable. + /// + public string? ResolvedToken => + !string.IsNullOrWhiteSpace(PersonalAccessToken) + ? PersonalAccessToken + : Environment.GetEnvironmentVariable("GITHUB_TOKEN"); +} + +/// +/// Plugin providing GitHub issue management tools for AI agents. +/// Register via . +/// +public class GitHubPlugin : IAgentPlugin +{ + private readonly HttpClient httpClient; + private readonly GitHubConfiguration config; + private readonly ILogger logger; + + public string Name => "GitHub"; + + public GitHubPlugin( + HttpClient httpClient, + IOptions options, + ILogger logger) + { + this.httpClient = httpClient; + this.config = options.Value; + this.logger = logger; + } + + [Description("Creates a new GitHub issue in the specified repository. Returns the issue URL and number.")] + public async Task CreateIssue( + [Description("Repository owner (org or user). Uses default if omitted.")] string? owner, + [Description("Repository name. Uses default if omitted.")] string? repo, + [Description("Issue title")] string title, + [Description("Issue body in Markdown format")] string body, + [Description("Comma-separated labels to apply (e.g., 'feature-spec,priority:high')")] string? labels = null, + [Description("Milestone name to assign")] string? milestone = null) + { + if (!EnsureConfigured(out var error)) return error; + + owner ??= config.DefaultOwner; + repo ??= config.DefaultRepo; + if (string.IsNullOrEmpty(owner) || string.IsNullOrEmpty(repo)) + return "Error: owner and repo are required. Set defaults in GitHubConfiguration or pass explicitly."; + if (!IsValidGitHubName(owner) || !IsValidGitHubName(repo)) + return "Error: owner and repo must contain only alphanumeric characters, dots, hyphens, or underscores."; + + logger.LogInformation("CreateIssue called for {Owner}/{Repo}: {Title}", owner, repo, title); + + try + { + var payload = new Dictionary { ["title"] = title, ["body"] = body }; + if (!string.IsNullOrEmpty(labels)) + payload["labels"] = labels.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + var request = CreateRequest(HttpMethod.Post, $"repos/{owner}/{repo}/issues", payload); + using var response = await httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + return JsonSerializer.Serialize(new + { + url = root.GetProperty("html_url").GetString(), + number = root.GetProperty("number").GetInt32(), + state = root.GetProperty("state").GetString() + }); + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "CreateIssue failed for {Owner}/{Repo}", owner, repo); + return FormatHttpError(ex, $"creating issue in {owner}/{repo}"); + } + } + + [Description("Gets details of a GitHub issue by number. Returns title, state, body, labels, and assignees.")] + public async Task GetIssue( + [Description("Repository owner")] string? owner, + [Description("Repository name")] string? repo, + [Description("Issue number")] int issueNumber) + { + if (!EnsureConfigured(out var error)) return error; + + owner ??= config.DefaultOwner; + repo ??= config.DefaultRepo; + if (string.IsNullOrEmpty(owner) || string.IsNullOrEmpty(repo)) + return "Error: owner and repo are required."; + if (!IsValidGitHubName(owner) || !IsValidGitHubName(repo)) + return "Error: owner and repo must contain only alphanumeric characters, dots, hyphens, or underscores."; + + logger.LogInformation("GetIssue called for {Owner}/{Repo}#{Number}", owner, repo, issueNumber); + + try + { + var request = CreateRequest(HttpMethod.Get, $"repos/{owner}/{repo}/issues/{issueNumber}"); + using var response = await httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + return JsonSerializer.Serialize(new + { + title = root.GetProperty("title").GetString(), + state = root.GetProperty("state").GetString(), + body = root.GetProperty("body").GetString(), + url = root.GetProperty("html_url").GetString(), + number = root.GetProperty("number").GetInt32(), + labels = root.GetProperty("labels").EnumerateArray() + .Select(l => l.GetProperty("name").GetString()).ToArray(), + created_at = root.GetProperty("created_at").GetString(), + updated_at = root.GetProperty("updated_at").GetString() + }); + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "GetIssue failed for {Owner}/{Repo}#{Number}", owner, repo, issueNumber); + return FormatHttpError(ex, $"getting issue #{issueNumber} in {owner}/{repo}"); + } + } + + [Description("Lists GitHub issues in a repository, filtered by state and labels.")] + public async Task ListIssues( + [Description("Repository owner")] string? owner, + [Description("Repository name")] string? repo, + [Description("Filter by state: open, closed, or all (default: open)")] string state = "open", + [Description("Comma-separated labels to filter by")] string? labels = null, + [Description("Maximum number of issues to return (default: 10)")] int perPage = 10) + { + if (!EnsureConfigured(out var error)) return error; + + owner ??= config.DefaultOwner; + repo ??= config.DefaultRepo; + if (string.IsNullOrEmpty(owner) || string.IsNullOrEmpty(repo)) + return "Error: owner and repo are required."; + if (!IsValidGitHubName(owner) || !IsValidGitHubName(repo)) + return "Error: owner and repo must contain only alphanumeric characters, dots, hyphens, or underscores."; + + logger.LogInformation("ListIssues called for {Owner}/{Repo} state={State}", owner, repo, state); + + try + { + var query = $"state={Uri.EscapeDataString(state)}&per_page={Math.Clamp(perPage, 1, 100)}"; + if (!string.IsNullOrEmpty(labels)) + query += $"&labels={Uri.EscapeDataString(labels)}"; + + var request = CreateRequest(HttpMethod.Get, $"repos/{owner}/{repo}/issues?{query}"); + using var response = await httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + var issues = doc.RootElement.EnumerateArray().Select(issue => new + { + number = issue.GetProperty("number").GetInt32(), + title = issue.GetProperty("title").GetString(), + state = issue.GetProperty("state").GetString(), + url = issue.GetProperty("html_url").GetString(), + labels = issue.GetProperty("labels").EnumerateArray() + .Select(l => l.GetProperty("name").GetString()).ToArray() + }).ToArray(); + + return JsonSerializer.Serialize(issues); + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "ListIssues failed for {Owner}/{Repo}", owner, repo); + return FormatHttpError(ex, $"listing issues in {owner}/{repo}"); + } + } + + [Description("Updates an existing GitHub issue (state, title, body, labels).")] + public async Task UpdateIssue( + [Description("Repository owner")] string? owner, + [Description("Repository name")] string? repo, + [Description("Issue number to update")] int issueNumber, + [Description("New state: open or closed")] string? state = null, + [Description("New title")] string? title = null, + [Description("New body")] string? body = null, + [Description("Comma-separated labels to set")] string? labels = null) + { + if (!EnsureConfigured(out var error)) return error; + + owner ??= config.DefaultOwner; + repo ??= config.DefaultRepo; + if (string.IsNullOrEmpty(owner) || string.IsNullOrEmpty(repo)) + return "Error: owner and repo are required."; + if (!IsValidGitHubName(owner) || !IsValidGitHubName(repo)) + return "Error: owner and repo must contain only alphanumeric characters, dots, hyphens, or underscores."; + + logger.LogInformation("UpdateIssue called for {Owner}/{Repo}#{Number}", owner, repo, issueNumber); + + try + { + var payload = new Dictionary(); + if (state != null) payload["state"] = state; + if (title != null) payload["title"] = title; + if (body != null) payload["body"] = body; + if (labels != null) + payload["labels"] = labels.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + var request = CreateRequest(HttpMethod.Patch, $"repos/{owner}/{repo}/issues/{issueNumber}", payload); + using var response = await httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + return JsonSerializer.Serialize(new + { + url = root.GetProperty("html_url").GetString(), + number = root.GetProperty("number").GetInt32(), + state = root.GetProperty("state").GetString() + }); + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "UpdateIssue failed for {Owner}/{Repo}#{Number}", owner, repo, issueNumber); + return FormatHttpError(ex, $"updating issue #{issueNumber} in {owner}/{repo}"); + } + } + + public IEnumerable CreateTools() + { + return + [ + AIFunctionFactory.Create(CreateIssue), + AIFunctionFactory.Create(GetIssue), + AIFunctionFactory.Create(ListIssues), + AIFunctionFactory.Create(UpdateIssue) + ]; + } + + private static string FormatHttpError(HttpRequestException ex, string operation) + { + return ex.StatusCode switch + { + System.Net.HttpStatusCode.Forbidden => + $"Error: GitHub returned 403 Forbidden for {operation}. " + + "Check that your Personal Access Token has the 'Issues: Read and write' permission " + + "and has access to the target repository.", + System.Net.HttpStatusCode.NotFound => + $"Error: GitHub returned 404 for {operation}. Check that the owner/repo exists and your token has access.", + System.Net.HttpStatusCode.Unauthorized => + $"Error: GitHub returned 401 Unauthorized for {operation}. Your Personal Access Token may be expired or invalid.", + System.Net.HttpStatusCode.UnprocessableEntity => + $"Error: GitHub returned 422 for {operation}. The request data may be invalid (e.g., unknown label or milestone).", + _ => $"Error in {operation}: {ex.Message}" + }; + } + + private bool EnsureConfigured(out string error) + { + if (string.IsNullOrWhiteSpace(config.ResolvedToken)) + { + error = "GitHub plugin is not configured. Set PersonalAccessToken in GitHubConfiguration or the GITHUB_TOKEN environment variable."; + return false; + } + error = string.Empty; + return true; + } + + private static bool IsValidGitHubName(string name) + => !string.IsNullOrEmpty(name) && name.All(c => char.IsLetterOrDigit(c) || c is '.' or '_' or '-'); + + private HttpRequestMessage CreateRequest(HttpMethod method, string path, object? payload = null) + => CreateGitHubRequest(method, path, config.ResolvedToken!, payload); + + /// + /// Creates an HttpRequestMessage with standard GitHub API headers. + /// + internal static HttpRequestMessage CreateGitHubRequest(HttpMethod method, string path, string pat, object? payload = null) + { + var request = new HttpRequestMessage(method, $"https://api.github.com/{path}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", pat); + request.Headers.Add("User-Agent", "MeshWeaver/1.0"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + request.Headers.Add("X-GitHub-Api-Version", "2022-11-28"); + + if (payload != null) + request.Content = new StringContent( + JsonSerializer.Serialize(payload), + Encoding.UTF8, "application/json"); + + return request; + } +} diff --git a/src/MeshWeaver.Documentation/Data/AI.md b/src/MeshWeaver.Documentation/Data/AI.md index 0db61f666..2887bf2a3 100644 --- a/src/MeshWeaver.Documentation/Data/AI.md +++ b/src/MeshWeaver.Documentation/Data/AI.md @@ -15,6 +15,7 @@ MeshWeaver provides comprehensive AI capabilities through agents, tools, and nat |---------|-------------| | [Agentic AI](AgenticAI) | Understand the paradigm shift to proactive, goal-oriented AI agents | | [Vibe Coding](VibeCoding) | Can AI build complex business apps? Watch the Mesh Bros put it to the test | +| [SpecWriter Agent](SpecWriter) | Generate structured specs from nodes and publish them as GitHub issues | --- @@ -23,6 +24,8 @@ MeshWeaver provides comprehensive AI capabilities through agents, tools, and nat | I want to... | Go here | |--------------|---------| | Use mesh tools in agents | [MeshPlugin Tools](Tools/MeshPlugin) - Get, Search, Create, Update, Delete, NavigateTo | +| Manage GitHub issues from agents | [GitHubPlugin Tools](Tools/GitHubPlugin) - CreateIssue, GetIssue, ListIssues, UpdateIssue | +| Generate specs from nodes | [SpecWriter Agent](SpecWriter) - Structured specs → GitHub issues | | Understand agent architecture | [Agentic AI](../Architecture/AgenticAI) - Multi-agent patterns | | Connect external AI via MCP | [MCP Integration](../Architecture/AgenticAI#exposing-meshweaver-as-mcp-server) - Claude Code, Copilot, Snowflake | @@ -50,6 +53,21 @@ Get supports **Unified Path prefixes** for accessing schemas and data models: [Read more: MeshPlugin Tools](Tools/MeshPlugin) +## GitHubPlugin + +The GitHubPlugin provides AI agents with tools to manage GitHub issues: + +| Tool | Purpose | +|------|---------| +| **CreateIssue** | Create a new issue with title, body, labels, and milestone | +| **GetIssue** | Retrieve full details of an issue by number | +| **ListIssues** | List issues filtered by state and labels | +| **UpdateIssue** | Update issue state, title, body, or labels | + +Requires a GitHub Personal Access Token via `GitHubConfiguration` or the `GITHUB_TOKEN` environment variable. + +[Read more: GitHubPlugin Tools](Tools/GitHubPlugin) + --- ## Agent Definition diff --git a/src/MeshWeaver.Documentation/Data/AI/SpecWriter.md b/src/MeshWeaver.Documentation/Data/AI/SpecWriter.md new file mode 100644 index 000000000..6b12f8bc9 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/AI/SpecWriter.md @@ -0,0 +1,42 @@ +--- +Name: SpecWriter Agent +Category: Documentation +Description: Agent that generates structured specifications from Markdown nodes and publishes them as GitHub issues +Icon: +--- + +SpecWriter is an AI agent that reads a Markdown node describing a bug or feature, generates a structured specification, and publishes it as a GitHub issue. + +## Plugins + +SpecWriter uses two plugins: + +| Plugin | Tools | Purpose | +|--------|-------|---------| +| **Mesh** | Get, Search, Create, Update | Read source nodes, find context, write specs back | +| **GitHub** | CreateIssue, GetIssue, ListIssues, UpdateIssue | Publish specs as GitHub issues | + +## Delegations + +| Agent | Purpose | +|-------|---------| +| **Research** | Gather broader context about related features, existing code patterns, and domain knowledge | + +## Usage + +Reference the agent in chat: + +``` +@agent/SpecWriter +``` + +Or combine with a prompt: + +``` +@agent/SpecWriter create a spec from @ACME/FeatureIdea +``` + +## Prerequisites + +- The **GitHub plugin** must be configured with a valid Personal Access Token. See [GitHubPlugin Tools](Tools/GitHubPlugin) for configuration details. +- If the GitHub plugin is not configured, SpecWriter will still generate the spec and update the node, but will skip GitHub issue creation and inform the user. diff --git a/src/MeshWeaver.Documentation/Data/AI/Tools/GitHubPlugin.md b/src/MeshWeaver.Documentation/Data/AI/Tools/GitHubPlugin.md new file mode 100644 index 000000000..bc082a845 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/AI/Tools/GitHubPlugin.md @@ -0,0 +1,159 @@ +--- +Name: GitHubPlugin Tools +Category: Documentation +Description: Complete reference for GitHubPlugin tools used by AI agents to manage GitHub issues +Icon: +--- + +GitHubPlugin provides tools for managing GitHub issues from AI agents. It authenticates via a Personal Access Token configured through `GitHubConfiguration` or the `GITHUB_TOKEN` environment variable. + +## CreateIssue + +Creates a new GitHub issue in the specified repository. Returns JSON with `url`, `number`, and `state`. + +### Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `owner` | string | No | Repository owner (org or user). Falls back to `DefaultOwner` if omitted. | +| `repo` | string | No | Repository name. Falls back to `DefaultRepo` if omitted. | +| `title` | string | Yes | Issue title | +| `body` | string | Yes | Issue body in Markdown format | +| `labels` | string | No | Comma-separated labels to apply (e.g., `feature-spec,priority:high`) | +| `milestone` | string | No | Milestone name to assign | + +### Example + +``` +CreateIssue(null, null, "Add export feature", "## Summary\nExport data to CSV...", "feature-spec,priority:high") +``` + +## GetIssue + +Gets details of a GitHub issue by number. Returns title, state, body, labels, URL, and timestamps. + +### Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `owner` | string | No | Repository owner | +| `repo` | string | No | Repository name | +| `issueNumber` | int | Yes | Issue number | + +### Example + +``` +GetIssue(null, null, 42) +``` + +## ListIssues + +Lists GitHub issues in a repository, filtered by state and labels. + +### Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `owner` | string | No | Repository owner | +| `repo` | string | No | Repository name | +| `state` | string | No | Filter by state: `open`, `closed`, or `all` (default: `open`) | +| `labels` | string | No | Comma-separated labels to filter by | +| `perPage` | int | No | Maximum number of issues to return (default: 10, max: 100) | + +### Example + +``` +ListIssues(null, null, "open", "feature-spec", 20) +``` + +## UpdateIssue + +Updates an existing GitHub issue. Only specified fields are changed. + +### Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `owner` | string | No | Repository owner | +| `repo` | string | No | Repository name | +| `issueNumber` | int | Yes | Issue number to update | +| `state` | string | No | New state: `open` or `closed` | +| `title` | string | No | New title | +| `body` | string | No | New body | +| `labels` | string | No | Comma-separated labels to set | + +### Example + +``` +UpdateIssue(null, null, 42, "closed") +``` + +## Configuration + +### Registration + +Register the plugin in your service configuration: + +```csharp +services.AddGitHubPlugin(config => +{ + config.DefaultOwner = "Systemorph"; + config.DefaultRepo = "MeshWeaver"; +}); +``` + +Or register with defaults and rely on environment variables: + +```csharp +services.AddGitHubPlugin(); +``` + +### Authentication + +The plugin requires a GitHub Personal Access Token (PAT) for issue management. + +> **Security recommendation:** Use fine-grained PATs scoped to specific repositories and grant only the "Issues: Read and write" permission. Avoid using classic tokens with the full `repo` scope. + +#### Token Resolution Order + +1. `GitHubConfiguration.PersonalAccessToken` — from app configuration (e.g., user secrets) +2. `GITHUB_TOKEN` environment variable — automatic fallback + +If neither is set, all tools return an error prompting configuration. + +#### Monolith (local dev) + +Set the token via .NET user secrets: + +```bash +dotnet user-secrets set "GitHub:PersonalAccessToken" "github_pat_xxx" --project memex/Memex.Portal.Monolith +``` + +Or export the `GITHUB_TOKEN` environment variable before running the app: + +```bash +export GITHUB_TOKEN="github_pat_xxx" +``` + +#### Aspire (distributed) + +Set the token as an Aspire parameter via user secrets: + +```bash +dotnet user-secrets set "Parameters:github-token" "github_pat_xxx" --project memex/aspire/Memex.AppHost +``` + +The AppHost wires this parameter as the `GitHub__PersonalAccessToken` environment variable to the portal container. + +### Agent Frontmatter + +To give an agent access to GitHub tools, include `GitHub` in the `plugins` list: + +```yaml +--- +nodeType: Agent +name: My Agent +plugins: + - GitHub +--- +``` diff --git a/test/MeshWeaver.AI.Test/GitHubPluginTest.cs b/test/MeshWeaver.AI.Test/GitHubPluginTest.cs new file mode 100644 index 000000000..d00f711e8 --- /dev/null +++ b/test/MeshWeaver.AI.Test/GitHubPluginTest.cs @@ -0,0 +1,576 @@ +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using MeshWeaver.AI.Persistence; +using MeshWeaver.AI.Plugins; +using MeshWeaver.Graph; +using MeshWeaver.Graph.Configuration; +using MeshWeaver.Hosting; +using MeshWeaver.Hosting.Monolith; +using MeshWeaver.Hosting.Monolith.TestBase; +using MeshWeaver.Hosting.Persistence; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace MeshWeaver.AI.Test; + +/// +/// Tests for GitHubPlugin tool surface and API integration. +/// API tests are conditionally skipped when GITHUB_TOKEN is not set. +/// +public class GitHubPluginTest : MonolithMeshTestBase +{ + private static readonly string TestDataPath = Path.Combine(AppContext.BaseDirectory, "TestData"); + + public GitHubPluginTest(ITestOutputHelper output) : base(output) { } + + protected override MeshBuilder ConfigureMesh(MeshBuilder builder) + { + return builder + .UseMonolithMesh() + .AddFileSystemPersistence(TestDataPath) + .ConfigureServices(services => + { + + services.AddGitHubPlugin(); + return services; + }) + .AddGraph() + .AddAI() + .ConfigureDefaultNodeHub(config => config.AddDefaultLayoutAreas()); + } + + #region Test Infrastructure + + internal class TestHttpMessageHandler : HttpMessageHandler + { + public HttpRequestMessage? LastRequest { get; private set; } + public string? LastRequestBody { get; private set; } + public HttpResponseMessage ConfiguredResponse { get; set; } = new(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }; + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken ct) + { + LastRequest = request; + if (request.Content != null) + LastRequestBody = await request.Content.ReadAsStringAsync(ct); + return ConfiguredResponse; + } + } + + private static (GitHubPlugin plugin, TestHttpMessageHandler handler) CreateTestPlugin( + string pat = "test-pat", string? owner = "TestOwner", string? repo = "TestRepo") + { + var handler = new TestHttpMessageHandler(); + var client = new HttpClient(handler); + var plugin = new GitHubPlugin(client, Options.Create(new GitHubConfiguration + { + PersonalAccessToken = pat, DefaultOwner = owner, DefaultRepo = repo + }), NullLogger.Instance); + return (plugin, handler); + } + + #endregion + + #region Group 1: Tool Registration (existing) + + /// + /// Verifies that GitHubPlugin is registered and exposes the expected tools. + /// + [Fact] + public void CreateTools_ReturnsExpectedTools() + { + var plugin = Mesh.ServiceProvider.GetRequiredService>() + .First(p => p.Name == "GitHub"); + var tools = plugin.CreateTools(); + var names = tools.OfType().Select(t => t.Name).ToList(); + + Output.WriteLine($"GitHub tools: {string.Join(", ", names)}"); + + names.Should().Contain("CreateIssue"); + names.Should().Contain("GetIssue"); + names.Should().Contain("ListIssues"); + names.Should().Contain("UpdateIssue"); + names.Should().HaveCount(4); + } + + #endregion + + #region Group 1: Request Building (via TestHttpMessageHandler) + + [Fact] + public async Task CreateIssue_SetsCorrectHeaders() + { + var (plugin, handler) = CreateTestPlugin(pat: "my-secret-pat"); + + // Configure a valid response so the plugin can parse it + handler.ConfiguredResponse = new(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new + { + html_url = "https://github.com/TestOwner/TestRepo/issues/1", + number = 1, + state = "open" + }), Encoding.UTF8, "application/json") + }; + + await plugin.CreateIssue(null, null, "Test", "Body"); + + var req = handler.LastRequest!; + req.Headers.Authorization!.Scheme.Should().Be("Bearer"); + req.Headers.Authorization.Parameter.Should().Be("my-secret-pat"); + req.Headers.UserAgent.ToString().Should().Contain("MeshWeaver"); + req.Headers.Accept.Should().Contain(a => a.MediaType == "application/vnd.github+json"); + req.Headers.GetValues("X-GitHub-Api-Version").Should().Contain("2022-11-28"); + req.RequestUri!.ToString().Should().Be("https://api.github.com/repos/TestOwner/TestRepo/issues"); + } + + [Fact] + public async Task CreateIssue_WithPayload_SerializesJson() + { + var (plugin, handler) = CreateTestPlugin(); + + handler.ConfiguredResponse = new(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new + { + html_url = "https://github.com/TestOwner/TestRepo/issues/1", + number = 1, + state = "open" + }), Encoding.UTF8, "application/json") + }; + + await plugin.CreateIssue(null, null, "My Title", "My Body"); + + handler.LastRequestBody.Should().NotBeNull(); + using var doc = JsonDocument.Parse(handler.LastRequestBody!); + doc.RootElement.GetProperty("title").GetString().Should().Be("My Title"); + doc.RootElement.GetProperty("body").GetString().Should().Be("My Body"); + handler.LastRequest!.Content!.Headers.ContentType!.MediaType.Should().Be("application/json"); + } + + #endregion + + #region Group 2: Error Paths + + /// + /// Verifies that all methods return a configuration error when PAT is not set. + /// + [Fact] + public async Task AllMethods_WithoutPAT_ReturnConfigError() + { + var (plugin, _) = CreateTestPlugin(pat: ""); + + var createResult = await plugin.CreateIssue(null, null, "Test", "Body"); + var getResult = await plugin.GetIssue(null, null, 1); + var listResult = await plugin.ListIssues(null, null); + var updateResult = await plugin.UpdateIssue(null, null, 1, state: "closed"); + + createResult.Should().Contain("not configured"); + getResult.Should().Contain("not configured"); + listResult.Should().Contain("not configured"); + updateResult.Should().Contain("not configured"); + } + + [Fact] + public async Task CreateIssue_WithoutOwnerOrRepo_ReturnsError() + { + var (plugin, _) = CreateTestPlugin(owner: null, repo: null); + + var result = await plugin.CreateIssue(null, null, "Test", "Body"); + + result.Should().Contain("owner and repo are required"); + } + + [Fact] + public async Task AllMethods_WithInvalidOwner_ReturnValidationError() + { + var (plugin, _) = CreateTestPlugin(owner: "evil/../..", repo: "TestRepo"); + + var createResult = await plugin.CreateIssue(null, null, "Test", "Body"); + var getResult = await plugin.GetIssue(null, null, 1); + var listResult = await plugin.ListIssues(null, null); + var updateResult = await plugin.UpdateIssue(null, null, 1, state: "closed"); + + createResult.Should().Contain("alphanumeric"); + getResult.Should().Contain("alphanumeric"); + listResult.Should().Contain("alphanumeric"); + updateResult.Should().Contain("alphanumeric"); + } + + [Fact] + public async Task AllMethods_WithInvalidRepo_ReturnValidationError() + { + var (plugin, _) = CreateTestPlugin(owner: "TestOwner", repo: "repo/../../etc"); + + var createResult = await plugin.CreateIssue(null, null, "Test", "Body"); + var getResult = await plugin.GetIssue(null, null, 1); + var listResult = await plugin.ListIssues(null, null); + var updateResult = await plugin.UpdateIssue(null, null, 1, state: "closed"); + + createResult.Should().Contain("alphanumeric"); + getResult.Should().Contain("alphanumeric"); + listResult.Should().Contain("alphanumeric"); + updateResult.Should().Contain("alphanumeric"); + } + + [Fact] + public async Task CreateIssue_WithExplicitInvalidOwner_ReturnValidationError() + { + var (plugin, _) = CreateTestPlugin(); + + var result = await plugin.CreateIssue("evil/../..", "TestRepo", "Test", "Body"); + + result.Should().Contain("alphanumeric"); + } + + [Fact] + public async Task CreateIssue_WithValidHyphensDotsUnderscores_Succeeds() + { + var (plugin, handler) = CreateTestPlugin(owner: "my-org.test_1", repo: "my-repo.v2_beta"); + + handler.ConfiguredResponse = new(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new + { + html_url = "https://github.com/my-org.test_1/my-repo.v2_beta/issues/1", + number = 1, + state = "open" + }), Encoding.UTF8, "application/json") + }; + + var result = await plugin.CreateIssue(null, null, "Title", "Body"); + + result.Should().NotContain("Error"); + result.Should().Contain("github.com"); + } + + #endregion + + #region Group 3: HTTP Behavior with TestHttpMessageHandler + + [Fact] + public async Task CreateIssue_UsesDefaultOwnerRepo() + { + var (plugin, handler) = CreateTestPlugin(owner: "MyOrg", repo: "MyRepo"); + + handler.ConfiguredResponse = new(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new + { + html_url = "https://github.com/MyOrg/MyRepo/issues/42", + number = 42, + state = "open" + }), Encoding.UTF8, "application/json") + }; + + await plugin.CreateIssue(null, null, "Title", "Body"); + + handler.LastRequest!.RequestUri!.ToString() + .Should().Contain("repos/MyOrg/MyRepo/issues"); + } + + [Fact] + public async Task CreateIssue_WithLabels_SplitsCommaString() + { + var (plugin, handler) = CreateTestPlugin(); + + handler.ConfiguredResponse = new(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new + { + html_url = "https://github.com/TestOwner/TestRepo/issues/1", + number = 1, + state = "open" + }), Encoding.UTF8, "application/json") + }; + + await plugin.CreateIssue(null, null, "Title", "Body", "a, b, c"); + + using var doc = JsonDocument.Parse(handler.LastRequestBody!); + var labels = doc.RootElement.GetProperty("labels").EnumerateArray() + .Select(l => l.GetString()).ToList(); + labels.Should().BeEquivalentTo(["a", "b", "c"]); + } + + [Fact] + public async Task ListIssues_ClampsPerPage() + { + var (plugin, handler) = CreateTestPlugin(); + + handler.ConfiguredResponse = new(HttpStatusCode.OK) + { + Content = new StringContent("[]", Encoding.UTF8, "application/json") + }; + + // perPage=200 should be clamped to 100 + await plugin.ListIssues(null, null, perPage: 200); + handler.LastRequest!.RequestUri!.Query.Should().Contain("per_page=100"); + + // Need a fresh response since the previous one gets disposed + handler.ConfiguredResponse = new(HttpStatusCode.OK) + { + Content = new StringContent("[]", Encoding.UTF8, "application/json") + }; + + // perPage=0 should be clamped to 1 + await plugin.ListIssues(null, null, perPage: 0); + handler.LastRequest!.RequestUri!.Query.Should().Contain("per_page=1"); + } + + [Fact] + public async Task UpdateIssue_SendsOnlyNonNullFields() + { + var (plugin, handler) = CreateTestPlugin(); + + handler.ConfiguredResponse = new(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new + { + html_url = "https://github.com/TestOwner/TestRepo/issues/5", + number = 5, + state = "closed" + }), Encoding.UTF8, "application/json") + }; + + await plugin.UpdateIssue(null, null, 5, state: "closed"); + + using var doc = JsonDocument.Parse(handler.LastRequestBody!); + doc.RootElement.TryGetProperty("state", out var stateVal).Should().BeTrue(); + stateVal.GetString().Should().Be("closed"); + doc.RootElement.TryGetProperty("title", out _).Should().BeFalse(); + doc.RootElement.TryGetProperty("body", out _).Should().BeFalse(); + doc.RootElement.TryGetProperty("labels", out _).Should().BeFalse(); + } + + [Fact] + public async Task UpdateIssue_WithAllFields_SendsAll() + { + var (plugin, handler) = CreateTestPlugin(); + + handler.ConfiguredResponse = new(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new + { + html_url = "https://github.com/TestOwner/TestRepo/issues/5", + number = 5, + state = "open" + }), Encoding.UTF8, "application/json") + }; + + await plugin.UpdateIssue(null, null, 5, + state: "open", title: "New Title", body: "New Body", labels: "x,y"); + + using var doc = JsonDocument.Parse(handler.LastRequestBody!); + doc.RootElement.GetProperty("state").GetString().Should().Be("open"); + doc.RootElement.GetProperty("title").GetString().Should().Be("New Title"); + doc.RootElement.GetProperty("body").GetString().Should().Be("New Body"); + var labels = doc.RootElement.GetProperty("labels").EnumerateArray() + .Select(l => l.GetString()).ToList(); + labels.Should().BeEquivalentTo(["x", "y"]); + } + + [Fact] + public async Task CreateIssue_HttpErrors_ReturnFormattedMessages() + { + var (plugin, handler) = CreateTestPlugin(); + + // 401 + handler.ConfiguredResponse = new(HttpStatusCode.Unauthorized); + var result401 = await plugin.CreateIssue(null, null, "T", "B"); + result401.Should().Contain("401").And.Contain("Unauthorized"); + + // 403 + handler.ConfiguredResponse = new(HttpStatusCode.Forbidden); + var result403 = await plugin.CreateIssue(null, null, "T", "B"); + result403.Should().Contain("403").And.Contain("Forbidden"); + + // 404 + handler.ConfiguredResponse = new(HttpStatusCode.NotFound); + var result404 = await plugin.CreateIssue(null, null, "T", "B"); + result404.Should().Contain("404"); + } + + [Fact] + public async Task GetIssue_ReturnsFormattedJson() + { + var (plugin, handler) = CreateTestPlugin(); + + handler.ConfiguredResponse = new(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new + { + title = "Fix login bug", + state = "open", + body = "Steps to reproduce...", + html_url = "https://github.com/TestOwner/TestRepo/issues/42", + number = 42, + labels = new[] { new { name = "bug" }, new { name = "priority:high" } }, + created_at = "2025-01-15T10:00:00Z", + updated_at = "2025-01-16T12:00:00Z" + }), Encoding.UTF8, "application/json") + }; + + var result = await plugin.GetIssue(null, null, 42); + + using var doc = JsonDocument.Parse(result); + doc.RootElement.GetProperty("title").GetString().Should().Be("Fix login bug"); + doc.RootElement.GetProperty("state").GetString().Should().Be("open"); + doc.RootElement.GetProperty("number").GetInt32().Should().Be(42); + doc.RootElement.GetProperty("url").GetString().Should().Contain("github.com"); + var labels = doc.RootElement.GetProperty("labels").EnumerateArray() + .Select(l => l.GetString()).ToList(); + labels.Should().BeEquivalentTo(["bug", "priority:high"]); + } + + [Fact] + public async Task ListIssues_ReturnsArrayOfFormattedIssues() + { + var (plugin, handler) = CreateTestPlugin(); + + handler.ConfiguredResponse = new(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new[] + { + new + { + number = 1, title = "First", state = "open", + html_url = "https://github.com/TestOwner/TestRepo/issues/1", + labels = new[] { new { name = "bug" } } + }, + new + { + number = 2, title = "Second", state = "closed", + html_url = "https://github.com/TestOwner/TestRepo/issues/2", + labels = new[] { new { name = "none" } } + } + }), Encoding.UTF8, "application/json") + }; + + var result = await plugin.ListIssues(null, null); + + using var doc = JsonDocument.Parse(result); + doc.RootElement.GetArrayLength().Should().Be(2); + doc.RootElement[0].GetProperty("title").GetString().Should().Be("First"); + doc.RootElement[1].GetProperty("title").GetString().Should().Be("Second"); + doc.RootElement[1].GetProperty("state").GetString().Should().Be("closed"); + } + + [Fact] + public async Task ListIssues_IncludesLabelsInQueryString() + { + var (plugin, handler) = CreateTestPlugin(); + + handler.ConfiguredResponse = new(HttpStatusCode.OK) + { + Content = new StringContent("[]", Encoding.UTF8, "application/json") + }; + + await plugin.ListIssues(null, null, labels: "bug,feature"); + + handler.LastRequest!.RequestUri!.Query.Should().Contain("labels="); + // URL-encoded comma + var query = handler.LastRequest.RequestUri.Query; + query.Should().Contain("bug"); + } + + #endregion + + #region Integration Tests (conditionally skipped) + + /// + /// Integration test: creates and closes an issue on GitHub. + /// Skipped when GITHUB_TOKEN is not set. + /// + [Fact] + public async Task CreateIssue_WithValidPAT_ReturnsIssueUrl() + { + var pat = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); + if (string.IsNullOrEmpty(pat)) + { + Output.WriteLine("Skipping: GITHUB_TOKEN not set"); + return; + } + + var loggerFactory = Mesh.ServiceProvider.GetRequiredService(); + var plugin = new GitHubPlugin( + new HttpClient(), + Options.Create(new GitHubConfiguration + { + PersonalAccessToken = pat, + DefaultOwner = "Systemorph", + DefaultRepo = "MeshWeaver" + }), + loggerFactory.CreateLogger()); + + var result = await plugin.CreateIssue( + null, null, + "[TEST] TDD Integration Test - Safe to Close", + "Automated test issue. Please close.", + "test"); + + Output.WriteLine($"CreateIssue result: {result}"); + result.Should().Contain("github.com"); + + // Clean up: close the issue + using var doc = JsonDocument.Parse(result); + var number = doc.RootElement.GetProperty("number").GetInt32(); + var closeResult = await plugin.UpdateIssue(null, null, number, state: "closed"); + Output.WriteLine($"CloseIssue result: {closeResult}"); + } + + /// + /// Integration test: reads an existing issue. + /// Skipped when GITHUB_TOKEN is not set. + /// + [Fact] + public async Task GetIssue_Integration_ReturnsIssueDetails() + { + var pat = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); + if (string.IsNullOrEmpty(pat)) + { + Output.WriteLine("Skipping: GITHUB_TOKEN not set"); + return; + } + + var loggerFactory = Mesh.ServiceProvider.GetRequiredService(); + var plugin = new GitHubPlugin( + new HttpClient(), + Options.Create(new GitHubConfiguration + { + PersonalAccessToken = pat, + DefaultOwner = "Systemorph", + DefaultRepo = "MeshWeaver" + }), + loggerFactory.CreateLogger()); + + var result = await plugin.GetIssue(null, null, 1); + Output.WriteLine($"GetIssue result: {result}"); + + result.Should().NotStartWith("Error"); + using var doc = JsonDocument.Parse(result); + doc.RootElement.TryGetProperty("title", out _).Should().BeTrue(); + doc.RootElement.TryGetProperty("state", out _).Should().BeTrue(); + } + + #endregion +} diff --git a/test/MeshWeaver.AI.Test/SpecWriterAgentTest.cs b/test/MeshWeaver.AI.Test/SpecWriterAgentTest.cs new file mode 100644 index 000000000..a2f08e0ed --- /dev/null +++ b/test/MeshWeaver.AI.Test/SpecWriterAgentTest.cs @@ -0,0 +1,311 @@ +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using MeshWeaver.AI.Persistence; +using MeshWeaver.AI.Plugins; +using MeshWeaver.Graph; +using MeshWeaver.Graph.Configuration; +using MeshWeaver.Hosting; +using MeshWeaver.Hosting.Monolith; +using MeshWeaver.Hosting.Monolith.TestBase; +using MeshWeaver.Hosting.Persistence; +using MeshWeaver.Layout; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Services; +using MeshWeaver.Messaging; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace MeshWeaver.AI.Test; + +/// +/// Tests for SpecWriter agent discovery, configuration, and tool wiring. +/// +[Collection("AgentToolWiringTests")] +public class SpecWriterAgentTest : MonolithMeshTestBase +{ + private static readonly string TestDataPath = Path.Combine(AppContext.BaseDirectory, "TestData"); + + public SpecWriterAgentTest(ITestOutputHelper output) : base(output) { } + + protected override MeshBuilder ConfigureMesh(MeshBuilder builder) + { + return builder + .UseMonolithMesh() + .AddFileSystemPersistence(TestDataPath) + .ConfigureServices(services => + { + services.AddGitHubPlugin(); + services.AddSingleton(); + services.AddSingleton(); + return services; + }) + .AddGraph() + .AddAI() + .ConfigureDefaultNodeHub(config => config.AddDefaultLayoutAreas()); + } + + /// + /// Verifies that SpecWriter is discovered as a built-in agent with correct plugins. + /// + [Fact] + public async Task SpecWriter_IsDiscovered_WithCorrectPlugins() + { + var chatClient = new AgentChatClient(Mesh.ServiceProvider); + await chatClient.InitializeAsync("ACME"); + + var agents = await chatClient.GetOrderedAgentsAsync(); + var specWriter = agents.FirstOrDefault(a => a.Name == "SpecWriter"); + + specWriter.Should().NotBeNull("SpecWriter agent should be discovered"); + Output.WriteLine($"SpecWriter found at path: {specWriter!.Path}"); + } + + /// + /// Verifies that SpecWriter has Research delegation configured. + /// + [Fact] + public async Task SpecWriter_HasResearchDelegation() + { + var chatClient = new AgentChatClient(Mesh.ServiceProvider); + await chatClient.InitializeAsync("ACME"); + + var agents = await chatClient.GetOrderedAgentsAsync(); + var specWriter = agents.First(a => a.Name == "SpecWriter"); + + specWriter.AgentConfiguration.Should().NotBeNull(); + specWriter.AgentConfiguration!.Delegations.Should().NotBeNullOrEmpty( + "SpecWriter should have delegations configured"); + specWriter.AgentConfiguration.Delegations!.Should().Contain( + d => d.AgentPath.Contains("Research"), + "SpecWriter should delegate to Research agent"); + } + + /// + /// Verifies that SpecWriter gets Mesh tools (Get, Search, Create, Update) and no Delete. + /// + [Fact] + public async Task SpecWriter_GetsReadOnlyMeshTools() + { + var capturingClient = Mesh.ServiceProvider.GetRequiredService(); + + var chatClient = new AgentChatClient(Mesh.ServiceProvider); + await chatClient.InitializeAsync("ACME"); + chatClient.SetSelectedAgent("SpecWriter"); + + // Send a message to trigger agent creation and tool wiring + var messages = new List { new(ChatRole.User, "Hello") }; + await foreach (var _ in chatClient.GetResponseAsync(messages, TestContext.Current.CancellationToken)) { } + + var toolNames = capturingClient.LastCapturedOptions?.Tools? + .OfType().Select(t => t.Name).ToList() ?? []; + + Output.WriteLine($"SpecWriter tools ({toolNames.Count}): {string.Join(", ", toolNames)}"); + + toolNames.Should().Contain("Get", "SpecWriter should have Get tool for reading nodes"); + toolNames.Should().Contain("Search", "SpecWriter should have Search tool for finding context"); + toolNames.Should().Contain("Create", "SpecWriter should have Create tool for creating nodes"); + toolNames.Should().Contain("Update", "SpecWriter should have Update tool for updating nodes"); + toolNames.Should().NotContain("Delete", "SpecWriter should NOT have Delete"); + } + + /// + /// Verifies that GitHubPlugin is registered in DI and exposes the expected tools. + /// This mirrors the portal registration path (AddGitHubPlugin with config binding). + /// + [Fact] + public async Task SpecWriter_GitHubPlugin_IsRegisteredWithExpectedTools() + { + var plugins = Mesh.ServiceProvider.GetServices().ToList(); + var githubPlugin = plugins.FirstOrDefault(p => p.Name == "GitHub"); + + githubPlugin.Should().NotBeNull("GitHubPlugin must be registered in DI for SpecWriter to create issues"); + + var tools = githubPlugin!.CreateTools(); + var toolNames = tools.Select(t => t.Name).ToList(); + + toolNames.Should().Contain("CreateIssue"); + toolNames.Should().Contain("ListIssues"); + } + + #region Group 4: SpecWriter Agent Tests + + [Fact] + public async Task SpecWriter_HasCorrectMetadata() + { + var chatClient = new AgentChatClient(Mesh.ServiceProvider); + await chatClient.InitializeAsync("ACME"); + + var agents = await chatClient.GetOrderedAgentsAsync(); + var specWriter = agents.First(a => a.Name == "SpecWriter"); + var config = specWriter.AgentConfiguration!; + + config.PreferredModel.Should().Be("claude-opus-4-6"); + config.ExposedInNavigator.Should().BeTrue(); + } + + [Fact] + public async Task SpecWriter_HasCorrectPluginReferences() + { + var chatClient = new AgentChatClient(Mesh.ServiceProvider); + await chatClient.InitializeAsync("ACME"); + + var agents = await chatClient.GetOrderedAgentsAsync(); + var specWriter = agents.First(a => a.Name == "SpecWriter"); + var config = specWriter.AgentConfiguration!; + + config.Plugins.Should().NotBeNull(); + config.Plugins.Should().HaveCount(2); + + var meshPlugin = config.Plugins!.First(p => p.Name == "Mesh"); + meshPlugin.Methods.Should().BeEquivalentTo(["Get", "Search", "Create", "Update"]); + + var githubPlugin = config.Plugins!.First(p => p.Name == "GitHub"); + githubPlugin.Methods.Should().BeNullOrEmpty("GitHub plugin exposes all methods"); + } + + [Fact] + public async Task SpecWriter_GetsGitHubTools() + { + var capturingClient = Mesh.ServiceProvider.GetRequiredService(); + + var chatClient = new AgentChatClient(Mesh.ServiceProvider); + await chatClient.InitializeAsync("ACME"); + chatClient.SetSelectedAgent("SpecWriter"); + + var messages = new List { new(ChatRole.User, "Hello") }; + await foreach (var _ in chatClient.GetResponseAsync(messages, TestContext.Current.CancellationToken)) { } + + var toolNames = capturingClient.LastCapturedOptions?.Tools? + .OfType().Select(t => t.Name).ToList() ?? []; + + Output.WriteLine($"SpecWriter tools: {string.Join(", ", toolNames)}"); + + toolNames.Should().Contain("CreateIssue"); + toolNames.Should().Contain("GetIssue"); + toolNames.Should().Contain("ListIssues"); + toolNames.Should().Contain("UpdateIssue"); + } + + [Fact] + public async Task SpecWriter_GetsExactToolSet() + { + var capturingClient = Mesh.ServiceProvider.GetRequiredService(); + + var chatClient = new AgentChatClient(Mesh.ServiceProvider); + await chatClient.InitializeAsync("ACME"); + chatClient.SetSelectedAgent("SpecWriter"); + + var messages = new List { new(ChatRole.User, "Hello") }; + await foreach (var _ in chatClient.GetResponseAsync(messages, TestContext.Current.CancellationToken)) { } + + var toolNames = capturingClient.LastCapturedOptions?.Tools? + .OfType().Select(t => t.Name).ToList() ?? []; + + Output.WriteLine($"SpecWriter full tool set ({toolNames.Count}): {string.Join(", ", toolNames)}"); + + // Mesh tools (filtered) + toolNames.Should().Contain("Get"); + toolNames.Should().Contain("Search"); + toolNames.Should().Contain("Create"); + toolNames.Should().Contain("Update"); + + // GitHub tools (all) + toolNames.Should().Contain("CreateIssue"); + toolNames.Should().Contain("GetIssue"); + toolNames.Should().Contain("ListIssues"); + toolNames.Should().Contain("UpdateIssue"); + + // Delegation tool + toolNames.Should().Contain("delegate_to_agent"); + + // Should NOT have these + toolNames.Should().NotContain("Delete"); + toolNames.Should().NotContain("Patch"); + } + + [Fact] + public async Task SpecWriter_InstructionsContainReferences() + { + var chatClient = new AgentChatClient(Mesh.ServiceProvider); + await chatClient.InitializeAsync("ACME"); + + var agents = await chatClient.GetOrderedAgentsAsync(); + var specWriter = agents.First(a => a.Name == "SpecWriter"); + var instructions = specWriter.AgentConfiguration!.Instructions; + + Output.WriteLine($"Instructions length: {instructions?.Length ?? 0}"); + if (instructions != null) + Output.WriteLine($"Instructions preview: {instructions[..Math.Min(200, instructions.Length)]}"); + + instructions.Should().NotBeNull(); + instructions.Should().NotBeEmpty(); + // Raw instructions contain @@references that are resolved lazily at runtime + instructions.Should().Contain("@@Agent/ToolsReference", + "SpecWriter instructions should reference ToolsReference for lazy resolution"); + instructions.Should().Contain("SpecWriter", + "Instructions should mention the agent's identity"); + } + + #endregion + + #region Capturing Infrastructure (same as AgentToolWiringIntegrationTest) + + internal class CapturingChatClientFactory(IMessageHub hub, CapturingChatClient client) : ChatClientAgentFactory(hub) + { + public override string Name => "CapturingFactory"; + public override IReadOnlyList Models => ["capturing-model"]; + public override int Order => 0; + + protected override IChatClient CreateChatClient(AgentConfiguration agentConfig) => client; + } + + internal class CapturingChatClient : IChatClient + { + public ChatClientMetadata Metadata => new("CapturingProvider"); + public List AllCapturedMessages { get; } = []; + public ChatOptions? LastCapturedOptions { get; set; } + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var messageList = messages.ToList(); + AllCapturedMessages.AddRange(messageList); + LastCapturedOptions = options; + + var msg = new ChatMessage(ChatRole.Assistant, "Captured response."); + return Task.FromResult(new ChatResponse(msg)); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var messageList = messages.ToList(); + AllCapturedMessages.AddRange(messageList); + LastCapturedOptions = options; + + yield return new ChatResponseUpdate(ChatRole.Assistant, "Captured response."); + await Task.Yield(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceType == typeof(IChatClient) ? this : null; + + public void Dispose() { } + } + + #endregion +} diff --git a/test/MeshWeaver.NodeOperations.Test/MeshPluginAccessContextTest.cs b/test/MeshWeaver.NodeOperations.Test/MeshPluginAccessContextTest.cs index 887614e7b..de7215b10 100644 --- a/test/MeshWeaver.NodeOperations.Test/MeshPluginAccessContextTest.cs +++ b/test/MeshWeaver.NodeOperations.Test/MeshPluginAccessContextTest.cs @@ -12,6 +12,7 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; +using System.Text.Json; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Xunit;