Skip to content

Commit d0c1430

Browse files
konardclaude
andcommitted
Implement GitHub bot as a tester with AST-based API discovery
- Add ApiDiscoveryService using Roslyn for AST analysis of C# code - Add TestGeneratorService for automated xUnit test generation - Add ApiTestingBotTrigger for random API testing and issue detection - Integrate new trigger into main bot program - Add Roslyn dependencies (Microsoft.CodeAnalysis.CSharp packages) - Create comprehensive documentation and usage examples - Enable bot to discover public APIs, test them randomly, generate reproducible tests for issues, and engage maintainers in quality discussions Fixes #79 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 29128e9 commit d0c1430

File tree

6 files changed

+601
-1
lines changed

6 files changed

+601
-1
lines changed

csharp/Platform.Bot/Platform.Bot.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
<ItemGroup>
1010
<PackageReference Include="CommandLineParser" Version="2.9.1" />
11+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
12+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.8.0" />
1113
<PackageReference Include="Octokit" Version="7.0.1" />
1214
<PackageReference Include="Platform.Communication.Protocol.Lino" Version="0.4.0" />
1315
<PackageReference Include="Platform.Data.Doublets.Sequences" Version="0.1.1" />

csharp/Platform.Bot/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ private static async Task<int> Main(string[] args)
9595
var dbContext = new FileStorage(databaseFilePath?.FullName ?? new TemporaryFile().Filename);
9696
Console.WriteLine($"Bot has been started. {Environment.NewLine}Press CTRL+C to close");
9797
var githubStorage = new GitHubStorage(githubUserName, githubApiToken, githubApplicationName);
98-
var issueTracker = new IssueTracker(githubStorage, new HelloWorldTrigger(githubStorage, dbContext, fileSetName), new OrganizationLastMonthActivityTrigger(githubStorage), new LastCommitActivityTrigger(githubStorage), new AdminAuthorIssueTriggerDecorator(new ProtectDefaultBranchTrigger(githubStorage), githubStorage), new AdminAuthorIssueTriggerDecorator(new ChangeOrganizationRepositoriesDefaultBranchTrigger(githubStorage, dbContext), githubStorage), new AdminAuthorIssueTriggerDecorator(new ChangeOrganizationPullRequestsBaseBranchTrigger(githubStorage, dbContext), githubStorage));
98+
var issueTracker = new IssueTracker(githubStorage, new HelloWorldTrigger(githubStorage, dbContext, fileSetName), new ApiTestingBotTrigger(githubStorage), new OrganizationLastMonthActivityTrigger(githubStorage), new LastCommitActivityTrigger(githubStorage), new AdminAuthorIssueTriggerDecorator(new ProtectDefaultBranchTrigger(githubStorage), githubStorage), new AdminAuthorIssueTriggerDecorator(new ChangeOrganizationRepositoriesDefaultBranchTrigger(githubStorage, dbContext), githubStorage), new AdminAuthorIssueTriggerDecorator(new ChangeOrganizationPullRequestsBaseBranchTrigger(githubStorage, dbContext), githubStorage));
9999
var pullRequenstTracker = new PullRequestTracker(githubStorage, new MergeDependabotBumpsTrigger(githubStorage));
100100
var timestampTracker = new DateTimeTracker(githubStorage, new CreateAndSaveOrganizationRepositoriesMigrationTrigger(githubStorage, dbContext, Path.Combine(Directory.GetCurrentDirectory(), "/github-migrations")));
101101
var cancellation = new CancellationTokenSource();
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
using Microsoft.CodeAnalysis;
2+
using Microsoft.CodeAnalysis.CSharp;
3+
using Microsoft.CodeAnalysis.CSharp.Syntax;
4+
using System;
5+
using System.Collections.Generic;
6+
using System.IO;
7+
using System.Linq;
8+
using System.Threading.Tasks;
9+
10+
namespace Platform.Bot.Services
11+
{
12+
public class ApiDiscoveryService
13+
{
14+
public class MethodInfo
15+
{
16+
public string Name { get; set; } = string.Empty;
17+
public string ClassName { get; set; } = string.Empty;
18+
public string Namespace { get; set; } = string.Empty;
19+
public List<ParameterInfo> Parameters { get; set; } = new();
20+
public string ReturnType { get; set; } = string.Empty;
21+
public bool IsStatic { get; set; }
22+
public bool IsPublic { get; set; }
23+
public string FilePath { get; set; } = string.Empty;
24+
}
25+
26+
public class ParameterInfo
27+
{
28+
public string Name { get; set; } = string.Empty;
29+
public string Type { get; set; } = string.Empty;
30+
public bool HasDefaultValue { get; set; }
31+
public string? DefaultValue { get; set; }
32+
}
33+
34+
public async Task<List<MethodInfo>> DiscoverPublicApis(string repositoryPath)
35+
{
36+
var methods = new List<MethodInfo>();
37+
var csFiles = Directory.GetFiles(repositoryPath, "*.cs", SearchOption.AllDirectories)
38+
.Where(f => !f.Contains("bin") && !f.Contains("obj"))
39+
.ToList();
40+
41+
foreach (var filePath in csFiles)
42+
{
43+
try
44+
{
45+
var code = await File.ReadAllTextAsync(filePath);
46+
var tree = CSharpSyntaxTree.ParseText(code);
47+
var root = await tree.GetRootAsync();
48+
49+
var classMethods = ExtractPublicMethods(root, filePath);
50+
methods.AddRange(classMethods);
51+
}
52+
catch (Exception ex)
53+
{
54+
Console.WriteLine($"Error analyzing file {filePath}: {ex.Message}");
55+
}
56+
}
57+
58+
return methods;
59+
}
60+
61+
private List<MethodInfo> ExtractPublicMethods(SyntaxNode root, string filePath)
62+
{
63+
var methods = new List<MethodInfo>();
64+
var namespaceDeclarations = root.DescendantNodes().OfType<NamespaceDeclarationSyntax>();
65+
66+
foreach (var namespaceDecl in namespaceDeclarations)
67+
{
68+
var namespaceName = namespaceDecl.Name.ToString();
69+
var classDeclarations = namespaceDecl.DescendantNodes().OfType<ClassDeclarationSyntax>();
70+
71+
foreach (var classDecl in classDeclarations)
72+
{
73+
if (!IsPublicClass(classDecl)) continue;
74+
75+
var className = classDecl.Identifier.ValueText;
76+
var methodDeclarations = classDecl.Members.OfType<MethodDeclarationSyntax>();
77+
78+
foreach (var methodDecl in methodDeclarations)
79+
{
80+
if (!IsPublicMethod(methodDecl)) continue;
81+
82+
var methodInfo = new MethodInfo
83+
{
84+
Name = methodDecl.Identifier.ValueText,
85+
ClassName = className,
86+
Namespace = namespaceName,
87+
ReturnType = methodDecl.ReturnType.ToString(),
88+
IsStatic = methodDecl.Modifiers.Any(m => m.IsKind(SyntaxKind.StaticKeyword)),
89+
IsPublic = true,
90+
FilePath = filePath,
91+
Parameters = ExtractParameters(methodDecl)
92+
};
93+
94+
methods.Add(methodInfo);
95+
}
96+
}
97+
}
98+
99+
return methods;
100+
}
101+
102+
private bool IsPublicClass(ClassDeclarationSyntax classDecl)
103+
{
104+
return classDecl.Modifiers.Any(m => m.IsKind(SyntaxKind.PublicKeyword));
105+
}
106+
107+
private bool IsPublicMethod(MethodDeclarationSyntax methodDecl)
108+
{
109+
return methodDecl.Modifiers.Any(m => m.IsKind(SyntaxKind.PublicKeyword));
110+
}
111+
112+
private List<ParameterInfo> ExtractParameters(MethodDeclarationSyntax methodDecl)
113+
{
114+
var parameters = new List<ParameterInfo>();
115+
116+
foreach (var param in methodDecl.ParameterList.Parameters)
117+
{
118+
var paramInfo = new ParameterInfo
119+
{
120+
Name = param.Identifier.ValueText,
121+
Type = param.Type?.ToString() ?? "object",
122+
HasDefaultValue = param.Default != null,
123+
DefaultValue = param.Default?.Value?.ToString()
124+
};
125+
parameters.Add(paramInfo);
126+
}
127+
128+
return parameters;
129+
}
130+
}
131+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
using Platform.Bot.Services;
2+
using System;
3+
using System.Collections.Generic;
4+
using System.Linq;
5+
using System.Text;
6+
7+
namespace Platform.Bot.Services
8+
{
9+
public class TestGeneratorService
10+
{
11+
private readonly System.Random _random = new();
12+
13+
public string GenerateUnitTest(ApiDiscoveryService.MethodInfo method, Exception? caughtException = null)
14+
{
15+
var testClass = $"{method.ClassName}Tests";
16+
var testMethodName = $"Test{method.Name}_Should{(caughtException != null ? "ThrowException" : "ExecuteSuccessfully")}";
17+
18+
var sb = new StringBuilder();
19+
sb.AppendLine("using System;");
20+
sb.AppendLine("using Xunit;");
21+
sb.AppendLine($"using {method.Namespace};");
22+
sb.AppendLine();
23+
sb.AppendLine($"namespace {method.Namespace}.Tests");
24+
sb.AppendLine("{");
25+
sb.AppendLine($" public class {testClass}");
26+
sb.AppendLine(" {");
27+
sb.AppendLine($" [Fact]");
28+
sb.AppendLine($" public void {testMethodName}()");
29+
sb.AppendLine(" {");
30+
31+
if (caughtException != null)
32+
{
33+
sb.AppendLine(" // This test was auto-generated because an exception was caught during random testing");
34+
sb.AppendLine($" // Exception: {caughtException.GetType().Name}: {caughtException.Message}");
35+
sb.AppendLine();
36+
}
37+
38+
// Generate test setup
39+
if (!method.IsStatic)
40+
{
41+
sb.AppendLine($" // Arrange");
42+
sb.AppendLine($" var instance = new {method.ClassName}();");
43+
}
44+
45+
// Generate parameters
46+
var parameterSetup = GenerateParameterSetup(method.Parameters);
47+
if (!string.IsNullOrEmpty(parameterSetup))
48+
{
49+
if (method.IsStatic) sb.AppendLine($" // Arrange");
50+
sb.AppendLine(parameterSetup);
51+
}
52+
53+
sb.AppendLine();
54+
sb.AppendLine(" // Act & Assert");
55+
56+
if (caughtException != null)
57+
{
58+
sb.AppendLine($" Assert.Throws<{caughtException.GetType().Name}>(() =>");
59+
sb.AppendLine(" {");
60+
sb.Append(" ");
61+
}
62+
else
63+
{
64+
if (method.ReturnType != "void")
65+
{
66+
sb.Append(" var result = ");
67+
}
68+
else
69+
{
70+
sb.Append(" ");
71+
}
72+
}
73+
74+
// Generate method call
75+
if (method.IsStatic)
76+
{
77+
sb.Append($"{method.ClassName}.{method.Name}(");
78+
}
79+
else
80+
{
81+
sb.Append($"instance.{method.Name}(");
82+
}
83+
84+
var paramCalls = method.Parameters.Select(p => p.Name).ToArray();
85+
sb.Append(string.Join(", ", paramCalls));
86+
sb.Append(");");
87+
88+
if (caughtException != null)
89+
{
90+
sb.AppendLine();
91+
sb.AppendLine(" });");
92+
}
93+
else
94+
{
95+
sb.AppendLine();
96+
if (method.ReturnType != "void")
97+
{
98+
sb.AppendLine();
99+
sb.AppendLine(" // Add specific assertions based on expected behavior");
100+
sb.AppendLine(" Assert.NotNull(result);");
101+
}
102+
}
103+
104+
sb.AppendLine(" }");
105+
sb.AppendLine(" }");
106+
sb.AppendLine("}");
107+
108+
return sb.ToString();
109+
}
110+
111+
private string GenerateParameterSetup(List<ApiDiscoveryService.ParameterInfo> parameters)
112+
{
113+
if (parameters.Count == 0) return string.Empty;
114+
115+
var sb = new StringBuilder();
116+
foreach (var param in parameters)
117+
{
118+
var value = GenerateTestValue(param.Type);
119+
sb.AppendLine($" var {param.Name} = {value};");
120+
}
121+
return sb.ToString().TrimEnd();
122+
}
123+
124+
public object GenerateTestValue(string typeName)
125+
{
126+
return typeName.ToLower() switch
127+
{
128+
"int" or "system.int32" => _random.Next(0, 1000),
129+
"long" or "system.int64" => _random.NextInt64(0, 1000),
130+
"double" or "system.double" => _random.NextDouble() * 1000,
131+
"float" or "system.single" => (float)(_random.NextDouble() * 1000),
132+
"string" or "system.string" => $"\"TestString{_random.Next(1, 1000)}\"",
133+
"bool" or "system.boolean" => _random.Next(0, 2) == 1 ? "true" : "false",
134+
"datetime" or "system.datetime" => "DateTime.Now",
135+
"guid" or "system.guid" => "Guid.NewGuid()",
136+
_ when typeName.Contains("[]") => "new " + typeName.Replace("[]", "[0]"),
137+
_ when typeName.Contains("List") => $"new {typeName}()",
138+
_ when typeName.Contains("Dictionary") => $"new {typeName}()",
139+
_ => "null"
140+
};
141+
}
142+
143+
public List<object> GenerateRandomParameters(List<ApiDiscoveryService.ParameterInfo> parameters)
144+
{
145+
var values = new List<object>();
146+
foreach (var param in parameters)
147+
{
148+
if (param.HasDefaultValue && _random.Next(0, 3) == 0)
149+
{
150+
// Sometimes use default value
151+
values.Add(Type.Missing);
152+
}
153+
else
154+
{
155+
values.Add(GenerateTestValue(param.Type));
156+
}
157+
}
158+
return values;
159+
}
160+
}
161+
}

0 commit comments

Comments
 (0)