From 92b0ede2e806df82001b80215e1f576afddd58d3 Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Thu, 30 Apr 2026 11:26:12 -0400 Subject: [PATCH 1/3] fix: resolve OOTB startup failure when plugins folder missing Issue #331: InvalidOperationException when initializing Step2 component due to missing IHandleFileStorage service registration during OOTB startup. Root cause: Plugin path initialization wasn't being centralized, causing path resolution to fail when plugins folder doesn't exist yet. Changes: - Introduce SharpSitePathProvider for centralized path management - Update PluginManager.Initialize() to accept contentRootPath parameter - Initialize SharpSitePathProvider with contentRootPath from WebApplicationBuilder - Update all path references to use SharpSitePathProvider constants This ensures proper service registration even when the plugins folder is not yet present during initial setup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PluginAssemblyValidator.cs | 3 +- .../SharpSitePathProvider.cs | 32 ++++++++++++++ src/SharpSite.Web/ApplicationState.cs | 4 +- src/SharpSite.Web/PluginManager.cs | 35 +++++++-------- src/SharpSite.Web/PluginManagerExtensions.cs | 4 +- .../Startup/Step2DependencyTests.cs | 43 +++++++++++++++++++ 6 files changed, 99 insertions(+), 22 deletions(-) create mode 100644 src/SharpSite.Plugins/SharpSitePathProvider.cs create mode 100644 tests/SharpSite.Tests.Web/Startup/Step2DependencyTests.cs diff --git a/src/SharpSite.Plugins/PluginAssemblyValidator.cs b/src/SharpSite.Plugins/PluginAssemblyValidator.cs index ab57175c..efc3fc8a 100644 --- a/src/SharpSite.Plugins/PluginAssemblyValidator.cs +++ b/src/SharpSite.Plugins/PluginAssemblyValidator.cs @@ -11,7 +11,6 @@ namespace SharpSite.Plugins; /// public class PluginAssemblyValidator(ILogger logger) { - private static readonly string HashRegistryPath = Path.Combine("plugins", "_assembly-hashes.json"); private static readonly object _hashFileLock = new(); /// @@ -132,4 +131,6 @@ private void SaveHashRegistry(Dictionary registry) File.WriteAllText(HashRegistryPath, json); } } + + private static string HashRegistryPath => SharpSitePathProvider.AssemblyHashRegistryPath; } diff --git a/src/SharpSite.Plugins/SharpSitePathProvider.cs b/src/SharpSite.Plugins/SharpSitePathProvider.cs new file mode 100644 index 00000000..7a8200b8 --- /dev/null +++ b/src/SharpSite.Plugins/SharpSitePathProvider.cs @@ -0,0 +1,32 @@ +namespace SharpSite.Plugins; + +public static class SharpSitePathProvider +{ + private static string _ContentRootPath = Directory.GetCurrentDirectory(); + + public static void Initialize(string? contentRootPath) + { + if (!string.IsNullOrWhiteSpace(contentRootPath)) + { + _ContentRootPath = contentRootPath; + } + } + + public static string ContentRootPath => _ContentRootPath; + + public static string PluginsRootPath => Path.Combine(_ContentRootPath, "plugins"); + + public static string UploadedPluginsRootPath => Path.Combine(PluginsRootPath, "_uploaded"); + + public static string PluginWebRootPath => Path.Combine(PluginsRootPath, "_wwwroot"); + + public static string DefaultPluginsRootPath => Path.Combine(_ContentRootPath, "defaultplugins"); + + public static string ApplicationStatePath => Path.Combine(PluginsRootPath, "applicationState.json"); + + public static string AssemblyHashRegistryPath => Path.Combine(PluginsRootPath, "_assembly-hashes.json"); + + public static string GetPluginInstallationPath(string pluginFolderName) => Path.Combine(PluginsRootPath, pluginFolderName); + + public static string GetPluginPrivateDirectoryPath(string directoryName) => Path.Combine(PluginsRootPath, "_" + directoryName); +} diff --git a/src/SharpSite.Web/ApplicationState.cs b/src/SharpSite.Web/ApplicationState.cs index 7dfc28c9..b0f6961e 100644 --- a/src/SharpSite.Web/ApplicationState.cs +++ b/src/SharpSite.Web/ApplicationState.cs @@ -113,7 +113,7 @@ public void SetTheme(PluginManifest manifest) private string GetApplicationStateFileContents() { // read the applicationState.json file in the root of the plugins folder -var appStateFile = Path.Combine("plugins", "applicationState.json"); +var appStateFile = SharpSitePathProvider.ApplicationStatePath; if (File.Exists(appStateFile)) { return File.ReadAllText(appStateFile); @@ -205,7 +205,7 @@ private Task PostLoadApplicationState(IServiceProvider services) public async Task Save() { // save application state to applicationState.json in the root of the plugins folder -var appStateFile = Path.Combine("plugins", "applicationState.json"); +var appStateFile = SharpSitePathProvider.ApplicationStatePath; var json = JsonSerializer.Serialize(this, SerializerOptions); await File.WriteAllTextAsync(appStateFile, json); diff --git a/src/SharpSite.Web/PluginManager.cs b/src/SharpSite.Web/PluginManager.cs index 0124527a..55367e03 100644 --- a/src/SharpSite.Web/PluginManager.cs +++ b/src/SharpSite.Web/PluginManager.cs @@ -30,12 +30,13 @@ public class PluginManager( private const long MaxTotalExtractedSize = 100L * 1024 * 1024; // 100MB private const long MaxSingleFileSize = 50L * 1024 * 1024; // 50MB private const double MaxCompressionRatio = 100.0; // 100:1 - - public static void Initialize() + + public static void Initialize(string? contentRootPath = null) { - Directory.CreateDirectory("plugins"); - Directory.CreateDirectory(Path.Combine("plugins", "_uploaded")); - Directory.CreateDirectory(Path.Combine("plugins", "_wwwroot")); + SharpSitePathProvider.Initialize(contentRootPath); + Directory.CreateDirectory(SharpSitePathProvider.PluginsRootPath); + Directory.CreateDirectory(SharpSitePathProvider.UploadedPluginsRootPath); + Directory.CreateDirectory(SharpSitePathProvider.PluginWebRootPath); } @@ -185,7 +186,7 @@ public async Task LoadPluginsAtStartup() _ServiceDescriptors.AddMemoryCache(); } - foreach (var pluginFolder in Directory.GetDirectories("plugins")) + foreach (var pluginFolder in Directory.GetDirectories(SharpSitePathProvider.PluginsRootPath)) { var pluginName = Path.GetFileName(pluginFolder); if (pluginName.StartsWith("_")) continue; @@ -378,7 +379,7 @@ private void ValidateArchiveSecurity(ZipArchive archive) DirectoryInfo pluginLibFolder; ZipArchive archive; - var pluginFolder = Directory.CreateDirectory(Path.Combine("plugins", "_uploaded")); + var pluginFolder = Directory.CreateDirectory(SharpSitePathProvider.UploadedPluginsRootPath); var filePath = Path.Combine(pluginFolder.FullName, $"{pluginManifest.IdVersionToString()}.sspkg"); using var pluginAssemblyFileStream = File.OpenWrite(filePath); @@ -386,7 +387,7 @@ private void ValidateArchiveSecurity(ZipArchive archive) logger.LogInformation("Plugin saved to {FilePath}", filePath); // Create a folder named after the plugin name under /plugins - pluginLibFolder = Directory.CreateDirectory(Path.Combine("plugins", pluginManifest.IdVersionToString())); + pluginLibFolder = Directory.CreateDirectory(SharpSitePathProvider.GetPluginInstallationPath(pluginManifest.IdVersionToString())); using var pluginMemoryStream = new MemoryStream(plugin.Bytes); archive = new ZipArchive(pluginMemoryStream, ZipArchiveMode.Read, true); @@ -397,7 +398,7 @@ private void ValidateArchiveSecurity(ZipArchive archive) if (hasWebContent) { - pluginWwwRootFolder = Directory.CreateDirectory(Path.Combine("plugins", "_wwwroot", pluginManifest.IdVersionToString())); + pluginWwwRootFolder = Directory.CreateDirectory(Path.Combine(SharpSitePathProvider.PluginWebRootPath, pluginManifest.IdVersionToString())); } foreach (var entry in archive.Entries) @@ -490,7 +491,7 @@ public Task CreateDirectoryInPluginsFolder(string name) { throw new InvalidFolderException($"Invalid path for folder: {name}"); } - return Task.FromResult(Directory.CreateDirectory(Path.Combine("plugins", "_" + name))); + return Task.FromResult(Directory.CreateDirectory(SharpSitePathProvider.GetPluginPrivateDirectoryPath(name))); } public T? GetPluginProvidedService() where T : class @@ -514,7 +515,7 @@ public Task MoveDirectoryInPluginsFolder(string oldName, string n { // check if the oldName directory exists - if (!Directory.Exists(Path.Combine("plugins", "_" + oldName))) + if (!Directory.Exists(SharpSitePathProvider.GetPluginPrivateDirectoryPath(oldName))) { throw new DirectoryNotFoundException($"Directory {oldName} not found in plugins folder."); } @@ -525,17 +526,17 @@ public Task MoveDirectoryInPluginsFolder(string oldName, string n // move the directory specified, which is prefixed with an underscore, to a new name Directory.Move( - Path.Combine("plugins", "_" + oldName), - Path.Combine("plugins", "_" + newName) + SharpSitePathProvider.GetPluginPrivateDirectoryPath(oldName), + SharpSitePathProvider.GetPluginPrivateDirectoryPath(newName) ); - return Task.FromResult(new DirectoryInfo(Path.Combine("plugins", "_" + newName))); + return Task.FromResult(new DirectoryInfo(SharpSitePathProvider.GetPluginPrivateDirectoryPath(newName))); } public DirectoryInfo GetDirectoryInPluginsFolder(string name) { - return new DirectoryInfo(Path.Combine("plugins", "_" + name)); + return new DirectoryInfo(SharpSitePathProvider.GetPluginPrivateDirectoryPath(name)); } private static readonly char[] _InvalidChars = Path.GetInvalidPathChars(); @@ -588,7 +589,7 @@ private static bool IsValidDirectory(string name) private static void EnsurePluginNotInstalled(PluginManifest? manifest, ILogger logger) { - if (manifest is not null && Directory.Exists(Path.Combine("plugins", manifest.IdVersionToString()))) + if (manifest is not null && Directory.Exists(SharpSitePathProvider.GetPluginInstallationPath(manifest.IdVersionToString()))) { var errMsg = string.Format(Locales.SharedResource.sharpsite_plugin_exists, manifest.IdVersionToString()); PluginException ex = new(errMsg); @@ -601,7 +602,7 @@ private static void EnsurePluginNotInstalled(PluginManifest? manifest, ILogger l public async Task InstallDefaultPlugins() { - var defaultPluginFolder = new DirectoryInfo("defaultplugins"); + var defaultPluginFolder = new DirectoryInfo(SharpSitePathProvider.DefaultPluginsRootPath); if (!defaultPluginFolder.Exists) return; foreach (var file in defaultPluginFolder.GetFiles("*.sspkg")) diff --git a/src/SharpSite.Web/PluginManagerExtensions.cs b/src/SharpSite.Web/PluginManagerExtensions.cs index a447b354..bce04095 100644 --- a/src/SharpSite.Web/PluginManagerExtensions.cs +++ b/src/SharpSite.Web/PluginManagerExtensions.cs @@ -11,7 +11,7 @@ public static class PluginManagerExtensions public static ApplicationState AddPluginManagerAndAppState(this WebApplicationBuilder builder) { - PluginManager.Initialize(); + PluginManager.Initialize(builder.Environment.ContentRootPath); var appState = new ApplicationState(); builder.Services.AddSingleton(appState); @@ -27,7 +27,7 @@ public static WebApplication ConfigurePluginFileSystem(this WebApplication app) { var pluginRoot = new PhysicalFileProvider( - Path.Combine(app.Environment.ContentRootPath, @"plugins/_wwwroot")); + SharpSitePathProvider.PluginWebRootPath); app.UseStaticFiles(); app.UseStaticFiles(new StaticFileOptions() { diff --git a/tests/SharpSite.Tests.Web/Startup/Step2DependencyTests.cs b/tests/SharpSite.Tests.Web/Startup/Step2DependencyTests.cs new file mode 100644 index 00000000..a8141c9e --- /dev/null +++ b/tests/SharpSite.Tests.Web/Startup/Step2DependencyTests.cs @@ -0,0 +1,43 @@ +using System.Reflection; +using Microsoft.AspNetCore.Components; +using SharpSite.Abstractions.FileStorage; +using Xunit; + +namespace SharpSite.Tests.Web.Startup; + +public class Step2DependencyTests +{ + [Fact] + public void Step2_ShouldNotInjectFileStorageDirectly() + { + // Arrange + var componentType = typeof(SharpSite.Web.Components.Startup.Step2); + + // Act + var injectedFileStorageProperties = componentType + .GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(property => + property.PropertyType == typeof(IHandleFileStorage) && + property.GetCustomAttribute() is not null); + + // Assert + Assert.Empty(injectedFileStorageProperties); + } + + [Fact] + public void Step2_ShouldInjectPluginManagerForOptionalFileStorageLookup() + { + // Arrange + var componentType = typeof(SharpSite.Web.Components.Startup.Step2); + + // Act + var pluginManagerProperty = componentType + .GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .SingleOrDefault(property => + property.PropertyType == typeof(SharpSite.Web.PluginManager) && + property.GetCustomAttribute() is not null); + + // Assert + Assert.NotNull(pluginManagerProperty); + } +} From eba624e313bef81836a1ccd51aab160b396f1773 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 30 Apr 2026 11:44:24 -0400 Subject: [PATCH 2/3] Remove Squad GitHub Actions workflows Stop Squad-specific automation from running: - squad-heartbeat.yml - squad-issue-assign.yml - squad-triage.yml - sync-squad-labels.yml Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-heartbeat.yml | 171 --------------- .github/workflows/squad-issue-assign.yml | 161 -------------- .github/workflows/squad-triage.yml | 260 ----------------------- .github/workflows/sync-squad-labels.yml | 169 --------------- 4 files changed, 761 deletions(-) delete mode 100644 .github/workflows/squad-heartbeat.yml delete mode 100644 .github/workflows/squad-issue-assign.yml delete mode 100644 .github/workflows/squad-triage.yml delete mode 100644 .github/workflows/sync-squad-labels.yml diff --git a/.github/workflows/squad-heartbeat.yml b/.github/workflows/squad-heartbeat.yml deleted file mode 100644 index 957915a4..00000000 --- a/.github/workflows/squad-heartbeat.yml +++ /dev/null @@ -1,171 +0,0 @@ -name: Squad Heartbeat (Ralph) -# ⚠️ SYNC: This workflow is maintained in 4 locations. Changes must be applied to all: -# - templates/workflows/squad-heartbeat.yml (source template) -# - packages/squad-cli/templates/workflows/squad-heartbeat.yml (CLI package) -# - .squad/templates/workflows/squad-heartbeat.yml (installed template) -# - .github/workflows/squad-heartbeat.yml (active workflow) -# Run 'squad upgrade' to sync installed copies from source templates. - -on: - schedule: - # Every 30 minutes — adjust via cron expression as needed - - cron: '*/30 * * * *' - - # React to completed work or new squad work - issues: - types: [closed, labeled] - pull_request: - types: [closed] - - # Manual trigger - workflow_dispatch: - -permissions: - issues: write - contents: read - pull-requests: read - -jobs: - heartbeat: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Check triage script - id: check-script - run: | - if [ -f ".squad/templates/ralph-triage.js" ]; then - echo "has_script=true" >> $GITHUB_OUTPUT - else - echo "has_script=false" >> $GITHUB_OUTPUT - echo "⚠️ ralph-triage.js not found — run 'squad upgrade' to install" - fi - - - name: Ralph — Smart triage - if: steps.check-script.outputs.has_script == 'true' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - node .squad/templates/ralph-triage.js \ - --squad-dir .squad \ - --output triage-results.json - - - name: Ralph — Apply triage decisions - if: steps.check-script.outputs.has_script == 'true' && hashFiles('triage-results.json') != '' - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const path = 'triage-results.json'; - if (!fs.existsSync(path)) { - core.info('No triage results — board is clear'); - return; - } - - const results = JSON.parse(fs.readFileSync(path, 'utf8')); - if (results.length === 0) { - core.info('📋 Board is clear — Ralph found no untriaged issues'); - return; - } - - for (const decision of results) { - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: decision.issueNumber, - labels: [decision.label] - }); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: decision.issueNumber, - body: [ - '### 🔄 Ralph — Auto-Triage', - '', - `**Assigned to:** ${decision.assignTo}`, - `**Reason:** ${decision.reason}`, - `**Source:** ${decision.source}`, - '', - '> Ralph auto-triaged this issue using routing rules.', - '> To reassign, swap the `squad:*` label.' - ].join('\n') - }); - - core.info(`Triaged #${decision.issueNumber} → ${decision.assignTo} (${decision.source})`); - } catch (e) { - core.warning(`Failed to triage #${decision.issueNumber}: ${e.message}`); - } - } - - core.info(`🔄 Ralph triaged ${results.length} issue(s)`); - - # Copilot auto-assign step (uses PAT if available) - - name: Ralph — Assign @copilot issues - if: success() - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - - let teamFile = '.squad/team.md'; - if (!fs.existsSync(teamFile)) { - teamFile = '.ai-team/team.md'; - } - if (!fs.existsSync(teamFile)) return; - - const content = fs.readFileSync(teamFile, 'utf8'); - - // Check if @copilot is on the team with auto-assign - const hasCopilot = content.includes('🤖 Coding Agent') || content.includes('@copilot'); - const autoAssign = content.includes(''); - if (!hasCopilot || !autoAssign) return; - - // Find issues labeled squad:copilot with no assignee - try { - const { data: copilotIssues } = await github.rest.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - labels: 'squad:copilot', - state: 'open', - per_page: 5 - }); - - const unassigned = copilotIssues.filter(i => - !i.assignees || i.assignees.length === 0 - ); - - if (unassigned.length === 0) { - core.info('No unassigned squad:copilot issues'); - return; - } - - // Get repo default branch - const { data: repoData } = await github.rest.repos.get({ - owner: context.repo.owner, - repo: context.repo.repo - }); - - for (const issue of unassigned) { - try { - await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - assignees: ['copilot-swe-agent[bot]'], - agent_assignment: { - target_repo: `${context.repo.owner}/${context.repo.repo}`, - base_branch: repoData.default_branch, - custom_instructions: `Read .squad/team.md (or .ai-team/team.md) for team context and .squad/routing.md (or .ai-team/routing.md) for routing rules.` - } - }); - core.info(`Assigned copilot-swe-agent[bot] to #${issue.number}`); - } catch (e) { - core.warning(`Failed to assign @copilot to #${issue.number}: ${e.message}`); - } - } - } catch (e) { - core.info(`No squad:copilot label found or error: ${e.message}`); - } diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml deleted file mode 100644 index ad140f42..00000000 --- a/.github/workflows/squad-issue-assign.yml +++ /dev/null @@ -1,161 +0,0 @@ -name: Squad Issue Assign - -on: - issues: - types: [labeled] - -permissions: - issues: write - contents: read - -jobs: - assign-work: - # Only trigger on squad:{member} labels (not the base "squad" label) - if: startsWith(github.event.label.name, 'squad:') - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Identify assigned member and trigger work - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const issue = context.payload.issue; - const label = context.payload.label.name; - - // Extract member name from label (e.g., "squad:ripley" → "ripley") - const memberName = label.replace('squad:', '').toLowerCase(); - - // Read team roster — check .squad/ first, fall back to .ai-team/ - let teamFile = '.squad/team.md'; - if (!fs.existsSync(teamFile)) { - teamFile = '.ai-team/team.md'; - } - if (!fs.existsSync(teamFile)) { - core.warning('No .squad/team.md or .ai-team/team.md found — cannot assign work'); - return; - } - - const content = fs.readFileSync(teamFile, 'utf8'); - const lines = content.split('\n'); - - // Check if this is a coding agent assignment - const isCopilotAssignment = memberName === 'copilot'; - - let assignedMember = null; - if (isCopilotAssignment) { - assignedMember = { name: '@copilot', role: 'Coding Agent' }; - } else { - let inMembersTable = false; - for (const line of lines) { - if (line.match(/^##\s+(Members|Team Roster)/i)) { - inMembersTable = true; - continue; - } - if (inMembersTable && line.startsWith('## ')) { - break; - } - if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { - const cells = line.split('|').map(c => c.trim()).filter(Boolean); - if (cells.length >= 2 && cells[0].toLowerCase() === memberName) { - assignedMember = { name: cells[0], role: cells[1] }; - break; - } - } - } - } - - if (!assignedMember) { - core.warning(`No member found matching label "${label}"`); - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `⚠️ No squad member found matching label \`${label}\`. Check \`.squad/team.md\` (or \`.ai-team/team.md\`) for valid member names.` - }); - return; - } - - // Post assignment acknowledgment - let comment; - if (isCopilotAssignment) { - comment = [ - `### 🤖 Routed to @copilot (Coding Agent)`, - '', - `**Issue:** #${issue.number} — ${issue.title}`, - '', - `@copilot has been assigned and will pick this up automatically.`, - '', - `> The coding agent will create a \`copilot/*\` branch and open a draft PR.`, - `> Review the PR as you would any team member's work.`, - ].join('\n'); - } else { - comment = [ - `### 📋 Assigned to ${assignedMember.name} (${assignedMember.role})`, - '', - `**Issue:** #${issue.number} — ${issue.title}`, - '', - `${assignedMember.name} will pick this up in the next Copilot session.`, - '', - `> **For Copilot coding agent:** If enabled, this issue will be worked automatically.`, - `> Otherwise, start a Copilot session and say:`, - `> \`${assignedMember.name}, work on issue #${issue.number}\``, - ].join('\n'); - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: comment - }); - - core.info(`Issue #${issue.number} assigned to ${assignedMember.name} (${assignedMember.role})`); - - # Separate step: assign @copilot using PAT (required for coding agent) - - name: Assign @copilot coding agent - if: github.event.label.name == 'squad:copilot' - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN }} - script: | - const owner = context.repo.owner; - const repo = context.repo.repo; - const issue_number = context.payload.issue.number; - - // Get the default branch name (main, master, etc.) - const { data: repoData } = await github.rest.repos.get({ owner, repo }); - const baseBranch = repoData.default_branch; - - try { - await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', { - owner, - repo, - issue_number, - assignees: ['copilot-swe-agent[bot]'], - agent_assignment: { - target_repo: `${owner}/${repo}`, - base_branch: baseBranch, - custom_instructions: '', - custom_agent: '', - model: '' - }, - headers: { - 'X-GitHub-Api-Version': '2022-11-28' - } - }); - core.info(`Assigned copilot-swe-agent to issue #${issue_number} (base: ${baseBranch})`); - } catch (err) { - core.warning(`Assignment with agent_assignment failed: ${err.message}`); - // Fallback: try without agent_assignment - try { - await github.rest.issues.addAssignees({ - owner, repo, issue_number, - assignees: ['copilot-swe-agent'] - }); - core.info(`Fallback assigned copilot-swe-agent to issue #${issue_number}`); - } catch (err2) { - core.warning(`Fallback also failed: ${err2.message}`); - } - } diff --git a/.github/workflows/squad-triage.yml b/.github/workflows/squad-triage.yml deleted file mode 100644 index a58be9b2..00000000 --- a/.github/workflows/squad-triage.yml +++ /dev/null @@ -1,260 +0,0 @@ -name: Squad Triage - -on: - issues: - types: [labeled] - -permissions: - issues: write - contents: read - -jobs: - triage: - if: github.event.label.name == 'squad' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Triage issue via Lead agent - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const issue = context.payload.issue; - - // Read team roster — check .squad/ first, fall back to .ai-team/ - let teamFile = '.squad/team.md'; - if (!fs.existsSync(teamFile)) { - teamFile = '.ai-team/team.md'; - } - if (!fs.existsSync(teamFile)) { - core.warning('No .squad/team.md or .ai-team/team.md found — cannot triage'); - return; - } - - const content = fs.readFileSync(teamFile, 'utf8'); - const lines = content.split('\n'); - - // Check if @copilot is on the team - const hasCopilot = content.includes('🤖 Coding Agent'); - const copilotAutoAssign = content.includes(''); - - // Parse @copilot capability profile - let goodFitKeywords = []; - let needsReviewKeywords = []; - let notSuitableKeywords = []; - - if (hasCopilot) { - // Extract capability tiers from team.md - const goodFitMatch = content.match(/🟢\s*Good fit[^:]*:\s*(.+)/i); - const needsReviewMatch = content.match(/🟡\s*Needs review[^:]*:\s*(.+)/i); - const notSuitableMatch = content.match(/🔴\s*Not suitable[^:]*:\s*(.+)/i); - - if (goodFitMatch) { - goodFitKeywords = goodFitMatch[1].toLowerCase().split(',').map(s => s.trim()); - } else { - goodFitKeywords = ['bug fix', 'test coverage', 'lint', 'format', 'dependency update', 'small feature', 'scaffolding', 'doc fix', 'documentation']; - } - if (needsReviewMatch) { - needsReviewKeywords = needsReviewMatch[1].toLowerCase().split(',').map(s => s.trim()); - } else { - needsReviewKeywords = ['medium feature', 'refactoring', 'api endpoint', 'migration']; - } - if (notSuitableMatch) { - notSuitableKeywords = notSuitableMatch[1].toLowerCase().split(',').map(s => s.trim()); - } else { - notSuitableKeywords = ['architecture', 'system design', 'security', 'auth', 'encryption', 'performance']; - } - } - - const members = []; - let inMembersTable = false; - for (const line of lines) { - if (line.match(/^##\s+(Members|Team Roster)/i)) { - inMembersTable = true; - continue; - } - if (inMembersTable && line.startsWith('## ')) { - break; - } - if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { - const cells = line.split('|').map(c => c.trim()).filter(Boolean); - if (cells.length >= 2 && cells[0] !== 'Scribe') { - members.push({ - name: cells[0], - role: cells[1] - }); - } - } - } - - // Read routing rules — check .squad/ first, fall back to .ai-team/ - let routingFile = '.squad/routing.md'; - if (!fs.existsSync(routingFile)) { - routingFile = '.ai-team/routing.md'; - } - let routingContent = ''; - if (fs.existsSync(routingFile)) { - routingContent = fs.readFileSync(routingFile, 'utf8'); - } - - // Find the Lead - const lead = members.find(m => - m.role.toLowerCase().includes('lead') || - m.role.toLowerCase().includes('architect') || - m.role.toLowerCase().includes('coordinator') - ); - - if (!lead) { - core.warning('No Lead role found in team roster — cannot triage'); - return; - } - - // Build triage context - const memberList = members.map(m => - `- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\`` - ).join('\n'); - - // Determine best assignee based on issue content and routing - const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase(); - - let assignedMember = null; - let triageReason = ''; - let copilotTier = null; - - // First, evaluate @copilot fit if enabled - if (hasCopilot) { - const isNotSuitable = notSuitableKeywords.some(kw => issueText.includes(kw)); - const isGoodFit = !isNotSuitable && goodFitKeywords.some(kw => issueText.includes(kw)); - const isNeedsReview = !isNotSuitable && !isGoodFit && needsReviewKeywords.some(kw => issueText.includes(kw)); - - if (isGoodFit) { - copilotTier = 'good-fit'; - assignedMember = { name: '@copilot', role: 'Coding Agent' }; - triageReason = '🟢 Good fit for @copilot — matches capability profile'; - } else if (isNeedsReview) { - copilotTier = 'needs-review'; - assignedMember = { name: '@copilot', role: 'Coding Agent' }; - triageReason = '🟡 Routing to @copilot (needs review) — a squad member should review the PR'; - } else if (isNotSuitable) { - copilotTier = 'not-suitable'; - // Fall through to normal routing - } - } - - // If not routed to @copilot, use keyword-based routing - if (!assignedMember) { - for (const member of members) { - const role = member.role.toLowerCase(); - if ((role.includes('frontend') || role.includes('ui')) && - (issueText.includes('ui') || issueText.includes('frontend') || - issueText.includes('css') || issueText.includes('component') || - issueText.includes('button') || issueText.includes('page') || - issueText.includes('layout') || issueText.includes('design'))) { - assignedMember = member; - triageReason = 'Issue relates to frontend/UI work'; - break; - } - if ((role.includes('backend') || role.includes('api') || role.includes('server')) && - (issueText.includes('api') || issueText.includes('backend') || - issueText.includes('database') || issueText.includes('endpoint') || - issueText.includes('server') || issueText.includes('auth'))) { - assignedMember = member; - triageReason = 'Issue relates to backend/API work'; - break; - } - if ((role.includes('test') || role.includes('qa') || role.includes('quality')) && - (issueText.includes('test') || issueText.includes('bug') || - issueText.includes('fix') || issueText.includes('regression') || - issueText.includes('coverage'))) { - assignedMember = member; - triageReason = 'Issue relates to testing/quality work'; - break; - } - if ((role.includes('devops') || role.includes('infra') || role.includes('ops')) && - (issueText.includes('deploy') || issueText.includes('ci') || - issueText.includes('pipeline') || issueText.includes('docker') || - issueText.includes('infrastructure'))) { - assignedMember = member; - triageReason = 'Issue relates to DevOps/infrastructure work'; - break; - } - } - } - - // Default to Lead if no routing match - if (!assignedMember) { - assignedMember = lead; - triageReason = 'No specific domain match — assigned to Lead for further analysis'; - } - - const isCopilot = assignedMember.name === '@copilot'; - const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`; - - // Add the member-specific label - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: [assignLabel] - }); - - // Apply default triage verdict - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: ['go:needs-research'] - }); - - // Auto-assign @copilot if enabled - if (isCopilot && copilotAutoAssign) { - try { - await github.rest.issues.addAssignees({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - assignees: ['copilot'] - }); - } catch (err) { - core.warning(`Could not auto-assign @copilot: ${err.message}`); - } - } - - // Build copilot evaluation note - let copilotNote = ''; - if (hasCopilot && !isCopilot) { - if (copilotTier === 'not-suitable') { - copilotNote = `\n\n**@copilot evaluation:** 🔴 Not suitable — issue involves work outside the coding agent's capability profile.`; - } else { - copilotNote = `\n\n**@copilot evaluation:** No strong capability match — routed to squad member.`; - } - } - - // Post triage comment - const comment = [ - `### 🏗️ Squad Triage — ${lead.name} (${lead.role})`, - '', - `**Issue:** #${issue.number} — ${issue.title}`, - `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`, - `**Reason:** ${triageReason}`, - copilotTier === 'needs-review' ? `\n⚠️ **PR review recommended** — a squad member should review @copilot's work on this one.` : '', - copilotNote, - '', - `---`, - '', - `**Team roster:**`, - memberList, - hasCopilot ? `- **@copilot** (Coding Agent) → label: \`squad:copilot\`` : '', - '', - `> To reassign, remove the current \`squad:*\` label and add the correct one.`, - ].filter(Boolean).join('\n'); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: comment - }); - - core.info(`Triaged issue #${issue.number} → ${assignedMember.name} (${assignLabel})`); diff --git a/.github/workflows/sync-squad-labels.yml b/.github/workflows/sync-squad-labels.yml deleted file mode 100644 index fbcfd9cc..00000000 --- a/.github/workflows/sync-squad-labels.yml +++ /dev/null @@ -1,169 +0,0 @@ -name: Sync Squad Labels - -on: - push: - paths: - - '.squad/team.md' - - '.ai-team/team.md' - workflow_dispatch: - -permissions: - issues: write - contents: read - -jobs: - sync-labels: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Parse roster and sync labels - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - let teamFile = '.squad/team.md'; - if (!fs.existsSync(teamFile)) { - teamFile = '.ai-team/team.md'; - } - - if (!fs.existsSync(teamFile)) { - core.info('No .squad/team.md or .ai-team/team.md found — skipping label sync'); - return; - } - - const content = fs.readFileSync(teamFile, 'utf8'); - const lines = content.split('\n'); - - // Parse the Members table for agent names - const members = []; - let inMembersTable = false; - for (const line of lines) { - if (line.match(/^##\s+(Members|Team Roster)/i)) { - inMembersTable = true; - continue; - } - if (inMembersTable && line.startsWith('## ')) { - break; - } - if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { - const cells = line.split('|').map(c => c.trim()).filter(Boolean); - if (cells.length >= 2 && cells[0] !== 'Scribe') { - members.push({ - name: cells[0], - role: cells[1] - }); - } - } - } - - core.info(`Found ${members.length} squad members: ${members.map(m => m.name).join(', ')}`); - - // Check if @copilot is on the team - const hasCopilot = content.includes('🤖 Coding Agent'); - - // Define label color palette for squad labels - const SQUAD_COLOR = '9B8FCC'; - const MEMBER_COLOR = '9B8FCC'; - const COPILOT_COLOR = '10b981'; - - // Define go: and release: labels (static) - const GO_LABELS = [ - { name: 'go:yes', color: '0E8A16', description: 'Ready to implement' }, - { name: 'go:no', color: 'B60205', description: 'Not pursuing' }, - { name: 'go:needs-research', color: 'FBCA04', description: 'Needs investigation' } - ]; - - const RELEASE_LABELS = [ - { name: 'release:v0.4.0', color: '6B8EB5', description: 'Targeted for v0.4.0' }, - { name: 'release:v0.5.0', color: '6B8EB5', description: 'Targeted for v0.5.0' }, - { name: 'release:v0.6.0', color: '8B7DB5', description: 'Targeted for v0.6.0' }, - { name: 'release:v1.0.0', color: '8B7DB5', description: 'Targeted for v1.0.0' }, - { name: 'release:backlog', color: 'D4E5F7', description: 'Not yet targeted' } - ]; - - const TYPE_LABELS = [ - { name: 'type:feature', color: 'DDD1F2', description: 'New capability' }, - { name: 'type:bug', color: 'FF0422', description: 'Something broken' }, - { name: 'type:spike', color: 'F2DDD4', description: 'Research/investigation — produces a plan, not code' }, - { name: 'type:docs', color: 'D4E5F7', description: 'Documentation work' }, - { name: 'type:chore', color: 'D4E5F7', description: 'Maintenance, refactoring, cleanup' }, - { name: 'type:epic', color: 'CC4455', description: 'Parent issue that decomposes into sub-issues' } - ]; - - // High-signal labels — these MUST visually dominate all others - const SIGNAL_LABELS = [ - { name: 'bug', color: 'FF0422', description: 'Something isn\'t working' }, - { name: 'feedback', color: '00E5FF', description: 'User feedback — high signal, needs attention' } - ]; - - const PRIORITY_LABELS = [ - { name: 'priority:p0', color: 'B60205', description: 'Blocking release' }, - { name: 'priority:p1', color: 'D93F0B', description: 'This sprint' }, - { name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' } - ]; - - // Ensure the base "squad" triage label exists - const labels = [ - { name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' } - ]; - - for (const member of members) { - labels.push({ - name: `squad:${member.name.toLowerCase()}`, - color: MEMBER_COLOR, - description: `Assigned to ${member.name} (${member.role})` - }); - } - - // Add @copilot label if coding agent is on the team - if (hasCopilot) { - labels.push({ - name: 'squad:copilot', - color: COPILOT_COLOR, - description: 'Assigned to @copilot (Coding Agent) for autonomous work' - }); - } - - // Add go:, release:, type:, priority:, and high-signal labels - labels.push(...GO_LABELS); - labels.push(...RELEASE_LABELS); - labels.push(...TYPE_LABELS); - labels.push(...PRIORITY_LABELS); - labels.push(...SIGNAL_LABELS); - - // Sync labels (create or update) - for (const label of labels) { - try { - await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name - }); - // Label exists — update it - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description - }); - core.info(`Updated label: ${label.name}`); - } catch (err) { - if (err.status === 404) { - // Label doesn't exist — create it - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description - }); - core.info(`Created label: ${label.name}`); - } else { - throw err; - } - } - } - - core.info(`Label sync complete: ${labels.length} labels synced`); From e795be1316ee78d5c491be92187439e0bc924823 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 30 Apr 2026 11:59:27 -0400 Subject: [PATCH 3/3] updated central package file --- .squad/agents/jayne/history.md | 9 ++- Directory.Packages.props | 52 ++++++++--------- .../ApplicationStateModel.cs | 58 +++++++++---------- 3 files changed, 63 insertions(+), 56 deletions(-) diff --git a/.squad/agents/jayne/history.md b/.squad/agents/jayne/history.md index 8d8b7699..dd5e8af3 100644 --- a/.squad/agents/jayne/history.md +++ b/.squad/agents/jayne/history.md @@ -7,4 +7,11 @@ ## Learnings - +### Commit Movement Pattern: Cherry-Pick for Safe Branch Transfers (2026-04-30) +When moving a commit from one branch to another without rewriting remote history: +1. Use `git cherry-pick ` on the destination branch — creates a new commit with identical author/message/tree, new commit hash +2. Verify the source commit still exists on its original branch (no deletion) +3. Use `git log --graph` to visualize both branches and confirm clean separation +4. Clean up stale `.git/index.lock` if git operations hang (e.g., from crashed prior processes) +5. After cherry-pick succeeds, push destination branch; source branch remains untouched +**Rationale:** Safer than rebasing for remote-tracked branches — no rewrite risk, clear audit trail. Source commit stays on its branch. diff --git a/Directory.Packages.props b/Directory.Packages.props index 1cf1b041..1dc2e99e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,40 +5,40 @@ true - - - + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - + + + + + diff --git a/src/SharpSite.Abstractions/ApplicationStateModel.cs b/src/SharpSite.Abstractions/ApplicationStateModel.cs index 20719db7..f50c69df 100644 --- a/src/SharpSite.Abstractions/ApplicationStateModel.cs +++ b/src/SharpSite.Abstractions/ApplicationStateModel.cs @@ -1,49 +1,49 @@ -using System.Text.Json.Serialization; using SharpSite.Abstractions.Base; +using System.Text.Json.Serialization; namespace SharpSite.Abstractions; public class ApplicationStateModel : IApplicationStateModel { -/// -/// Indicates whether the application state has been initialized from the applicationState.json file. -/// -[JsonIgnore] -public bool Initialized { get; protected set; } = false; + /// + /// Indicates whether the application state has been initialized from the applicationState.json file. + /// + [JsonIgnore] + public bool Initialized { get; protected set; } = false; -public bool StartupCompleted { get; set; } = false; + public bool StartupCompleted { get; set; } = false; -[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] -public string? RobotsTxtCustomContent { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? RobotsTxtCustomContent { get; set; } -public string SiteName { get; set; } = "SharpSite"; + public string SiteName { get; set; } = "SharpSite"; -/// -/// Maximum file upload size in megabytes. -/// -public long MaximumUploadSizeMB { get; set; } = 10; // 10MB + /// + /// Maximum file upload size in megabytes. + /// + public long MaximumUploadSizeMB { get; set; } = 10; // 10MB -public string PageNotFoundContent { get; set; } = string.Empty; + public string PageNotFoundContent { get; set; } = string.Empty; -public virtual string GetConfigurationByName(string name, string defaultValue = "") -{ + public virtual string GetConfigurationByName(string name, string defaultValue = "") + { -return name switch -{ -"SiteName" => SiteName, -"PageNotFoundContent" => PageNotFoundContent, -"MaximumUploadSizeMB" => MaximumUploadSizeMB.ToString(), -_ => defaultValue -}; + return name switch + { + "SiteName" => SiteName, + "PageNotFoundContent" => PageNotFoundContent, + "MaximumUploadSizeMB" => MaximumUploadSizeMB.ToString(), + _ => defaultValue + }; -} + } -public virtual void SetConfigurationByName(string name, string value) -{ + public virtual void SetConfigurationByName(string name, string value) + { -// do nothing + // do nothing -} + } }