Skip to content

Commit 97e4287

Browse files
Merge pull request #72 from bartoszclapinski/sprint2/phase2.5-hangfire-setup-#71
feat(hangfire): add background job processing with PostgreSQL storage
2 parents a7c5fc9 + 1d6dc8c commit 97e4287

File tree

7 files changed

+413
-0
lines changed

7 files changed

+413
-0
lines changed

src/DevMetricsPro.Application/DTOs/GitHub/GitHubRepositoryDto.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ public class GitHubRepositoryDto
2525
/// </summary>
2626
public string HtmlUrl { get; set; } = string.Empty;
2727

28+
/// <summary>
29+
/// Full repository name in owner/repo format (e.g., "octocat/Hello-World")
30+
/// </summary>
31+
public string? FullName { get; set; }
32+
2833
/// <summary>
2934
/// Whether the repository is private
3035
/// </summary>

src/DevMetricsPro.Infrastructure/Services/GitHubRepositoryService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ public async Task<IEnumerable<GitHubRepositoryDto>> GetUserRepositoriesAsync(
4545
Name = repo.Name,
4646
Description = repo.Description,
4747
HtmlUrl = repo.HtmlUrl,
48+
FullName = repo.FullName,
4849
IsPrivate = repo.Private,
4950
IsFork = repo.Fork,
5051
StargazersCount = repo.StargazersCount,

src/DevMetricsPro.Web/Controllers/GitHubController.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,4 +609,61 @@ public async Task<IActionResult> GetRecentCommits(
609609

610610
return (addedCount, updatedCount);
611611
}
612+
613+
/// <summary>
614+
/// Triggers a background job to sync all GitHub data (repositories and commits) for the current user
615+
/// </summary>
616+
/// <returns>Job ID</returns>
617+
[HttpPost("sync-all")]
618+
[Authorize(AuthenticationSchemes = "Bearer")]
619+
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
620+
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
621+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
622+
public async Task<IActionResult> TriggerFullSync(
623+
CancellationToken cancellationToken = default)
624+
{
625+
try
626+
{
627+
// Get authenticated user
628+
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
629+
if (string.IsNullOrEmpty(userId))
630+
{
631+
_logger.LogWarning("User ID not found in JWT claims");
632+
return Unauthorized(new { message = "User not authenticated" });
633+
}
634+
635+
var user = await _userManager.FindByIdAsync(userId);
636+
if (user == null)
637+
{
638+
_logger.LogWarning("User {UserId} not found", userId);
639+
return BadRequest(new { message = "User not found" });
640+
}
641+
642+
if (string.IsNullOrEmpty(user.GitHubAccessToken))
643+
{
644+
_logger.LogWarning("User {UserId} does not have GitHub connected", userId);
645+
return BadRequest(new { message = "GitHub account not connected" });
646+
}
647+
648+
// Enqueue background job
649+
var jobId = Hangfire.BackgroundJob.Enqueue<Web.Jobs.SyncGitHubDataJob>(
650+
job => job.ExecuteAsync(user.Id));
651+
652+
_logger.LogInformation(
653+
"Enqueued full GitHub sync job {JobId} for user {UserId}",
654+
jobId, userId);
655+
656+
return Ok(new
657+
{
658+
success = true,
659+
message = "GitHub sync job started",
660+
jobId = jobId
661+
});
662+
}
663+
catch (Exception ex)
664+
{
665+
_logger.LogError(ex, "Error triggering full GitHub sync");
666+
return StatusCode(500, new { message = "Failed to start sync job" });
667+
}
668+
}
612669
}

src/DevMetricsPro.Web/DevMetricsPro.Web.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
</ItemGroup>
88

99
<ItemGroup>
10+
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.21" />
11+
<PackageReference Include="Hangfire.Core" Version="1.8.21" />
12+
<PackageReference Include="Hangfire.PostgreSql" Version="1.20.12" />
1013
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.10" />
1114
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="9.0.10" />
1215
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
using DevMetricsPro.Application.Interfaces;
2+
using DevMetricsPro.Core.Entities;
3+
using DevMetricsPro.Core.Enums;
4+
using DevMetricsPro.Core.Interfaces;
5+
using Microsoft.AspNetCore.Identity;
6+
7+
namespace DevMetricsPro.Web.Jobs;
8+
9+
/// <summary>
10+
/// Background job for syncing GitHub data (repositories and commits) for a user.
11+
/// This job is executed by Hangfire on a schedule or manually triggered.
12+
/// </summary>
13+
public class SyncGitHubDataJob
14+
{
15+
private readonly IGitHubRepositoryService _repositoryService;
16+
private readonly IGitHubCommitsService _commitsService;
17+
private readonly IUnitOfWork _unitOfWork;
18+
private readonly UserManager<ApplicationUser> _userManager;
19+
private readonly ILogger<SyncGitHubDataJob> _logger;
20+
21+
public SyncGitHubDataJob(
22+
IGitHubRepositoryService repositoryService,
23+
IGitHubCommitsService commitsService,
24+
IUnitOfWork unitOfWork,
25+
UserManager<ApplicationUser> userManager,
26+
ILogger<SyncGitHubDataJob> logger)
27+
{
28+
_repositoryService = repositoryService;
29+
_commitsService = commitsService;
30+
_unitOfWork = unitOfWork;
31+
_userManager = userManager;
32+
_logger = logger;
33+
}
34+
35+
/// <summary>
36+
/// Executes the GitHub data sync for a specific user.
37+
/// This method syncs repositories first, then commits for each repository.
38+
/// </summary>
39+
/// <param name="userId">The ID of the user to sync data for</param>
40+
public async Task ExecuteAsync(Guid userId)
41+
{
42+
try
43+
{
44+
_logger.LogInformation("Starting GitHub data sync for user {UserId}", userId);
45+
46+
// Get user with GitHub connection
47+
var user = await _userManager.FindByIdAsync(userId.ToString());
48+
if (user == null)
49+
{
50+
_logger.LogWarning("User {UserId} not found", userId);
51+
return;
52+
}
53+
54+
if (string.IsNullOrEmpty(user.GitHubAccessToken))
55+
{
56+
_logger.LogWarning("User {UserId} does not have GitHub connected", userId);
57+
return;
58+
}
59+
60+
// Step 1: Sync repositories
61+
_logger.LogInformation("Syncing repositories for user {UserId}", userId);
62+
var repositories = await _repositoryService.GetUserRepositoriesAsync(
63+
user.GitHubAccessToken,
64+
CancellationToken.None);
65+
66+
var repositoryList = repositories.ToList();
67+
_logger.LogInformation("Found {Count} repositories for user {UserId}",
68+
repositoryList.Count, userId);
69+
70+
// Save repositories to database
71+
var syncedRepos = await SaveRepositoriesToDatabaseAsync(repositoryList);
72+
_logger.LogInformation("Saved {Count} repositories to database", syncedRepos.Count);
73+
74+
// Step 2: Sync commits for each repository
75+
var totalCommitsSynced = 0;
76+
foreach (var repo in syncedRepos)
77+
{
78+
try
79+
{
80+
_logger.LogInformation("Syncing commits for repository {RepoName}", repo.Name);
81+
82+
// Parse owner/repo from FullName
83+
var parts = repo.FullName?.Split('/');
84+
if (parts == null || parts.Length != 2)
85+
{
86+
_logger.LogWarning("Invalid repository FullName format: {FullName}", repo.FullName);
87+
continue;
88+
}
89+
90+
var owner = parts[0];
91+
var repoName = parts[1];
92+
93+
// Fetch commits (incremental sync using LastSyncedAt)
94+
var commits = await _commitsService.GetRepositoryCommitsAsync(
95+
owner,
96+
repoName,
97+
user.GitHubAccessToken,
98+
repo.LastSyncedAt,
99+
CancellationToken.None);
100+
101+
var commitsList = commits.ToList();
102+
_logger.LogInformation("Found {Count} commits for repository {RepoName}",
103+
commitsList.Count, repoName);
104+
105+
// Save commits to database
106+
if (commitsList.Any())
107+
{
108+
var (added, updated) = await SaveCommitsToDatabaseAsync(commitsList, repo.Id);
109+
totalCommitsSynced += added + updated;
110+
111+
// Update LastSyncedAt
112+
repo.LastSyncedAt = DateTime.UtcNow;
113+
await _unitOfWork.Repository<Core.Entities.Repository>().UpdateAsync(repo);
114+
await _unitOfWork.SaveChangesAsync();
115+
}
116+
}
117+
catch (Exception ex)
118+
{
119+
_logger.LogError(ex, "Error syncing commits for repository {RepoName}", repo.Name);
120+
// Continue with next repository
121+
}
122+
}
123+
124+
_logger.LogInformation(
125+
"GitHub data sync completed for user {UserId}. " +
126+
"Synced {RepoCount} repositories and {CommitCount} commits",
127+
userId, syncedRepos.Count, totalCommitsSynced);
128+
}
129+
catch (Exception ex)
130+
{
131+
_logger.LogError(ex, "Error during GitHub data sync for user {UserId}", userId);
132+
throw; // Re-throw to let Hangfire handle retry
133+
}
134+
}
135+
136+
private async Task<List<Core.Entities.Repository>> SaveRepositoriesToDatabaseAsync(
137+
List<Application.DTOs.GitHub.GitHubRepositoryDto> githubRepos)
138+
{
139+
var savedRepos = new List<Core.Entities.Repository>();
140+
var repoRepository = _unitOfWork.Repository<Core.Entities.Repository>();
141+
142+
foreach (var githubRepo in githubRepos)
143+
{
144+
// Check if repository already exists
145+
var existingRepos = await repoRepository.GetAllAsync();
146+
var existingRepo = existingRepos.FirstOrDefault(r =>
147+
r.ExternalId == githubRepo.Id.ToString() &&
148+
r.Platform == PlatformType.GitHub);
149+
150+
if (existingRepo != null)
151+
{
152+
// Update existing repository
153+
existingRepo.Name = githubRepo.Name;
154+
existingRepo.Description = githubRepo.Description;
155+
existingRepo.Url = githubRepo.HtmlUrl;
156+
existingRepo.FullName = githubRepo.FullName;
157+
existingRepo.IsPrivate = githubRepo.IsPrivate;
158+
existingRepo.IsFork = githubRepo.IsFork;
159+
existingRepo.StargazersCount = githubRepo.StargazersCount;
160+
existingRepo.ForksCount = githubRepo.ForksCount;
161+
existingRepo.OpenIssuesCount = githubRepo.OpenIssuesCount;
162+
existingRepo.Language = githubRepo.Language;
163+
existingRepo.PushedAt = githubRepo.PushedAt;
164+
existingRepo.UpdatedAt = DateTime.UtcNow;
165+
166+
await repoRepository.UpdateAsync(existingRepo);
167+
savedRepos.Add(existingRepo);
168+
}
169+
else
170+
{
171+
// Create new repository
172+
var newRepo = new Core.Entities.Repository
173+
{
174+
Name = githubRepo.Name,
175+
Description = githubRepo.Description,
176+
Platform = PlatformType.GitHub,
177+
ExternalId = githubRepo.Id.ToString(),
178+
Url = githubRepo.HtmlUrl,
179+
FullName = githubRepo.FullName,
180+
IsPrivate = githubRepo.IsPrivate,
181+
IsFork = githubRepo.IsFork,
182+
StargazersCount = githubRepo.StargazersCount,
183+
ForksCount = githubRepo.ForksCount,
184+
OpenIssuesCount = githubRepo.OpenIssuesCount,
185+
Language = githubRepo.Language,
186+
PushedAt = githubRepo.PushedAt,
187+
CreatedAt = DateTime.UtcNow,
188+
UpdatedAt = DateTime.UtcNow
189+
};
190+
191+
var added = await repoRepository.AddAsync(newRepo);
192+
savedRepos.Add(added);
193+
}
194+
}
195+
196+
await _unitOfWork.SaveChangesAsync();
197+
return savedRepos;
198+
}
199+
200+
private async Task<(int addedCount, int updatedCount)> SaveCommitsToDatabaseAsync(
201+
List<Application.DTOs.GitHub.GitHubCommitDto> githubCommits,
202+
Guid repositoryId)
203+
{
204+
var addedCount = 0;
205+
var updatedCount = 0;
206+
var commitRepository = _unitOfWork.Repository<Commit>();
207+
var developerRepository = _unitOfWork.Repository<Developer>();
208+
209+
foreach (var githubCommit in githubCommits)
210+
{
211+
// Check if commit already exists
212+
var existingCommits = await commitRepository.GetAllAsync();
213+
var existingCommit = existingCommits.FirstOrDefault(c =>
214+
c.Sha == githubCommit.Sha &&
215+
c.RepositoryId == repositoryId);
216+
217+
// Find or create developer
218+
var developers = await developerRepository.GetAllAsync();
219+
var developer = developers.FirstOrDefault(d =>
220+
d.Email == githubCommit.CommitterEmail);
221+
222+
if (developer == null)
223+
{
224+
// Create new developer
225+
developer = new Developer
226+
{
227+
DisplayName = githubCommit.CommitterName,
228+
Email = githubCommit.CommitterEmail,
229+
GitHubUsername = githubCommit.CommitterEmail.Split('@')[0], // Temporary
230+
CreatedAt = DateTime.UtcNow,
231+
UpdatedAt = DateTime.UtcNow
232+
};
233+
developer = await developerRepository.AddAsync(developer);
234+
await _unitOfWork.SaveChangesAsync(); // Save to get ID
235+
}
236+
237+
if (existingCommit != null)
238+
{
239+
// Update existing commit
240+
existingCommit.Message = githubCommit.Message;
241+
existingCommit.LinesAdded = githubCommit.Additions;
242+
existingCommit.LinesRemoved = githubCommit.Deletions;
243+
existingCommit.FilesChanged = githubCommit.TotalChanges > 0 ? githubCommit.TotalChanges : 1;
244+
existingCommit.CommittedAt = githubCommit.CommitterDate;
245+
existingCommit.DeveloperId = developer.Id;
246+
existingCommit.UpdatedAt = DateTime.UtcNow;
247+
248+
await commitRepository.UpdateAsync(existingCommit);
249+
updatedCount++;
250+
}
251+
else
252+
{
253+
// Create new commit
254+
var newCommit = new Commit
255+
{
256+
Sha = githubCommit.Sha,
257+
Message = githubCommit.Message,
258+
CommittedAt = githubCommit.CommitterDate,
259+
LinesAdded = githubCommit.Additions,
260+
LinesRemoved = githubCommit.Deletions,
261+
FilesChanged = githubCommit.TotalChanges > 0 ? githubCommit.TotalChanges : 1,
262+
RepositoryId = repositoryId,
263+
DeveloperId = developer.Id,
264+
CreatedAt = DateTime.UtcNow,
265+
UpdatedAt = DateTime.UtcNow
266+
};
267+
268+
await commitRepository.AddAsync(newCommit);
269+
addedCount++;
270+
}
271+
}
272+
273+
await _unitOfWork.SaveChangesAsync();
274+
return (addedCount, updatedCount);
275+
}
276+
}
277+
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using Hangfire.Dashboard;
2+
3+
namespace DevMetricsPro.Web.Middleware;
4+
5+
/// <summary>
6+
/// Authorization filter for Hangfire Dashboard.
7+
/// In development: allows all access
8+
/// In production: requires authentication
9+
/// </summary>
10+
public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter
11+
{
12+
public bool Authorize(DashboardContext context)
13+
{
14+
var httpContext = context.GetHttpContext();
15+
16+
// In development, allow all access for testing
17+
// TODO: In production, require authentication
18+
#if DEBUG
19+
return true;
20+
#else
21+
return httpContext.User.Identity?.IsAuthenticated ?? false;
22+
#endif
23+
}
24+
}
25+

0 commit comments

Comments
 (0)