diff --git a/tests/Orchestrator.App.Tests.csproj b/tests/Orchestrator.App.Tests.csproj index c3e23055..5d506bfa 100644 --- a/tests/Orchestrator.App.Tests.csproj +++ b/tests/Orchestrator.App.Tests.csproj @@ -1,9 +1,10 @@ net8.0 - false - enable enable + enable + false + true @@ -13,6 +14,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + diff --git a/tests/TestHelpers/MockWorkContext.cs b/tests/TestHelpers/MockWorkContext.cs new file mode 100644 index 00000000..4c5d441d --- /dev/null +++ b/tests/TestHelpers/MockWorkContext.cs @@ -0,0 +1,106 @@ +using Moq; +using Orchestrator.App; + +namespace Orchestrator.App.Tests.TestHelpers; + +internal static class MockWorkContext +{ + public static WorkContext Create( + WorkItem? workItem = null, + OctokitGitHubClient? github = null, + OrchestratorConfig? config = null, + RepoWorkspace? workspace = null, + RepoGit? repo = null, + LlmClient? llm = null) + { + workItem ??= CreateWorkItem(); + github ??= CreateGitHubClient(); + config ??= CreateConfig(); + workspace ??= CreateWorkspace(); + repo ??= CreateRepo(config); + llm ??= CreateLlmClient(config); + + return new WorkContext(workItem, github, config, workspace, repo, llm); + } + + public static WorkItem CreateWorkItem( + int number = 1, + string title = "Test Issue", + string body = "Test body", + string url = "https://github.com/test/repo/issues/1", + IReadOnlyList? labels = null) + { + labels ??= new List { "ready-for-agents" }; + return new WorkItem(number, title, body, url, labels); + } + + public static OctokitGitHubClient CreateGitHubClient() + { + var config = CreateConfig(); + return new OctokitGitHubClient(config); + } + + public static OrchestratorConfig CreateConfig( + string? workspacePath = null) + { + return new OrchestratorConfig( + OpenAiBaseUrl: "https://api.openai.com/v1", + OpenAiApiKey: "test-key", + OpenAiModel: "gpt-4o-mini", + DevModel: "gpt-4o", + TechLeadModel: "gpt-4o-mini", + WorkspacePath: workspacePath ?? "/tmp/test-workspace", + GitRemoteUrl: "https://github.com/test/repo.git", + GitAuthorName: "Test Agent", + GitAuthorEmail: "test@example.com", + GitHubToken: "test-token", + RepoOwner: "test-owner", + RepoName: "test-repo", + DefaultBaseBranch: "main", + PollIntervalSeconds: 120, + FastPollIntervalSeconds: 30, + WorkItemLabel: "ready-for-agents", + InProgressLabel: "in-progress", + DoneLabel: "done", + BlockedLabel: "blocked", + PlannerLabel: "agent:planner", + TechLeadLabel: "agent:techlead", + DevLabel: "agent:dev", + TestLabel: "agent:test", + ReleaseLabel: "agent:release", + UserReviewRequiredLabel: "user-review-required", + ReviewNeededLabel: "agent:review-needed", + ReviewedLabel: "agent:reviewed", + SpecQuestionsLabel: "spec-questions", + SpecClarifiedLabel: "spec-clarified", + CodeReviewNeededLabel: "code-review-needed", + CodeReviewApprovedLabel: "code-review-approved", + CodeReviewChangesRequestedLabel: "code-review-changes-requested", + ResetLabel: "agent:reset", + ProjectStatusInProgress: "In Progress", + ProjectStatusInReview: "In Review", + ProjectOwner: "test-owner", + ProjectOwnerType: "user", + ProjectNumber: 1, + ProjectStatusDone: "Done", + UseWorkflowMode: false + ); + } + + public static RepoWorkspace CreateWorkspace(string? path = null) + { + path ??= Path.Combine(Path.GetTempPath(), $"test-workspace-{Guid.NewGuid()}"); + Directory.CreateDirectory(path); + return new RepoWorkspace(path); + } + + public static RepoGit CreateRepo(OrchestratorConfig config) + { + return new RepoGit(config, config.WorkspacePath); + } + + public static LlmClient CreateLlmClient(OrchestratorConfig config) + { + return new LlmClient(config); + } +} diff --git a/tests/TestHelpers/TempWorkspace.cs b/tests/TestHelpers/TempWorkspace.cs new file mode 100644 index 00000000..e36bc484 --- /dev/null +++ b/tests/TestHelpers/TempWorkspace.cs @@ -0,0 +1,45 @@ +using Orchestrator.App; + +namespace Orchestrator.App.Tests.TestHelpers; + +internal sealed class TempWorkspace : IDisposable +{ + private readonly string _path; + private readonly RepoWorkspace _workspace; + + public TempWorkspace() + { + _path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"test-workspace-{Guid.NewGuid()}"); + Directory.CreateDirectory(_path); + _workspace = new RepoWorkspace(_path); + } + + public RepoWorkspace Workspace => _workspace; + public string WorkspacePath => _path; + + public void CreateFile(string relativePath, string content) + { + var fullPath = System.IO.Path.Combine(_path, relativePath); + var directory = System.IO.Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + File.WriteAllText(fullPath, content); + } + + public void Dispose() + { + if (Directory.Exists(_path)) + { + try + { + Directory.Delete(_path, recursive: true); + } + catch + { + // Best effort cleanup + } + } + } +} diff --git a/tests/Utilities/AgentHelpersTests.cs b/tests/Utilities/AgentHelpersTests.cs new file mode 100644 index 00000000..7b717fd2 --- /dev/null +++ b/tests/Utilities/AgentHelpersTests.cs @@ -0,0 +1,117 @@ +using Orchestrator.App.Agents; +using Orchestrator.App.Tests.TestHelpers; +using Xunit; + +namespace Orchestrator.App.Tests.Utilities; + +public class AgentHelpersTests +{ + [Fact] + public void ValidateSpecFiles_WithValidPaths() + { + using var temp = new TempWorkspace(); + var path = "orchestrator/src/Orchestrator.App/Valid.cs"; + temp.CreateFile(path, "// ok"); + + var invalid = AgentHelpers.ValidateSpecFiles(new[] { path }, temp.Workspace); + + Assert.Empty(invalid); + } + + [Fact] + public void ValidateSpecFiles_WithPathTraversalAttempts() + { + using var temp = new TempWorkspace(); + var path = "orchestrator/src/../secrets.txt"; + + var invalid = AgentHelpers.ValidateSpecFiles(new[] { path }, temp.Workspace); + + Assert.Contains(path, invalid); + } + + [Fact] + public void ValidateSpecFiles_WithDisallowedExtensions() + { + using var temp = new TempWorkspace(); + var path = "orchestrator/src/Orchestrator.App/Bad.exe"; + + var invalid = AgentHelpers.ValidateSpecFiles(new[] { path }, temp.Workspace); + + Assert.Contains(path, invalid); + } + + [Fact] + public void ValidateSpecFiles_WithNonExistentNewFiles() + { + using var temp = new TempWorkspace(); + var path = "orchestrator/src/Orchestrator.App/NewFile.cs"; + + var invalid = AgentHelpers.ValidateSpecFiles(new[] { path }, temp.Workspace); + + Assert.Empty(invalid); + } + + [Fact] + public void IsAllowedPath_WithVariousPrefixes() + { + Assert.True(AgentHelpers.IsAllowedPath("orchestrator/src/Orchestrator.App/Program.cs")); + Assert.True(AgentHelpers.IsAllowedPath("orchestrator/tests/Utilities/ExampleTests.cs")); + Assert.True(AgentHelpers.IsAllowedPath("Assets/Scripts/Gameplay.cs")); + Assert.True(AgentHelpers.IsAllowedPath("Assets/Tests/EditorTests.cs")); + Assert.True(AgentHelpers.IsAllowedPath("orchestrator/README.md")); + Assert.False(AgentHelpers.IsAllowedPath("orchestrator/docs/README.md")); + } + + [Fact] + public void IsAllowedExtension_ForSupportedTypes() + { + Assert.True(AgentHelpers.IsAllowedExtension(".cs")); + Assert.True(AgentHelpers.IsAllowedExtension(".md")); + Assert.True(AgentHelpers.IsAllowedExtension(".json")); + Assert.True(AgentHelpers.IsAllowedExtension(".yml")); + Assert.True(AgentHelpers.IsAllowedExtension(".yaml")); + Assert.False(AgentHelpers.IsAllowedExtension(".txt")); + } + + [Fact] + public void IsTestFile_Detection() + { + Assert.True(AgentHelpers.IsTestFile("src/Tests/Example.cs")); + Assert.True(AgentHelpers.IsTestFile("src\\Tests\\Example.cs")); + Assert.True(AgentHelpers.IsTestFile("ExampleTests.cs")); + Assert.False(AgentHelpers.IsTestFile("src/Example.cs")); + } + + [Fact] + public void StripCodeFence_WithVariousFormats() + { + var csharp = "```csharp\nvar x = 1;\n```"; + var json = "```json\n{ \"value\": 1 }\n```"; + var noLang = "```\nplain\n```"; + + Assert.Equal("var x = 1;", AgentHelpers.StripCodeFence(csharp)); + Assert.Equal("{ \"value\": 1 }", AgentHelpers.StripCodeFence(json)); + Assert.Equal("plain", AgentHelpers.StripCodeFence(noLang)); + } + + [Fact] + public void StripCodeFence_WithNoFence() + { + Assert.Equal("plain", AgentHelpers.StripCodeFence("plain")); + } + + [Fact] + public void StripCodeFence_WithIncompleteFence() + { + var content = "```\nstill open"; + + Assert.Equal("```\nstill open", AgentHelpers.StripCodeFence(content)); + } + + [Fact] + public void Truncate_WithVariousLengths() + { + Assert.Equal("short", AgentHelpers.Truncate("short", 10)); + Assert.Equal("abcde\n...truncated...", AgentHelpers.Truncate("abcdef", 5)); + } +} diff --git a/tests/Utilities/AgentTemplateUtilTests.cs b/tests/Utilities/AgentTemplateUtilTests.cs new file mode 100644 index 00000000..74f767ff --- /dev/null +++ b/tests/Utilities/AgentTemplateUtilTests.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using Orchestrator.App; +using Orchestrator.App.Agents; +using Orchestrator.App.Tests.TestHelpers; +using Xunit; + +namespace Orchestrator.App.Tests.Utilities; + +public class AgentTemplateUtilTests +{ + [Fact] + public void BuildTokens_CreatesCorrectDictionary() + { + using var temp = new TempWorkspace(); + var ctx = CreateContext(temp.Workspace); + + var tokens = AgentTemplateUtil.BuildTokens(ctx); + + Assert.Equal("42", tokens["{{ISSUE_NUMBER}}"]); + Assert.Equal("Fix bug", tokens["{{ISSUE_TITLE}}"]); + Assert.Equal("https://example.com/issue/42", tokens["{{ISSUE_URL}}"]); + Assert.EndsWith("UTC", tokens["{{UPDATED_AT_UTC}}"]); + } + + [Fact] + public void RenderTemplate_WithExistingTemplateFile() + { + using var temp = new TempWorkspace(); + var ctx = CreateContext(temp.Workspace); + var templatePath = "templates/review.md"; + temp.Workspace.WriteAllText(templatePath, "Issue {{ISSUE_NUMBER}}: {{ISSUE_TITLE}}"); + + var tokens = AgentTemplateUtil.BuildTokens(ctx); + var result = AgentTemplateUtil.RenderTemplate(temp.Workspace, templatePath, tokens, "fallback"); + + Assert.Equal("Issue 42: Fix bug", result); + } + + [Fact] + public void RenderTemplate_WithFallback() + { + using var temp = new TempWorkspace(); + var ctx = CreateContext(temp.Workspace); + + var tokens = AgentTemplateUtil.BuildTokens(ctx); + var result = AgentTemplateUtil.RenderTemplate(temp.Workspace, "missing.md", tokens, "Issue {{ISSUE_NUMBER}} fallback"); + + Assert.Equal("Issue 42 fallback", result); + } + + [Fact] + public void RenderTemplate_TokenReplacementIsCaseInsensitive() + { + using var temp = new TempWorkspace(); + var ctx = CreateContext(temp.Workspace); + var templatePath = "templates/review.md"; + temp.Workspace.WriteAllText(templatePath, "Issue {{issue_number}}: {{issue_title}}"); + + var tokens = AgentTemplateUtil.BuildTokens(ctx); + var result = AgentTemplateUtil.RenderTemplate(temp.Workspace, templatePath, tokens, "fallback"); + + Assert.Equal("Issue 42: Fix bug", result); + } + + [Fact] + public void UpdateStatus_UpdatesStatusLine() + { + var content = "STATUS: PENDING\nUPDATED: 2020-01-01 00:00:00 UTC\n"; + + var updated = AgentTemplateUtil.UpdateStatus(content, "COMPLETE"); + + Assert.Contains("STATUS: COMPLETE", updated); + } + + [Fact] + public void UpdateStatus_UpdatesUpdatedLine() + { + var content = "STATUS: PENDING\nUPDATED: 2020-01-01 00:00:00 UTC\n"; + + var updated = AgentTemplateUtil.UpdateStatus(content, "COMPLETE"); + + foreach (var line in updated.Split('\n')) + { + if (line.StartsWith("UPDATED:", StringComparison.OrdinalIgnoreCase)) + { + Assert.EndsWith(" UTC", line); + Assert.Contains("UPDATED:", line); + } + } + } + + [Fact] + public void UpdateStatus_WithMissingMarkers() + { + var content = "No status here."; + + var updated = AgentTemplateUtil.UpdateStatus(content, "COMPLETE"); + + Assert.Equal(content, updated); + } + + [Fact] + public void IsStatus_Detection() + { + var content = "STATUS: PENDING"; + + Assert.True(AgentTemplateUtil.IsStatus(content, "pending")); + Assert.False(AgentTemplateUtil.IsStatus(content, "complete")); + } + + [Fact] + public void IsStatusComplete_Detection() + { + var content = "STATUS: COMPLETE"; + + Assert.True(AgentTemplateUtil.IsStatusComplete(content)); + } + + [Fact] + public void AppendQuestion_ToExistingQuestionsSection() + { + var content = "## Questions\n- Existing\n"; + + var updated = AgentTemplateUtil.AppendQuestion(content, "New question?"); + + Assert.Contains("- New question?", updated); + Assert.Contains("- Existing", updated); + } + + [Fact] + public void AppendQuestion_CreatesNewSection() + { + var content = "Some content"; + + var updated = AgentTemplateUtil.AppendQuestion(content, "New question?"); + + Assert.Contains("## Questions", updated); + Assert.Contains("- New question?", updated); + } + + [Fact] + public void AppendQuestion_AvoidsDuplicates() + { + var content = "## Questions\n- Duplicate?\n"; + + var updated = AgentTemplateUtil.AppendQuestion(content, "Duplicate?"); + + Assert.Equal(content.Trim(), updated.Trim()); + } + + [Fact] + public void EnsureTemplateHeader_AddsHeaderIfMissing() + { + using var temp = new TempWorkspace(); + var ctx = CreateContext(temp.Workspace); + var templatePath = "templates/spec.md"; + temp.Workspace.WriteAllText(templatePath, "# Spec: Issue {{ISSUE_NUMBER}} - {{ISSUE_TITLE}}"); + + var updated = AgentTemplateUtil.EnsureTemplateHeader("Body section", ctx, templatePath); + + Assert.StartsWith("# Spec: Issue 42 - Fix bug", updated, StringComparison.Ordinal); + Assert.Contains("Body section", updated); + } + + [Fact] + public void EnsureTemplateHeader_PreservesExistingHeader() + { + using var temp = new TempWorkspace(); + var ctx = CreateContext(temp.Workspace); + var content = "# Spec: Issue 42 - Fix bug\n\nBody"; + + var updated = AgentTemplateUtil.EnsureTemplateHeader(content, ctx, "templates/spec.md"); + + Assert.Equal(content, updated); + } + + [Fact] + public void ReplaceSection_ReplacesExistingSection() + { + var content = "## Files\n- old\n## Next\n"; + + var updated = AgentTemplateUtil.ReplaceSection(content, "## Files", "- new"); + + Assert.Contains("## Files\n- new", updated); + Assert.Contains("## Next", updated); + } + + [Fact] + public void ReplaceSection_CreatesNewSection() + { + var content = "Existing content"; + + var updated = AgentTemplateUtil.ReplaceSection(content, "## Files", "- new"); + + Assert.Contains("## Files\n- new", updated); + } + + private static WorkContext CreateContext(RepoWorkspace workspace) + { + var item = new WorkItem(42, "Fix bug", "body", "https://example.com/issue/42", new List()); + return new WorkContext(item, null!, OrchestratorConfig.FromEnvironment(), workspace, null!, null!); + } +} diff --git a/tests/Utilities/ProjectSummaryFormatterTests.cs b/tests/Utilities/ProjectSummaryFormatterTests.cs new file mode 100644 index 00000000..34c2a32e --- /dev/null +++ b/tests/Utilities/ProjectSummaryFormatterTests.cs @@ -0,0 +1,112 @@ +using System.Collections.Generic; +using Orchestrator.App; +using Xunit; + +namespace Orchestrator.App.Tests.Utilities; + +public class ProjectSummaryFormatterTests +{ + [Fact] + public void Format_WithGroupedItems() + { + var snapshot = new ProjectSnapshot( + Owner: "robin", + Number: 1, + OwnerType: ProjectOwnerType.User, + Title: "Demo", + Items: new List + { + new("First", 1, "https://example.com/1", "Ready"), + new("Second", 2, "https://example.com/2", "Backlog"), + new("Third", 3, "https://example.com/3", "In Progress"), + new("Fourth", 4, "https://example.com/4", "") + }); + + var result = ProjectSummaryFormatter.Format(snapshot); + + Assert.Contains("- Ready: 1", result); + Assert.Contains("- Backlog: 1", result); + Assert.Contains("- In Progress: 1", result); + Assert.Contains("- Unknown: 1", result); + } + + [Fact] + public void Format_WithEmptyProject() + { + var snapshot = new ProjectSnapshot( + Owner: "robin", + Number: 2, + OwnerType: ProjectOwnerType.User, + Title: "Empty", + Items: new List()); + + var result = ProjectSummaryFormatter.Format(snapshot); + + Assert.Contains("Top-3 next steps:", result); + Assert.Contains("- No Ready/Backlog items found.", result); + } + + [Fact] + public void Format_WithReadyAndBacklogItems() + { + var snapshot = new ProjectSnapshot( + Owner: "robin", + Number: 3, + OwnerType: ProjectOwnerType.User, + Title: "Queue", + Items: new List + { + new("First", 1, "https://example.com/1", "Ready"), + new("Second", 2, "https://example.com/2", "Backlog"), + new("Third", 3, "https://example.com/3", "Ready") + }); + + var result = ProjectSummaryFormatter.Format(snapshot); + + Assert.Contains("- First (https://example.com/1)", result); + Assert.Contains("- Second (https://example.com/2)", result); + Assert.Contains("- Third (https://example.com/3)", result); + } + + [Fact] + public void Format_WithNoReadyOrBacklogItems() + { + var snapshot = new ProjectSnapshot( + Owner: "robin", + Number: 4, + OwnerType: ProjectOwnerType.User, + Title: "No Ready", + Items: new List + { + new("First", 1, "https://example.com/1", "In Progress") + }); + + var result = ProjectSummaryFormatter.Format(snapshot); + + Assert.Contains("- No Ready/Backlog items found.", result); + } + + [Fact] + public void Format_UserVsOrganizationUrls() + { + var userSnapshot = new ProjectSnapshot( + Owner: "robin", + Number: 5, + OwnerType: ProjectOwnerType.User, + Title: "User", + Items: new List()); + + var orgSnapshot = new ProjectSnapshot( + Owner: "acme", + Number: 6, + OwnerType: ProjectOwnerType.Organization, + Title: "Org", + Items: new List()); + + var userResult = ProjectSummaryFormatter.Format(userSnapshot); + var orgResult = ProjectSummaryFormatter.Format(orgSnapshot); + + Assert.Contains("https://github.com/users/robin/projects/5", userResult); + Assert.Contains("https://github.com/orgs/acme/projects/6", orgResult); + } +} diff --git a/tests/Utilities/WorkItemBranchTests.cs b/tests/Utilities/WorkItemBranchTests.cs new file mode 100644 index 00000000..ad3f53ec --- /dev/null +++ b/tests/Utilities/WorkItemBranchTests.cs @@ -0,0 +1,89 @@ +using FluentAssertions; +using Orchestrator.App; +using Orchestrator.App.Tests.TestHelpers; +using Xunit; + +namespace Orchestrator.App.Tests.Utilities; + +public class WorkItemBranchTests +{ + [Fact] + public void BuildBranchName_WithNormalTitle_CreatesValidBranchName() + { + var workItem = MockWorkContext.CreateWorkItem(number: 42, title: "Add new feature"); + + var branchName = WorkItemBranch.BuildBranchName(workItem); + + branchName.Should().Be("agent/issue-42-add-new-feature"); + } + + [Fact] + public void BuildBranchName_WithSpecialCharacters_SanitizesName() + { + var workItem = MockWorkContext.CreateWorkItem(number: 10, title: "Fix bug #123 & improve performance!"); + + var branchName = WorkItemBranch.BuildBranchName(workItem); + + branchName.Should().Be("agent/issue-10-fix-bug-123-improve-performance"); + } + + [Fact] + public void BuildBranchName_WithEmptyTitle_UsesDefaultSlug() + { + var workItem = MockWorkContext.CreateWorkItem(number: 5, title: ""); + + var branchName = WorkItemBranch.BuildBranchName(workItem); + + branchName.Should().Be("agent/issue-5-work-item"); + } + + [Fact] + public void BuildBranchName_WithWhitespaceOnly_UsesDefaultSlug() + { + var workItem = MockWorkContext.CreateWorkItem(number: 7, title: " "); + + var branchName = WorkItemBranch.BuildBranchName(workItem); + + branchName.Should().Be("agent/issue-7-work-item"); + } + + [Fact] + public void BuildBranchName_WithMultipleSpaces_CollapseToSingleDash() + { + var workItem = MockWorkContext.CreateWorkItem(number: 15, title: "Multiple spaces here"); + + var branchName = WorkItemBranch.BuildBranchName(workItem); + + branchName.Should().Be("agent/issue-15-multiple-spaces-here"); + } + + [Fact] + public void BuildBranchName_WithUnicodeCharacters_ReplacesWithDash() + { + var workItem = MockWorkContext.CreateWorkItem(number: 20, title: "Fix äöü encoding issues"); + + var branchName = WorkItemBranch.BuildBranchName(workItem); + + branchName.Should().Be("agent/issue-20-fix-encoding-issues"); + } + + [Fact] + public void BuildBranchName_WithLeadingAndTrailingDashes_TrimsCorrectly() + { + var workItem = MockWorkContext.CreateWorkItem(number: 25, title: "---Title---"); + + var branchName = WorkItemBranch.BuildBranchName(workItem); + + branchName.Should().Be("agent/issue-25-title"); + } + + [Fact] + public void BuildBranchName_WithMixedCase_ConvertsToLowerCase() + { + var workItem = MockWorkContext.CreateWorkItem(number: 30, title: "Fix CamelCase Issue"); + + var branchName = WorkItemBranch.BuildBranchName(workItem); + + branchName.Should().Be("agent/issue-30-fix-camelcase-issue"); + } +} diff --git a/tests/Utilities/WorkItemParsersTests.cs b/tests/Utilities/WorkItemParsersTests.cs new file mode 100644 index 00000000..7ccdd32b --- /dev/null +++ b/tests/Utilities/WorkItemParsersTests.cs @@ -0,0 +1,149 @@ +using System.Collections.Generic; +using Orchestrator.App; +using Xunit; + +namespace Orchestrator.App.Tests.Utilities; + +public class WorkItemParsersTests +{ + [Fact] + public void TryParseProjectReference_WithUserProjects() + { + var body = "See https://github.com/users/robin/projects/12 for details."; + + var result = WorkItemParsers.TryParseProjectReference(body); + + Assert.NotNull(result); + Assert.Equal("robin", result!.Owner); + Assert.Equal(12, result.Number); + Assert.Equal(ProjectOwnerType.User, result.OwnerType); + } + + [Fact] + public void TryParseProjectReference_WithOrgProjects() + { + var body = "Project: https://github.com/orgs/acme/projects/3"; + + var result = WorkItemParsers.TryParseProjectReference(body); + + Assert.NotNull(result); + Assert.Equal("acme", result!.Owner); + Assert.Equal(3, result.Number); + Assert.Equal(ProjectOwnerType.Organization, result.OwnerType); + } + + [Fact] + public void TryParseProjectReference_WithInvalidUrls() + { + Assert.Null(WorkItemParsers.TryParseProjectReference("https://github.com/users/robin/project/1")); + Assert.Null(WorkItemParsers.TryParseProjectReference("https://github.com/users/robin/projects/")); + Assert.Null(WorkItemParsers.TryParseProjectReference("https://github.com/orgs/acme/projects/foo")); + } + + [Fact] + public void TryParseProjectReference_WithNullOrEmptyBody() + { + Assert.Null(WorkItemParsers.TryParseProjectReference("")); + Assert.Null(WorkItemParsers.TryParseProjectReference(" ")); + Assert.Null(WorkItemParsers.TryParseProjectReference(null!)); + } + + [Fact] + public void TryParseIssueNumber_WithVariousFormats() + { + Assert.Equal(123, WorkItemParsers.TryParseIssueNumber("Issue #123")); + Assert.Equal(45, WorkItemParsers.TryParseIssueNumber("issue #45 - done")); + Assert.Equal(7, WorkItemParsers.TryParseIssueNumber("ISSUE #7 is ready")); + } + + [Fact] + public void TryParseIssueNumber_EdgeCases() + { + Assert.Null(WorkItemParsers.TryParseIssueNumber("Issue #")); + Assert.Null(WorkItemParsers.TryParseIssueNumber("Issue #abc")); + Assert.Equal(12, WorkItemParsers.TryParseIssueNumber("Issue #12a")); + } + + [Fact] + public void TryParseAcceptanceCriteria_WithBulletLists() + { + var body = "Acceptance Criteria\n- First item\n- Second item\n"; + + var results = WorkItemParsers.TryParseAcceptanceCriteria(body); + + Assert.Equal(new List { "First item", "Second item" }, results); + } + + [Fact] + public void TryParseAcceptanceCriteria_WithNumberedLists() + { + var body = "Acceptance criteria\n1. First item\n2. Second item\n"; + + var results = WorkItemParsers.TryParseAcceptanceCriteria(body); + + Assert.Empty(results); + } + + [Fact] + public void TryParseAcceptanceCriteria_WithNoCriteria() + { + var body = "No acceptance section here."; + + var results = WorkItemParsers.TryParseAcceptanceCriteria(body); + + Assert.Empty(results); + } + + [Fact] + public void MarkAcceptanceCriteriaDone_CheckboxReplacement() + { + var content = "- [ ] First item\n- [x] Second item\n* [ ] Third item\n"; + + var updated = WorkItemParsers.MarkAcceptanceCriteriaDone(content); + + Assert.Contains("- [x] First item", updated); + Assert.Contains("- [x] Second item", updated); + Assert.Contains("* [ ] Third item", updated); + } + + [Fact] + public void TryParseSpecFiles_FromMarkdown() + { + var content = "## Files\n- src/Orchestrator.App/Program.cs\n- docs/spec.md\n## Next\n"; + + var results = WorkItemParsers.TryParseSpecFiles(content); + + Assert.Equal(new List { "src/Orchestrator.App/Program.cs", "docs/spec.md" }, results); + } + + [Fact] + public void TryParseSpecFiles_WithInlineDescriptions() + { + var content = "## Files\n- src/Orchestrator.App/Program.cs (main entry point)\n- docs/spec.md (notes)\n"; + + var results = WorkItemParsers.TryParseSpecFiles(content); + + Assert.Equal(new List { "src/Orchestrator.App/Program.cs", "docs/spec.md" }, results); + } + + [Fact] + public void TryParseSection_ExtractsSection() + { + var content = "## Answers\nOne line\nTwo line\n## Next\n"; + + var result = WorkItemParsers.TryParseSection(content, "## Answers"); + + Assert.Equal("One line\nTwo line", result); + } + + [Fact] + public void IsSafeRelativePath_Validation() + { + Assert.False(WorkItemParsers.IsSafeRelativePath("/etc/passwd")); + Assert.False(WorkItemParsers.IsSafeRelativePath("\\share\\file")); + Assert.False(WorkItemParsers.IsSafeRelativePath("src/../secret.txt")); + Assert.False(WorkItemParsers.IsSafeRelativePath("src..//file.txt")); + Assert.True(WorkItemParsers.IsSafeRelativePath("src/file.txt")); + Assert.True(WorkItemParsers.IsSafeRelativePath("docs/readme.md")); + } +}