Skip to content
This repository was archived by the owner on Jul 6, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions backend/src/Loopless.Api/Endpoints/ProjectEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -290,14 +290,15 @@ public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuild
ISender sender,
CancellationToken ct) =>
{
await sender.Send(new TriggerCommitSyncCommand(projectId), ct);
return Results.Accepted($"/api/v1/projects/{projectId}/commits");
// Synchronous fetch+store so the client can refresh the commit list right away.
var newCommits = await sender.Send(new TriggerCommitSyncCommand(projectId), ct);
return Results.Ok(new { newCommits });
})
// Membership (owner OR accepted member) is enforced in the handler, so the
// freelancer who actually pushes the commits can refresh them too.
.RequireAuthorization()
.WithName("TriggerCommitSync")
.Produces(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
Expand Down Expand Up @@ -349,13 +350,14 @@ public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuild
ISender sender,
CancellationToken ct) =>
{
// Generated synchronously — returns the full summary (content + generatedAt).
var dto = await sender.Send(new GenerateProjectSummaryCommand(projectId), ct);
return Results.Accepted(value: dto);
return Results.Ok(dto);
})
.RequireAuthorization()
.RequireRateLimiting("ai")
.WithName("GenerateProjectSummary")
.Produces<SummaryJobStatusDto>(StatusCodes.Status202Accepted)
.Produces<ProjectSummaryDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@

namespace Loopless.Application.Features.Projects.GenerateSummary;

public sealed record GenerateProjectSummaryCommand(Guid ProjectId) : IRequest<SummaryJobStatusDto>;
public sealed record GenerateProjectSummaryCommand(Guid ProjectId) : IRequest<ProjectSummaryDto>;
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ namespace Loopless.Application.Features.Projects.GenerateSummary;
public sealed class GenerateProjectSummaryCommandHandler(
IAppDbContext db,
ICurrentUser currentUser,
ISummaryJobEnqueuer jobEnqueuer) : IRequestHandler<GenerateProjectSummaryCommand, SummaryJobStatusDto>
IAiSummaryService aiSummary) : IRequestHandler<GenerateProjectSummaryCommand, ProjectSummaryDto>
{
public async Task<SummaryJobStatusDto> Handle(GenerateProjectSummaryCommand request, CancellationToken cancellationToken)
private const string Model = "gpt-4o-mini";

public async Task<ProjectSummaryDto> Handle(GenerateProjectSummaryCommand request, CancellationToken cancellationToken)
{
var keycloakId = currentUser.KeycloakId
?? throw new UnauthorizedException("Missing subject claim.");
Expand All @@ -37,20 +39,58 @@ public async Task<SummaryJobStatusDto> Handle(GenerateProjectSummaryCommand requ
if (!isOwner && !isMember)
throw new ForbiddenException("Only project members can generate summaries.");

// Generate synchronously so the response carries the real content + timestamp
// (the client renders "Generated <generatedAt>" straight from this DTO).
var summary = new ProjectSummary
{
ProjectId = project.Id,
Status = SummaryStatus.Queued,
Status = SummaryStatus.Running,
};

db.ProjectSummaries.Add(summary);
await db.SaveChangesAsync(cancellationToken);

var jobId = jobEnqueuer.Enqueue(project.Id, summary.Id);
try
{
var commitMessages = await db.GitHubCommits
.Where(c => c.ProjectId == project.Id)
.OrderByDescending(c => c.CommittedAt)
.Take(50)
.Select(c => $"{c.Author}: {c.Message}")
.ToListAsync(cancellationToken);

summary.HangfireJobId = jobId;
await db.SaveChangesAsync(cancellationToken);
var standupEntries = await db.Standups
.Where(s => s.ProjectId == project.Id)
.OrderByDescending(s => s.CreatedAt)
.Take(10)
.Join(
db.Users,
s => s.FreelancerId,
u => u.Id,
(s, u) => $"[{u.Name}] Did: {s.WhatDid} | Plan: {s.WhatPlan} | Blockers: {s.Blockers}")
.ToListAsync(cancellationToken);

return new SummaryJobStatusDto(jobId, "queued");
var content = await aiSummary.GenerateSummaryAsync(
project.Title,
project.Description,
commitMessages,
standupEntries,
cancellationToken);

summary.Content = content;
summary.ModelUsed = Model;
summary.GeneratedAt = DateTimeOffset.UtcNow;
summary.Status = SummaryStatus.Completed;
await db.SaveChangesAsync(cancellationToken);

return new ProjectSummaryDto(
summary.Id, summary.ProjectId, summary.Content!, summary.ModelUsed!, summary.GeneratedAt);
}
catch (Exception ex)
{
summary.Status = SummaryStatus.Failed;
summary.FailureReason = ex.Message;
await db.SaveChangesAsync(CancellationToken.None);
throw;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,14 @@ public async Task<PagedResult<GitHubCommitDto>> Handle(GetProjectCommitsQuery re
if (!isOwner && !isMember)
throw new ForbiddenException("Only project members can view commits.");

var baseQuery = db.GitHubCommits.Where(c => c.ProjectId == request.ProjectId);
// Only show commits whose git author email matches a project member's account
// email (owner + accepted invitation participants). Commits by non-members are hidden.
var memberEmails = await ProjectMembers.GetMemberEmailsAsync(db, project.Id, project.OwnerId, cancellationToken);

var baseQuery = db.GitHubCommits.Where(c =>
c.ProjectId == request.ProjectId
&& c.AuthorEmail != null
&& memberEmails.Contains(c.AuthorEmail.ToLower()));
var totalCount = await baseQuery.CountAsync(cancellationToken);

var items = await baseQuery
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,17 @@ public async Task<ProjectDashboardDto> Handle(GetProjectDashboardQuery request,
.ToListAsync(cancellationToken)
: new List<ProjectTaskDto>();

// Only surface commits whose git author email matches a project member (same rule
// as the Commits tab — see GetProjectCommitsQueryHandler).
var memberEmails = hasFullAccess
? await ProjectMembers.GetMemberEmailsAsync(db, project.Id, project.OwnerId, cancellationToken)
: new List<string>();

var recentCommits = hasFullAccess
? await db.GitHubCommits
.Where(c => c.ProjectId == project.Id)
.Where(c => c.ProjectId == project.Id
&& c.AuthorEmail != null
&& memberEmails.Contains(c.AuthorEmail.ToLower()))
.OrderByDescending(c => c.CommittedAt)
.Take(10)
.Select(c => new GitHubCommitDto(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Loopless.Application.Interfaces;
using Loopless.Domain.Enums;
using Microsoft.EntityFrameworkCore;

namespace Loopless.Application.Features.Projects;

internal static class ProjectMembers
{
/// <summary>
/// Lower-cased account emails of everyone on the project's team — the owner plus the
/// users in accepted invitations (enterprise + freelancer sides). Used to match a
/// GitHub commit's author email to a member so non-member commits can be hidden.
/// </summary>
public static async Task<List<string>> GetMemberEmailsAsync(
IAppDbContext db, Guid projectId, Guid ownerId, CancellationToken cancellationToken)
{
var invited = await db.ProjectInvitations
.Where(i => i.ProjectId == projectId && i.Status == InvitationStatus.Accepted)
.Select(i => new { i.EnterpriseId, i.FreelancerId })
.ToListAsync(cancellationToken);

var memberIds = new HashSet<Guid> { ownerId };
foreach (var inv in invited)
{
memberIds.Add(inv.EnterpriseId);
if (inv.FreelancerId.HasValue)
memberIds.Add(inv.FreelancerId.Value);
}

var ids = memberIds.ToList();
return await db.Users
.Where(u => ids.Contains(u.Id))
.Select(u => u.Email.ToLower())
.ToListAsync(cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@

namespace Loopless.Application.Features.Projects.SyncCommits;

public sealed record TriggerCommitSyncCommand(Guid ProjectId) : IRequest<Unit>;
// Returns the number of newly stored commits.
public sealed record TriggerCommitSyncCommand(Guid ProjectId) : IRequest<int>;
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ namespace Loopless.Application.Features.Projects.SyncCommits;
public sealed class TriggerCommitSyncCommandHandler(
IAppDbContext db,
ICurrentUser currentUser,
ICommitSyncEnqueuer enqueuer) : IRequestHandler<TriggerCommitSyncCommand, Unit>
ICommitSyncService commitSync) : IRequestHandler<TriggerCommitSyncCommand, int>
{
public async Task<Unit> Handle(TriggerCommitSyncCommand request, CancellationToken cancellationToken)
public async Task<int> Handle(TriggerCommitSyncCommand request, CancellationToken cancellationToken)
{
var keycloakId = currentUser.KeycloakId
?? throw new UnauthorizedException("Missing subject claim.");
Expand Down Expand Up @@ -39,7 +39,8 @@ public async Task<Unit> Handle(TriggerCommitSyncCommand request, CancellationTok
if (string.IsNullOrWhiteSpace(project.GitHubRepoUrl))
throw new ConflictException("Project has no GitHub repository linked.");

enqueuer.EnqueueProjectSync(project.Id);
return Unit.Value;
// Fetch + store synchronously so the caller can refresh the commit list immediately.
// forceFull backfills author emails on commits stored before that column existed.
return await commitSync.SyncProjectAsync(project.Id, forceFull: true, cancellationToken);
}
}
17 changes: 17 additions & 0 deletions backend/src/Loopless.Application/Interfaces/ICommitSyncService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Loopless.Application.Interfaces;

/// <summary>
/// Fetches commits from a project's linked GitHub repo and stores them. Shared by the
/// on-demand "Sync now" path (synchronous, <c>forceFull: true</c>) and the recurring
/// background job (incremental, <c>forceFull: false</c>).
/// </summary>
public interface ICommitSyncService
{
/// <summary>
/// Syncs commits for the project. When <paramref name="forceFull"/> is true the full
/// recent page is fetched (ignoring the last-synced timestamp) so author emails are
/// backfilled on commits stored before that column existed. Returns the number of
/// newly stored commits.
/// </summary>
Task<int> SyncProjectAsync(Guid projectId, bool forceFull, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ public sealed record GitHubCommitPayload(
string Sha,
string Message,
string Author,
string? AuthorEmail,
DateTimeOffset CommittedAt);
6 changes: 6 additions & 0 deletions backend/src/Loopless.Domain/Entities/GitHubCommit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ public class GitHubCommit : BaseEntity
public required string Sha { get; set; }
public required string Message { get; set; }
public required string Author { get; set; }

// Git author email — used to match a commit to a project member (see
// GetProjectCommitsQueryHandler). Nullable: commits synced before this column
// existed have null until the next sync backfills it.
public string? AuthorEmail { get; set; }

public required DateTimeOffset CommittedAt { get; set; }
public DateTimeOffset SyncedAt { get; set; } = DateTimeOffset.UtcNow;

Expand Down
Loading
Loading