Skip to content

Commit c31f71d

Browse files
konardclaude
andcommitted
Implement automatic C# XML comments stubs generation for GitHub Bot
This implementation adds a new trigger that automatically generates XML comment stubs for all public members in C# files when requested through GitHub issues. Features: - Detects issues requesting XML comment generation based on title/body keywords - Scans all C# files in the repository recursively - Uses Roslyn analyzers to parse C# code and identify public members - Generates appropriate XML comment stubs for classes, methods, properties, and constructors - Creates a new branch, updates files, and creates a pull request automatically - Follows the existing XML comment format used in the codebase The trigger responds to issues containing keywords like "xml comment", "xml doc", or "generate comments". Related to issue #100 and linksplatform/Data.Doublets#226 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 07d7964 commit c31f71d

File tree

5 files changed

+433
-1
lines changed

5 files changed

+433
-1
lines changed

csharp/Platform.Bot/Platform.Bot.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
<ItemGroup>
1010
<PackageReference Include="CommandLineParser" Version="2.9.1" />
11+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.7.0" />
1112
<PackageReference Include="Octokit" Version="7.0.1" />
1213
<PackageReference Include="Platform.Communication.Protocol.Lino" Version="0.4.0" />
1314
<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 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), new GenerateXmlCommentsStubsTrigger(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: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
5+
using System.Threading.Tasks;
6+
using Interfaces;
7+
using Microsoft.CodeAnalysis;
8+
using Microsoft.CodeAnalysis.CSharp;
9+
using Microsoft.CodeAnalysis.CSharp.Syntax;
10+
using Octokit;
11+
using Storage.Remote.GitHub;
12+
13+
namespace Platform.Bot.Triggers
14+
{
15+
using TContext = Issue;
16+
17+
/// <summary>
18+
/// <para>
19+
/// Represents the generate XML comments stubs trigger.
20+
/// </para>
21+
/// <para></para>
22+
/// </summary>
23+
/// <seealso cref="ITrigger{TContext}"/>
24+
internal class GenerateXmlCommentsStubsTrigger : ITrigger<TContext>
25+
{
26+
private readonly GitHubStorage _storage;
27+
28+
/// <summary>
29+
/// <para>
30+
/// Initializes a new <see cref="GenerateXmlCommentsStubsTrigger"/> instance.
31+
/// </para>
32+
/// <para></para>
33+
/// </summary>
34+
/// <param name="storage">
35+
/// <para>A GitHub storage.</para>
36+
/// <para></para>
37+
/// </param>
38+
public GenerateXmlCommentsStubsTrigger(GitHubStorage storage)
39+
{
40+
_storage = storage;
41+
}
42+
43+
/// <summary>
44+
/// <para>
45+
/// Determines whether this instance condition.
46+
/// </para>
47+
/// <para></para>
48+
/// </summary>
49+
/// <param name="context">
50+
/// <para>The context.</para>
51+
/// <para></para>
52+
/// </param>
53+
/// <returns>
54+
/// <para>The bool</para>
55+
/// <para></para>
56+
/// </returns>
57+
public async Task<bool> Condition(TContext context)
58+
{
59+
var title = context.Title.ToLower();
60+
var body = context.Body?.ToLower() ?? "";
61+
62+
return title.Contains("xml comment") || title.Contains("xml doc") ||
63+
body.Contains("xml comment") || body.Contains("xml doc") ||
64+
title.Contains("generate comments") || body.Contains("generate comments");
65+
}
66+
67+
/// <summary>
68+
/// <para>
69+
/// Actions the context.
70+
/// </para>
71+
/// <para></para>
72+
/// </summary>
73+
/// <param name="context">
74+
/// <para>The context.</para>
75+
/// <para></para>
76+
/// </param>
77+
public async Task Action(TContext context)
78+
{
79+
try
80+
{
81+
var repository = context.Repository;
82+
var branchName = $"generate-xml-comments-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
83+
84+
// Get all C# files in the repository
85+
var csharpFiles = await GetCSharpFilesFromRepository(repository);
86+
87+
var modifiedFiles = new List<(string path, string content)>();
88+
89+
foreach (var file in csharpFiles)
90+
{
91+
var modifiedContent = await GenerateXmlCommentsForFile(file.content, file.path);
92+
if (modifiedContent != file.content)
93+
{
94+
modifiedFiles.Add((file.path, modifiedContent));
95+
}
96+
}
97+
98+
if (modifiedFiles.Any())
99+
{
100+
// Create a new branch using Octokit directly
101+
await CreateBranch(repository, branchName, repository.DefaultBranch);
102+
103+
// Update files with XML comments
104+
foreach (var (path, content) in modifiedFiles)
105+
{
106+
await _storage.CreateOrUpdateFile(content, repository, branchName, path,
107+
"Generate XML comment stubs for public members");
108+
}
109+
110+
// Create a pull request using Octokit directly
111+
var pullRequest = await CreatePullRequest(repository,
112+
title: "Generate XML comment stubs for public members",
113+
head: branchName,
114+
baseRef: repository.DefaultBranch,
115+
body: $"This PR automatically generates XML comment stubs for all public members.\n\nGenerated from issue #{context.Number}");
116+
117+
// Comment on the original issue
118+
await _storage.CreateIssueComment(repository.Id, context.Number,
119+
$"XML comment stubs have been generated! Please review the pull request: #{pullRequest.Number}");
120+
}
121+
else
122+
{
123+
await _storage.CreateIssueComment(repository.Id, context.Number,
124+
"No C# files found or all public members already have XML comments.");
125+
}
126+
127+
_storage.CloseIssue(context);
128+
}
129+
catch (Exception ex)
130+
{
131+
await _storage.CreateIssueComment(context.Repository.Id, context.Number,
132+
$"Error generating XML comments: {ex.Message}");
133+
}
134+
}
135+
136+
private async Task<List<(string path, string content)>> GetCSharpFilesFromRepository(Repository repository)
137+
{
138+
var files = new List<(string path, string content)>();
139+
var contents = await _storage.Client.Repository.Content.GetAllContents(repository.Id, "");
140+
141+
await CollectCSharpFiles(repository, contents, "", files);
142+
return files;
143+
}
144+
145+
private async Task CollectCSharpFiles(Repository repository, IReadOnlyList<RepositoryContent> contents,
146+
string currentPath, List<(string path, string content)> files)
147+
{
148+
foreach (var item in contents)
149+
{
150+
var fullPath = string.IsNullOrEmpty(currentPath) ? item.Name : $"{currentPath}/{item.Name}";
151+
152+
if (item.Type == ContentType.File && item.Name.EndsWith(".cs"))
153+
{
154+
var fileContents = await _storage.Client.Repository.Content.GetAllContents(repository.Id, fullPath);
155+
var fileContent = fileContents.First().Content;
156+
files.Add((fullPath, fileContent));
157+
}
158+
else if (item.Type == ContentType.Dir)
159+
{
160+
var subContents = await _storage.Client.Repository.Content.GetAllContents(repository.Id, fullPath);
161+
await CollectCSharpFiles(repository, subContents, fullPath, files);
162+
}
163+
}
164+
}
165+
166+
private async Task CreateBranch(Repository repository, string branchName, string baseBranchName)
167+
{
168+
var baseBranch = await _storage.Client.Repository.Branch.Get(repository.Id, baseBranchName);
169+
var newReference = new NewReference($"refs/heads/{branchName}", baseBranch.Commit.Sha);
170+
await _storage.Client.Git.Reference.Create(repository.Id, newReference);
171+
}
172+
173+
private async Task<PullRequest> CreatePullRequest(Repository repository, string title, string head, string baseRef, string body)
174+
{
175+
var newPullRequest = new NewPullRequest(title, head, baseRef)
176+
{
177+
Body = body
178+
};
179+
return await _storage.Client.PullRequest.Create(repository.Id, newPullRequest);
180+
}
181+
182+
private async Task<string> GenerateXmlCommentsForFile(string fileContent, string filePath)
183+
{
184+
try
185+
{
186+
var tree = CSharpSyntaxTree.ParseText(fileContent);
187+
var root = await tree.GetRootAsync();
188+
var rewriter = new XmlCommentsRewriter();
189+
var newRoot = rewriter.Visit(root);
190+
191+
return newRoot.ToFullString();
192+
}
193+
catch
194+
{
195+
// If parsing fails, return original content
196+
return fileContent;
197+
}
198+
}
199+
}
200+
201+
internal class XmlCommentsRewriter : CSharpSyntaxRewriter
202+
{
203+
public override SyntaxNode VisitClassDeclaration(ClassDeclarationSyntax node)
204+
{
205+
if (IsPublic(node.Modifiers) && !HasXmlComment(node))
206+
{
207+
var xmlComment = CreateXmlComment($"Represents the {node.Identifier.ValueText.ToLower()}.");
208+
node = node.WithLeadingTrivia(node.GetLeadingTrivia().Insert(0, xmlComment));
209+
}
210+
return base.VisitClassDeclaration(node);
211+
}
212+
213+
public override SyntaxNode VisitMethodDeclaration(MethodDeclarationSyntax node)
214+
{
215+
if (IsPublic(node.Modifiers) && !HasXmlComment(node))
216+
{
217+
var xmlComment = GenerateMethodXmlComment(node);
218+
node = node.WithLeadingTrivia(node.GetLeadingTrivia().Insert(0, xmlComment));
219+
}
220+
return base.VisitMethodDeclaration(node);
221+
}
222+
223+
public override SyntaxNode VisitPropertyDeclaration(PropertyDeclarationSyntax node)
224+
{
225+
if (IsPublic(node.Modifiers) && !HasXmlComment(node))
226+
{
227+
var xmlComment = CreateXmlComment($"Gets or sets the {node.Identifier.ValueText.ToLower()}.");
228+
node = node.WithLeadingTrivia(node.GetLeadingTrivia().Insert(0, xmlComment));
229+
}
230+
return base.VisitPropertyDeclaration(node);
231+
}
232+
233+
public override SyntaxNode VisitConstructorDeclaration(ConstructorDeclarationSyntax node)
234+
{
235+
if (IsPublic(node.Modifiers) && !HasXmlComment(node))
236+
{
237+
var xmlComment = GenerateConstructorXmlComment(node);
238+
node = node.WithLeadingTrivia(node.GetLeadingTrivia().Insert(0, xmlComment));
239+
}
240+
return base.VisitConstructorDeclaration(node);
241+
}
242+
243+
private static bool IsPublic(SyntaxTokenList modifiers)
244+
{
245+
return modifiers.Any(m => m.IsKind(SyntaxKind.PublicKeyword));
246+
}
247+
248+
private static bool HasXmlComment(SyntaxNode node)
249+
{
250+
var leadingTrivia = node.GetLeadingTrivia();
251+
return leadingTrivia.Any(t => t.IsKind(SyntaxKind.SingleLineDocumentationCommentTrivia) ||
252+
t.IsKind(SyntaxKind.MultiLineDocumentationCommentTrivia));
253+
}
254+
255+
private static SyntaxTrivia CreateXmlComment(string summary)
256+
{
257+
var commentText = $@" /// <summary>
258+
/// <para>
259+
/// {summary}
260+
/// </para>
261+
/// <para></para>
262+
/// </summary>";
263+
264+
return SyntaxFactory.Comment(commentText + Environment.NewLine);
265+
}
266+
267+
private static SyntaxTrivia GenerateMethodXmlComment(MethodDeclarationSyntax method)
268+
{
269+
var sb = new StringBuilder();
270+
sb.AppendLine(" /// <summary>");
271+
sb.AppendLine(" /// <para>");
272+
sb.AppendLine($" /// {GetMethodDescription(method)}");
273+
sb.AppendLine(" /// </para>");
274+
sb.AppendLine(" /// <para></para>");
275+
sb.AppendLine(" /// </summary>");
276+
277+
foreach (var parameter in method.ParameterList.Parameters)
278+
{
279+
sb.AppendLine($" /// <param name=\"{parameter.Identifier}\">");
280+
sb.AppendLine(" /// <para>The parameter.</para>");
281+
sb.AppendLine(" /// <para></para>");
282+
sb.AppendLine(" /// </param>");
283+
}
284+
285+
if (!method.ReturnType.ToString().Equals("void", StringComparison.OrdinalIgnoreCase))
286+
{
287+
sb.AppendLine(" /// <returns>");
288+
sb.AppendLine($" /// <para>The {method.ReturnType.ToString().ToLower()}</para>");
289+
sb.AppendLine(" /// <para></para>");
290+
sb.AppendLine(" /// </returns>");
291+
}
292+
293+
return SyntaxFactory.Comment(sb.ToString());
294+
}
295+
296+
private static SyntaxTrivia GenerateConstructorXmlComment(ConstructorDeclarationSyntax constructor)
297+
{
298+
var sb = new StringBuilder();
299+
sb.AppendLine(" /// <summary>");
300+
sb.AppendLine(" /// <para>");
301+
sb.AppendLine($" /// Initializes a new <see cref=\"{constructor.Identifier}\"/> instance.");
302+
sb.AppendLine(" /// </para>");
303+
sb.AppendLine(" /// <para></para>");
304+
sb.AppendLine(" /// </summary>");
305+
306+
foreach (var parameter in constructor.ParameterList.Parameters)
307+
{
308+
sb.AppendLine($" /// <param name=\"{parameter.Identifier}\">");
309+
sb.AppendLine(" /// <para>The parameter.</para>");
310+
sb.AppendLine(" /// <para></para>");
311+
sb.AppendLine(" /// </param>");
312+
}
313+
314+
return SyntaxFactory.Comment(sb.ToString());
315+
}
316+
317+
private static string GetMethodDescription(MethodDeclarationSyntax method)
318+
{
319+
var methodName = method.Identifier.ValueText;
320+
321+
if (methodName.StartsWith("Get"))
322+
return $"Gets the {methodName.Substring(3).ToLower()}.";
323+
if (methodName.StartsWith("Set"))
324+
return $"Sets the {methodName.Substring(3).ToLower()}.";
325+
if (methodName.StartsWith("Create"))
326+
return $"Creates the {methodName.Substring(6).ToLower()}.";
327+
if (methodName.StartsWith("Update"))
328+
return $"Updates the {methodName.Substring(6).ToLower()}.";
329+
if (methodName.StartsWith("Delete"))
330+
return $"Deletes the {methodName.Substring(6).ToLower()}.";
331+
332+
return $"Performs the {methodName.ToLower()} operation.";
333+
}
334+
}
335+
}

examples/TestClass.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using System;
2+
3+
namespace Example
4+
{
5+
public class TestClass
6+
{
7+
public string Name { get; set; }
8+
9+
public int Count { get; set; }
10+
11+
public TestClass(string name)
12+
{
13+
Name = name;
14+
}
15+
16+
public void DoSomething()
17+
{
18+
Console.WriteLine($"Doing something with {Name}");
19+
}
20+
21+
public string GetFormattedName()
22+
{
23+
return $"Name: {Name}";
24+
}
25+
26+
public void SetCount(int value)
27+
{
28+
Count = value;
29+
}
30+
31+
public bool CreateNewInstance()
32+
{
33+
return true;
34+
}
35+
}
36+
}

0 commit comments

Comments
 (0)