Skip to content

Commit 8c93516

Browse files
committed
chore: save local changes
1 parent 82e313a commit 8c93516

20 files changed

Lines changed: 30215 additions & 0 deletions

CLAUDE.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# gsd-orchestrator
2+
3+
Autonomous GitHub workflow engine. Reads issues, plans code changes via Claude (MCP), branches, edits, commits, opens PRs, and generates tests — driven by a state machine. Production-grade .NET 10 C# with enterprise patterns.
4+
5+
## Project Context
6+
7+
See `.planning/PROJECT.md` for goals, milestone status, and requirements.
8+
9+
**Current milestone**: v3.0 — multi-repo support, test generation, PR review loop. All 5 phases complete, 35 tests green.
10+
11+
## Tech Stack
12+
13+
| Layer | Technology |
14+
|---|---|
15+
| Language | C# / .NET 10 |
16+
| State machine | Custom `GsdStateMachine` with 11 `IWorkflowState` implementations |
17+
| Claude integration | MCP stdio client (`McpStdioClient` / `IMcpClient`) — JSON-RPC over subprocess |
18+
| Resilience | Polly (`Microsoft.Extensions.Resilience`) |
19+
| Logging | Serilog with structured JSON output |
20+
| DI | `Microsoft.Extensions.DependencyInjection` + `IHost` |
21+
| Checkpointing | File-based (`FileCheckpointStore`) — namespaced per `{owner}_{repo}_{workflowId}.json` |
22+
| Auth | GitHub PAT (`GitHubPatProvider`) via env var |
23+
| Tests | xUnit + NSubstitute |
24+
25+
## State Machine Flow
26+
27+
```
28+
Idle → Triaging → Analyzing → Branching → Editing → Committing
29+
→ TestGenerating → Reviewing → Validating → Documenting → PrCreating
30+
```
31+
32+
Each state implements `IWorkflowState` in `src/GsdOrchestrator/Workflows/States/`.
33+
34+
## Key Files
35+
36+
| File | Purpose |
37+
|---|---|
38+
| `src/GsdOrchestrator/Program.cs` | DI wiring, host setup, CLI entrypoint |
39+
| `src/GsdOrchestrator/Workflows/GsdStateMachine.cs` | State machine orchestrator |
40+
| `src/GsdOrchestrator/Mcp/McpStdioClient.cs` | JSON-RPC stdio client to Claude |
41+
| `src/GsdOrchestrator/Mcp/McpToolDispatcher.cs` | Tool dispatch over MCP |
42+
| `src/GsdOrchestrator/Checkpointing/FileCheckpointStore.cs` | Workflow persistence |
43+
| `src/GsdOrchestrator/Workflows/Models/RepoConfigLoader.cs` | `GSD_REPOS` JSON config |
44+
45+
## Configuration
46+
47+
All configuration is environment-variable driven (`.env` or shell):
48+
- `GSD_REPOS` — JSON array of `{owner, repo}` objects for multi-repo support
49+
- `GITHUB_TOKEN` — PAT for GitHub API
50+
- `ANTHROPIC_API_KEY` — or via MCP config
51+
- `GSD_CHECKPOINT_DIR` — defaults to `state/checkpoints/`
52+
53+
## Build & Test
54+
55+
```powershell
56+
dotnet build src/GsdOrchestrator.sln
57+
dotnet test src/GsdOrchestrator.Tests/GsdOrchestrator.Tests.csproj
58+
```
59+
60+
## Conventions
61+
62+
- `PascalCase` for types, `camelCase` for locals, `_camelCase` for private fields
63+
- State classes are self-contained — inject only what they need, return `IWorkflowState` for transitions
64+
- Never access `HttpClient` or GitHub API directly from state classes — go through injected services
65+
- Tests use NSubstitute mocks, never live API calls
66+
67+
## GSD Workflow
68+
69+
Use `/gsd:plan-phase` before any multi-file change. Use `/gsd:quick` for single-file fixes.

coverage-results/2cc7c2ec-58e3-42e1-844e-e1248c401094/coverage.cobertura.xml

Lines changed: 7932 additions & 0 deletions
Large diffs are not rendered by default.

coverage-results/567d92fc-bc21-498c-9bd7-90b876780eb6/coverage.cobertura.xml

Lines changed: 7932 additions & 0 deletions
Large diffs are not rendered by default.

coverage-results/7acf5f7a-00e1-43ed-8c5f-920b77563b7d/coverage.cobertura.xml

Lines changed: 6376 additions & 0 deletions
Large diffs are not rendered by default.

coverage-results/f697cb52-e85a-479c-84d3-5489b58c1321/coverage.cobertura.xml

Lines changed: 6376 additions & 0 deletions
Large diffs are not rendered by default.

create_validating.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import os
2+
base = "C:/PersonalRepo/portfolio/gsd-orchestrator/src/GsdOrchestrator.Tests"
3+
content = open("C:/PersonalRepo/portfolio/gsd-orchestrator/validating_template.cs").read()
4+
with open(f"{base}/States/ValidatingStateTests.cs", "w") as f:
5+
f.write(content)
6+
print("done")
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
using GsdOrchestrator.Auth;
2+
using Microsoft.Extensions.Configuration;
3+
using NSubstitute;
4+
using Xunit;
5+
6+
namespace GsdOrchestrator.Tests;
7+
8+
/// <summary>
9+
/// Tests for GitHubPatProvider — reads GitHub PAT from IConfiguration.
10+
/// </summary>
11+
public class GitHubPatProviderTests
12+
{
13+
private static IConfiguration BuildConfig(string? token)
14+
{
15+
var config = Substitute.For<IConfiguration>();
16+
config["GITHUB_PERSONAL_ACCESS_TOKEN"].Returns(token);
17+
return config;
18+
}
19+
20+
// PATPROV-01: valid ghp_ token is accepted and stored
21+
[Fact]
22+
public void Constructor_ValidGhpToken_StoresToken()
23+
{
24+
var config = BuildConfig("ghp_abc123XYZ");
25+
var sut = new GitHubPatProvider(config);
26+
Assert.Equal("ghp_abc123XYZ", sut.Token);
27+
}
28+
29+
// PATPROV-01: valid ghs_ token is accepted
30+
[Fact]
31+
public void Constructor_ValidGhsToken_StoresToken()
32+
{
33+
var config = BuildConfig("ghs_servicesecret99");
34+
var sut = new GitHubPatProvider(config);
35+
Assert.Equal("ghs_servicesecret99", sut.Token);
36+
}
37+
38+
// PATPROV-01: valid github_pat_ fine-grained token is accepted
39+
[Fact]
40+
public void Constructor_ValidGithubPatPrefixToken_StoresToken()
41+
{
42+
var config = BuildConfig("github_pat_11ABCDE0000_LONGTOKEN");
43+
var sut = new GitHubPatProvider(config);
44+
Assert.Equal("github_pat_11ABCDE0000_LONGTOKEN", sut.Token);
45+
}
46+
47+
// PATPROV-02: missing token throws InvalidOperationException
48+
[Fact]
49+
public void Constructor_NullToken_ThrowsInvalidOperationException()
50+
{
51+
var config = BuildConfig(null);
52+
var ex = Assert.Throws<InvalidOperationException>(() => new GitHubPatProvider(config));
53+
Assert.Contains("GITHUB_PERSONAL_ACCESS_TOKEN", ex.Message);
54+
}
55+
56+
// PATPROV-02: whitespace-only token throws InvalidOperationException
57+
[Fact]
58+
public void Constructor_WhitespaceToken_ThrowsInvalidOperationException()
59+
{
60+
var config = BuildConfig(" ");
61+
var ex = Assert.Throws<InvalidOperationException>(() => new GitHubPatProvider(config));
62+
Assert.Contains("GITHUB_PERSONAL_ACCESS_TOKEN", ex.Message);
63+
}
64+
65+
// PATPROV-03: token with wrong prefix throws InvalidOperationException
66+
[Fact]
67+
public void Constructor_WrongPrefixToken_ThrowsInvalidOperationException()
68+
{
69+
var config = BuildConfig("xgh_invalidtoken123");
70+
var ex = Assert.Throws<InvalidOperationException>(() => new GitHubPatProvider(config));
71+
Assert.Contains("valid GitHub token", ex.Message);
72+
}
73+
74+
// PATPROV-03: plain token with no prefix throws
75+
[Fact]
76+
public void Constructor_PlainStringToken_ThrowsInvalidOperationException()
77+
{
78+
var config = BuildConfig("mysecrettoken");
79+
var ex = Assert.Throws<InvalidOperationException>(() => new GitHubPatProvider(config));
80+
Assert.Contains("valid GitHub token", ex.Message);
81+
}
82+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
using System.Text.Json.Nodes;
2+
using GsdOrchestrator.Checkpointing;
3+
using GsdOrchestrator.Mcp;
4+
using GsdOrchestrator.Workflows;
5+
using GsdOrchestrator.Workflows.Models;
6+
using GsdOrchestrator.Workflows.States;
7+
using Microsoft.Extensions.Logging.Abstractions;
8+
using NSubstitute;
9+
using Polly.Registry;
10+
using Xunit;
11+
12+
namespace GsdOrchestrator.Tests;
13+
14+
/// <summary>
15+
/// Additional tests covering GsdStateMachine gaps: GetState, PostFailureCommentAsync.
16+
/// </summary>
17+
public class GsdStateMachineAdditionalTests
18+
{
19+
private static GsdStateMachine BuildSut(
20+
ICheckpointStore checkpoints,
21+
IWorkflowState[] states,
22+
IMcpClient? mcpClient = null)
23+
{
24+
var client = mcpClient ?? Substitute.For<IMcpClient>();
25+
var registry = new ResiliencePipelineRegistry<string>();
26+
registry.TryAddBuilder("mcp-tools", (b, _) => { });
27+
var dispatcher = new McpToolDispatcher(
28+
client, registry, NullLogger<McpToolDispatcher>.Instance);
29+
return new GsdStateMachine(
30+
checkpoints, dispatcher, states, NullLogger<GsdStateMachine>.Instance);
31+
}
32+
33+
private static IWorkflowState MakeState(WorkflowState from, WorkflowState to)
34+
{
35+
var s = Substitute.For<IWorkflowState>();
36+
s.State.Returns(from);
37+
s.ExecuteAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
38+
.Returns(ci => Task.FromResult(ci.Arg<GsdWorkflowContext>() with { CurrentState = to }));
39+
return s;
40+
}
41+
42+
// SM-GETSTATE-01: GetState returns registered state handler
43+
[Fact]
44+
public void GetState_RegisteredState_ReturnsHandler()
45+
{
46+
var checkpoints = Substitute.For<ICheckpointStore>();
47+
var idleState = MakeState(WorkflowState.Idle, WorkflowState.Done);
48+
var sut = BuildSut(checkpoints, [idleState]);
49+
50+
var handler = sut.GetState(WorkflowState.Idle);
51+
52+
Assert.NotNull(handler);
53+
Assert.Equal(WorkflowState.Idle, handler.State);
54+
}
55+
56+
// SM-GETSTATE-02: GetState for unregistered state throws InvalidOperationException
57+
[Fact]
58+
public void GetState_UnregisteredState_ThrowsInvalidOperationException()
59+
{
60+
var checkpoints = Substitute.For<ICheckpointStore>();
61+
var sut = BuildSut(checkpoints, []);
62+
63+
var ex = Assert.Throws<InvalidOperationException>(
64+
() => sut.GetState(WorkflowState.Idle));
65+
Assert.Contains("Idle", ex.Message);
66+
}
67+
68+
// SM-FAILURE-01: on failure, add_issue_comment is called with failure details
69+
[Fact]
70+
public async Task RunAsync_StateThrowsException_PostsFailureComment()
71+
{
72+
var checkpoints = Substitute.For<ICheckpointStore>();
73+
checkpoints.SaveAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
74+
.Returns(Task.CompletedTask);
75+
76+
var mcp = Substitute.For<IMcpClient>();
77+
mcp.CallToolAsync(Arg.Any<string>(), Arg.Any<JsonObject>(), Arg.Any<CancellationToken>())
78+
.Returns(Task.FromResult(new McpToolResult("", false)));
79+
80+
var failingState = Substitute.For<IWorkflowState>();
81+
failingState.State.Returns(WorkflowState.Idle);
82+
failingState.ExecuteAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
83+
.Returns<Task<GsdWorkflowContext>>(_ => throw new InvalidOperationException("boom"));
84+
85+
var sut = BuildSut(checkpoints, [failingState], mcp);
86+
87+
var ctx = await sut.RunAsync("owner", "repo", 1, triageModeOnly: false, CancellationToken.None);
88+
89+
Assert.Equal(WorkflowState.Failed, ctx.CurrentState);
90+
// Verify add_issue_comment was called for the failure notification
91+
await mcp.Received().CallToolAsync(
92+
Arg.Is<string>("add_issue_comment"),
93+
Arg.Any<JsonObject>(),
94+
Arg.Any<CancellationToken>());
95+
}
96+
97+
// SM-FAILURE-02: when Issue is null, PostFailureCommentAsync is a no-op (no MCP call)
98+
[Fact]
99+
public async Task RunAsync_FailureWithNullIssue_DoesNotCallMcp()
100+
{
101+
// Create a state machine with NO states and override so failure happens with no issue context
102+
var checkpoints = Substitute.For<ICheckpointStore>();
103+
checkpoints.SaveAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
104+
.Returns(Task.CompletedTask);
105+
106+
var mcp = Substitute.For<IMcpClient>();
107+
108+
var failingState = Substitute.For<IWorkflowState>();
109+
failingState.State.Returns(WorkflowState.Idle);
110+
failingState.ExecuteAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
111+
.Returns<Task<GsdWorkflowContext>>(ci =>
112+
{
113+
// Return a failed context with no issue
114+
var ctx = ci.Arg<GsdWorkflowContext>() with
115+
{
116+
Issue = null,
117+
FailureReason = "no issue",
118+
CurrentState = WorkflowState.Failed
119+
};
120+
return Task.FromResult(ctx);
121+
});
122+
123+
var sut = BuildSut(checkpoints, [failingState], mcp);
124+
var result = await sut.RunAsync("owner", "repo", 1, triageModeOnly: false, CancellationToken.None);
125+
126+
Assert.Equal(WorkflowState.Failed, result.CurrentState);
127+
// No add_issue_comment should be fired when Issue is null
128+
await mcp.DidNotReceive().CallToolAsync(
129+
Arg.Is<string>("add_issue_comment"),
130+
Arg.Any<JsonObject>(),
131+
Arg.Any<CancellationToken>());
132+
}
133+
134+
// SM-FAILURE-03: PostFailureCommentAsync McpException is swallowed (logged, not rethrown)
135+
[Fact]
136+
public async Task RunAsync_FailureCommentThrows_DoesNotRethrow()
137+
{
138+
var checkpoints = Substitute.For<ICheckpointStore>();
139+
checkpoints.SaveAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
140+
.Returns(Task.CompletedTask);
141+
142+
var mcp = Substitute.For<IMcpClient>();
143+
mcp.CallToolAsync(Arg.Any<string>(), Arg.Any<JsonObject>(), Arg.Any<CancellationToken>())
144+
.Returns<Task<McpToolResult>>(_ => throw new McpException("mcp dead", isTransient: false));
145+
146+
var failingState = Substitute.For<IWorkflowState>();
147+
failingState.State.Returns(WorkflowState.Idle);
148+
failingState.ExecuteAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
149+
.Returns<Task<GsdWorkflowContext>>(_ => throw new InvalidOperationException("state exploded"));
150+
151+
var sut = BuildSut(checkpoints, [failingState], mcp);
152+
153+
// Should not throw even when MCP call for comment also fails
154+
var ctx = await sut.RunAsync("owner", "repo", 1, triageModeOnly: false, CancellationToken.None);
155+
Assert.Equal(WorkflowState.Failed, ctx.CurrentState);
156+
}
157+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using System.Reflection;
2+
using GsdOrchestrator.Mcp;
3+
using Xunit;
4+
5+
namespace GsdOrchestrator.Tests;
6+
7+
/// <summary>
8+
/// Covers McpException.FromToolError (internal static factory — lines 18-27)
9+
/// via reflection since the method is internal to GsdOrchestrator.
10+
/// </summary>
11+
public class McpExceptionFromToolErrorTests
12+
{
13+
private static McpException InvokeFromToolError(string text)
14+
{
15+
var method = typeof(McpException)
16+
.GetMethod("FromToolError", BindingFlags.NonPublic | BindingFlags.Static)!;
17+
return (McpException)method.Invoke(null, [text])!;
18+
}
19+
20+
[Fact]
21+
public void FromToolError_PlainText_IsNotTransientNotSecondary()
22+
{
23+
var ex = InvokeFromToolError("generic API error");
24+
Assert.False(ex.IsTransient);
25+
Assert.False(ex.IsSecondaryRateLimit);
26+
Assert.Equal("generic API error", ex.Message);
27+
}
28+
29+
[Fact]
30+
public void FromToolError_SecondaryRateLimitText_SetsBothFlags()
31+
{
32+
var ex = InvokeFromToolError("You have exceeded the secondary rate limit.");
33+
Assert.True(ex.IsSecondaryRateLimit);
34+
Assert.True(ex.IsTransient);
35+
}
36+
37+
[Fact]
38+
public void FromToolError_SecondaryRateLimitMixedCase_Detected()
39+
{
40+
var ex = InvokeFromToolError("Secondary Rate Limit triggered");
41+
Assert.True(ex.IsSecondaryRateLimit);
42+
Assert.True(ex.IsTransient);
43+
}
44+
45+
[Fact]
46+
public void FromToolError_PrimaryRateLimitExceeded_IsTransientNotSecondary()
47+
{
48+
var ex = InvokeFromToolError("rate limit exceeded on this endpoint");
49+
Assert.True(ex.IsTransient);
50+
Assert.False(ex.IsSecondaryRateLimit);
51+
}
52+
53+
[Fact]
54+
public void FromToolError_ApiRateLimit_IsTransientNotSecondary()
55+
{
56+
var ex = InvokeFromToolError("API rate limit reached for user");
57+
Assert.True(ex.IsTransient);
58+
Assert.False(ex.IsSecondaryRateLimit);
59+
}
60+
61+
[Fact]
62+
public void FromToolError_PreservesMessageVerbatim()
63+
{
64+
var msg = "Tool error: NOT_FOUND (404)";
65+
var ex = InvokeFromToolError(msg);
66+
Assert.Equal(msg, ex.Message);
67+
}
68+
}

0 commit comments

Comments
 (0)