| sidebar_position | 12 |
|---|---|
| title | GitHub Issue Agent |
| description | AI-powered issue analysis for GitHub repositories |
Description: The GitHub Issue Agent is an AI-powered application that automatically analyzes repository issues, extracts key themes, categorizes content, and generates insightful recommendations to help development teams prioritize their work.
Purpose: To demonstrate how to build intelligent agents using the Aevatar Framework to process and analyze GitHub issues. This tutorial showcases real-world AI integration for software development processes, focusing on event-driven architecture, LLM integration, and practical developer experience improvements.
Difficulty Level: Moderate
Before getting started, make sure you have the following installed:
- .NET 9.0 SDK or later
- Visual Studio 2022 or Visual Studio Code with C# extensions
- Basic understanding of C#, .NET, and event-driven programming
- A GitHub account with a personal access token (optional for private repositories)
Let's start by cloning the project template:
git clone https://github.com/aelfproject/github-issue-agent.git
cd github-issue-agentAlternatively, you can use the .NET template to create a new project:
dotnet new aevatar -n GitHubIssueAgentLet's explore the overall architecture:
- Client Application: Interface to initiate GitHub issue analysis
- Silo Server: Hosts the Orleans grains and agent system
- GAgents: Core components implementing Aevatar agents for GitHub analysis
The project follows the actor model using Orleans, with agents designed to:
- Process GitHub issues collected from repositories
- Analyze issue content to extract themes and tags using AI
- Generate insightful recommendations based on issue patterns
- Communicate via event streams in an asynchronous fashion
First, let's understand the key components we'll be implementing:
- GAgent State: Stores information about analyzed issues and tags
- Event Handlers: Process GitHub issue data
- LLM Integration: Connect to AI services for theme extraction
- Stream Processing: Event-based communication between components
First, create a new file in the GitHubIssueAnalysis.GAgents project:
using System.Collections.Generic;
using GitHubIssueAnalysis.GAgents.GrainInterfaces.Models;
namespace GitHubIssueAnalysis.GAgents.GitHubAnalysis;
[Serializable]
public class GitHubAnalysisGAgentState
{
// Collection of analyzed issues
public Dictionary<string, List<GitHubIssueInfo>> RepositoryIssues { get; set; } = new();
// Collection of tags extracted from issues
public Dictionary<string, Dictionary<string, string[]>> IssueTags { get; set; } = new();
// Collection of summary reports
public Dictionary<string, RepositorySummaryReport> SummaryReports { get; set; } = new();
// Last analysis timestamp for each repository
public Dictionary<string, long> LastAnalysisTimestamp { get; set; } = new();
}Next, let's implement the core agent that will analyze GitHub issues:
using Aevatar.Core;
using Aevatar.Core.Abstractions;
using Microsoft.Extensions.Logging;
using GitHubIssueAnalysis.GAgents.Common;
using GitHubIssueAnalysis.GAgents.Services;
using Orleans.Streams;
using Orleans;
using Orleans.Concurrency;
using System.Threading.Tasks;
using GitHubIssueAnalysis.GAgents.GrainInterfaces.Models;
namespace GitHubIssueAnalysis.GAgents.GitHubAnalysis;
[Reentrant]
public class GitHubAnalysisGAgent : GAgentBase<GitHubAnalysisGAgentState, GitHubAnalysisLogEvent>, IGitHubAnalysisGAgent, IGrainWithGuidKey
{
private readonly ILogger<GitHubAnalysisGAgent> _logger;
private readonly ILLMService _llmService;
private StreamSubscriptionHandle<GitHubIssueEvent>? _streamSubscription;
public GitHubAnalysisGAgent(
ILogger<GitHubAnalysisGAgent> logger,
ILLMService llmService)
{
_logger = logger;
_llmService = llmService;
// Log grain creation
_logger.LogInformation("GitHubAnalysisGAgent created with ID: {GrainId}", this.GetPrimaryKey());
// Setup stream subscription
SetupStreamSubscriptionAsync().Ignore();
}
public async Task SetupStreamSubscriptionAsync()
{
try
{
_logger.LogInformation("Setting up stream subscription for grain {GrainId}", this.GetPrimaryKey());
var streamProvider = this.GetStreamProvider("MemoryStreams");
var issuesStreamId = StreamId.Create(GitHubAnalysisStream.StreamNamespace, GitHubAnalysisStream.TagsStreamKey);
var issuesStream = streamProvider.GetStream<GitHubIssueEvent>(issuesStreamId);
_streamSubscription = await issuesStream.SubscribeAsync(this);
_logger.LogInformation("Successfully subscribed to issues stream");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to set up stream subscription");
}
}
[EventHandler]
public async Task HandleGitHubIssueEventAsync(GitHubIssueEvent @event)
{
_logger.LogInformation("Handling GitHub issue event for repository {Repository}, issue #{IssueNumber}",
@event.Repository, @event.IssueNumber);
try
{
// Store issue in state
if (!State.RepositoryIssues.TryGetValue(@event.Repository, out var issues))
{
issues = new List<GitHubIssueInfo>();
State.RepositoryIssues[@event.Repository] = issues;
}
var existingIssue = issues.FirstOrDefault(i => i.Number == @event.IssueNumber);
if (existingIssue != null)
{
// Update existing issue
issues.Remove(existingIssue);
}
// Create issue info object
var issueInfo = new GitHubIssueInfo
{
Repository = @event.Repository,
Number = @event.IssueNumber,
Title = @event.Title,
Description = @event.Description,
State = @event.State,
CreatedAt = @event.CreatedAt,
UpdatedAt = @event.UpdatedAt,
Labels = @event.Labels?.ToArray() ?? Array.Empty<string>()
};
issues.Add(issueInfo);
// Extract tags using LLM
var tags = await ExtractTagsUsingLLMAsync(issueInfo);
// Store tags in state
if (!State.IssueTags.TryGetValue(@event.Repository, out var repositoryTags))
{
repositoryTags = new Dictionary<string, string[]>();
State.IssueTags[@event.Repository] = repositoryTags;
}
repositoryTags[@event.IssueNumber.ToString()] = tags;
// Publish tags extracted event
await PublishTagsExtractedEventAsync(@event.Repository, issueInfo, tags);
_logger.LogInformation("Successfully processed GitHub issue {IssueNumber} for {Repository}",
@event.IssueNumber, @event.Repository);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to handle GitHub issue event");
}
}
private async Task<string[]> ExtractTagsUsingLLMAsync(GitHubIssueInfo issueInfo)
{
try
{
// Generate prompt for tag extraction
var prompt = GenerateExtractTagsPrompt(issueInfo);
// Use LLM service to extract tags
var response = await _llmService.CompletePromptAsync(prompt);
// Parse tags from response
var tags = ParseTagsFromLLMResponse(response);
return tags;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error extracting tags with LLM, falling back to basic extraction");
return ExtractBasicTagsFromIssue(issueInfo);
}
}
private string[] ParseTagsFromLLMResponse(string response)
{
// Split by newlines and process each line
var lines = response.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
// Extract tags from lines (assuming format like "- tag" or "• tag" or just "tag")
var tags = lines
.Select(line => line.Trim())
.Where(line => !string.IsNullOrWhiteSpace(line))
.Select(line => line.TrimStart('-', '•', '*', ' '))
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.ToArray();
return tags;
}
private string[] ExtractBasicTagsFromIssue(GitHubIssueInfo issueInfo)
{
// Fallback method to extract basic tags when LLM is unavailable
var tags = new HashSet<string>();
// Use existing labels
foreach (var label in issueInfo.Labels)
{
tags.Add(label);
}
// Add issue state
tags.Add(issueInfo.State.ToLowerInvariant());
// Check for common keywords in title and description
string content = $"{issueInfo.Title} {issueInfo.Description}".ToLowerInvariant();
if (content.Contains("bug") || content.Contains("fix") || content.Contains("issue"))
tags.Add("bug");
if (content.Contains("feature") || content.Contains("enhancement"))
tags.Add("feature");
if (content.Contains("documentation") || content.Contains("docs"))
tags.Add("documentation");
if (content.Contains("performance") || content.Contains("slow"))
tags.Add("performance");
return tags.ToArray();
}
private async Task PublishTagsExtractedEventAsync(string repository, GitHubIssueInfo issueInfo, string[] tags)
{
try
{
var streamProvider = this.GetStreamProvider("MemoryStreams");
var tagsStreamId = StreamId.Create(GitHubAnalysisStream.StreamNamespace, GitHubAnalysisStream.TagsStreamKey);
var tagsStream = streamProvider.GetStream<IssueTagsEvent>(tagsStreamId);
var tagsEvent = new IssueTagsEvent
{
Repository = repository,
IssueId = issueInfo.Number.ToString(),
Title = issueInfo.Title,
ExtractedTags = tags
};
await tagsStream.OnNextAsync(tagsEvent);
_logger.LogInformation("Published tags extracted event for issue #{IssueNumber}", issueInfo.Number);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to publish tags extracted event");
}
}
private string GenerateExtractTagsPrompt(GitHubIssueInfo issueInfo)
{
return $@"Extract 5-8 most relevant tags from this GitHub issue.
Only output the tags as a simple list, one tag per line. Do not include numbers, bullets, or any other formatting.
Repository: {issueInfo.Repository}
Title: {issueInfo.Title}
Description: {issueInfo.Description}
Status: {issueInfo.State}
Existing Labels: {string.Join(", ", issueInfo.Labels)}";
}
// Implementation for stream event handlers
public Task OnNextAsync(GitHubIssueEvent @event, StreamSequenceToken? token = null)
{
return HandleGitHubIssueEventAsync(@event);
}
public Task OnCompletedAsync() => Task.CompletedTask;
public Task OnErrorAsync(Exception ex)
{
_logger.LogError(ex, "Error in stream");
return Task.CompletedTask;
}
}Now, let's create an interface for our LLM service:
namespace GitHubIssueAnalysis.GAgents.Services;
/// <summary>
/// Interface for LLM (Large Language Model) service
/// </summary>
public interface ILLMService
{
/// <summary>
/// Completes a prompt using the configured LLM
/// </summary>
/// <param name="prompt">The prompt to complete</param>
/// <returns>The completion response</returns>
Task<string> CompletePromptAsync(string prompt);
}And a concrete implementation using Google Gemini:
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;
namespace GitHubIssueAnalysis.GAgents.Services;
public class GeminiLLMService : ILLMService
{
private readonly HttpClient _httpClient;
private readonly ILogger<GeminiLLMService> _logger;
private readonly GeminiOptions _options;
public GeminiLLMService(
HttpClient httpClient,
ILogger<GeminiLLMService> logger,
IOptions<GeminiOptions> options)
{
_httpClient = httpClient;
_logger = logger;
_options = options.Value;
// Configure HTTP client with Gemini API base URL
_httpClient.BaseAddress = new Uri("https://generativelanguage.googleapis.com/");
}
public async Task<string> CompletePromptAsync(string prompt)
{
try
{
_logger.LogInformation("Sending prompt to Gemini API, length: {Length}", prompt.Length);
// Construct request payload
var requestPayload = new
{
contents = new[]
{
new { role = "user", parts = new[] { new { text = prompt } } }
},
generationConfig = new
{
temperature = 0.2,
topP = 0.8,
topK = 40,
maxOutputTokens = 1024
}
};
// Send request to Gemini API
var requestUri = $"v1/models/{_options.Model}:generateContent?key={_options.ApiKey}";
var response = await _httpClient.PostAsJsonAsync(requestUri, requestPayload);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogError("Gemini API returned error: {StatusCode}, {ErrorContent}",
response.StatusCode, errorContent);
throw new Exception($"Gemini API error: {response.StatusCode}");
}
// Parse response
var responseJson = await response.Content.ReadFromJsonAsync<JsonElement>();
var textResponse = responseJson
.GetProperty("candidates")[0]
.GetProperty("content")
.GetProperty("parts")[0]
.GetProperty("text")
.GetString();
return textResponse ?? string.Empty;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error completing prompt with Gemini API");
throw;
}
}
}
public class GeminiOptions
{
public string ApiKey { get; set; } = string.Empty;
public string Model { get; set; } = "gemini-1.5-flash";
}Now let's define the events our agent will handle:
using System;
namespace GitHubIssueAnalysis.GAgents.Common;
public class GitHubIssueEvent
{
public string Repository { get; set; } = string.Empty;
public int IssueNumber { get; set; }
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string State { get; set; } = string.Empty;
public long CreatedAt { get; set; }
public long UpdatedAt { get; set; }
public string[] Labels { get; set; } = Array.Empty<string>();
}
public class IssueTagsEvent
{
public string Repository { get; set; } = string.Empty;
public string IssueId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string[] ExtractedTags { get; set; } = Array.Empty<string>();
}
public class RepositorySummaryReport
{
public string Repository { get; set; } = string.Empty;
public DateTime GeneratedAt { get; set; } = DateTime.UtcNow;
public int TotalIssues { get; set; }
public int OpenIssues { get; set; }
public int ClosedIssues { get; set; }
public Dictionary<string, int> TagCounts { get; set; } = new();
public IssueRecommendation[] Recommendations { get; set; } = Array.Empty<IssueRecommendation>();
}
public class IssueRecommendation
{
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public Priority Priority { get; set; }
public string[] SupportingIssues { get; set; } = Array.Empty<string>();
}
public enum Priority
{
Low,
Medium,
High
}Next, we need to register our agent with the Orleans silo. Add the following code to your Program.cs in the GitHubIssueAnalysis.Silo project:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Aevatar.Core;
using GitHubIssueAnalysis.GAgents;
using GitHubIssueAnalysis.GAgents.Services;
var builder = Host.CreateDefaultBuilder(args)
.ConfigureServices((context, services) =>
{
// Configure Gemini options
services.Configure<GeminiOptions>(context.Configuration.GetSection("Google:Gemini"));
// Register the LLM service with fallback
var useFallback = context.Configuration.GetValue<bool>("UseFallbackLLM", false);
if (useFallback)
{
services.AddSingleton<ILLMService, FallbackLLMService>();
}
else
{
services.AddHttpClient<ILLMService, GeminiLLMService>();
}
// Add Orleans-specific services
services.AddGAgentsModule<GitHubIssueAnalysisGAgentsModule>();
})
.UseOrleans(siloBuilder =>
{
siloBuilder.UseLocalhostClustering()
.AddMemoryGrainStorage("PubSubStore")
.AddMemoryGrainStorageAsDefault()
.AddMemoryStreams("MemoryStreams")
.Configure<ClusterOptions>(options =>
{
options.ClusterId = "github-analysis-cluster";
options.ServiceId = "github-analysis-service";
});
});
var host = builder.Build();
await host.RunAsync();Now, let's implement the client application that will interact with our agent system:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Orleans;
using GitHubIssueAnalysis.GAgents.Common;
using Octokit;
using System;
using System.Linq;
using System.Threading.Tasks;
var builder = Host.CreateDefaultBuilder(args)
.UseOrleansClient(clientBuilder =>
{
clientBuilder.UseLocalhostClustering();
})
.ConfigureServices((context, services) =>
{
// Register GitHub client
services.AddSingleton<GitHubClient>(provider =>
{
var token = context.Configuration["GitHub:PersonalAccessToken"];
var client = new GitHubClient(new ProductHeaderValue("github-issue-agent"));
if (!string.IsNullOrEmpty(token))
{
client.Credentials = new Credentials(token);
}
return client;
});
});
var host = builder.Build();
await host.StartAsync();
var client = host.Services.GetRequiredService<IClusterClient>();
var logger = host.Services.GetRequiredService<ILogger<Program>>();
var gitHubClient = host.Services.GetRequiredService<GitHubClient>();
Console.WriteLine("============================================");
Console.WriteLine(" GitHub Issue Analysis Agent");
Console.WriteLine("============================================");
Console.WriteLine();
Console.WriteLine("This application analyzes GitHub issues using AI to extract themes and provide recommendations.");
Console.WriteLine();
while (true)
{
Console.WriteLine("Enter a GitHub repository (owner/repo) to analyze, or 'exit' to quit:");
var repoInput = Console.ReadLine();
if (string.IsNullOrWhiteSpace(repoInput) || repoInput.ToLower() == "exit")
break;
var parts = repoInput.Split('/');
if (parts.Length != 2)
{
Console.WriteLine("Invalid repository format. Please use 'owner/repo' format.");
continue;
}
var owner = parts[0];
var repo = parts[1];
Console.WriteLine($"Analyzing issues from {owner}/{repo}...");
try
{
// Get stream provider
var streamProvider = client.GetStreamProvider("MemoryStreams");
// Get stream for publishing GitHub issue events
var issuesStream = streamProvider.GetStream<GitHubIssueEvent>(
StreamId.Create(GitHubAnalysisStream.StreamNamespace, GitHubAnalysisStream.TagsStreamKey));
// Retrieve issues from GitHub
var issues = await gitHubClient.Issue.GetAllForRepository(owner, repo, new RepositoryIssueRequest
{
State = ItemStateFilter.All
});
Console.WriteLine($"Found {issues.Count} issues. Processing...");
// Process each issue
foreach (var issue in issues.Take(10)) // Limit to 10 issues for demo
{
Console.WriteLine($"Processing issue #{issue.Number}: {issue.Title}");
// Convert to our event model
var issueEvent = new GitHubIssueEvent
{
Repository = $"{owner}/{repo}",
IssueNumber = issue.Number,
Title = issue.Title,
Description = issue.Body ?? string.Empty,
State = issue.State.StringValue,
CreatedAt = issue.CreatedAt.Ticks,
UpdatedAt = issue.UpdatedAt.Ticks,
Labels = issue.Labels.Select(l => l.Name).ToArray()
};
// Publish issue event to stream
await issuesStream.OnNextAsync(issueEvent);
}
Console.WriteLine("Analysis initiated. Results will be processed by the agent system.");
Console.WriteLine();
// Subscribe to summary report stream
var summaryStream = streamProvider.GetStream<RepositorySummaryReport>(
StreamId.Create(GitHubAnalysisStream.StreamNamespace, GitHubAnalysisStream.SummaryStreamKey));
var summarySubscription = await summaryStream.SubscribeAsync((summary, _) =>
{
if (summary.Repository == $"{owner}/{repo}")
{
Console.WriteLine();
Console.WriteLine("============================================");
Console.WriteLine($" Summary Report for {summary.Repository}");
Console.WriteLine("============================================");
Console.WriteLine($"Generated at: {summary.GeneratedAt}");
Console.WriteLine($"Total Issues: {summary.TotalIssues} (Open: {summary.OpenIssues}, Closed: {summary.ClosedIssues})");
Console.WriteLine();
Console.WriteLine("Top Tags:");
foreach (var tag in summary.TagCounts.OrderByDescending(t => t.Value).Take(5))
{
Console.WriteLine($" - {tag.Key}: {tag.Value} issues");
}
Console.WriteLine();
Console.WriteLine("Recommendations:");
foreach (var rec in summary.Recommendations)
{
Console.WriteLine($" [{rec.Priority}] {rec.Title}");
Console.WriteLine($" {rec.Description}");
if (rec.SupportingIssues.Length > 0)
{
Console.WriteLine($" Supporting Issues: {string.Join(", ", rec.SupportingIssues)}");
}
Console.WriteLine();
}
return Task.CompletedTask;
}
return Task.CompletedTask;
});
// Wait for user to press Enter to continue
Console.WriteLine("Press Enter to analyze another repository, or type 'exit' to quit.");
var input = Console.ReadLine();
if (input?.ToLower() == "exit")
break;
await summarySubscription.UnsubscribeAsync();
}
catch (Exception ex)
{
Console.WriteLine($"Error analyzing repository: {ex.Message}");
}
}
await host.StopAsync();Now that we've implemented our GitHub issue agent, let's run it:
- First, create a
.envfile in both the client and silo directories with your API keys:
# Google Gemini Configuration
GOOGLE_GEMINI_API_KEY=your_gemini_key_here
GOOGLE_GEMINI_MODEL=gemini-1.5-flash
# GitHub API Configuration
GITHUB_PERSONAL_ACCESS_TOKEN=your_github_token_here
# Use Fallback LLM when API keys are missing
USE_FALLBACK_LLM=true
- Start the Silo server:
cd src/Samples/GitHubIssueAnalysis/GitHubIssueAnalysis.Silo/
dotnet run- In a separate terminal, start the Client:
cd src/Samples/GitHubIssueAnalysis/GitHubIssueAnalysis.Client/
dotnet run- Follow the prompts in the client to analyze GitHub repositories.
You should see:
- Tag extraction for each issue
- Summary reports with recommendations
- Statistical breakdowns of issues by category
Congratulations! You've successfully built and implemented a GitHub Issue Agent using the Aevatar Framework. This agent can analyze GitHub repository issues, extract key themes, categorize content, and generate insightful recommendations to help development teams prioritize their work.
This example demonstrates several powerful features of the Aevatar Framework:
- Event-driven architecture for asynchronous processing
- Integration with AI/LLM services for intelligent analysis
- Robust fallback mechanisms for resilience
- Actor model for scalable, distributed computing
You can extend this agent in many ways:
- Add support for additional AI models
- Implement more sophisticated recommendation algorithms
- Create a web interface for visualizing results
- Add support for automatic issue labeling via the GitHub API
The Aevatar Framework makes it easy to build intelligent agents that can process and analyze data from various sources, making it ideal for creating AI-powered tools for software development and other domains.