Implement alpha foundation workflow and GitHub sync
This commit is contained in:
parent
5d62ea2df4
commit
3fd655e1f2
37 changed files with 7352 additions and 365 deletions
29
README.md
29
README.md
|
|
@ -4,15 +4,20 @@ Bifrost is the planned GameCult member, governance, and labor platform running u
|
|||
|
||||
## Status
|
||||
|
||||
This repository now includes the first implementation slice of the ASP.NET Core app:
|
||||
This repository now includes a viable alpha foundation slice of the ASP.NET Core app:
|
||||
|
||||
- Razor Pages app scaffold under `src/Bifrost.Web`
|
||||
- PostgreSQL-backed EF Core domain model for members, projects, work items, motions, and ledgers
|
||||
- GitHub OAuth wiring plus invite/approval-based membership gating
|
||||
- initial member console pages for projects, work items, motions, ledger activity, and member approvals
|
||||
- Razor Pages app under `src/Bifrost.Web`
|
||||
- PostgreSQL-backed EF Core model with an initial migration under `src/Bifrost.Web/Data/Migrations`
|
||||
- GitHub OAuth sign-in plus invite/approval-based membership gating
|
||||
- explicit member roles and admin-managed patron or contributor tier snapshots
|
||||
- shared work board with estimates, actual time logs, review flow, and GitHub issue or PR links
|
||||
- member-facing motions with category thresholds aligned to the labor-platform doc
|
||||
- health and readiness endpoints plus startup config validation
|
||||
- GitHub App webhook ingestion for issue, pull request, and review sync
|
||||
- xUnit integration tests under `tests/Bifrost.Web.Tests`
|
||||
- matching deploy artifacts and runbooks in `E:\Projects\gamecult-ops`
|
||||
|
||||
It is not yet ready for production deployment. The current state is suitable for continued development and, after deploy-foundation work, an internal staging rollout.
|
||||
It is closer to deployable now, but it is still not a finished internet-facing member alpha. The current state is suitable for a private deploy candidate on Yggdrasil while we finish the remaining operational hardening and post-MVP economics work.
|
||||
|
||||
## Chosen Stack
|
||||
|
||||
|
|
@ -39,13 +44,13 @@ It is not yet ready for production deployment. The current state is suitable for
|
|||
|
||||
## Next Build Target
|
||||
|
||||
The next build target is to deepen the first implementation milestone by adding:
|
||||
The next build target is to deepen the alpha candidate by adding:
|
||||
|
||||
- database migrations and bootstrap seed/run instructions
|
||||
- GitHub issue and pull request sync
|
||||
- richer membership/admin role management
|
||||
- work item completion and ledger approval flow
|
||||
- payout proposal batch generation and review
|
||||
- GitHub App installation/admin guidance and live webhook verification against the real repo
|
||||
- richer workflow polish around work review, blocking, and closure ergonomics
|
||||
- payout proposal batch generation and tighter ledger approval workflow
|
||||
- staged deploy validation on Yggdrasil, including backup/restore rehearsal
|
||||
- post-MVP economics automation for point transactions, decay, and revenue-share calculation
|
||||
|
||||
For the full staged roadmap, including contribution points, revenue share, patron or contributor decay, and labor-platform alignment, see [docs/full-implementation-strategy.md](E:\Projects\Bifrost\docs\full-implementation-strategy.md).
|
||||
|
||||
|
|
|
|||
|
|
@ -12,23 +12,25 @@ It is currently a strong first application slice, not an internet-facing alpha.
|
|||
|
||||
### What Exists Already
|
||||
|
||||
- ASP.NET Core 8 Razor Pages app scaffold
|
||||
- PostgreSQL-backed EF Core model
|
||||
- GitHub OAuth wiring
|
||||
- invite and approval gate
|
||||
- ASP.NET Core 8 Razor Pages app
|
||||
- PostgreSQL-backed EF Core model plus initial migration
|
||||
- GitHub OAuth wiring and startup config validation
|
||||
- invite and approval gate with explicit member roles
|
||||
- member profile editing plus patron or contributor tier snapshots
|
||||
- project, work item, motion, member, and ledger UI
|
||||
- basic integration tests
|
||||
- work logs, work reviews, and completion flow
|
||||
- GitHub App webhook ingestion for issue, pull request, and review sync
|
||||
- health and readiness endpoints plus request logging
|
||||
- deployment artifacts and runbooks in `gamecult-ops`
|
||||
- integration tests covering console access, health/readiness, and GitHub webhook sync
|
||||
|
||||
### What Is Still Missing Before Deployment
|
||||
|
||||
- initial EF Core migrations and a repeatable database bootstrap path
|
||||
- production config validation, secrets handling guidance, and startup failure checks
|
||||
- GitHub App integration, webhook ingestion, and issue or pull request sync
|
||||
- health checks, structured logging, and deployment smoke tests
|
||||
- nginx and systemd deployment artifacts or matching runbooks in `gamecult-ops`
|
||||
- backup and restore instructions for PostgreSQL and app secrets
|
||||
- role and permission hardening beyond the initial admin flags
|
||||
- production-safe ledger workflow, payout proposal generation, and approval gates
|
||||
- live GitHub App installation validation against the real `GameCult/Bifrost` repository
|
||||
- deploy rehearsal on Yggdrasil with real secrets and a real PostgreSQL database
|
||||
- backup and restore rehearsal for PostgreSQL and app secrets
|
||||
- fuller workflow polish around blockers, archival, and review ergonomics
|
||||
- production-safe payout proposal batching and finance review workflow
|
||||
- contribution point rules, patron point rules, decay jobs, and revenue share calculation
|
||||
|
||||
## Product Alignment With Labor Platform
|
||||
|
|
|
|||
|
|
@ -5,4 +5,6 @@ public sealed class BootstrapOptions
|
|||
public const string SectionName = "Bootstrap";
|
||||
|
||||
public string[] AdminGitHubLogins { get; init; } = [];
|
||||
|
||||
public bool ApplyMigrationsOnStartup { get; init; }
|
||||
}
|
||||
|
|
|
|||
26
src/Bifrost.Web/Configuration/GitHubAppOptions.cs
Normal file
26
src/Bifrost.Web/Configuration/GitHubAppOptions.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
namespace Bifrost.Web.Configuration;
|
||||
|
||||
public sealed class GitHubAppOptions
|
||||
{
|
||||
public const string SectionName = "GitHubApp";
|
||||
|
||||
public string AppId { get; init; } = string.Empty;
|
||||
|
||||
public string ClientId { get; init; } = string.Empty;
|
||||
|
||||
public string ClientSecret { get; init; } = string.Empty;
|
||||
|
||||
public string PrivateKeyPem { get; init; } = string.Empty;
|
||||
|
||||
public string WebhookSecret { get; init; } = string.Empty;
|
||||
|
||||
public bool EnableWebhookSync { get; init; } = true;
|
||||
|
||||
public bool IsConfigured =>
|
||||
!string.IsNullOrWhiteSpace(AppId) &&
|
||||
!string.IsNullOrWhiteSpace(PrivateKeyPem) &&
|
||||
!string.IsNullOrWhiteSpace(WebhookSecret);
|
||||
|
||||
public bool IsWebhookConfigured =>
|
||||
!string.IsNullOrWhiteSpace(WebhookSecret);
|
||||
}
|
||||
16
src/Bifrost.Web/Configuration/HostOptions.cs
Normal file
16
src/Bifrost.Web/Configuration/HostOptions.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
namespace Bifrost.Web.Configuration;
|
||||
|
||||
public sealed class BifrostHostOptions
|
||||
{
|
||||
public const string SectionName = "Host";
|
||||
|
||||
public string PublicBaseUrl { get; init; } = string.Empty;
|
||||
|
||||
public string ExpectedHost { get; init; } = string.Empty;
|
||||
|
||||
public bool RequireStrictHostValidation { get; init; } = true;
|
||||
|
||||
public bool IsConfigured =>
|
||||
Uri.TryCreate(PublicBaseUrl, UriKind.Absolute, out _) &&
|
||||
!string.IsNullOrWhiteSpace(ExpectedHost);
|
||||
}
|
||||
|
|
@ -11,6 +11,10 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
|
||||
public DbSet<Membership> Memberships => Set<Membership>();
|
||||
|
||||
public DbSet<RoleAssignment> RoleAssignments => Set<RoleAssignment>();
|
||||
|
||||
public DbSet<TierSnapshot> TierSnapshots => Set<TierSnapshot>();
|
||||
|
||||
public DbSet<MembershipInvitation> MembershipInvitations => Set<MembershipInvitation>();
|
||||
|
||||
public DbSet<Project> Projects => Set<Project>();
|
||||
|
|
@ -21,6 +25,14 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
|
||||
public DbSet<Assignment> Assignments => Set<Assignment>();
|
||||
|
||||
public DbSet<WorkLog> WorkLogs => Set<WorkLog>();
|
||||
|
||||
public DbSet<WorkReview> WorkReviews => Set<WorkReview>();
|
||||
|
||||
public DbSet<GitHubIssueLink> GitHubIssueLinks => Set<GitHubIssueLink>();
|
||||
|
||||
public DbSet<GitHubPullRequestLink> GitHubPullRequestLinks => Set<GitHubPullRequestLink>();
|
||||
|
||||
public DbSet<Motion> Motions => Set<Motion>();
|
||||
|
||||
public DbSet<Vote> Votes => Set<Vote>();
|
||||
|
|
@ -29,6 +41,18 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
|
||||
public DbSet<PayoutProposalBatch> PayoutProposalBatches => Set<PayoutProposalBatch>();
|
||||
|
||||
public DbSet<PointTransaction> PointTransactions => Set<PointTransaction>();
|
||||
|
||||
public DbSet<PatronSupportEvent> PatronSupportEvents => Set<PatronSupportEvent>();
|
||||
|
||||
public DbSet<DecayRun> DecayRuns => Set<DecayRun>();
|
||||
|
||||
public DbSet<RevenueEvent> RevenueEvents => Set<RevenueEvent>();
|
||||
|
||||
public DbSet<RevenueShareBatch> RevenueShareBatches => Set<RevenueShareBatch>();
|
||||
|
||||
public DbSet<RevenueShareLine> RevenueShareLines => Set<RevenueShareLine>();
|
||||
|
||||
public DbSet<AuditEvent> AuditEvents => Set<AuditEvent>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
|
|
@ -48,6 +72,10 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
entity.HasOne(x => x.UserAccount)
|
||||
.WithOne(x => x.MemberProfile)
|
||||
.HasForeignKey<MemberProfile>(x => x.UserAccountId);
|
||||
|
||||
entity.Property(x => x.Nickname).HasMaxLength(120);
|
||||
entity.Property(x => x.Headline).HasMaxLength(240);
|
||||
entity.Property(x => x.PortfolioUrl).HasMaxLength(500);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Membership>(entity =>
|
||||
|
|
@ -57,8 +85,30 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
.HasForeignKey<Membership>(x => x.UserAccountId);
|
||||
|
||||
entity.Property(x => x.Status).HasConversion<string>();
|
||||
entity.Property(x => x.PatronWeight).HasPrecision(18, 2);
|
||||
entity.Property(x => x.ContributorWeight).HasPrecision(18, 2);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RoleAssignment>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => new { x.MembershipId, x.Role }).IsUnique();
|
||||
entity.Property(x => x.Role).HasConversion<string>();
|
||||
entity.HasOne(x => x.Membership)
|
||||
.WithMany(x => x.RoleAssignments)
|
||||
.HasForeignKey(x => x.MembershipId);
|
||||
entity.HasOne(x => x.AssignedByUserAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.AssignedByUserAccountId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<TierSnapshot>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Kind).HasConversion<string>();
|
||||
entity.Property(x => x.Label).HasMaxLength(120);
|
||||
entity.Property(x => x.Weight).HasPrecision(18, 2);
|
||||
entity.HasIndex(x => new { x.MembershipId, x.Kind, x.IsCurrent });
|
||||
entity.HasOne(x => x.Membership)
|
||||
.WithMany(x => x.TierSnapshots)
|
||||
.HasForeignKey(x => x.MembershipId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<MembershipInvitation>(entity =>
|
||||
|
|
@ -79,8 +129,10 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
modelBuilder.Entity<Project>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => x.Slug).IsUnique();
|
||||
entity.HasIndex(x => x.GitHubRepository);
|
||||
entity.Property(x => x.Name).HasMaxLength(180);
|
||||
entity.Property(x => x.Slug).HasMaxLength(120);
|
||||
entity.Property(x => x.GitHubRepository).HasMaxLength(240);
|
||||
entity.Property(x => x.Status).HasConversion<string>();
|
||||
entity.HasOne(x => x.OwnerUserAccount)
|
||||
.WithMany(x => x.OwnedProjects)
|
||||
|
|
@ -92,9 +144,13 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
{
|
||||
entity.Property(x => x.SourceType).HasConversion<string>();
|
||||
entity.Property(x => x.Status).HasConversion<string>();
|
||||
entity.Property(x => x.ReviewStatus).HasConversion<string>();
|
||||
entity.Property(x => x.SkillLevel).HasConversion<string>();
|
||||
entity.Property(x => x.Title).HasMaxLength(180);
|
||||
entity.Property(x => x.ExternalSourceId).HasMaxLength(120);
|
||||
entity.Property(x => x.EffortPoints).HasPrecision(18, 2);
|
||||
entity.Property(x => x.Category).HasMaxLength(120);
|
||||
entity.Property(x => x.ExternalSourceId).HasMaxLength(240);
|
||||
entity.Property(x => x.EstimatedHours).HasPrecision(18, 2);
|
||||
entity.Property(x => x.ContributionPoints).HasPrecision(18, 2);
|
||||
entity.HasOne(x => x.Project)
|
||||
.WithMany(x => x.WorkItems)
|
||||
.HasForeignKey(x => x.ProjectId);
|
||||
|
|
@ -136,9 +192,66 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<WorkLog>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Hours).HasPrecision(18, 2);
|
||||
entity.Property(x => x.ApprovalStatus).HasConversion<string>();
|
||||
entity.HasOne(x => x.WorkItem)
|
||||
.WithMany(x => x.WorkLogs)
|
||||
.HasForeignKey(x => x.WorkItemId);
|
||||
entity.HasOne(x => x.UserAccount)
|
||||
.WithMany(x => x.WorkLogs)
|
||||
.HasForeignKey(x => x.UserAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.ReviewedByUserAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.ReviewedByUserAccountId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<WorkReview>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Status).HasConversion<string>();
|
||||
entity.Property(x => x.ReviewerName).HasMaxLength(200);
|
||||
entity.HasOne(x => x.WorkItem)
|
||||
.WithMany(x => x.WorkReviews)
|
||||
.HasForeignKey(x => x.WorkItemId);
|
||||
entity.HasOne(x => x.ReviewerUserAccount)
|
||||
.WithMany(x => x.WorkReviews)
|
||||
.HasForeignKey(x => x.ReviewerUserAccountId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<GitHubIssueLink>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => x.WorkItemId).IsUnique();
|
||||
entity.HasIndex(x => new { x.RepositoryFullName, x.IssueNumber }).IsUnique();
|
||||
entity.Property(x => x.RepositoryFullName).HasMaxLength(240);
|
||||
entity.Property(x => x.State).HasConversion<string>();
|
||||
entity.Property(x => x.IssueUrl).HasMaxLength(500);
|
||||
entity.Property(x => x.TitleSnapshot).HasMaxLength(240);
|
||||
entity.HasOne(x => x.WorkItem)
|
||||
.WithOne(x => x.GitHubIssueLink)
|
||||
.HasForeignKey<GitHubIssueLink>(x => x.WorkItemId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<GitHubPullRequestLink>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => new { x.RepositoryFullName, x.PullRequestNumber }).IsUnique();
|
||||
entity.Property(x => x.RepositoryFullName).HasMaxLength(240);
|
||||
entity.Property(x => x.State).HasConversion<string>();
|
||||
entity.Property(x => x.ReviewDecision).HasConversion<string>();
|
||||
entity.Property(x => x.PullRequestUrl).HasMaxLength(500);
|
||||
entity.Property(x => x.TitleSnapshot).HasMaxLength(240);
|
||||
entity.HasOne(x => x.WorkItem)
|
||||
.WithMany(x => x.GitHubPullRequestLinks)
|
||||
.HasForeignKey(x => x.WorkItemId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Motion>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Scope).HasConversion<string>();
|
||||
entity.Property(x => x.Category).HasConversion<string>();
|
||||
entity.Property(x => x.Status).HasConversion<string>();
|
||||
entity.Property(x => x.ApprovalThreshold).HasPrecision(6, 3);
|
||||
entity.HasOne(x => x.Project)
|
||||
|
|
@ -198,6 +311,79 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PointTransaction>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Type).HasConversion<string>();
|
||||
entity.Property(x => x.Amount).HasPrecision(18, 2);
|
||||
entity.HasOne(x => x.UserAccount)
|
||||
.WithMany(x => x.PointTransactions)
|
||||
.HasForeignKey(x => x.UserAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(x => x.Project)
|
||||
.WithMany(x => x.PointTransactions)
|
||||
.HasForeignKey(x => x.ProjectId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
entity.HasOne(x => x.WorkItem)
|
||||
.WithMany(x => x.PointTransactions)
|
||||
.HasForeignKey(x => x.WorkItemId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PatronSupportEvent>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Amount).HasPrecision(18, 2);
|
||||
entity.Property(x => x.ExternalSupportId).HasMaxLength(240);
|
||||
entity.Property(x => x.CurrencyCode).HasMaxLength(12);
|
||||
entity.HasOne(x => x.UserAccount)
|
||||
.WithMany(x => x.PatronSupportEvents)
|
||||
.HasForeignKey(x => x.UserAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<DecayRun>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Rate).HasPrecision(8, 4);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RevenueEvent>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Amount).HasPrecision(18, 2);
|
||||
entity.Property(x => x.CurrencyCode).HasMaxLength(12);
|
||||
entity.HasOne(x => x.Project)
|
||||
.WithMany(x => x.RevenueEvents)
|
||||
.HasForeignKey(x => x.ProjectId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RevenueShareBatch>(entity =>
|
||||
{
|
||||
entity.HasOne(x => x.RevenueEvent)
|
||||
.WithMany(x => x.RevenueShareBatches)
|
||||
.HasForeignKey(x => x.RevenueEventId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
entity.HasOne(x => x.CreatedByUserAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.CreatedByUserAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RevenueShareLine>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Amount).HasPrecision(18, 2);
|
||||
entity.Property(x => x.Basis).HasMaxLength(240);
|
||||
entity.HasOne(x => x.RevenueShareBatch)
|
||||
.WithMany(x => x.Lines)
|
||||
.HasForeignKey(x => x.RevenueShareBatchId);
|
||||
entity.HasOne(x => x.UserAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.UserAccountId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
entity.HasOne(x => x.Project)
|
||||
.WithMany(x => x.RevenueShareLines)
|
||||
.HasForeignKey(x => x.ProjectId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<AuditEvent>(entity =>
|
||||
{
|
||||
entity.Property(x => x.EntityType).HasMaxLength(120);
|
||||
|
|
|
|||
1576
src/Bifrost.Web/Data/Migrations/20260422084653_InitialAlphaFoundation.Designer.cs
generated
Normal file
1576
src/Bifrost.Web/Data/Migrations/20260422084653_InitialAlphaFoundation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1573
src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs
Normal file
1573
src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -10,6 +10,21 @@ public enum MembershipStatus
|
|||
Suspended
|
||||
}
|
||||
|
||||
public enum MemberRole
|
||||
{
|
||||
StandardMember,
|
||||
Producer,
|
||||
Maintainer,
|
||||
LedgerReviewer,
|
||||
PlatformAdmin
|
||||
}
|
||||
|
||||
public enum TierSnapshotKind
|
||||
{
|
||||
Patron,
|
||||
Contributor
|
||||
}
|
||||
|
||||
public enum ProjectStatus
|
||||
{
|
||||
Proposed,
|
||||
|
|
@ -26,15 +41,27 @@ public enum WorkItemSourceType
|
|||
|
||||
public enum WorkItemStatus
|
||||
{
|
||||
Backlog,
|
||||
Proposed,
|
||||
Open,
|
||||
Claimed,
|
||||
Volunteered,
|
||||
Assigned,
|
||||
InProgress,
|
||||
Done,
|
||||
SubmittedForReview,
|
||||
ChangesRequested,
|
||||
Approved,
|
||||
Completed,
|
||||
Blocked,
|
||||
Archived
|
||||
}
|
||||
|
||||
public enum WorkItemSkillLevel
|
||||
{
|
||||
Routine,
|
||||
Specialized,
|
||||
Senior,
|
||||
Lead
|
||||
}
|
||||
|
||||
public enum VolunteerClaimStatus
|
||||
{
|
||||
Active,
|
||||
|
|
@ -42,12 +69,36 @@ public enum VolunteerClaimStatus
|
|||
Accepted
|
||||
}
|
||||
|
||||
public enum WorkLogApprovalStatus
|
||||
{
|
||||
Submitted,
|
||||
Approved,
|
||||
Rejected
|
||||
}
|
||||
|
||||
public enum WorkReviewStatus
|
||||
{
|
||||
Pending,
|
||||
ChangesRequested,
|
||||
Approved
|
||||
}
|
||||
|
||||
public enum MotionScope
|
||||
{
|
||||
Management,
|
||||
Project
|
||||
}
|
||||
|
||||
public enum MotionCategory
|
||||
{
|
||||
Bugs,
|
||||
Cosmetics,
|
||||
BalanceChanges,
|
||||
Features,
|
||||
NewContent,
|
||||
FundamentalDesignChanges
|
||||
}
|
||||
|
||||
public enum MotionStatus
|
||||
{
|
||||
Open,
|
||||
|
|
@ -85,6 +136,30 @@ public enum PayoutProposalBatchStatus
|
|||
Closed
|
||||
}
|
||||
|
||||
public enum GitHubEntityState
|
||||
{
|
||||
Open,
|
||||
Closed,
|
||||
Merged
|
||||
}
|
||||
|
||||
public enum GitHubReviewDecision
|
||||
{
|
||||
None,
|
||||
Commented,
|
||||
ChangesRequested,
|
||||
Approved
|
||||
}
|
||||
|
||||
public enum PointTransactionType
|
||||
{
|
||||
ManualAdjustment,
|
||||
ApprovedWork,
|
||||
ContinuousRoleCredit,
|
||||
PatronSupport,
|
||||
Decay
|
||||
}
|
||||
|
||||
public sealed class UserAccount
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
|
@ -117,12 +192,20 @@ public sealed class UserAccount
|
|||
|
||||
public ICollection<Assignment> Assignments { get; set; } = [];
|
||||
|
||||
public ICollection<WorkLog> WorkLogs { get; set; } = [];
|
||||
|
||||
public ICollection<WorkReview> WorkReviews { get; set; } = [];
|
||||
|
||||
public ICollection<Motion> CreatedMotions { get; set; } = [];
|
||||
|
||||
public ICollection<Vote> Votes { get; set; } = [];
|
||||
|
||||
public ICollection<LedgerEntry> LedgerEntries { get; set; } = [];
|
||||
|
||||
public ICollection<PointTransaction> PointTransactions { get; set; } = [];
|
||||
|
||||
public ICollection<PatronSupportEvent> PatronSupportEvents { get; set; } = [];
|
||||
|
||||
public ICollection<AuditEvent> AuditEvents { get; set; } = [];
|
||||
}
|
||||
|
||||
|
|
@ -132,8 +215,12 @@ public sealed class MemberProfile
|
|||
|
||||
public Guid UserAccountId { get; set; }
|
||||
|
||||
public string Nickname { get; set; } = string.Empty;
|
||||
|
||||
public string Headline { get; set; } = string.Empty;
|
||||
|
||||
public string PortfolioUrl { get; set; } = string.Empty;
|
||||
|
||||
public string Skills { get; set; } = string.Empty;
|
||||
|
||||
public string Availability { get; set; } = string.Empty;
|
||||
|
|
@ -161,20 +248,65 @@ public sealed class Membership
|
|||
|
||||
public bool CanModerateMotions { get; set; }
|
||||
|
||||
public decimal PatronWeight { get; set; }
|
||||
|
||||
public decimal ContributorWeight { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAtUtc { get; set; }
|
||||
|
||||
public DateTimeOffset? ApprovedAtUtc { get; set; }
|
||||
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
|
||||
[NotMapped]
|
||||
public decimal EffectiveVotingWeight => Math.Max(1m, PatronWeight + ContributorWeight);
|
||||
|
||||
public UserAccount UserAccount { get; set; } = null!;
|
||||
|
||||
public ICollection<RoleAssignment> RoleAssignments { get; set; } = [];
|
||||
|
||||
public ICollection<TierSnapshot> TierSnapshots { get; set; } = [];
|
||||
|
||||
[NotMapped]
|
||||
public decimal EffectiveVotingWeight =>
|
||||
Status != MembershipStatus.Active
|
||||
? 0m
|
||||
: Math.Max(1m, TierSnapshots.Where(x => x.IsCurrent).Sum(x => x.Weight));
|
||||
}
|
||||
|
||||
public sealed class RoleAssignment
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid MembershipId { get; set; }
|
||||
|
||||
public MemberRole Role { get; set; } = MemberRole.StandardMember;
|
||||
|
||||
public Guid? AssignedByUserAccountId { get; set; }
|
||||
|
||||
public DateTimeOffset AssignedAtUtc { get; set; }
|
||||
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
|
||||
public Membership Membership { get; set; } = null!;
|
||||
|
||||
public UserAccount? AssignedByUserAccount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TierSnapshot
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid MembershipId { get; set; }
|
||||
|
||||
public TierSnapshotKind Kind { get; set; }
|
||||
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
public decimal Weight { get; set; }
|
||||
|
||||
public bool IsCurrent { get; set; } = true;
|
||||
|
||||
public DateTimeOffset EffectiveFromUtc { get; set; }
|
||||
|
||||
public DateTimeOffset CapturedAtUtc { get; set; }
|
||||
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
|
||||
public Membership Membership { get; set; } = null!;
|
||||
}
|
||||
|
||||
public sealed class MembershipInvitation
|
||||
|
|
@ -214,6 +346,8 @@ public sealed class Project
|
|||
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
|
||||
public string GitHubRepository { get; set; } = string.Empty;
|
||||
|
||||
public ProjectStatus Status { get; set; } = ProjectStatus.Active;
|
||||
|
||||
public DateTimeOffset CreatedAtUtc { get; set; }
|
||||
|
|
@ -227,6 +361,12 @@ public sealed class Project
|
|||
public ICollection<Motion> Motions { get; set; } = [];
|
||||
|
||||
public ICollection<LedgerEntry> LedgerEntries { get; set; } = [];
|
||||
|
||||
public ICollection<PointTransaction> PointTransactions { get; set; } = [];
|
||||
|
||||
public ICollection<RevenueEvent> RevenueEvents { get; set; } = [];
|
||||
|
||||
public ICollection<RevenueShareLine> RevenueShareLines { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class WorkItem
|
||||
|
|
@ -247,9 +387,23 @@ public sealed class WorkItem
|
|||
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
public WorkItemSkillLevel SkillLevel { get; set; } = WorkItemSkillLevel.Routine;
|
||||
|
||||
public WorkItemStatus Status { get; set; } = WorkItemStatus.Open;
|
||||
|
||||
public decimal EffortPoints { get; set; }
|
||||
public WorkReviewStatus ReviewStatus { get; set; } = WorkReviewStatus.Pending;
|
||||
|
||||
public decimal EstimatedHours { get; set; }
|
||||
|
||||
public decimal ContributionPoints { get; set; }
|
||||
|
||||
public DateTimeOffset? TargetDateUtc { get; set; }
|
||||
|
||||
public DateTimeOffset? SubmittedAtUtc { get; set; }
|
||||
|
||||
public DateTimeOffset? CompletedAtUtc { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAtUtc { get; set; }
|
||||
|
||||
|
|
@ -265,7 +419,17 @@ public sealed class WorkItem
|
|||
|
||||
public ICollection<Assignment> Assignments { get; set; } = [];
|
||||
|
||||
public ICollection<WorkLog> WorkLogs { get; set; } = [];
|
||||
|
||||
public ICollection<WorkReview> WorkReviews { get; set; } = [];
|
||||
|
||||
public ICollection<LedgerEntry> LedgerEntries { get; set; } = [];
|
||||
|
||||
public ICollection<PointTransaction> PointTransactions { get; set; } = [];
|
||||
|
||||
public GitHubIssueLink? GitHubIssueLink { get; set; }
|
||||
|
||||
public ICollection<GitHubPullRequestLink> GitHubPullRequestLinks { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class VolunteerClaim
|
||||
|
|
@ -308,6 +472,100 @@ public sealed class Assignment
|
|||
public UserAccount AssignedByUserAccount { get; set; } = null!;
|
||||
}
|
||||
|
||||
public sealed class WorkLog
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid WorkItemId { get; set; }
|
||||
|
||||
public Guid UserAccountId { get; set; }
|
||||
|
||||
public decimal Hours { get; set; }
|
||||
|
||||
public string Note { get; set; } = string.Empty;
|
||||
|
||||
public WorkLogApprovalStatus ApprovalStatus { get; set; } = WorkLogApprovalStatus.Submitted;
|
||||
|
||||
public Guid? ReviewedByUserAccountId { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAtUtc { get; set; }
|
||||
|
||||
public DateTimeOffset? ReviewedAtUtc { get; set; }
|
||||
|
||||
public WorkItem WorkItem { get; set; } = null!;
|
||||
|
||||
public UserAccount UserAccount { get; set; } = null!;
|
||||
|
||||
public UserAccount? ReviewedByUserAccount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class WorkReview
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid WorkItemId { get; set; }
|
||||
|
||||
public Guid? ReviewerUserAccountId { get; set; }
|
||||
|
||||
public string ReviewerName { get; set; } = string.Empty;
|
||||
|
||||
public WorkReviewStatus Status { get; set; }
|
||||
|
||||
public string Note { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset ReviewedAtUtc { get; set; }
|
||||
|
||||
public WorkItem WorkItem { get; set; } = null!;
|
||||
|
||||
public UserAccount? ReviewerUserAccount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class GitHubIssueLink
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid WorkItemId { get; set; }
|
||||
|
||||
public string RepositoryFullName { get; set; } = string.Empty;
|
||||
|
||||
public int IssueNumber { get; set; }
|
||||
|
||||
public GitHubEntityState State { get; set; } = GitHubEntityState.Open;
|
||||
|
||||
public string IssueUrl { get; set; } = string.Empty;
|
||||
|
||||
public string TitleSnapshot { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset LastSynchronizedAtUtc { get; set; }
|
||||
|
||||
public WorkItem WorkItem { get; set; } = null!;
|
||||
}
|
||||
|
||||
public sealed class GitHubPullRequestLink
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid WorkItemId { get; set; }
|
||||
|
||||
public string RepositoryFullName { get; set; } = string.Empty;
|
||||
|
||||
public int PullRequestNumber { get; set; }
|
||||
|
||||
public GitHubEntityState State { get; set; } = GitHubEntityState.Open;
|
||||
|
||||
public bool IsMerged { get; set; }
|
||||
|
||||
public string PullRequestUrl { get; set; } = string.Empty;
|
||||
|
||||
public string TitleSnapshot { get; set; } = string.Empty;
|
||||
|
||||
public GitHubReviewDecision ReviewDecision { get; set; } = GitHubReviewDecision.None;
|
||||
|
||||
public DateTimeOffset LastSynchronizedAtUtc { get; set; }
|
||||
|
||||
public WorkItem WorkItem { get; set; } = null!;
|
||||
}
|
||||
|
||||
public sealed class Motion
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
|
@ -318,13 +576,15 @@ public sealed class Motion
|
|||
|
||||
public MotionScope Scope { get; set; } = MotionScope.Project;
|
||||
|
||||
public MotionCategory Category { get; set; } = MotionCategory.Features;
|
||||
|
||||
public MotionStatus Status { get; set; } = MotionStatus.Open;
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
|
||||
public decimal ApprovalThreshold { get; set; } = 0.60m;
|
||||
public decimal ApprovalThreshold { get; set; } = 0.50m;
|
||||
|
||||
public DateTimeOffset OpensAtUtc { get; set; }
|
||||
|
||||
|
|
@ -332,6 +592,10 @@ public sealed class Motion
|
|||
|
||||
public DateTimeOffset CreatedAtUtc { get; set; }
|
||||
|
||||
public DateTimeOffset? ResolvedAtUtc { get; set; }
|
||||
|
||||
public string ResolutionNote { get; set; } = string.Empty;
|
||||
|
||||
public Project? Project { get; set; }
|
||||
|
||||
public UserAccount CreatedByUserAccount { get; set; } = null!;
|
||||
|
|
@ -410,6 +674,126 @@ public sealed class PayoutProposalBatch
|
|||
public UserAccount CreatedByUserAccount { get; set; } = null!;
|
||||
}
|
||||
|
||||
public sealed class PointTransaction
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid UserAccountId { get; set; }
|
||||
|
||||
public Guid? ProjectId { get; set; }
|
||||
|
||||
public Guid? WorkItemId { get; set; }
|
||||
|
||||
public PointTransactionType Type { get; set; } = PointTransactionType.ManualAdjustment;
|
||||
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public bool IsDecaying { get; set; } = true;
|
||||
|
||||
public string Note { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CreatedAtUtc { get; set; }
|
||||
|
||||
public UserAccount UserAccount { get; set; } = null!;
|
||||
|
||||
public Project? Project { get; set; }
|
||||
|
||||
public WorkItem? WorkItem { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PatronSupportEvent
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid UserAccountId { get; set; }
|
||||
|
||||
public string ExternalSupportId { get; set; } = string.Empty;
|
||||
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public string CurrencyCode { get; set; } = "USD";
|
||||
|
||||
public DateTimeOffset SupportedAtUtc { get; set; }
|
||||
|
||||
public DateTimeOffset RecordedAtUtc { get; set; }
|
||||
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
|
||||
public UserAccount UserAccount { get; set; } = null!;
|
||||
}
|
||||
|
||||
public sealed class DecayRun
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public decimal Rate { get; set; }
|
||||
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset RanAtUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RevenueEvent
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid? ProjectId { get; set; }
|
||||
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public string CurrencyCode { get; set; } = "USD";
|
||||
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset ReceivedAtUtc { get; set; }
|
||||
|
||||
public Project? Project { get; set; }
|
||||
|
||||
public ICollection<RevenueShareBatch> RevenueShareBatches { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class RevenueShareBatch
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid? RevenueEventId { get; set; }
|
||||
|
||||
public Guid CreatedByUserAccountId { get; set; }
|
||||
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CreatedAtUtc { get; set; }
|
||||
|
||||
public RevenueEvent? RevenueEvent { get; set; }
|
||||
|
||||
public UserAccount CreatedByUserAccount { get; set; } = null!;
|
||||
|
||||
public ICollection<RevenueShareLine> Lines { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class RevenueShareLine
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid RevenueShareBatchId { get; set; }
|
||||
|
||||
public Guid? UserAccountId { get; set; }
|
||||
|
||||
public Guid? ProjectId { get; set; }
|
||||
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public string Basis { get; set; } = string.Empty;
|
||||
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
|
||||
public RevenueShareBatch RevenueShareBatch { get; set; } = null!;
|
||||
|
||||
public UserAccount? UserAccount { get; set; }
|
||||
|
||||
public Project? Project { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AuditEvent
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
|
|
|||
461
src/Bifrost.Web/Features/GitHub/GitHubWebhookService.cs
Normal file
461
src/Bifrost.Web/Features/GitHub/GitHubWebhookService.cs
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Features.GitHub;
|
||||
|
||||
public sealed class GitHubWebhookService(
|
||||
BifrostDbContext dbContext,
|
||||
IOptions<GitHubAppOptions> gitHubAppOptions,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<GitHubWebhookService> logger)
|
||||
{
|
||||
private static readonly Regex IssueReferenceRegex = new("#(?<number>\\d+)", RegexOptions.Compiled);
|
||||
|
||||
public async Task<GitHubWebhookResult> ProcessAsync(
|
||||
string eventName,
|
||||
string deliveryId,
|
||||
string signature,
|
||||
string payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var options = gitHubAppOptions.Value;
|
||||
if (!options.EnableWebhookSync)
|
||||
{
|
||||
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Ignored, "Webhook sync is disabled.");
|
||||
}
|
||||
|
||||
if (!options.IsWebhookConfigured)
|
||||
{
|
||||
return new GitHubWebhookResult(GitHubWebhookProcessStatus.NotConfigured, "Webhook secret is not configured.");
|
||||
}
|
||||
|
||||
if (!IsValidSignature(options.WebhookSecret, payload, signature))
|
||||
{
|
||||
logger.LogWarning("Rejected GitHub webhook delivery {DeliveryId} because the signature did not match.", deliveryId);
|
||||
return new GitHubWebhookResult(GitHubWebhookProcessStatus.InvalidSignature, "Invalid signature.");
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
var root = document.RootElement;
|
||||
|
||||
var result = eventName switch
|
||||
{
|
||||
"issues" => await ProcessIssueAsync(root, cancellationToken),
|
||||
"pull_request" => await ProcessPullRequestAsync(root, cancellationToken),
|
||||
"pull_request_review" => await ProcessPullRequestReviewAsync(root, cancellationToken),
|
||||
_ => new GitHubWebhookResult(GitHubWebhookProcessStatus.Ignored, $"Ignoring unsupported event '{eventName}'.")
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<GitHubWebhookResult> ProcessIssueAsync(JsonElement root, CancellationToken cancellationToken)
|
||||
{
|
||||
var action = ReadString(root, "action");
|
||||
if (action is not ("opened" or "edited" or "reopened" or "closed"))
|
||||
{
|
||||
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Ignored, $"Ignoring issue action '{action}'.");
|
||||
}
|
||||
|
||||
var issue = root.GetProperty("issue");
|
||||
if (issue.TryGetProperty("pull_request", out _))
|
||||
{
|
||||
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Ignored, "Ignoring pull-request issue shadow payload.");
|
||||
}
|
||||
|
||||
var repositoryFullName = NormalizeRepositoryName(ReadString(root.GetProperty("repository"), "full_name"));
|
||||
var issueNumber = issue.GetProperty("number").GetInt32();
|
||||
var issueState = ParseGitHubEntityState(ReadString(issue, "state"), false);
|
||||
var issueUrl = ReadString(issue, "html_url");
|
||||
var title = ReadString(issue, "title");
|
||||
var body = ReadString(issue, "body");
|
||||
var senderLogin = ReadString(root.GetProperty("sender"), "login");
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var owner = await ResolveFallbackOwnerAsync(senderLogin, cancellationToken);
|
||||
|
||||
if (owner is null)
|
||||
{
|
||||
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Ignored, "No local member exists yet to own the synced project.");
|
||||
}
|
||||
|
||||
var project = await ResolveProjectAsync(repositoryFullName, owner, now, cancellationToken);
|
||||
var issueLink = await dbContext.GitHubIssueLinks
|
||||
.Include(x => x.WorkItem)
|
||||
.SingleOrDefaultAsync(
|
||||
x => x.RepositoryFullName == repositoryFullName && x.IssueNumber == issueNumber,
|
||||
cancellationToken);
|
||||
|
||||
var workItem = issueLink?.WorkItem;
|
||||
if (workItem is null)
|
||||
{
|
||||
workItem = new WorkItem
|
||||
{
|
||||
ProjectId = project.Id,
|
||||
RequestedByUserAccountId = owner.Id,
|
||||
SourceType = WorkItemSourceType.GitHubIssue,
|
||||
ExternalSourceId = $"{repositoryFullName}#{issueNumber}",
|
||||
Title = title,
|
||||
Summary = body,
|
||||
Category = "GitHub issue",
|
||||
SkillLevel = WorkItemSkillLevel.Specialized,
|
||||
Status = issueState == GitHubEntityState.Closed ? WorkItemStatus.Completed : WorkItemStatus.Open,
|
||||
ReviewStatus = WorkReviewStatus.Pending,
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now
|
||||
};
|
||||
|
||||
dbContext.WorkItems.Add(workItem);
|
||||
issueLink = new GitHubIssueLink
|
||||
{
|
||||
WorkItem = workItem,
|
||||
RepositoryFullName = repositoryFullName,
|
||||
IssueNumber = issueNumber,
|
||||
State = issueState,
|
||||
IssueUrl = issueUrl,
|
||||
TitleSnapshot = title,
|
||||
LastSynchronizedAtUtc = now
|
||||
};
|
||||
|
||||
dbContext.GitHubIssueLinks.Add(issueLink);
|
||||
}
|
||||
else
|
||||
{
|
||||
workItem.ProjectId = project.Id;
|
||||
workItem.SourceType = WorkItemSourceType.GitHubIssue;
|
||||
workItem.ExternalSourceId = $"{repositoryFullName}#{issueNumber}";
|
||||
workItem.Title = title;
|
||||
workItem.Summary = body;
|
||||
workItem.UpdatedAtUtc = now;
|
||||
workItem.Status = issueState == GitHubEntityState.Closed
|
||||
? WorkItemStatus.Completed
|
||||
: workItem.Status == WorkItemStatus.Archived
|
||||
? WorkItemStatus.Archived
|
||||
: workItem.Status == WorkItemStatus.Completed
|
||||
? WorkItemStatus.Open
|
||||
: workItem.Status;
|
||||
|
||||
issueLink!.State = issueState;
|
||||
issueLink.IssueUrl = issueUrl;
|
||||
issueLink.TitleSnapshot = title;
|
||||
issueLink.LastSynchronizedAtUtc = now;
|
||||
}
|
||||
|
||||
workItem.CompletedAtUtc = issueState == GitHubEntityState.Closed ? now : null;
|
||||
dbContext.AuditEvents.Add(CreateAuditEvent(
|
||||
owner.Id,
|
||||
nameof(WorkItem),
|
||||
workItem.Id,
|
||||
"github.issue.synced",
|
||||
$"Synced issue {repositoryFullName}#{issueNumber} into work item '{workItem.Title}'."));
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Processed, $"Processed issue {repositoryFullName}#{issueNumber}.");
|
||||
}
|
||||
|
||||
private async Task<GitHubWebhookResult> ProcessPullRequestAsync(JsonElement root, CancellationToken cancellationToken)
|
||||
{
|
||||
var action = ReadString(root, "action");
|
||||
if (action is not ("opened" or "edited" or "reopened" or "synchronize" or "closed" or "ready_for_review"))
|
||||
{
|
||||
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Ignored, $"Ignoring pull request action '{action}'.");
|
||||
}
|
||||
|
||||
var repositoryFullName = NormalizeRepositoryName(ReadString(root.GetProperty("repository"), "full_name"));
|
||||
var pullRequest = root.GetProperty("pull_request");
|
||||
var pullRequestNumber = pullRequest.GetProperty("number").GetInt32();
|
||||
var pullRequestState = ParseGitHubEntityState(ReadString(pullRequest, "state"), pullRequest.GetProperty("merged").GetBoolean());
|
||||
var pullRequestUrl = ReadString(pullRequest, "html_url");
|
||||
var title = ReadString(pullRequest, "title");
|
||||
var body = ReadString(pullRequest, "body");
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var pullRequestLink = await dbContext.GitHubPullRequestLinks
|
||||
.Include(x => x.WorkItem)
|
||||
.SingleOrDefaultAsync(
|
||||
x => x.RepositoryFullName == repositoryFullName && x.PullRequestNumber == pullRequestNumber,
|
||||
cancellationToken);
|
||||
|
||||
var workItem = pullRequestLink?.WorkItem
|
||||
?? await ResolveWorkItemFromReferencesAsync(repositoryFullName, body, title, cancellationToken);
|
||||
|
||||
if (workItem is null)
|
||||
{
|
||||
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Ignored, $"No work item mapping found for pull request {repositoryFullName}#{pullRequestNumber}.");
|
||||
}
|
||||
|
||||
if (pullRequestLink is null)
|
||||
{
|
||||
pullRequestLink = new GitHubPullRequestLink
|
||||
{
|
||||
WorkItemId = workItem.Id,
|
||||
RepositoryFullName = repositoryFullName,
|
||||
PullRequestNumber = pullRequestNumber,
|
||||
LastSynchronizedAtUtc = now
|
||||
};
|
||||
|
||||
dbContext.GitHubPullRequestLinks.Add(pullRequestLink);
|
||||
}
|
||||
|
||||
pullRequestLink.State = pullRequestState;
|
||||
pullRequestLink.IsMerged = pullRequestState == GitHubEntityState.Merged;
|
||||
pullRequestLink.PullRequestUrl = pullRequestUrl;
|
||||
pullRequestLink.TitleSnapshot = title;
|
||||
pullRequestLink.LastSynchronizedAtUtc = now;
|
||||
|
||||
workItem.UpdatedAtUtc = now;
|
||||
if (pullRequestState == GitHubEntityState.Merged)
|
||||
{
|
||||
workItem.Status = WorkItemStatus.Completed;
|
||||
workItem.ReviewStatus = WorkReviewStatus.Approved;
|
||||
workItem.CompletedAtUtc = now;
|
||||
}
|
||||
else if (pullRequestState == GitHubEntityState.Closed)
|
||||
{
|
||||
workItem.Status = WorkItemStatus.InProgress;
|
||||
}
|
||||
else
|
||||
{
|
||||
workItem.Status = WorkItemStatus.SubmittedForReview;
|
||||
workItem.ReviewStatus = WorkReviewStatus.Pending;
|
||||
workItem.SubmittedAtUtc ??= now;
|
||||
}
|
||||
|
||||
dbContext.AuditEvents.Add(CreateAuditEvent(
|
||||
workItem.AssignedToUserAccountId ?? workItem.RequestedByUserAccountId,
|
||||
nameof(WorkItem),
|
||||
workItem.Id,
|
||||
"github.pull-request.synced",
|
||||
$"Synced pull request {repositoryFullName}#{pullRequestNumber} for work item '{workItem.Title}'."));
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Processed, $"Processed pull request {repositoryFullName}#{pullRequestNumber}.");
|
||||
}
|
||||
|
||||
private async Task<GitHubWebhookResult> ProcessPullRequestReviewAsync(JsonElement root, CancellationToken cancellationToken)
|
||||
{
|
||||
var action = ReadString(root, "action");
|
||||
if (action is not ("submitted" or "edited" or "dismissed"))
|
||||
{
|
||||
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Ignored, $"Ignoring pull request review action '{action}'.");
|
||||
}
|
||||
|
||||
var repositoryFullName = NormalizeRepositoryName(ReadString(root.GetProperty("repository"), "full_name"));
|
||||
var pullRequest = root.GetProperty("pull_request");
|
||||
var review = root.GetProperty("review");
|
||||
var pullRequestNumber = pullRequest.GetProperty("number").GetInt32();
|
||||
var reviewState = ParseReviewDecision(ReadString(review, "state"));
|
||||
var reviewerLogin = ReadString(root.GetProperty("sender"), "login");
|
||||
var reviewBody = ReadString(review, "body");
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var pullRequestLink = await dbContext.GitHubPullRequestLinks
|
||||
.Include(x => x.WorkItem)
|
||||
.SingleOrDefaultAsync(
|
||||
x => x.RepositoryFullName == repositoryFullName && x.PullRequestNumber == pullRequestNumber,
|
||||
cancellationToken);
|
||||
|
||||
if (pullRequestLink?.WorkItem is null)
|
||||
{
|
||||
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Ignored, $"No work item mapping found for review on {repositoryFullName}#{pullRequestNumber}.");
|
||||
}
|
||||
|
||||
var reviewer = await dbContext.UserAccounts
|
||||
.SingleOrDefaultAsync(
|
||||
x => x.NormalizedGitHubLogin == reviewerLogin.Trim().ToUpperInvariant(),
|
||||
cancellationToken);
|
||||
|
||||
pullRequestLink.ReviewDecision = reviewState;
|
||||
pullRequestLink.LastSynchronizedAtUtc = now;
|
||||
|
||||
pullRequestLink.WorkItem.UpdatedAtUtc = now;
|
||||
pullRequestLink.WorkItem.ReviewStatus = reviewState switch
|
||||
{
|
||||
GitHubReviewDecision.Approved => WorkReviewStatus.Approved,
|
||||
GitHubReviewDecision.ChangesRequested => WorkReviewStatus.ChangesRequested,
|
||||
_ => WorkReviewStatus.Pending
|
||||
};
|
||||
|
||||
pullRequestLink.WorkItem.Status = reviewState switch
|
||||
{
|
||||
GitHubReviewDecision.Approved => WorkItemStatus.Approved,
|
||||
GitHubReviewDecision.ChangesRequested => WorkItemStatus.ChangesRequested,
|
||||
_ => WorkItemStatus.SubmittedForReview
|
||||
};
|
||||
|
||||
dbContext.WorkReviews.Add(new WorkReview
|
||||
{
|
||||
WorkItemId = pullRequestLink.WorkItemId,
|
||||
ReviewerUserAccountId = reviewer?.Id,
|
||||
ReviewerName = reviewer?.DisplayName ?? reviewerLogin,
|
||||
Status = pullRequestLink.WorkItem.ReviewStatus,
|
||||
Note = reviewBody,
|
||||
ReviewedAtUtc = now
|
||||
});
|
||||
|
||||
dbContext.AuditEvents.Add(CreateAuditEvent(
|
||||
reviewer?.Id,
|
||||
nameof(WorkItem),
|
||||
pullRequestLink.WorkItemId,
|
||||
"github.pull-request.reviewed",
|
||||
$"Applied GitHub review state {reviewState} to work item '{pullRequestLink.WorkItem.Title}'."));
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new GitHubWebhookResult(GitHubWebhookProcessStatus.Processed, $"Processed review for {repositoryFullName}#{pullRequestNumber}.");
|
||||
}
|
||||
|
||||
private async Task<Project> ResolveProjectAsync(
|
||||
string repositoryFullName,
|
||||
UserAccount owner,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var project = await dbContext.Projects
|
||||
.SingleOrDefaultAsync(x => x.GitHubRepository == repositoryFullName, cancellationToken);
|
||||
|
||||
if (project is not null)
|
||||
{
|
||||
return project;
|
||||
}
|
||||
|
||||
var repositoryName = repositoryFullName.Split('/').LastOrDefault() ?? repositoryFullName;
|
||||
var slugBase = SlugGenerator.Create(repositoryName);
|
||||
var slug = slugBase;
|
||||
var suffix = 2;
|
||||
while (await dbContext.Projects.AnyAsync(x => x.Slug == slug, cancellationToken))
|
||||
{
|
||||
slug = $"{slugBase}-{suffix++}";
|
||||
}
|
||||
|
||||
project = new Project
|
||||
{
|
||||
OwnerUserAccountId = owner.Id,
|
||||
Slug = slug,
|
||||
Name = repositoryName,
|
||||
Summary = $"GitHub-synced project container for {repositoryFullName}.",
|
||||
GitHubRepository = repositoryFullName,
|
||||
Status = ProjectStatus.Active,
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now
|
||||
};
|
||||
|
||||
dbContext.Projects.Add(project);
|
||||
return project;
|
||||
}
|
||||
|
||||
private async Task<WorkItem?> ResolveWorkItemFromReferencesAsync(
|
||||
string repositoryFullName,
|
||||
string pullRequestBody,
|
||||
string pullRequestTitle,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var referencedIssueNumbers = IssueReferenceRegex.Matches($"{pullRequestTitle} {pullRequestBody}")
|
||||
.Select(match => int.TryParse(match.Groups["number"].Value, out var issueNumber) ? issueNumber : 0)
|
||||
.Where(issueNumber => issueNumber > 0)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
if (referencedIssueNumbers.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await dbContext.GitHubIssueLinks
|
||||
.Include(x => x.WorkItem)
|
||||
.Where(x => x.RepositoryFullName == repositoryFullName && referencedIssueNumbers.Contains(x.IssueNumber))
|
||||
.Select(x => x.WorkItem)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<UserAccount?> ResolveFallbackOwnerAsync(string senderLogin, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedLogin = senderLogin.Trim().ToUpperInvariant();
|
||||
var matchedUser = await dbContext.UserAccounts
|
||||
.SingleOrDefaultAsync(x => x.NormalizedGitHubLogin == normalizedLogin, cancellationToken);
|
||||
|
||||
if (matchedUser is not null)
|
||||
{
|
||||
return matchedUser;
|
||||
}
|
||||
|
||||
return await dbContext.UserAccounts
|
||||
.Include(x => x.Membership)
|
||||
.OrderByDescending(x => x.Membership!.IsPlatformAdmin)
|
||||
.ThenBy(x => x.DisplayName)
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.Membership != null && x.Membership.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static bool IsValidSignature(string secret, string payload, string signature)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(secret) || string.IsNullOrWhiteSpace(signature))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||
var hash = hasher.ComputeHash(Encoding.UTF8.GetBytes(payload));
|
||||
var expected = $"sha256={Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(expected),
|
||||
Encoding.UTF8.GetBytes(signature));
|
||||
}
|
||||
|
||||
private static GitHubEntityState ParseGitHubEntityState(string state, bool merged) =>
|
||||
merged
|
||||
? GitHubEntityState.Merged
|
||||
: string.Equals(state, "closed", StringComparison.OrdinalIgnoreCase)
|
||||
? GitHubEntityState.Closed
|
||||
: GitHubEntityState.Open;
|
||||
|
||||
private static GitHubReviewDecision ParseReviewDecision(string state) =>
|
||||
state.ToLowerInvariant() switch
|
||||
{
|
||||
"approved" => GitHubReviewDecision.Approved,
|
||||
"changes_requested" => GitHubReviewDecision.ChangesRequested,
|
||||
"commented" => GitHubReviewDecision.Commented,
|
||||
_ => GitHubReviewDecision.None
|
||||
};
|
||||
|
||||
private static string ReadString(JsonElement element, string propertyName) =>
|
||||
element.TryGetProperty(propertyName, out var property) && property.ValueKind != JsonValueKind.Null
|
||||
? property.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
|
||||
private static string NormalizeRepositoryName(string repositoryFullName) =>
|
||||
repositoryFullName.Trim().Trim('/').ToLowerInvariant();
|
||||
|
||||
private AuditEvent CreateAuditEvent(
|
||||
Guid? actorUserAccountId,
|
||||
string entityType,
|
||||
Guid entityId,
|
||||
string action,
|
||||
string detail) =>
|
||||
new()
|
||||
{
|
||||
ActorUserAccountId = actorUserAccountId,
|
||||
EntityType = entityType,
|
||||
EntityId = entityId.ToString(),
|
||||
Action = action,
|
||||
Detail = detail,
|
||||
OccurredAtUtc = timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record GitHubWebhookResult(GitHubWebhookProcessStatus Status, string Message);
|
||||
|
||||
public enum GitHubWebhookProcessStatus
|
||||
{
|
||||
Processed,
|
||||
Ignored,
|
||||
InvalidSignature,
|
||||
NotConfigured
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ using System.Security.Claims;
|
|||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
|
|
@ -26,7 +27,10 @@ public sealed class MembershipSynchronizationService(
|
|||
|
||||
var userAccount = await dbContext.UserAccounts
|
||||
.Include(x => x.MemberProfile)
|
||||
.Include(x => x.Membership)
|
||||
.Include(x => x.Membership!)
|
||||
.ThenInclude(x => x.RoleAssignments)
|
||||
.Include(x => x.Membership!)
|
||||
.ThenInclude(x => x.TierSnapshots)
|
||||
.SingleOrDefaultAsync(x => x.GitHubUserId == gitHubUserId, cancellationToken);
|
||||
|
||||
var invitation = await dbContext.MembershipInvitations
|
||||
|
|
@ -47,6 +51,7 @@ public sealed class MembershipSynchronizationService(
|
|||
LastSeenAtUtc = now,
|
||||
MemberProfile = new MemberProfile
|
||||
{
|
||||
Nickname = displayName ?? gitHubLogin,
|
||||
Headline = "New member record",
|
||||
UpdatedAtUtc = now
|
||||
},
|
||||
|
|
@ -70,6 +75,7 @@ public sealed class MembershipSynchronizationService(
|
|||
userAccount.MemberProfile ??= new MemberProfile
|
||||
{
|
||||
UserAccountId = userAccount.Id,
|
||||
Nickname = userAccount.DisplayName,
|
||||
Headline = "Imported from GitHub sign-in",
|
||||
UpdatedAtUtc = now
|
||||
};
|
||||
|
|
@ -86,6 +92,11 @@ public sealed class MembershipSynchronizationService(
|
|||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userAccount.MemberProfile!.Nickname))
|
||||
{
|
||||
userAccount.MemberProfile.Nickname = userAccount.DisplayName;
|
||||
}
|
||||
|
||||
if (invitation is not null && invitation.AcceptedByUserAccountId is null)
|
||||
{
|
||||
invitation.AcceptedByUserAccountId = userAccount.Id;
|
||||
|
|
@ -94,12 +105,16 @@ public sealed class MembershipSynchronizationService(
|
|||
|
||||
if (IsBootstrapAdmin(gitHubLogin, bootstrapOptions.Value))
|
||||
{
|
||||
userAccount.Membership!.Status = MembershipStatus.Active;
|
||||
userAccount.Membership.IsPlatformAdmin = true;
|
||||
userAccount.Membership.CanManageProjects = true;
|
||||
userAccount.Membership.CanManageLedger = true;
|
||||
userAccount.Membership.CanModerateMotions = true;
|
||||
userAccount.Membership.ApprovedAtUtc ??= now;
|
||||
PromoteToBootstrapAdmin(userAccount.Membership!, now);
|
||||
}
|
||||
else if (userAccount.Membership!.Status == MembershipStatus.Active)
|
||||
{
|
||||
ApplicationBootstrapper.EnsureRole(
|
||||
userAccount.Membership,
|
||||
MemberRole.StandardMember,
|
||||
null,
|
||||
now,
|
||||
"Default active member role");
|
||||
}
|
||||
|
||||
userAccount.MemberProfile!.UpdatedAtUtc = now;
|
||||
|
|
@ -107,6 +122,19 @@ public sealed class MembershipSynchronizationService(
|
|||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static void PromoteToBootstrapAdmin(Bifrost.Web.Domain.Membership membership, DateTimeOffset now)
|
||||
{
|
||||
membership.Status = MembershipStatus.Active;
|
||||
membership.IsPlatformAdmin = true;
|
||||
membership.CanManageProjects = true;
|
||||
membership.CanManageLedger = true;
|
||||
membership.CanModerateMotions = true;
|
||||
membership.ApprovedAtUtc ??= now;
|
||||
|
||||
ApplicationBootstrapper.EnsureRole(membership, MemberRole.PlatformAdmin, null, now, "Bootstrap admin");
|
||||
ApplicationBootstrapper.EnsureRole(membership, MemberRole.StandardMember, null, now, "Default active member role");
|
||||
}
|
||||
|
||||
private static bool TryReadGitHubIdentity(
|
||||
ClaimsPrincipal principal,
|
||||
out long gitHubUserId,
|
||||
|
|
|
|||
30
src/Bifrost.Web/Features/Motions/MotionCategoryPolicy.cs
Normal file
30
src/Bifrost.Web/Features/Motions/MotionCategoryPolicy.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
using Bifrost.Web.Domain;
|
||||
|
||||
namespace Bifrost.Web.Features.Motions;
|
||||
|
||||
public static class MotionCategoryPolicy
|
||||
{
|
||||
public static decimal GetThreshold(MotionCategory category) =>
|
||||
category switch
|
||||
{
|
||||
MotionCategory.Bugs => 0.15m,
|
||||
MotionCategory.Cosmetics => 0.30m,
|
||||
MotionCategory.BalanceChanges => 0.40m,
|
||||
MotionCategory.Features => 0.50m,
|
||||
MotionCategory.NewContent => 0.50m,
|
||||
MotionCategory.FundamentalDesignChanges => 0.66m,
|
||||
_ => 0.50m
|
||||
};
|
||||
|
||||
public static string GetLabel(MotionCategory category) =>
|
||||
category switch
|
||||
{
|
||||
MotionCategory.Bugs => "Bugs",
|
||||
MotionCategory.Cosmetics => "Cosmetics",
|
||||
MotionCategory.BalanceChanges => "Balance changes",
|
||||
MotionCategory.Features => "Features",
|
||||
MotionCategory.NewContent => "New content",
|
||||
MotionCategory.FundamentalDesignChanges => "Fundamental design changes",
|
||||
_ => category.ToString()
|
||||
};
|
||||
}
|
||||
81
src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs
Normal file
81
src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Features.Shared;
|
||||
|
||||
public sealed class ApplicationBootstrapper(
|
||||
BifrostDbContext dbContext,
|
||||
IOptions<BootstrapOptions> bootstrapOptions,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<ApplicationBootstrapper> logger)
|
||||
{
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (dbContext.Database.IsRelational() && bootstrapOptions.Value.ApplyMigrationsOnStartup)
|
||||
{
|
||||
logger.LogInformation("Applying database migrations on startup.");
|
||||
await dbContext.Database.MigrateAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var adminLogins = bootstrapOptions.Value.AdminGitHubLogins
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(x => x.Trim().ToUpperInvariant())
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
if (adminLogins.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var adminUsers = await dbContext.UserAccounts
|
||||
.Include(x => x.Membership!)
|
||||
.ThenInclude(x => x.RoleAssignments)
|
||||
.Where(x => adminLogins.Contains(x.NormalizedGitHubLogin))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var adminUser in adminUsers.Where(x => x.Membership is not null))
|
||||
{
|
||||
var membership = adminUser.Membership!;
|
||||
membership.Status = MembershipStatus.Active;
|
||||
membership.IsPlatformAdmin = true;
|
||||
membership.CanManageProjects = true;
|
||||
membership.CanManageLedger = true;
|
||||
membership.CanModerateMotions = true;
|
||||
membership.ApprovedAtUtc ??= now;
|
||||
|
||||
EnsureRole(membership, MemberRole.PlatformAdmin, null, now, "Bootstrap admin");
|
||||
EnsureRole(membership, MemberRole.StandardMember, null, now, "Default active member role");
|
||||
}
|
||||
|
||||
if (dbContext.ChangeTracker.HasChanges())
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public static void EnsureRole(
|
||||
Bifrost.Web.Domain.Membership membership,
|
||||
MemberRole role,
|
||||
Guid? actorUserAccountId,
|
||||
DateTimeOffset assignedAtUtc,
|
||||
string note)
|
||||
{
|
||||
if (membership.RoleAssignments.Any(x => x.Role == role))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
membership.RoleAssignments.Add(new RoleAssignment
|
||||
{
|
||||
Role = role,
|
||||
AssignedByUserAccountId = actorUserAccountId,
|
||||
AssignedAtUtc = assignedAtUtc,
|
||||
Notes = note
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -9,25 +9,50 @@ public sealed record CurrentBifrostActor(bool IsAuthenticated, UserAccount? User
|
|||
public Bifrost.Web.Domain.Membership? Membership => UserAccount?.Membership;
|
||||
|
||||
public string DisplayName =>
|
||||
UserAccount?.DisplayName
|
||||
UserAccount?.MemberProfile?.Nickname
|
||||
?? UserAccount?.DisplayName
|
||||
?? UserAccount?.GitHubLogin
|
||||
?? "Guest";
|
||||
|
||||
public bool IsActiveMember => Membership?.Status == MembershipStatus.Active;
|
||||
|
||||
public bool CanManageMembership => Membership?.IsPlatformAdmin == true;
|
||||
public IReadOnlyCollection<MemberRole> Roles =>
|
||||
Membership?.RoleAssignments
|
||||
.Select(x => x.Role)
|
||||
.Distinct()
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
public bool IsPlatformAdmin =>
|
||||
Membership?.IsPlatformAdmin == true ||
|
||||
Roles.Contains(MemberRole.PlatformAdmin);
|
||||
|
||||
public bool CanManageMembership => IsPlatformAdmin;
|
||||
|
||||
public bool CanManageProjects =>
|
||||
Membership?.IsPlatformAdmin == true ||
|
||||
Membership?.CanManageProjects == true;
|
||||
IsPlatformAdmin ||
|
||||
Membership?.CanManageProjects == true ||
|
||||
Roles.Contains(MemberRole.Producer);
|
||||
|
||||
public bool CanAssignWork =>
|
||||
CanManageProjects ||
|
||||
Roles.Contains(MemberRole.Maintainer);
|
||||
|
||||
public bool CanReviewWork =>
|
||||
IsPlatformAdmin ||
|
||||
Roles.Contains(MemberRole.Producer) ||
|
||||
Roles.Contains(MemberRole.Maintainer);
|
||||
|
||||
public bool CanManageLedger =>
|
||||
Membership?.IsPlatformAdmin == true ||
|
||||
Membership?.CanManageLedger == true;
|
||||
IsPlatformAdmin ||
|
||||
Membership?.CanManageLedger == true ||
|
||||
Roles.Contains(MemberRole.LedgerReviewer);
|
||||
|
||||
public bool CanModerateMotions =>
|
||||
Membership?.IsPlatformAdmin == true ||
|
||||
Membership?.CanModerateMotions == true;
|
||||
IsPlatformAdmin ||
|
||||
Membership?.CanModerateMotions == true ||
|
||||
Roles.Contains(MemberRole.Producer) ||
|
||||
Roles.Contains(MemberRole.Maintainer);
|
||||
|
||||
public decimal EffectiveVotingWeight => Membership?.EffectiveVotingWeight ?? 0m;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,10 @@ public sealed class CurrentBifrostActorAccessor(
|
|||
var userAccount = await dbContext.UserAccounts
|
||||
.AsNoTracking()
|
||||
.Include(x => x.MemberProfile)
|
||||
.Include(x => x.Membership)
|
||||
.Include(x => x.Membership!)
|
||||
.ThenInclude(x => x.RoleAssignments)
|
||||
.Include(x => x.Membership!)
|
||||
.ThenInclude(x => x.TierSnapshots)
|
||||
.SingleOrDefaultAsync(x => x.GitHubUserId == gitHubUserId, cancellationToken);
|
||||
|
||||
return Cache(httpContext, new CurrentBifrostActor(true, userAccount));
|
||||
|
|
|
|||
|
|
@ -8,16 +8,22 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro
|
|||
{
|
||||
public async Task<DashboardSnapshot> GetAsync(Guid? currentUserAccountId, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var recentWorkItems = await dbContext.WorkItems
|
||||
.AsNoTracking()
|
||||
.Include(x => x.Project)
|
||||
.Include(x => x.AssignedToUserAccount)
|
||||
.OrderByDescending(x => x.UpdatedAtUtc)
|
||||
.Take(5)
|
||||
.Take(6)
|
||||
.Select(x => new DashboardWorkItem(
|
||||
x.Id,
|
||||
x.Title,
|
||||
x.Project.Name,
|
||||
x.AssignedToUserAccount != null ? x.AssignedToUserAccount.DisplayName : "Unassigned",
|
||||
x.Status.ToString(),
|
||||
x.Id))
|
||||
x.EstimatedHours,
|
||||
x.WorkLogs.Sum(log => log.Hours)))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var openMotions = await dbContext.Motions
|
||||
|
|
@ -26,12 +32,14 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro
|
|||
.OrderBy(x => x.ClosesAtUtc)
|
||||
.Take(5)
|
||||
.Select(x => new DashboardMotion(
|
||||
x.Id,
|
||||
x.Title,
|
||||
x.Scope.ToString(),
|
||||
x.Category.ToString(),
|
||||
x.Votes.Sum(v => v.Choice == VoteChoice.For ? v.Weight : 0),
|
||||
x.Votes.Sum(v => v.Choice == VoteChoice.Against ? v.Weight : 0),
|
||||
x.ClosesAtUtc,
|
||||
x.Id))
|
||||
x.ApprovalThreshold,
|
||||
x.ClosesAtUtc))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var outstandingApprovals = await dbContext.Memberships
|
||||
|
|
@ -39,14 +47,58 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro
|
|||
.CountAsync(x => x.Status == MembershipStatus.PendingApproval, cancellationToken);
|
||||
|
||||
var assignedToMe = currentUserAccountId is null
|
||||
? 0
|
||||
? []
|
||||
: await dbContext.WorkItems
|
||||
.AsNoTracking()
|
||||
.CountAsync(
|
||||
x => x.AssignedToUserAccountId == currentUserAccountId &&
|
||||
x.Status != WorkItemStatus.Done &&
|
||||
x.Status != WorkItemStatus.Archived,
|
||||
cancellationToken);
|
||||
.Include(x => x.Project)
|
||||
.Where(x => x.AssignedToUserAccountId == currentUserAccountId &&
|
||||
x.Status != WorkItemStatus.Completed &&
|
||||
x.Status != WorkItemStatus.Archived)
|
||||
.OrderByDescending(x => x.UpdatedAtUtc)
|
||||
.Take(5)
|
||||
.Select(x => new DashboardLaneItem(x.Id, x.Title, x.Project.Name, x.Status.ToString(), x.TargetDateUtc))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var volunteeredByMe = currentUserAccountId is null
|
||||
? []
|
||||
: await dbContext.VolunteerClaims
|
||||
.AsNoTracking()
|
||||
.Where(x => x.UserAccountId == currentUserAccountId && x.Status == VolunteerClaimStatus.Active)
|
||||
.OrderByDescending(x => x.CreatedAtUtc)
|
||||
.Take(5)
|
||||
.Select(x => new DashboardLaneItem(
|
||||
x.WorkItemId,
|
||||
x.WorkItem.Title,
|
||||
x.WorkItem.Project.Name,
|
||||
x.WorkItem.Status.ToString(),
|
||||
x.WorkItem.TargetDateUtc))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var submittedByMe = currentUserAccountId is null
|
||||
? []
|
||||
: await dbContext.WorkItems
|
||||
.AsNoTracking()
|
||||
.Include(x => x.Project)
|
||||
.Where(x => x.AssignedToUserAccountId == currentUserAccountId &&
|
||||
(x.Status == WorkItemStatus.SubmittedForReview ||
|
||||
x.Status == WorkItemStatus.ChangesRequested ||
|
||||
x.Status == WorkItemStatus.Approved))
|
||||
.OrderByDescending(x => x.UpdatedAtUtc)
|
||||
.Take(5)
|
||||
.Select(x => new DashboardLaneItem(x.Id, x.Title, x.Project.Name, x.Status.ToString(), x.TargetDateUtc))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var recentActivity = await dbContext.AuditEvents
|
||||
.AsNoTracking()
|
||||
.Include(x => x.ActorUserAccount)
|
||||
.OrderByDescending(x => x.OccurredAtUtc)
|
||||
.Take(8)
|
||||
.Select(x => new DashboardActivity(
|
||||
x.Action,
|
||||
x.Detail,
|
||||
x.ActorUserAccount != null ? x.ActorUserAccount.DisplayName : "System",
|
||||
x.OccurredAtUtc))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var payoutPreviewNominal = await dbContext.LedgerEntries
|
||||
.AsNoTracking()
|
||||
|
|
@ -56,13 +108,19 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro
|
|||
return new DashboardSnapshot(
|
||||
ActiveMembers: await dbContext.Memberships.AsNoTracking().CountAsync(x => x.Status == MembershipStatus.Active, cancellationToken),
|
||||
OpenProjects: await dbContext.Projects.AsNoTracking().CountAsync(x => x.Status == ProjectStatus.Active, cancellationToken),
|
||||
OpenWorkItems: await dbContext.WorkItems.AsNoTracking().CountAsync(x => x.Status == WorkItemStatus.Open || x.Status == WorkItemStatus.Claimed || x.Status == WorkItemStatus.InProgress, cancellationToken),
|
||||
OpenMotions: await dbContext.Motions.AsNoTracking().CountAsync(x => x.Status == MotionStatus.Open && x.ClosesAtUtc >= timeProvider.GetUtcNow(), cancellationToken),
|
||||
OpenWorkItems: await dbContext.WorkItems.AsNoTracking().CountAsync(
|
||||
x => x.Status != WorkItemStatus.Completed && x.Status != WorkItemStatus.Archived,
|
||||
cancellationToken),
|
||||
OpenMotions: await dbContext.Motions.AsNoTracking().CountAsync(x => x.Status == MotionStatus.Open && x.ClosesAtUtc >= now, cancellationToken),
|
||||
PendingMembershipApprovals: outstandingApprovals,
|
||||
AssignedToCurrentMember: assignedToMe,
|
||||
AssignedToCurrentMember: assignedToMe.Count,
|
||||
ApprovedNominalPayoutValue: payoutPreviewNominal,
|
||||
RecentWorkItems: recentWorkItems,
|
||||
OpenMotionHighlights: openMotions);
|
||||
OpenMotionHighlights: openMotions,
|
||||
MyAssignedWork: assignedToMe,
|
||||
MyVolunteeredWork: volunteeredByMe,
|
||||
MySubmittedWork: submittedByMe,
|
||||
RecentActivity: recentActivity);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -75,18 +133,40 @@ public sealed record DashboardSnapshot(
|
|||
int AssignedToCurrentMember,
|
||||
decimal ApprovedNominalPayoutValue,
|
||||
IReadOnlyList<DashboardWorkItem> RecentWorkItems,
|
||||
IReadOnlyList<DashboardMotion> OpenMotionHighlights);
|
||||
IReadOnlyList<DashboardMotion> OpenMotionHighlights,
|
||||
IReadOnlyList<DashboardLaneItem> MyAssignedWork,
|
||||
IReadOnlyList<DashboardLaneItem> MyVolunteeredWork,
|
||||
IReadOnlyList<DashboardLaneItem> MySubmittedWork,
|
||||
IReadOnlyList<DashboardActivity> RecentActivity);
|
||||
|
||||
public sealed record DashboardWorkItem(
|
||||
Guid WorkItemId,
|
||||
string Title,
|
||||
string ProjectName,
|
||||
string AssigneeName,
|
||||
string Status,
|
||||
decimal EstimatedHours,
|
||||
decimal ActualHours);
|
||||
|
||||
public sealed record DashboardMotion(
|
||||
Guid MotionId,
|
||||
string Title,
|
||||
string Scope,
|
||||
string Category,
|
||||
decimal VotesFor,
|
||||
decimal VotesAgainst,
|
||||
decimal Threshold,
|
||||
DateTimeOffset ClosesAtUtc);
|
||||
|
||||
public sealed record DashboardLaneItem(
|
||||
Guid WorkItemId,
|
||||
string Title,
|
||||
string ProjectName,
|
||||
string Status,
|
||||
Guid WorkItemId);
|
||||
DateTimeOffset? TargetDateUtc);
|
||||
|
||||
public sealed record DashboardMotion(
|
||||
string Title,
|
||||
string Scope,
|
||||
decimal VotesFor,
|
||||
decimal VotesAgainst,
|
||||
DateTimeOffset ClosesAtUtc,
|
||||
Guid MotionId);
|
||||
public sealed record DashboardActivity(
|
||||
string Action,
|
||||
string Detail,
|
||||
string ActorName,
|
||||
DateTimeOffset OccurredAtUtc);
|
||||
|
|
|
|||
60
src/Bifrost.Web/Features/Shared/ReadinessService.cs
Normal file
60
src/Bifrost.Web/Features/Shared/ReadinessService.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Features.Shared;
|
||||
|
||||
public sealed class ReadinessService(
|
||||
BifrostDbContext dbContext,
|
||||
IConfiguration configuration,
|
||||
IOptions<GitHubOAuthOptions> gitHubOAuthOptions,
|
||||
IOptions<GitHubAppOptions> gitHubAppOptions,
|
||||
IOptions<BifrostHostOptions> hostOptions)
|
||||
{
|
||||
public async Task<ReadinessReport> GetReportAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var failures = new List<string>();
|
||||
|
||||
if (!gitHubOAuthOptions.Value.IsConfigured)
|
||||
{
|
||||
failures.Add("GitHub OAuth is not configured.");
|
||||
}
|
||||
|
||||
if (gitHubAppOptions.Value.EnableWebhookSync && !gitHubAppOptions.Value.IsWebhookConfigured)
|
||||
{
|
||||
failures.Add("GitHub App webhook sync is enabled but the webhook secret is missing.");
|
||||
}
|
||||
|
||||
if (!hostOptions.Value.IsConfigured)
|
||||
{
|
||||
failures.Add("Host public base URL or expected host is missing.");
|
||||
}
|
||||
|
||||
var connectionString = configuration.GetConnectionString("Bifrost");
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
failures.Add("Connection string 'Bifrost' is missing.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (dbContext.Database.IsRelational())
|
||||
{
|
||||
var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken);
|
||||
if (!canConnect)
|
||||
{
|
||||
failures.Add("Database connectivity check failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
failures.Add($"Database readiness check failed: {exception.Message}");
|
||||
}
|
||||
|
||||
return new ReadinessReport(failures.Count == 0, failures);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ReadinessReport(bool IsReady, IReadOnlyList<string> Failures);
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
using Bifrost.Web.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Features.Shared;
|
||||
|
||||
public sealed class StartupConfigurationValidator(
|
||||
IConfiguration configuration,
|
||||
IHostEnvironment hostEnvironment,
|
||||
IOptions<GitHubOAuthOptions> gitHubOAuthOptions,
|
||||
IOptions<GitHubAppOptions> gitHubAppOptions,
|
||||
IOptions<BifrostHostOptions> hostOptions)
|
||||
{
|
||||
public void Validate()
|
||||
{
|
||||
if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var failures = new List<string>();
|
||||
|
||||
if (!gitHubOAuthOptions.Value.IsConfigured)
|
||||
{
|
||||
failures.Add("GitHub OAuth client configuration is required outside development.");
|
||||
}
|
||||
|
||||
if (gitHubAppOptions.Value.EnableWebhookSync && !gitHubAppOptions.Value.IsConfigured)
|
||||
{
|
||||
failures.Add("GitHub App configuration is required when webhook sync is enabled.");
|
||||
}
|
||||
|
||||
if (!hostOptions.Value.IsConfigured)
|
||||
{
|
||||
failures.Add("Host:PublicBaseUrl and Host:ExpectedHost must be configured.");
|
||||
}
|
||||
|
||||
var allowedHosts = configuration["AllowedHosts"] ?? string.Empty;
|
||||
if (hostOptions.Value.RequireStrictHostValidation &&
|
||||
(string.IsNullOrWhiteSpace(allowedHosts) || allowedHosts.Contains('*')))
|
||||
{
|
||||
failures.Add("AllowedHosts must be explicitly configured for production.");
|
||||
}
|
||||
|
||||
var connectionString = configuration.GetConnectionString("Bifrost");
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
failures.Add("Connection string 'Bifrost' must be configured.");
|
||||
}
|
||||
|
||||
if (failures.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Bifrost startup configuration is incomplete: " + string.Join(" ", failures));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,8 +10,8 @@
|
|||
<span class="eyebrow">Member console</span>
|
||||
<h1>Operational picture for @Model.Actor.DisplayName</h1>
|
||||
<p class="panel-intro">
|
||||
This view ties together the slices we care about first: who can participate,
|
||||
what work is moving, what motions are open, and what ledger value is accruing.
|
||||
This is the alpha control room: personal lanes for your work, a shared operational picture,
|
||||
governance pressure, and recent team activity in one place.
|
||||
</p>
|
||||
</div>
|
||||
<button class="button secondary"
|
||||
|
|
@ -27,24 +27,88 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-grid">
|
||||
<section class="panel-grid" style="margin-top: 1.5rem;">
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Recent work</h2>
|
||||
<p class="muted">Latest work item movement across projects.</p>
|
||||
<h2>My assigned work</h2>
|
||||
<p class="muted">What is currently on your plate.</p>
|
||||
</div>
|
||||
<a class="button ghost" asp-page="/App/WorkItems/Index">Manage work items</a>
|
||||
<a class="button ghost" asp-page="/App/WorkItems/Index">Open work board</a>
|
||||
</div>
|
||||
|
||||
@if (Model.Snapshot.RecentWorkItems.Count == 0)
|
||||
@if (Model.Snapshot.MyAssignedWork.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No work items exist yet.</div>
|
||||
<div class="empty-state">Nothing is currently assigned to you.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="list-grid">
|
||||
@foreach (var item in Model.Snapshot.RecentWorkItems)
|
||||
@foreach (var item in Model.Snapshot.MyAssignedWork)
|
||||
{
|
||||
<div class="status-banner">
|
||||
<strong>@item.Title</strong>
|
||||
<span class="muted">@item.ProjectName</span>
|
||||
<div class="inline-actions">
|
||||
<span class="pill">@item.Status</span>
|
||||
@if (item.TargetDateUtc is not null)
|
||||
{
|
||||
<span class="pill warning">Due @item.TargetDateUtc.Value.ToString("dd MMM")</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>My submitted work</h2>
|
||||
<p class="muted">Items waiting on review or closure.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.Snapshot.MySubmittedWork.Count == 0)
|
||||
{
|
||||
<div class="empty-state">Nothing of yours is waiting on review.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="list-grid">
|
||||
@foreach (var item in Model.Snapshot.MySubmittedWork)
|
||||
{
|
||||
<div class="status-banner">
|
||||
<strong>@item.Title</strong>
|
||||
<span class="muted">@item.ProjectName</span>
|
||||
<div class="inline-actions">
|
||||
<span class="pill">@item.Status</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel-grid" style="margin-top: 1.5rem;">
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>My volunteered work</h2>
|
||||
<p class="muted">Things you raised your hand for but are not fully assigned yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.Snapshot.MyVolunteeredWork.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No active volunteer claims right now.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="list-grid">
|
||||
@foreach (var item in Model.Snapshot.MyVolunteeredWork)
|
||||
{
|
||||
<div class="status-banner">
|
||||
<strong>@item.Title</strong>
|
||||
|
|
@ -62,7 +126,7 @@
|
|||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Open motions</h2>
|
||||
<p class="muted">A quick read on governance traffic and current vote pressure.</p>
|
||||
<p class="muted">A quick read on governance traffic and threshold pressure.</p>
|
||||
</div>
|
||||
<a class="button ghost" asp-page="/App/Motions/Index">Review motions</a>
|
||||
</div>
|
||||
|
|
@ -80,10 +144,75 @@
|
|||
<h3>@motion.Title</h3>
|
||||
<div class="inline-actions">
|
||||
<span class="pill">@motion.Scope</span>
|
||||
<span class="pill">@motion.Category</span>
|
||||
<span class="pill success">For @motion.VotesFor</span>
|
||||
<span class="pill danger">Against @motion.VotesAgainst</span>
|
||||
</div>
|
||||
<p class="muted">Closes @motion.ClosesAtUtc.ToString("dd MMM yyyy HH:mm") UTC</p>
|
||||
<p class="muted">
|
||||
Threshold @motion.Threshold.ToString("P0")
|
||||
· closes @motion.ClosesAtUtc.ToString("dd MMM yyyy HH:mm") UTC
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel-grid" style="margin-top: 1.5rem;">
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Recent work</h2>
|
||||
<p class="muted">Latest movement across the shared work board.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.Snapshot.RecentWorkItems.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No work items exist yet.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="list-grid">
|
||||
@foreach (var item in Model.Snapshot.RecentWorkItems)
|
||||
{
|
||||
<div class="status-banner">
|
||||
<strong>@item.Title</strong>
|
||||
<span class="muted">@item.ProjectName</span>
|
||||
<div class="inline-actions">
|
||||
<span class="pill">@item.Status</span>
|
||||
<span class="pill">@item.AssigneeName</span>
|
||||
<span class="pill">@item.ActualHours.ToString("0.##") / @item.EstimatedHours.ToString("0.##")h</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Recent activity</h2>
|
||||
<p class="muted">Audit trail excerpts across membership, work, motions, and ledger changes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.Snapshot.RecentActivity.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No team activity yet.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="list-grid">
|
||||
@foreach (var activity in Model.Snapshot.RecentActivity)
|
||||
{
|
||||
<div class="status-banner">
|
||||
<strong>@activity.ActorName</strong>
|
||||
<span class="muted">@activity.Action</span>
|
||||
<div class="muted">@activity.Detail</div>
|
||||
<div class="muted">@activity.OccurredAtUtc.ToString("dd MMM yyyy HH:mm") UTC</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ public sealed class IndexModel(
|
|||
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
|
||||
|
||||
public DashboardSnapshot Snapshot { get; private set; } =
|
||||
new(0, 0, 0, 0, 0, 0, 0m, [], []);
|
||||
new(0, 0, 0, 0, 0, 0, 0m, [], [], [], [], [], []);
|
||||
|
||||
public async Task OnGetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,58 +13,72 @@
|
|||
payout review possible later, with explicit status and enough context to audit the trail.
|
||||
</p>
|
||||
|
||||
<form method="post" asp-page-handler="Create">
|
||||
<div asp-validation-summary="ModelOnly" class="validation-summary-errors"></div>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label asp-for="Input.UserAccountId"></label>
|
||||
<select asp-for="Input.UserAccountId" asp-items="Model.MemberOptions"></select>
|
||||
<span asp-validation-for="Input.UserAccountId"></span>
|
||||
<div class="callout">
|
||||
Post-alpha economics automation is planned separately. The schema now leaves room for point transactions,
|
||||
patron support events, weekly decay runs, revenue events, and revenue-share batches without making them launch blockers.
|
||||
</div>
|
||||
|
||||
@if (Model.Actor.CanManageLedger)
|
||||
{
|
||||
<form method="post" asp-page-handler="Create">
|
||||
<div asp-validation-summary="ModelOnly" class="validation-summary-errors"></div>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label asp-for="Input.UserAccountId"></label>
|
||||
<select asp-for="Input.UserAccountId" asp-items="Model.MemberOptions"></select>
|
||||
<span asp-validation-for="Input.UserAccountId"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.EntryType"></label>
|
||||
<select asp-for="Input.EntryType" asp-items="Html.GetEnumSelectList<LedgerEntryType>()"></select>
|
||||
<span asp-validation-for="Input.EntryType"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.Status"></label>
|
||||
<select asp-for="Input.Status" asp-items="Html.GetEnumSelectList<LedgerEntryStatus>()"></select>
|
||||
<span asp-validation-for="Input.Status"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.ProjectId"></label>
|
||||
<select asp-for="Input.ProjectId" asp-items="Model.ProjectOptions">
|
||||
<option value="">No project</option>
|
||||
</select>
|
||||
<span asp-validation-for="Input.ProjectId"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.WorkItemId"></label>
|
||||
<select asp-for="Input.WorkItemId" asp-items="Model.WorkItemOptions">
|
||||
<option value="">No work item</option>
|
||||
</select>
|
||||
<span asp-validation-for="Input.WorkItemId"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.Points"></label>
|
||||
<input asp-for="Input.Points" />
|
||||
<span asp-validation-for="Input.Points"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.NominalAmount"></label>
|
||||
<input asp-for="Input.NominalAmount" />
|
||||
<span asp-validation-for="Input.NominalAmount"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.EntryType"></label>
|
||||
<select asp-for="Input.EntryType" asp-items="Html.GetEnumSelectList<LedgerEntryType>()"></select>
|
||||
<span asp-validation-for="Input.EntryType"></span>
|
||||
<label asp-for="Input.Note"></label>
|
||||
<textarea asp-for="Input.Note"></textarea>
|
||||
<span asp-validation-for="Input.Note"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.Status"></label>
|
||||
<select asp-for="Input.Status" asp-items="Html.GetEnumSelectList<LedgerEntryStatus>()"></select>
|
||||
<span asp-validation-for="Input.Status"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.ProjectId"></label>
|
||||
<select asp-for="Input.ProjectId" asp-items="Model.ProjectOptions">
|
||||
<option value="">No project</option>
|
||||
</select>
|
||||
<span asp-validation-for="Input.ProjectId"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.WorkItemId"></label>
|
||||
<select asp-for="Input.WorkItemId" asp-items="Model.WorkItemOptions">
|
||||
<option value="">No work item</option>
|
||||
</select>
|
||||
<span asp-validation-for="Input.WorkItemId"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.Points"></label>
|
||||
<input asp-for="Input.Points" />
|
||||
<span asp-validation-for="Input.Points"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.NominalAmount"></label>
|
||||
<input asp-for="Input.NominalAmount" />
|
||||
<span asp-validation-for="Input.NominalAmount"></span>
|
||||
<div class="panel-actions">
|
||||
<button class="button" type="submit">Record entry</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="callout warning">
|
||||
Ledger review actions are limited to finance reviewers and platform admins, but the ledger remains visible to all active members.
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.Note"></label>
|
||||
<textarea asp-for="Input.Note"></textarea>
|
||||
<span asp-validation-for="Input.Note"></span>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<button class="button" type="submit">Record entry</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ public sealed class IndexModel(
|
|||
public async Task<IActionResult> OnPostCreateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
if (!Actor.CanManageLedger || Actor.UserAccount is null)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
|
|
@ -51,7 +55,7 @@ public sealed class IndexModel(
|
|||
UserAccountId = Input.UserAccountId,
|
||||
ProjectId = Input.ProjectId,
|
||||
WorkItemId = Input.WorkItemId,
|
||||
CreatedByUserAccountId = Actor.UserAccount!.Id,
|
||||
CreatedByUserAccountId = Actor.UserAccount.Id,
|
||||
EntryType = Input.EntryType,
|
||||
Status = Input.Status,
|
||||
Points = Input.Points,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
@page
|
||||
@using Bifrost.Web.Domain
|
||||
@model Bifrost.Web.Pages.App.Members.IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "Members";
|
||||
|
|
@ -37,73 +38,196 @@
|
|||
else
|
||||
{
|
||||
<div class="callout warning">
|
||||
Only platform admins can issue invites or approve members.
|
||||
Only platform admins can issue invites, approve members, or manage tier snapshots.
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="table-panel">
|
||||
<div class="panel-header" style="padding: 1.25rem 1.25rem 0;">
|
||||
<div>
|
||||
<h2>Current member records</h2>
|
||||
<p class="table-meta">@Model.Users.Count authenticated GitHub identities tracked.</p>
|
||||
</div>
|
||||
</div>
|
||||
<article class="panel">
|
||||
<span class="eyebrow">My profile</span>
|
||||
<h2>Work allocation context</h2>
|
||||
<p class="panel-intro">
|
||||
Keep your profile current so producers and maintainers can see your skills, availability, and links at a glance.
|
||||
</p>
|
||||
|
||||
@if (Model.Users.Count == 0)
|
||||
{
|
||||
<div class="empty-state" style="margin: 1.25rem;">No member records yet.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-shell">
|
||||
<table>
|
||||
<thead>
|
||||
<form method="post" asp-page-handler="UpdateProfile">
|
||||
<div asp-validation-summary="ModelOnly" class="validation-summary-errors"></div>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label asp-for="Profile.DisplayName"></label>
|
||||
<input asp-for="Profile.DisplayName" />
|
||||
<span asp-validation-for="Profile.DisplayName"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Profile.Nickname"></label>
|
||||
<input asp-for="Profile.Nickname" />
|
||||
<span asp-validation-for="Profile.Nickname"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Profile.Headline"></label>
|
||||
<input asp-for="Profile.Headline" />
|
||||
<span asp-validation-for="Profile.Headline"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Profile.PortfolioUrl"></label>
|
||||
<input asp-for="Profile.PortfolioUrl" />
|
||||
<span asp-validation-for="Profile.PortfolioUrl"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Profile.Skills"></label>
|
||||
<textarea asp-for="Profile.Skills"></textarea>
|
||||
<span asp-validation-for="Profile.Skills"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Profile.Availability"></label>
|
||||
<input asp-for="Profile.Availability" />
|
||||
<span asp-validation-for="Profile.Availability"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Profile.Notes"></label>
|
||||
<textarea asp-for="Profile.Notes"></textarea>
|
||||
<span asp-validation-for="Profile.Notes"></span>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<button class="button" type="submit">Update profile</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="table-panel" style="margin-top: 1.5rem;">
|
||||
<div class="panel-header" style="padding: 1.25rem 1.25rem 0;">
|
||||
<div>
|
||||
<h2>Current member records</h2>
|
||||
<p class="table-meta">@Model.Users.Count authenticated GitHub identities tracked.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.Users.Count == 0)
|
||||
{
|
||||
<div class="empty-state" style="margin: 1.25rem;">No member records yet.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-shell">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Status</th>
|
||||
<th>Profile</th>
|
||||
<th>Roles and tiers</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var user in Model.Users)
|
||||
{
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Status</th>
|
||||
<th>Permissions</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var user in Model.Users)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<strong>@user.DisplayName</strong><br />
|
||||
<span class="muted">@user.GitHubLogin</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="pill">@user.Membership!.Status</span>
|
||||
</td>
|
||||
<td>
|
||||
@(user.Membership.IsPlatformAdmin ? "Platform admin" : "Member")
|
||||
<div class="muted">
|
||||
Projects @(user.Membership.CanManageProjects ? "on" : "off"),
|
||||
ledger @(user.Membership.CanManageLedger ? "on" : "off")
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if (Model.Actor.CanManageMembership && user.Membership.Status != MembershipStatus.Active)
|
||||
<td>
|
||||
<strong>@user.DisplayName</strong><br />
|
||||
<span class="muted">@user.GitHubLogin</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="pill">@user.Status</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrWhiteSpace(user.Nickname))
|
||||
{
|
||||
<div><strong>@user.Nickname</strong></div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(user.Headline))
|
||||
{
|
||||
<div class="muted">@user.Headline</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(user.Skills))
|
||||
{
|
||||
<div class="muted">@user.Skills</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(user.Availability))
|
||||
{
|
||||
<div class="muted">Availability: @user.Availability</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(user.PortfolioUrl))
|
||||
{
|
||||
<div><a href="@user.PortfolioUrl" target="_blank" rel="noreferrer">Portfolio</a></div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="inline-actions">
|
||||
@if (user.Roles.Count == 0)
|
||||
{
|
||||
<form method="post" asp-page-handler="Approve">
|
||||
<input type="hidden" name="userAccountId" value="@user.Id" />
|
||||
<button class="button secondary" type="submit">Approve</button>
|
||||
</form>
|
||||
<span class="pill">No roles yet</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="muted">No action</span>
|
||||
@foreach (var role in user.Roles)
|
||||
{
|
||||
<span class="pill">@role</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
</div>
|
||||
<div class="muted" style="margin-top: 0.6rem;">
|
||||
Patron: @(string.IsNullOrWhiteSpace(user.PatronTierLabel) ? "none" : $"{user.PatronTierLabel} ({user.PatronTierWeight:0.##})")
|
||||
</div>
|
||||
<div class="muted">
|
||||
Contributor: @(string.IsNullOrWhiteSpace(user.ContributorTierLabel) ? "none" : $"{user.ContributorTierLabel} ({user.ContributorTierWeight:0.##})")
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if (Model.Actor.CanManageMembership && user.Status != MembershipStatus.Active)
|
||||
{
|
||||
<form method="post" asp-page-handler="Approve">
|
||||
<input type="hidden" name="userAccountId" value="@user.UserAccountId" />
|
||||
<button class="button secondary" type="submit">Approve</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
@if (Model.Actor.CanManageMembership)
|
||||
{
|
||||
<div class="stack-form" style="margin-top: 0.8rem;">
|
||||
@foreach (var role in Enum.GetValues<MemberRole>())
|
||||
{
|
||||
<form method="post" asp-page-handler="ToggleRole">
|
||||
<input type="hidden" name="userAccountId" value="@user.UserAccountId" />
|
||||
<input type="hidden" name="role" value="@role" />
|
||||
<button class="button @(user.Roles.Contains(role) ? "warm" : "ghost")" type="submit">
|
||||
@role
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
|
||||
<form method="post" asp-page-handler="SetTier" class="stack-form" style="margin-top: 0.8rem;">
|
||||
<input type="hidden" name="userAccountId" value="@user.UserAccountId" />
|
||||
<input type="hidden" name="kind" value="@TierSnapshotKind.Patron" />
|
||||
<input name="label" value="@user.PatronTierLabel" placeholder="Patron tier label" />
|
||||
<input name="weight" value="@user.PatronTierWeight.ToString("0.##")" placeholder="Weight" />
|
||||
<input name="notes" placeholder="Notes" />
|
||||
<button class="button ghost" type="submit">Save patron tier</button>
|
||||
</form>
|
||||
|
||||
<form method="post" asp-page-handler="SetTier" class="stack-form" style="margin-top: 0.6rem;">
|
||||
<input type="hidden" name="userAccountId" value="@user.UserAccountId" />
|
||||
<input type="hidden" name="kind" value="@TierSnapshotKind.Contributor" />
|
||||
<input name="label" value="@user.ContributorTierLabel" placeholder="Contributor tier label" />
|
||||
<input name="weight" value="@user.ContributorTierWeight.ToString("0.##")" placeholder="Weight" />
|
||||
<input name="notes" placeholder="Notes" />
|
||||
<button class="button ghost" type="submit">Save contributor tier</button>
|
||||
</form>
|
||||
}
|
||||
else if (user.Status == MembershipStatus.Active)
|
||||
{
|
||||
<span class="muted">Visible to all active members.</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="table-panel" style="margin-top: 1.5rem;">
|
||||
|
|
|
|||
|
|
@ -17,9 +17,12 @@ public sealed class IndexModel(
|
|||
[BindProperty]
|
||||
public InviteMemberInput Input { get; set; } = new();
|
||||
|
||||
[BindProperty]
|
||||
public UpdateProfileInput Profile { get; set; } = new();
|
||||
|
||||
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
|
||||
|
||||
public IReadOnlyList<UserAccount> Users { get; private set; } = [];
|
||||
public IReadOnlyList<MemberDirectoryItem> Users { get; private set; } = [];
|
||||
|
||||
public IReadOnlyList<MembershipInvitation> Invitations { get; private set; } = [];
|
||||
|
||||
|
|
@ -31,7 +34,7 @@ public sealed class IndexModel(
|
|||
public async Task<IActionResult> OnPostInviteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
if (!Actor.CanManageMembership)
|
||||
if (!Actor.CanManageMembership || Actor.UserAccount is null)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
|
@ -52,7 +55,7 @@ public sealed class IndexModel(
|
|||
{
|
||||
GitHubLogin = Input.GitHubLogin.Trim(),
|
||||
NormalizedGitHubLogin = normalizedLogin,
|
||||
IssuedByUserAccountId = Actor.UserAccount!.Id,
|
||||
IssuedByUserAccountId = Actor.UserAccount.Id,
|
||||
IssuedAtUtc = timeProvider.GetUtcNow(),
|
||||
Notes = Input.Notes.Trim()
|
||||
};
|
||||
|
|
@ -76,7 +79,7 @@ public sealed class IndexModel(
|
|||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await auditTrailService.RecordAsync(
|
||||
Actor.UserAccount!.Id,
|
||||
Actor.UserAccount.Id,
|
||||
nameof(MembershipInvitation),
|
||||
invitation.Id,
|
||||
"membership.invited",
|
||||
|
|
@ -89,21 +92,29 @@ public sealed class IndexModel(
|
|||
public async Task<IActionResult> OnPostApproveAsync(Guid userAccountId, CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
if (!Actor.CanManageMembership)
|
||||
if (!Actor.CanManageMembership || Actor.UserAccount is null)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var user = await dbContext.UserAccounts
|
||||
.Include(x => x.Membership)
|
||||
.Include(x => x.Membership!)
|
||||
.ThenInclude(x => x.RoleAssignments)
|
||||
.SingleAsync(x => x.Id == userAccountId, cancellationToken);
|
||||
|
||||
user.Membership!.Status = MembershipStatus.Active;
|
||||
user.Membership.ApprovedAtUtc = timeProvider.GetUtcNow();
|
||||
ApplicationBootstrapper.EnsureRole(
|
||||
user.Membership,
|
||||
MemberRole.StandardMember,
|
||||
Actor.UserAccount.Id,
|
||||
timeProvider.GetUtcNow(),
|
||||
"Approved into the member alpha.");
|
||||
ApplyMembershipFlags(user.Membership);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await auditTrailService.RecordAsync(
|
||||
Actor.UserAccount!.Id,
|
||||
Actor.UserAccount.Id,
|
||||
nameof(Membership),
|
||||
user.Membership.Id,
|
||||
"membership.approved",
|
||||
|
|
@ -113,21 +124,238 @@ public sealed class IndexModel(
|
|||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostToggleRoleAsync(Guid userAccountId, MemberRole role, CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
if (!Actor.CanManageMembership || Actor.UserAccount is null)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var user = await dbContext.UserAccounts
|
||||
.Include(x => x.Membership!)
|
||||
.ThenInclude(x => x.RoleAssignments)
|
||||
.SingleAsync(x => x.Id == userAccountId, cancellationToken);
|
||||
|
||||
if (user.Membership is null)
|
||||
{
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
var existingAssignment = user.Membership.RoleAssignments.SingleOrDefault(x => x.Role == role);
|
||||
if (existingAssignment is null)
|
||||
{
|
||||
user.Membership.RoleAssignments.Add(new RoleAssignment
|
||||
{
|
||||
Role = role,
|
||||
AssignedByUserAccountId = Actor.UserAccount.Id,
|
||||
AssignedAtUtc = timeProvider.GetUtcNow(),
|
||||
Notes = "Assigned from the member admin page."
|
||||
});
|
||||
}
|
||||
else if (!(user.Id == Actor.UserAccount.Id && role == MemberRole.PlatformAdmin))
|
||||
{
|
||||
dbContext.RoleAssignments.Remove(existingAssignment);
|
||||
}
|
||||
|
||||
if (user.Membership.Status == MembershipStatus.Active)
|
||||
{
|
||||
ApplicationBootstrapper.EnsureRole(
|
||||
user.Membership,
|
||||
MemberRole.StandardMember,
|
||||
Actor.UserAccount.Id,
|
||||
timeProvider.GetUtcNow(),
|
||||
"Default active member role.");
|
||||
}
|
||||
|
||||
ApplyMembershipFlags(user.Membership);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await auditTrailService.RecordAsync(
|
||||
Actor.UserAccount.Id,
|
||||
nameof(Membership),
|
||||
user.Membership.Id,
|
||||
"membership.role-toggled",
|
||||
$"Toggled role {role} for {user.DisplayName}.",
|
||||
cancellationToken);
|
||||
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostSetTierAsync(
|
||||
Guid userAccountId,
|
||||
TierSnapshotKind kind,
|
||||
string label,
|
||||
decimal weight,
|
||||
string notes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
if (!Actor.CanManageMembership || Actor.UserAccount is null)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var user = await dbContext.UserAccounts
|
||||
.Include(x => x.Membership!)
|
||||
.ThenInclude(x => x.TierSnapshots)
|
||||
.SingleAsync(x => x.Id == userAccountId, cancellationToken);
|
||||
|
||||
if (user.Membership is null)
|
||||
{
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
foreach (var snapshot in user.Membership.TierSnapshots.Where(x => x.Kind == kind && x.IsCurrent))
|
||||
{
|
||||
snapshot.IsCurrent = false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(label) || weight > 0)
|
||||
{
|
||||
user.Membership.TierSnapshots.Add(new TierSnapshot
|
||||
{
|
||||
Kind = kind,
|
||||
Label = string.IsNullOrWhiteSpace(label) ? "Tier snapshot" : label.Trim(),
|
||||
Weight = weight,
|
||||
IsCurrent = true,
|
||||
EffectiveFromUtc = now,
|
||||
CapturedAtUtc = now,
|
||||
Notes = notes.Trim()
|
||||
});
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await auditTrailService.RecordAsync(
|
||||
Actor.UserAccount.Id,
|
||||
nameof(TierSnapshot),
|
||||
user.Membership.Id,
|
||||
"membership.tier-updated",
|
||||
$"Updated {kind} tier snapshot for {user.DisplayName}.",
|
||||
cancellationToken);
|
||||
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostUpdateProfileAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
if (Actor.UserAccount is null)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await LoadAsync(cancellationToken);
|
||||
return Page();
|
||||
}
|
||||
|
||||
var user = await dbContext.UserAccounts
|
||||
.Include(x => x.MemberProfile)
|
||||
.SingleAsync(x => x.Id == Actor.UserAccount.Id, cancellationToken);
|
||||
|
||||
user.DisplayName = Profile.DisplayName.Trim();
|
||||
user.MemberProfile ??= new MemberProfile
|
||||
{
|
||||
UserAccountId = user.Id,
|
||||
UpdatedAtUtc = timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
user.MemberProfile.Nickname = Profile.Nickname.Trim();
|
||||
user.MemberProfile.Headline = Profile.Headline.Trim();
|
||||
user.MemberProfile.PortfolioUrl = Profile.PortfolioUrl.Trim();
|
||||
user.MemberProfile.Skills = Profile.Skills.Trim();
|
||||
user.MemberProfile.Availability = Profile.Availability.Trim();
|
||||
user.MemberProfile.Notes = Profile.Notes.Trim();
|
||||
user.MemberProfile.UpdatedAtUtc = timeProvider.GetUtcNow();
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await auditTrailService.RecordAsync(
|
||||
Actor.UserAccount.Id,
|
||||
nameof(MemberProfile),
|
||||
user.MemberProfile.Id,
|
||||
"member-profile.updated",
|
||||
$"Updated member profile for {user.DisplayName}.",
|
||||
cancellationToken);
|
||||
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
private async Task LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
|
||||
Users = await dbContext.UserAccounts
|
||||
var users = await dbContext.UserAccounts
|
||||
.AsNoTracking()
|
||||
.Include(x => x.Membership)
|
||||
.Include(x => x.MemberProfile)
|
||||
.Include(x => x.Membership!)
|
||||
.ThenInclude(x => x.RoleAssignments)
|
||||
.Include(x => x.Membership!)
|
||||
.ThenInclude(x => x.TierSnapshots)
|
||||
.OrderBy(x => x.DisplayName)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
Users = users.Select(user =>
|
||||
{
|
||||
var patronTier = user.Membership?.TierSnapshots
|
||||
.Where(x => x.Kind == TierSnapshotKind.Patron && x.IsCurrent)
|
||||
.OrderByDescending(x => x.CapturedAtUtc)
|
||||
.FirstOrDefault();
|
||||
var contributorTier = user.Membership?.TierSnapshots
|
||||
.Where(x => x.Kind == TierSnapshotKind.Contributor && x.IsCurrent)
|
||||
.OrderByDescending(x => x.CapturedAtUtc)
|
||||
.FirstOrDefault();
|
||||
|
||||
return new MemberDirectoryItem(
|
||||
user.Id,
|
||||
user.DisplayName,
|
||||
user.GitHubLogin,
|
||||
user.MemberProfile?.Nickname ?? string.Empty,
|
||||
user.MemberProfile?.Headline ?? string.Empty,
|
||||
user.MemberProfile?.PortfolioUrl ?? string.Empty,
|
||||
user.MemberProfile?.Skills ?? string.Empty,
|
||||
user.MemberProfile?.Availability ?? string.Empty,
|
||||
user.Membership?.Status ?? MembershipStatus.Authenticated,
|
||||
user.Membership?.RoleAssignments.Select(x => x.Role).OrderBy(x => x.ToString()).ToArray() ?? [],
|
||||
patronTier?.Label ?? string.Empty,
|
||||
patronTier?.Weight ?? 0m,
|
||||
contributorTier?.Label ?? string.Empty,
|
||||
contributorTier?.Weight ?? 0m);
|
||||
}).ToList();
|
||||
|
||||
Invitations = await dbContext.MembershipInvitations
|
||||
.AsNoTracking()
|
||||
.Where(x => x.RevokedAtUtc == null)
|
||||
.OrderByDescending(x => x.IssuedAtUtc)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (Actor.UserAccount is not null)
|
||||
{
|
||||
var currentUser = users.SingleOrDefault(x => x.Id == Actor.UserAccount.Id);
|
||||
if (currentUser is not null)
|
||||
{
|
||||
Profile = new UpdateProfileInput
|
||||
{
|
||||
DisplayName = currentUser.DisplayName,
|
||||
Nickname = currentUser.MemberProfile?.Nickname ?? string.Empty,
|
||||
Headline = currentUser.MemberProfile?.Headline ?? string.Empty,
|
||||
PortfolioUrl = currentUser.MemberProfile?.PortfolioUrl ?? string.Empty,
|
||||
Skills = currentUser.MemberProfile?.Skills ?? string.Empty,
|
||||
Availability = currentUser.MemberProfile?.Availability ?? string.Empty,
|
||||
Notes = currentUser.MemberProfile?.Notes ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyMembershipFlags(Bifrost.Web.Domain.Membership membership)
|
||||
{
|
||||
var roles = membership.RoleAssignments.Select(x => x.Role).ToHashSet();
|
||||
membership.IsPlatformAdmin = roles.Contains(MemberRole.PlatformAdmin);
|
||||
membership.CanManageProjects = membership.IsPlatformAdmin || roles.Contains(MemberRole.Producer);
|
||||
membership.CanManageLedger = membership.IsPlatformAdmin || roles.Contains(MemberRole.LedgerReviewer);
|
||||
membership.CanModerateMotions = membership.IsPlatformAdmin || roles.Contains(MemberRole.Producer) || roles.Contains(MemberRole.Maintainer);
|
||||
}
|
||||
|
||||
public sealed class InviteMemberInput
|
||||
|
|
@ -139,4 +367,46 @@ public sealed class IndexModel(
|
|||
[StringLength(500)]
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class UpdateProfileInput
|
||||
{
|
||||
[Required, StringLength(200)]
|
||||
[Display(Name = "Display name")]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(120)]
|
||||
public string Nickname { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(240)]
|
||||
public string Headline { get; set; } = string.Empty;
|
||||
|
||||
[Display(Name = "Portfolio URL")]
|
||||
[StringLength(500)]
|
||||
public string PortfolioUrl { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(2000)]
|
||||
public string Skills { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(500)]
|
||||
public string Availability { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(2000)]
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed record MemberDirectoryItem(
|
||||
Guid UserAccountId,
|
||||
string DisplayName,
|
||||
string GitHubLogin,
|
||||
string Nickname,
|
||||
string Headline,
|
||||
string PortfolioUrl,
|
||||
string Skills,
|
||||
string Availability,
|
||||
MembershipStatus Status,
|
||||
IReadOnlyList<MemberRole> Roles,
|
||||
string PatronTierLabel,
|
||||
decimal PatronTierWeight,
|
||||
string ContributorTierLabel,
|
||||
decimal ContributorTierWeight);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
@page
|
||||
@using Bifrost.Web.Domain
|
||||
@model Bifrost.Web.Pages.App.Motions.IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "Motions";
|
||||
|
|
@ -9,8 +10,8 @@
|
|||
<span class="eyebrow">Governance</span>
|
||||
<h1>Open motions and voting</h1>
|
||||
<p class="panel-intro">
|
||||
Motions stay app-native here: no token theater, just explicit thresholds,
|
||||
weighted votes, and an auditable trail of who changed what.
|
||||
Motions stay app-native here: no token theater, just explicit thresholds from the labor platform,
|
||||
weighted votes from current tier snapshots, and an auditable trail of who changed what.
|
||||
</p>
|
||||
|
||||
<form method="post" asp-page-handler="Create">
|
||||
|
|
@ -28,6 +29,11 @@
|
|||
</select>
|
||||
<span asp-validation-for="Input.ProjectId"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.Category"></label>
|
||||
<select asp-for="Input.Category" asp-items="Html.GetEnumSelectList<MotionCategory>()"></select>
|
||||
<span asp-validation-for="Input.Category"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.ClosesAtUtc"></label>
|
||||
<input asp-for="Input.ClosesAtUtc" />
|
||||
|
|
@ -54,54 +60,94 @@
|
|||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Vote ledger</h2>
|
||||
<p class="muted">Your current effective weight is @Model.Actor.EffectiveVotingWeight.</p>
|
||||
<p class="muted">Your current effective vote weight is @Model.Actor.EffectiveVotingWeight.ToString("0.##").</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.Motions.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No motions yet.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="motion-grid">
|
||||
@foreach (var motion in Model.Motions)
|
||||
{
|
||||
<article class="motion-card">
|
||||
<div class="inline-actions">
|
||||
<span class="pill">@motion.Scope</span>
|
||||
<span class="pill @motion.StatusTone">@motion.StatusLabel</span>
|
||||
</div>
|
||||
<h3>@motion.Title</h3>
|
||||
<p class="muted">@motion.Summary</p>
|
||||
<p class="muted">
|
||||
@motion.ProjectName
|
||||
· closes @motion.ClosesAtUtc.ToString("dd MMM yyyy HH:mm") UTC
|
||||
</p>
|
||||
<div class="inline-actions">
|
||||
<span class="pill success">For @motion.VotesFor</span>
|
||||
<span class="pill danger">Against @motion.VotesAgainst</span>
|
||||
<span class="pill">Abstain @motion.VotesAbstain</span>
|
||||
</div>
|
||||
<div class="vote-strip">
|
||||
@foreach (var choice in Enum.GetValues<VoteChoice>())
|
||||
{
|
||||
<form method="post" asp-page-handler="Vote">
|
||||
<input type="hidden" name="motionId" value="@motion.Id" />
|
||||
<input type="hidden" name="choice" value="@choice" />
|
||||
<button class="button @(motion.CurrentUserVote == choice ? "warm" : "secondary")" type="submit">
|
||||
Vote @choice
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
<div class="list-grid">
|
||||
<div class="status-banner">
|
||||
<strong>Bugs</strong>
|
||||
<span class="muted">15% threshold</span>
|
||||
</div>
|
||||
}
|
||||
<div class="status-banner">
|
||||
<strong>Cosmetics</strong>
|
||||
<span class="muted">30% threshold</span>
|
||||
</div>
|
||||
<div class="status-banner">
|
||||
<strong>Balance changes</strong>
|
||||
<span class="muted">40% threshold</span>
|
||||
</div>
|
||||
<div class="status-banner">
|
||||
<strong>Features and new content</strong>
|
||||
<span class="muted">50% threshold</span>
|
||||
</div>
|
||||
<div class="status-banner">
|
||||
<strong>Fundamental design changes</strong>
|
||||
<span class="muted">66% threshold</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel" style="margin-top: 1.5rem;">
|
||||
@if (Model.Motions.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No motions yet.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="motion-grid">
|
||||
@foreach (var motion in Model.Motions)
|
||||
{
|
||||
<article class="motion-card">
|
||||
<div class="inline-actions">
|
||||
<span class="pill">@motion.Scope</span>
|
||||
<span class="pill">@motion.Category</span>
|
||||
<span class="pill @motion.StatusTone">@motion.StatusLabel</span>
|
||||
</div>
|
||||
<h3>@motion.Title</h3>
|
||||
<p class="muted">@motion.Summary</p>
|
||||
<p class="muted">
|
||||
@motion.ProjectName
|
||||
· threshold @motion.Threshold.ToString("P0")
|
||||
· closes @motion.ClosesAtUtc.ToString("dd MMM yyyy HH:mm") UTC
|
||||
</p>
|
||||
<div class="inline-actions">
|
||||
<span class="pill success">For @motion.VotesFor</span>
|
||||
<span class="pill danger">Against @motion.VotesAgainst</span>
|
||||
<span class="pill">Abstain @motion.VotesAbstain</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(motion.ResolutionNote))
|
||||
{
|
||||
<p class="muted" style="margin-top: 0.8rem;">@motion.ResolutionNote</p>
|
||||
}
|
||||
<div class="vote-strip">
|
||||
@foreach (var choice in Enum.GetValues<VoteChoice>())
|
||||
{
|
||||
<form method="post" asp-page-handler="Vote">
|
||||
<input type="hidden" name="motionId" value="@motion.Id" />
|
||||
<input type="hidden" name="choice" value="@choice" />
|
||||
<input type="hidden" name="comment" value="" />
|
||||
<button class="button @(motion.CurrentUserVote == choice ? "warm" : "secondary")" type="submit">
|
||||
Vote @choice
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
@if (Model.Actor.CanModerateMotions || motion.StatusLabel == "Expired")
|
||||
{
|
||||
<form method="post" asp-page-handler="Close">
|
||||
<input type="hidden" name="motionId" value="@motion.Id" />
|
||||
<button class="button ghost" type="submit">Close motion</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Motions;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
|
@ -51,11 +52,13 @@ public sealed class IndexModel(
|
|||
var now = timeProvider.GetUtcNow();
|
||||
var motion = new Motion
|
||||
{
|
||||
ProjectId = Input.ProjectId,
|
||||
ProjectId = Input.Scope == MotionScope.Project ? Input.ProjectId : null,
|
||||
CreatedByUserAccountId = actorUser.Id,
|
||||
Scope = Input.Scope,
|
||||
Category = Input.Category,
|
||||
Title = Input.Title.Trim(),
|
||||
Summary = Input.Summary.Trim(),
|
||||
ApprovalThreshold = MotionCategoryPolicy.GetThreshold(Input.Category),
|
||||
OpensAtUtc = now,
|
||||
ClosesAtUtc = Input.ClosesAtUtc.ToUniversalTime(),
|
||||
CreatedAtUtc = now
|
||||
|
|
@ -74,7 +77,7 @@ public sealed class IndexModel(
|
|||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostVoteAsync(Guid motionId, VoteChoice choice, CancellationToken cancellationToken)
|
||||
public async Task<IActionResult> OnPostVoteAsync(Guid motionId, VoteChoice choice, string comment, CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
var actorUser = Actor.UserAccount;
|
||||
|
|
@ -102,6 +105,7 @@ public sealed class IndexModel(
|
|||
UserAccountId = actorUser.Id,
|
||||
Choice = choice,
|
||||
Weight = Actor.EffectiveVotingWeight,
|
||||
Comment = comment?.Trim() ?? string.Empty,
|
||||
CastAtUtc = timeProvider.GetUtcNow()
|
||||
});
|
||||
}
|
||||
|
|
@ -109,6 +113,7 @@ public sealed class IndexModel(
|
|||
{
|
||||
vote.Choice = choice;
|
||||
vote.Weight = Actor.EffectiveVotingWeight;
|
||||
vote.Comment = comment?.Trim() ?? string.Empty;
|
||||
vote.CastAtUtc = timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
|
|
@ -124,6 +129,50 @@ public sealed class IndexModel(
|
|||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostCloseAsync(Guid motionId, CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
var actorUser = Actor.UserAccount;
|
||||
if (actorUser is null)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var motion = await dbContext.Motions
|
||||
.Include(x => x.Votes)
|
||||
.SingleAsync(x => x.Id == motionId, cancellationToken);
|
||||
|
||||
if (!Actor.CanModerateMotions && motion.CreatedByUserAccountId != actorUser.Id)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var votesFor = motion.Votes.Where(v => v.Choice == VoteChoice.For).Sum(v => v.Weight);
|
||||
var votesAgainst = motion.Votes.Where(v => v.Choice == VoteChoice.Against).Sum(v => v.Weight);
|
||||
var decisiveVotes = votesFor + votesAgainst;
|
||||
|
||||
motion.Status = decisiveVotes > 0 && votesFor / decisiveVotes >= motion.ApprovalThreshold
|
||||
? MotionStatus.Passed
|
||||
: decisiveVotes > 0
|
||||
? MotionStatus.Failed
|
||||
: MotionStatus.Closed;
|
||||
motion.ResolvedAtUtc = timeProvider.GetUtcNow();
|
||||
motion.ResolutionNote = decisiveVotes > 0
|
||||
? $"Closed with {votesFor:0.##} for and {votesAgainst:0.##} against at a {motion.ApprovalThreshold:P0} threshold."
|
||||
: "Closed without decisive votes.";
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await auditTrailService.RecordAsync(
|
||||
actorUser.Id,
|
||||
nameof(Motion),
|
||||
motion.Id,
|
||||
"motion.closed",
|
||||
$"Closed motion {motion.Title} with status {motion.Status}.",
|
||||
cancellationToken);
|
||||
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
private async Task LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
|
|
@ -147,13 +196,16 @@ public sealed class IndexModel(
|
|||
x.Summary,
|
||||
x.Project?.Name ?? "Management-wide",
|
||||
x.Scope.ToString(),
|
||||
MotionCategoryPolicy.GetLabel(x.Category),
|
||||
x.ApprovalThreshold,
|
||||
ResolveStatusLabel(x, timeProvider.GetUtcNow()),
|
||||
ResolveStatusTone(x, timeProvider.GetUtcNow()),
|
||||
x.Votes.Where(v => v.Choice == VoteChoice.For).Sum(v => v.Weight),
|
||||
x.Votes.Where(v => v.Choice == VoteChoice.Against).Sum(v => v.Weight),
|
||||
x.Votes.Where(v => v.Choice == VoteChoice.Abstain).Sum(v => v.Weight),
|
||||
x.Votes.Where(v => v.UserAccountId == Actor.UserAccount?.Id).Select(v => (VoteChoice?)v.Choice).FirstOrDefault(),
|
||||
x.ClosesAtUtc)).ToList();
|
||||
x.ClosesAtUtc,
|
||||
x.ResolutionNote)).ToList();
|
||||
}
|
||||
|
||||
private static string ResolveStatusLabel(Motion motion, DateTimeOffset now)
|
||||
|
|
@ -170,13 +222,13 @@ public sealed class IndexModel(
|
|||
|
||||
var votesFor = motion.Votes.Where(v => v.Choice == VoteChoice.For).Sum(v => v.Weight);
|
||||
var votesAgainst = motion.Votes.Where(v => v.Choice == VoteChoice.Against).Sum(v => v.Weight);
|
||||
var total = votesFor + votesAgainst;
|
||||
if (total <= 0)
|
||||
var decisiveVotes = votesFor + votesAgainst;
|
||||
if (decisiveVotes <= 0)
|
||||
{
|
||||
return "Closed";
|
||||
return "Expired";
|
||||
}
|
||||
|
||||
return votesFor / total >= motion.ApprovalThreshold ? "Passed" : "Failed";
|
||||
return votesFor / decisiveVotes >= motion.ApprovalThreshold ? "Passed" : "Failed";
|
||||
}
|
||||
|
||||
private static string ResolveStatusTone(Motion motion, DateTimeOffset now) =>
|
||||
|
|
@ -184,8 +236,8 @@ public sealed class IndexModel(
|
|||
{
|
||||
"Passed" => "success",
|
||||
"Failed" => "danger",
|
||||
"Open" => string.Empty,
|
||||
_ => "warning"
|
||||
"Expired" => "warning",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
public sealed class CreateMotionInput
|
||||
|
|
@ -196,6 +248,9 @@ public sealed class IndexModel(
|
|||
[Display(Name = "Project")]
|
||||
public Guid? ProjectId { get; set; }
|
||||
|
||||
[Display(Name = "Category")]
|
||||
public MotionCategory Category { get; set; } = MotionCategory.Features;
|
||||
|
||||
[Required, StringLength(180)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
|
|
@ -212,11 +267,14 @@ public sealed class IndexModel(
|
|||
string Summary,
|
||||
string ProjectName,
|
||||
string Scope,
|
||||
string Category,
|
||||
decimal Threshold,
|
||||
string StatusLabel,
|
||||
string StatusTone,
|
||||
decimal VotesFor,
|
||||
decimal VotesAgainst,
|
||||
decimal VotesAbstain,
|
||||
VoteChoice? CurrentUserVote,
|
||||
DateTimeOffset ClosesAtUtc);
|
||||
DateTimeOffset ClosesAtUtc,
|
||||
string ResolutionNote);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,30 +13,45 @@
|
|||
Keeping them explicit is what lets the rest of the system stay legible.
|
||||
</p>
|
||||
|
||||
<form method="post" asp-page-handler="Create">
|
||||
<div asp-validation-summary="ModelOnly" class="validation-summary-errors"></div>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label asp-for="Input.Name"></label>
|
||||
<input asp-for="Input.Name" />
|
||||
<span asp-validation-for="Input.Name"></span>
|
||||
@if (Model.Actor.CanManageProjects)
|
||||
{
|
||||
<form method="post" asp-page-handler="Create">
|
||||
<div asp-validation-summary="ModelOnly" class="validation-summary-errors"></div>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label asp-for="Input.Name"></label>
|
||||
<input asp-for="Input.Name" />
|
||||
<span asp-validation-for="Input.Name"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.Slug"></label>
|
||||
<input asp-for="Input.Slug" />
|
||||
<span class="hint">Leave blank to derive from the name.</span>
|
||||
<span asp-validation-for="Input.Slug"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.GitHubRepository"></label>
|
||||
<input asp-for="Input.GitHubRepository" />
|
||||
<span class="hint">Use <code>owner/repo</code> when the project should sync GitHub issues and PRs.</span>
|
||||
<span asp-validation-for="Input.GitHubRepository"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.Slug"></label>
|
||||
<input asp-for="Input.Slug" />
|
||||
<span class="hint">Leave blank to derive from the name.</span>
|
||||
<span asp-validation-for="Input.Slug"></span>
|
||||
<label asp-for="Input.Summary"></label>
|
||||
<textarea asp-for="Input.Summary"></textarea>
|
||||
<span asp-validation-for="Input.Summary"></span>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<button class="button" type="submit">Create project</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="callout warning">
|
||||
Producers and platform admins can create or map project containers.
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.Summary"></label>
|
||||
<textarea asp-for="Input.Summary"></textarea>
|
||||
<span asp-validation-for="Input.Summary"></span>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<button class="button" type="submit">Create project</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="table-panel">
|
||||
|
|
@ -60,6 +75,7 @@
|
|||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Owner</th>
|
||||
<th>Repository</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -71,9 +87,20 @@
|
|||
<strong>@project.Name</strong><br />
|
||||
<span class="muted">@project.Slug</span>
|
||||
<p class="muted">@project.Summary</p>
|
||||
<div class="muted">@project.OpenWorkItems open work items</div>
|
||||
</td>
|
||||
<td><span class="pill">@project.Status</span></td>
|
||||
<td>@project.OwnerUserAccount.DisplayName</td>
|
||||
<td>@project.OwnerName</td>
|
||||
<td>
|
||||
@if (string.IsNullOrWhiteSpace(project.GitHubRepository))
|
||||
{
|
||||
<span class="muted">Internal only</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<code>@project.GitHubRepository</code>
|
||||
}
|
||||
</td>
|
||||
<td>@project.UpdatedAtUtc.ToString("dd MMM yyyy")</td>
|
||||
</tr>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public sealed class IndexModel(
|
|||
|
||||
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
|
||||
|
||||
public IReadOnlyList<Project> Projects { get; private set; } = [];
|
||||
public IReadOnlyList<ProjectListItem> Projects { get; private set; } = [];
|
||||
|
||||
public async Task OnGetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
|
|
@ -29,6 +29,10 @@ public sealed class IndexModel(
|
|||
public async Task<IActionResult> OnPostCreateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
if (!Actor.CanManageProjects || Actor.UserAccount is null)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
|
|
@ -45,13 +49,15 @@ public sealed class IndexModel(
|
|||
return Page();
|
||||
}
|
||||
|
||||
var repository = NormalizeRepository(Input.GitHubRepository);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var project = new Project
|
||||
{
|
||||
OwnerUserAccountId = Actor.UserAccount!.Id,
|
||||
OwnerUserAccountId = Actor.UserAccount.Id,
|
||||
Slug = slug,
|
||||
Name = Input.Name.Trim(),
|
||||
Summary = Input.Summary.Trim(),
|
||||
GitHubRepository = repository,
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now
|
||||
};
|
||||
|
|
@ -77,9 +83,36 @@ public sealed class IndexModel(
|
|||
.AsNoTracking()
|
||||
.Include(x => x.OwnerUserAccount)
|
||||
.OrderBy(x => x.Name)
|
||||
.Select(x => new ProjectListItem(
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.Slug,
|
||||
x.Summary,
|
||||
x.Status.ToString(),
|
||||
x.OwnerUserAccount.DisplayName,
|
||||
x.GitHubRepository,
|
||||
x.WorkItems.Count(workItem => workItem.Status != WorkItemStatus.Completed && workItem.Status != WorkItemStatus.Archived),
|
||||
x.UpdatedAtUtc))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string NormalizeRepository(string input)
|
||||
{
|
||||
var trimmed = input.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
const string prefix = "https://github.com/";
|
||||
if (trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
trimmed = trimmed[prefix.Length..];
|
||||
}
|
||||
|
||||
return trimmed.Trim('/').ToLowerInvariant();
|
||||
}
|
||||
|
||||
public sealed class CreateProjectInput
|
||||
{
|
||||
[Required, StringLength(180)]
|
||||
|
|
@ -88,7 +121,22 @@ public sealed class IndexModel(
|
|||
[StringLength(120)]
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(240)]
|
||||
[Display(Name = "GitHub repository")]
|
||||
public string GitHubRepository { get; set; } = string.Empty;
|
||||
|
||||
[Required, StringLength(2000)]
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed record ProjectListItem(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Slug,
|
||||
string Summary,
|
||||
string Status,
|
||||
string OwnerName,
|
||||
string GitHubRepository,
|
||||
int OpenWorkItems,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
@page
|
||||
@using Bifrost.Web.Domain
|
||||
@model Bifrost.Web.Pages.App.WorkItems.IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "Work items";
|
||||
|
|
@ -9,8 +10,8 @@
|
|||
<span class="eyebrow">Work items</span>
|
||||
<h1>Internal and GitHub-backed work</h1>
|
||||
<p class="panel-intro">
|
||||
The goal here is one shared list with enough structure to support volunteering,
|
||||
assignment, and later ledger attribution without turning task intake into ceremony.
|
||||
This is the shared queue for internal operations, GitHub issues, assignment, actual hours, and review.
|
||||
Everyone can see the board; mutation stays role-gated where it needs to.
|
||||
</p>
|
||||
|
||||
<form method="post" asp-page-handler="Create">
|
||||
|
|
@ -27,9 +28,29 @@
|
|||
<span asp-validation-for="Input.Title"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.EffortPoints"></label>
|
||||
<input asp-for="Input.EffortPoints" />
|
||||
<span asp-validation-for="Input.EffortPoints"></span>
|
||||
<label asp-for="Input.Category"></label>
|
||||
<input asp-for="Input.Category" />
|
||||
<span asp-validation-for="Input.Category"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.SkillLevel"></label>
|
||||
<select asp-for="Input.SkillLevel" asp-items="Html.GetEnumSelectList<WorkItemSkillLevel>()"></select>
|
||||
<span asp-validation-for="Input.SkillLevel"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.EstimatedHours"></label>
|
||||
<input asp-for="Input.EstimatedHours" />
|
||||
<span asp-validation-for="Input.EstimatedHours"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.ContributionPoints"></label>
|
||||
<input asp-for="Input.ContributionPoints" />
|
||||
<span asp-validation-for="Input.ContributionPoints"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label asp-for="Input.TargetDateUtc"></label>
|
||||
<input asp-for="Input.TargetDateUtc" />
|
||||
<span asp-validation-for="Input.TargetDateUtc"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
|
|
@ -43,96 +64,232 @@
|
|||
</form>
|
||||
</article>
|
||||
|
||||
<article class="table-panel">
|
||||
<div class="panel-header" style="padding: 1.25rem 1.25rem 0;">
|
||||
<div>
|
||||
<h2>Queue</h2>
|
||||
<p class="table-meta">@Model.WorkItems.Count tracked work items.</p>
|
||||
<article class="panel">
|
||||
<span class="eyebrow">Workflow</span>
|
||||
<h2>How this MVP flows</h2>
|
||||
<div class="list-grid">
|
||||
<div class="status-banner">
|
||||
<strong>States</strong>
|
||||
<span class="muted">Proposed, open, volunteered, assigned, in progress, submitted for review, changes requested, approved, completed, blocked, archived.</span>
|
||||
</div>
|
||||
<div class="status-banner">
|
||||
<strong>Time tracking</strong>
|
||||
<span class="muted">Members log actual hours per item. Producers and maintainers approve or reject those logs.</span>
|
||||
</div>
|
||||
<div class="status-banner">
|
||||
<strong>GitHub sync</strong>
|
||||
<span class="muted">Issues and pull requests show up here when the GitHub App webhook is configured.</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="table-panel" style="margin-top: 1.5rem;">
|
||||
<div class="panel-header" style="padding: 1.25rem 1.25rem 0;">
|
||||
<div>
|
||||
<h2>Shared work board</h2>
|
||||
<p class="table-meta">@Model.WorkItems.Count tracked work items.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.WorkItems.Count == 0)
|
||||
{
|
||||
<div class="empty-state" style="margin: 1.25rem;">No work items yet.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-shell">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Work</th>
|
||||
<th>Workflow</th>
|
||||
<th>Time and points</th>
|
||||
<th>GitHub</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var workItem in Model.WorkItems)
|
||||
{
|
||||
var hasClaim = workItem.VolunteerClaims.Any(x => x.UserAccountId == Model.Actor.UserAccount?.Id && x.Status == VolunteerClaimStatus.Active);
|
||||
var canWorkOnItem =
|
||||
workItem.AssignedToUserAccountId == Model.Actor.UserAccount?.Id ||
|
||||
workItem.RequestedByUserAccountId == Model.Actor.UserAccount?.Id ||
|
||||
hasClaim;
|
||||
var pendingLogs = workItem.WorkLogs.Where(x => x.ApprovalStatus == WorkLogApprovalStatus.Submitted).ToList();
|
||||
|
||||
@if (Model.WorkItems.Count == 0)
|
||||
{
|
||||
<div class="empty-state" style="margin: 1.25rem;">No work items yet.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-shell">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Work</th>
|
||||
<th>Status</th>
|
||||
<th>Claims</th>
|
||||
<th>Assignment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var workItem in Model.WorkItems)
|
||||
{
|
||||
var hasClaim = workItem.VolunteerClaims.Any(x => x.UserAccountId == Model.Actor.UserAccount?.Id && x.Status == VolunteerClaimStatus.Active);
|
||||
<tr>
|
||||
<td>
|
||||
<strong>@workItem.Title</strong><br />
|
||||
<span class="muted">@workItem.Project.Name</span>
|
||||
<p class="muted">@workItem.Summary</p>
|
||||
</td>
|
||||
<td>
|
||||
<span class="pill">@workItem.Status</span><br />
|
||||
<span class="muted">@workItem.EffortPoints.ToString("0.##") pts</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (workItem.VolunteerClaims.Count == 0)
|
||||
{
|
||||
<span class="muted">No claims yet.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var claim in workItem.VolunteerClaims)
|
||||
<td>
|
||||
<strong>@workItem.Title</strong><br />
|
||||
<span class="muted">@workItem.Project.Name</span>
|
||||
<div class="inline-actions" style="margin-top: 0.4rem;">
|
||||
<span class="pill">@workItem.SourceType</span>
|
||||
<span class="pill">@workItem.Category</span>
|
||||
<span class="pill">@workItem.SkillLevel</span>
|
||||
</div>
|
||||
<p class="muted">@workItem.Summary</p>
|
||||
</td>
|
||||
<td>
|
||||
<span class="pill">@workItem.Status</span>
|
||||
<div class="muted" style="margin-top: 0.5rem;">Review: @workItem.ReviewStatus</div>
|
||||
<div class="muted">
|
||||
Assignee:
|
||||
@(workItem.AssignedToUserAccount is null ? "Unassigned" : workItem.AssignedToUserAccount.DisplayName)
|
||||
</div>
|
||||
<div class="muted">
|
||||
Target:
|
||||
@(workItem.TargetDateUtc is null ? "None" : workItem.TargetDateUtc.Value.ToString("dd MMM yyyy HH:mm") + " UTC")
|
||||
</div>
|
||||
@if (workItem.VolunteerClaims.Count > 0)
|
||||
{
|
||||
<div class="muted" style="margin-top: 0.4rem;">
|
||||
Volunteers:
|
||||
@string.Join(", ", workItem.VolunteerClaims.Select(x => x.UserAccount.DisplayName))
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div>@workItem.EstimatedHours.ToString("0.##")h estimated</div>
|
||||
<div>@workItem.WorkLogs.Sum(x => x.Hours).ToString("0.##")h logged</div>
|
||||
<div>@workItem.ContributionPoints.ToString("0.##") contribution points</div>
|
||||
@if (workItem.WorkLogs.Count > 0)
|
||||
{
|
||||
<div class="muted" style="margin-top: 0.6rem;">
|
||||
@foreach (var log in workItem.WorkLogs.OrderByDescending(x => x.CreatedAtUtc).Take(3))
|
||||
{
|
||||
<div>@claim.UserAccount.DisplayName <span class="muted">(@claim.Status)</span></div>
|
||||
<div>@log.UserAccount.DisplayName: @log.Hours.ToString("0.##")h (@log.ApprovalStatus)</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (workItem.GitHubIssueLink is null && workItem.GitHubPullRequestLinks.Count == 0)
|
||||
{
|
||||
<span class="muted">Internal-only</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (workItem.GitHubIssueLink is not null)
|
||||
{
|
||||
<div>
|
||||
<a href="@workItem.GitHubIssueLink.IssueUrl" target="_blank" rel="noreferrer">
|
||||
Issue #@workItem.GitHubIssueLink.IssueNumber
|
||||
</a>
|
||||
<span class="muted">(@workItem.GitHubIssueLink.State)</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!hasClaim)
|
||||
@foreach (var pr in workItem.GitHubPullRequestLinks.OrderByDescending(x => x.LastSynchronizedAtUtc))
|
||||
{
|
||||
<div style="margin-top: 0.35rem;">
|
||||
<a href="@pr.PullRequestUrl" target="_blank" rel="noreferrer">
|
||||
PR #@pr.PullRequestNumber
|
||||
</a>
|
||||
<span class="muted">(@pr.State, @pr.ReviewDecision)</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="stack-form">
|
||||
@if (!hasClaim && workItem.Status != WorkItemStatus.Completed && workItem.Status != WorkItemStatus.Archived)
|
||||
{
|
||||
<form method="post" asp-page-handler="Volunteer">
|
||||
<input type="hidden" name="workItemId" value="@workItem.Id" />
|
||||
<button class="button ghost" type="submit">Volunteer</button>
|
||||
</form>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (workItem.AssignedToUserAccount is not null)
|
||||
{
|
||||
<strong>@workItem.AssignedToUserAccount.DisplayName</strong>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="muted">Unassigned</span>
|
||||
}
|
||||
|
||||
@if (Model.Actor.CanManageProjects && workItem.VolunteerClaims.Any(x => x.Status == VolunteerClaimStatus.Active))
|
||||
@if (Model.Actor.CanAssignWork)
|
||||
{
|
||||
<form method="post" asp-page-handler="Assign" class="inline-actions">
|
||||
<form method="post" asp-page-handler="Assign">
|
||||
<input type="hidden" name="workItemId" value="@workItem.Id" />
|
||||
<select name="userAccountId">
|
||||
@foreach (var claim in workItem.VolunteerClaims.Where(x => x.Status == VolunteerClaimStatus.Active))
|
||||
@foreach (var member in Model.MemberOptions)
|
||||
{
|
||||
<option value="@claim.UserAccountId">@claim.UserAccount.DisplayName</option>
|
||||
if (member.Value == workItem.AssignedToUserAccountId?.ToString())
|
||||
{
|
||||
<option value="@member.Value" selected="selected">@member.Text</option>
|
||||
}
|
||||
else
|
||||
{
|
||||
<option value="@member.Value">@member.Text</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
<button class="button secondary" type="submit">Assign</button>
|
||||
</form>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
|
||||
@if (canWorkOnItem && workItem.Status is WorkItemStatus.Assigned or WorkItemStatus.Volunteered or WorkItemStatus.Proposed or WorkItemStatus.Open or WorkItemStatus.ChangesRequested)
|
||||
{
|
||||
<form method="post" asp-page-handler="Start">
|
||||
<input type="hidden" name="workItemId" value="@workItem.Id" />
|
||||
<button class="button secondary" type="submit">Start</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
@if (canWorkOnItem && workItem.Status != WorkItemStatus.Completed && workItem.Status != WorkItemStatus.Archived)
|
||||
{
|
||||
<form method="post" asp-page-handler="LogTime">
|
||||
<input type="hidden" name="workItemId" value="@workItem.Id" />
|
||||
<input name="hours" placeholder="Hours" />
|
||||
<input name="note" placeholder="What moved?" />
|
||||
<button class="button ghost" type="submit">Log time</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
@if (canWorkOnItem && workItem.Status is WorkItemStatus.InProgress or WorkItemStatus.ChangesRequested or WorkItemStatus.Approved)
|
||||
{
|
||||
<form method="post" asp-page-handler="Submit">
|
||||
<input type="hidden" name="workItemId" value="@workItem.Id" />
|
||||
<button class="button" type="submit">Submit for review</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
@if (Model.Actor.CanReviewWork && workItem.Status is WorkItemStatus.SubmittedForReview or WorkItemStatus.ChangesRequested or WorkItemStatus.Approved)
|
||||
{
|
||||
<form method="post" asp-page-handler="Review">
|
||||
<input type="hidden" name="workItemId" value="@workItem.Id" />
|
||||
<select name="status">
|
||||
<option value="@WorkReviewStatus.Approved">Approve</option>
|
||||
<option value="@WorkReviewStatus.ChangesRequested">Request changes</option>
|
||||
</select>
|
||||
<input name="note" placeholder="Review note" />
|
||||
<button class="button warm" type="submit">Record review</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
@if (Model.Actor.CanReviewWork && workItem.Status == WorkItemStatus.Approved)
|
||||
{
|
||||
<form method="post" asp-page-handler="Complete">
|
||||
<input type="hidden" name="workItemId" value="@workItem.Id" />
|
||||
<button class="button" type="submit">Mark completed</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
@if (Model.Actor.CanReviewWork && pendingLogs.Count > 0)
|
||||
{
|
||||
@foreach (var workLog in pendingLogs)
|
||||
{
|
||||
<form method="post" asp-page-handler="ModerateLog">
|
||||
<input type="hidden" name="workLogId" value="@workLog.Id" />
|
||||
<select name="approvalStatus">
|
||||
<option value="@WorkLogApprovalStatus.Approved">Approve @workLog.Hours.ToString("0.##")h</option>
|
||||
<option value="@WorkLogApprovalStatus.Rejected">Reject @workLog.Hours.ToString("0.##")h</option>
|
||||
</select>
|
||||
<button class="button ghost" type="submit">@workLog.UserAccount.DisplayName log</button>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@section Scripts {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ public sealed class IndexModel(
|
|||
|
||||
public IReadOnlyList<SelectListItem> ProjectOptions { get; private set; } = [];
|
||||
|
||||
public IReadOnlyList<SelectListItem> MemberOptions { get; private set; } = [];
|
||||
|
||||
public IReadOnlyList<WorkItem> WorkItems { get; private set; } = [];
|
||||
|
||||
public async Task OnGetAsync(CancellationToken cancellationToken)
|
||||
|
|
@ -52,9 +54,14 @@ public sealed class IndexModel(
|
|||
RequestedByUserAccountId = actorUser.Id,
|
||||
Title = Input.Title.Trim(),
|
||||
Summary = Input.Summary.Trim(),
|
||||
EffortPoints = Input.EffortPoints,
|
||||
Category = Input.Category.Trim(),
|
||||
SkillLevel = Input.SkillLevel,
|
||||
EstimatedHours = Input.EstimatedHours,
|
||||
ContributionPoints = Input.ContributionPoints,
|
||||
TargetDateUtc = Input.TargetDateUtc?.ToUniversalTime(),
|
||||
SourceType = WorkItemSourceType.Internal,
|
||||
Status = WorkItemStatus.Open,
|
||||
Status = Actor.CanManageProjects ? WorkItemStatus.Open : WorkItemStatus.Proposed,
|
||||
ReviewStatus = WorkReviewStatus.Pending,
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now
|
||||
};
|
||||
|
|
@ -76,7 +83,6 @@ public sealed class IndexModel(
|
|||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
var actorUser = Actor.UserAccount;
|
||||
|
||||
if (actorUser is null)
|
||||
{
|
||||
return Forbid();
|
||||
|
|
@ -101,9 +107,9 @@ public sealed class IndexModel(
|
|||
existingClaim.Status = VolunteerClaimStatus.Active;
|
||||
}
|
||||
|
||||
if (workItem.Status == WorkItemStatus.Open)
|
||||
if (workItem.Status is WorkItemStatus.Proposed or WorkItemStatus.Open)
|
||||
{
|
||||
workItem.Status = WorkItemStatus.Claimed;
|
||||
workItem.Status = WorkItemStatus.Volunteered;
|
||||
workItem.UpdatedAtUtc = timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
|
|
@ -123,12 +129,7 @@ public sealed class IndexModel(
|
|||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
var actorUser = Actor.UserAccount;
|
||||
if (!Actor.CanManageProjects)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
if (actorUser is null)
|
||||
if (!Actor.CanAssignWork || actorUser is null)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
|
@ -138,7 +139,7 @@ public sealed class IndexModel(
|
|||
.SingleAsync(x => x.Id == workItemId, cancellationToken);
|
||||
|
||||
workItem.AssignedToUserAccountId = userAccountId;
|
||||
workItem.Status = WorkItemStatus.InProgress;
|
||||
workItem.Status = WorkItemStatus.Assigned;
|
||||
workItem.UpdatedAtUtc = timeProvider.GetUtcNow();
|
||||
|
||||
var claim = workItem.VolunteerClaims.SingleOrDefault(x => x.UserAccountId == userAccountId);
|
||||
|
|
@ -167,26 +168,240 @@ public sealed class IndexModel(
|
|||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostStartAsync(Guid workItemId, CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
var workItem = await dbContext.WorkItems.SingleAsync(x => x.Id == workItemId, cancellationToken);
|
||||
if (!CanWorkOnItem(Actor, workItem))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
workItem.Status = WorkItemStatus.InProgress;
|
||||
workItem.UpdatedAtUtc = timeProvider.GetUtcNow();
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await auditTrailService.RecordAsync(
|
||||
Actor.UserAccount!.Id,
|
||||
nameof(WorkItem),
|
||||
workItem.Id,
|
||||
"work-item.started",
|
||||
$"Started work on {workItem.Title}.",
|
||||
cancellationToken);
|
||||
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostLogTimeAsync(Guid workItemId, decimal hours, string note, CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
var actorUser = Actor.UserAccount;
|
||||
if (actorUser is null)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var workItem = await dbContext.WorkItems.SingleAsync(x => x.Id == workItemId, cancellationToken);
|
||||
if (!CanWorkOnItem(Actor, workItem))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
dbContext.WorkLogs.Add(new WorkLog
|
||||
{
|
||||
WorkItemId = workItemId,
|
||||
UserAccountId = actorUser.Id,
|
||||
Hours = hours,
|
||||
Note = note?.Trim() ?? string.Empty,
|
||||
ApprovalStatus = WorkLogApprovalStatus.Submitted,
|
||||
CreatedAtUtc = timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
workItem.UpdatedAtUtc = timeProvider.GetUtcNow();
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await auditTrailService.RecordAsync(
|
||||
actorUser.Id,
|
||||
nameof(WorkItem),
|
||||
workItem.Id,
|
||||
"work-log.created",
|
||||
$"Logged {hours:0.##}h on {workItem.Title}.",
|
||||
cancellationToken);
|
||||
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostModerateLogAsync(
|
||||
Guid workLogId,
|
||||
WorkLogApprovalStatus approvalStatus,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
if (!Actor.CanReviewWork || Actor.UserAccount is null)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var workLog = await dbContext.WorkLogs
|
||||
.Include(x => x.WorkItem)
|
||||
.SingleAsync(x => x.Id == workLogId, cancellationToken);
|
||||
|
||||
workLog.ApprovalStatus = approvalStatus;
|
||||
workLog.ReviewedByUserAccountId = Actor.UserAccount.Id;
|
||||
workLog.ReviewedAtUtc = timeProvider.GetUtcNow();
|
||||
workLog.WorkItem.UpdatedAtUtc = timeProvider.GetUtcNow();
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await auditTrailService.RecordAsync(
|
||||
Actor.UserAccount.Id,
|
||||
nameof(WorkLog),
|
||||
workLog.Id,
|
||||
"work-log.moderated",
|
||||
$"{approvalStatus} work log for {workLog.WorkItem.Title}.",
|
||||
cancellationToken);
|
||||
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostSubmitAsync(Guid workItemId, CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
var workItem = await dbContext.WorkItems.SingleAsync(x => x.Id == workItemId, cancellationToken);
|
||||
if (!CanWorkOnItem(Actor, workItem))
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
workItem.Status = WorkItemStatus.SubmittedForReview;
|
||||
workItem.ReviewStatus = WorkReviewStatus.Pending;
|
||||
workItem.SubmittedAtUtc = timeProvider.GetUtcNow();
|
||||
workItem.UpdatedAtUtc = timeProvider.GetUtcNow();
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await auditTrailService.RecordAsync(
|
||||
Actor.UserAccount!.Id,
|
||||
nameof(WorkItem),
|
||||
workItem.Id,
|
||||
"work-item.submitted",
|
||||
$"Submitted {workItem.Title} for review.",
|
||||
cancellationToken);
|
||||
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostReviewAsync(
|
||||
Guid workItemId,
|
||||
WorkReviewStatus status,
|
||||
string note,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
if (!Actor.CanReviewWork || Actor.UserAccount is null)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var workItem = await dbContext.WorkItems.SingleAsync(x => x.Id == workItemId, cancellationToken);
|
||||
workItem.ReviewStatus = status;
|
||||
workItem.Status = status == WorkReviewStatus.Approved
|
||||
? WorkItemStatus.Approved
|
||||
: WorkItemStatus.ChangesRequested;
|
||||
workItem.UpdatedAtUtc = timeProvider.GetUtcNow();
|
||||
|
||||
dbContext.WorkReviews.Add(new WorkReview
|
||||
{
|
||||
WorkItemId = workItemId,
|
||||
ReviewerUserAccountId = Actor.UserAccount.Id,
|
||||
ReviewerName = Actor.DisplayName,
|
||||
Status = status,
|
||||
Note = note?.Trim() ?? string.Empty,
|
||||
ReviewedAtUtc = timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await auditTrailService.RecordAsync(
|
||||
Actor.UserAccount.Id,
|
||||
nameof(WorkItem),
|
||||
workItem.Id,
|
||||
"work-item.reviewed",
|
||||
$"Reviewed {workItem.Title} as {status}.",
|
||||
cancellationToken);
|
||||
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostCompleteAsync(Guid workItemId, CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
if (!Actor.CanReviewWork || Actor.UserAccount is null)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var workItem = await dbContext.WorkItems.SingleAsync(x => x.Id == workItemId, cancellationToken);
|
||||
workItem.Status = WorkItemStatus.Completed;
|
||||
workItem.CompletedAtUtc = timeProvider.GetUtcNow();
|
||||
workItem.UpdatedAtUtc = timeProvider.GetUtcNow();
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await auditTrailService.RecordAsync(
|
||||
Actor.UserAccount.Id,
|
||||
nameof(WorkItem),
|
||||
workItem.Id,
|
||||
"work-item.completed",
|
||||
$"Completed {workItem.Title}.",
|
||||
cancellationToken);
|
||||
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
private async Task LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
|
||||
ProjectOptions = await dbContext.Projects
|
||||
.AsNoTracking()
|
||||
.Where(x => x.Status == ProjectStatus.Active)
|
||||
.OrderBy(x => x.Name)
|
||||
.Select(x => new SelectListItem(x.Name, x.Id.ToString()))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
MemberOptions = await dbContext.UserAccounts
|
||||
.AsNoTracking()
|
||||
.Include(x => x.Membership)
|
||||
.Where(x => x.Membership != null && x.Membership.Status == MembershipStatus.Active)
|
||||
.OrderBy(x => x.DisplayName)
|
||||
.Select(x => new SelectListItem(x.DisplayName, x.Id.ToString()))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
WorkItems = await dbContext.WorkItems
|
||||
.AsNoTracking()
|
||||
.Include(x => x.Project)
|
||||
.Include(x => x.AssignedToUserAccount)
|
||||
.Include(x => x.VolunteerClaims)
|
||||
.ThenInclude(x => x.UserAccount)
|
||||
.Include(x => x.WorkLogs)
|
||||
.ThenInclude(x => x.UserAccount)
|
||||
.Include(x => x.WorkReviews)
|
||||
.Include(x => x.GitHubIssueLink)
|
||||
.Include(x => x.GitHubPullRequestLinks)
|
||||
.OrderByDescending(x => x.UpdatedAtUtc)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static bool CanWorkOnItem(CurrentBifrostActor actor, WorkItem workItem)
|
||||
{
|
||||
var actorId = actor.UserAccount?.Id;
|
||||
if (actorId is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return workItem.AssignedToUserAccountId == actorId ||
|
||||
workItem.RequestedByUserAccountId == actorId ||
|
||||
workItem.VolunteerClaims.Any(x => x.UserAccountId == actorId && x.Status == VolunteerClaimStatus.Active);
|
||||
}
|
||||
|
||||
public sealed class CreateWorkItemInput
|
||||
{
|
||||
[Required]
|
||||
|
|
@ -196,11 +411,24 @@ public sealed class IndexModel(
|
|||
[Required, StringLength(180)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[Required, StringLength(2000)]
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
[Required, StringLength(120)]
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
[Display(Name = "Skill level")]
|
||||
public WorkItemSkillLevel SkillLevel { get; set; } = WorkItemSkillLevel.Routine;
|
||||
|
||||
[Range(0, 1000)]
|
||||
[Display(Name = "Effort points")]
|
||||
public decimal EffortPoints { get; set; } = 1m;
|
||||
[Display(Name = "Estimated hours")]
|
||||
public decimal EstimatedHours { get; set; } = 1m;
|
||||
|
||||
[Range(0, 1000)]
|
||||
[Display(Name = "Contribution points")]
|
||||
public decimal ContributionPoints { get; set; } = 1m;
|
||||
|
||||
[Display(Name = "Target date (UTC)")]
|
||||
public DateTimeOffset? TargetDateUtc { get; set; }
|
||||
|
||||
[Required, StringLength(2000)]
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ using System.Security.Claims;
|
|||
using System.Text.Json;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Features.GitHub;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.OAuth;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.HttpLogging;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
|
|
@ -18,10 +20,18 @@ builder.Services
|
|||
.AddOptions<GitHubOAuthOptions>()
|
||||
.BindConfiguration(GitHubOAuthOptions.SectionName);
|
||||
|
||||
builder.Services
|
||||
.AddOptions<GitHubAppOptions>()
|
||||
.BindConfiguration(GitHubAppOptions.SectionName);
|
||||
|
||||
builder.Services
|
||||
.AddOptions<BootstrapOptions>()
|
||||
.BindConfiguration(BootstrapOptions.SectionName);
|
||||
|
||||
builder.Services
|
||||
.AddOptions<BifrostHostOptions>()
|
||||
.BindConfiguration(BifrostHostOptions.SectionName);
|
||||
|
||||
builder.Services.AddRazorPages(options =>
|
||||
{
|
||||
options.Conventions.AuthorizePage("/Membership/Status");
|
||||
|
|
@ -36,12 +46,25 @@ builder.Services.AddDbContext<BifrostDbContext>(options =>
|
|||
options.UseNpgsql(connectionString);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpLogging(options =>
|
||||
{
|
||||
options.LoggingFields =
|
||||
HttpLoggingFields.RequestMethod |
|
||||
HttpLoggingFields.RequestPath |
|
||||
HttpLoggingFields.ResponseStatusCode |
|
||||
HttpLoggingFields.Duration;
|
||||
});
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddScoped<ICurrentBifrostActorAccessor, CurrentBifrostActorAccessor>();
|
||||
builder.Services.AddScoped<AuditTrailService>();
|
||||
builder.Services.AddScoped<DashboardSnapshotService>();
|
||||
builder.Services.AddScoped<MembershipSynchronizationService>();
|
||||
builder.Services.AddScoped<GitHubWebhookService>();
|
||||
builder.Services.AddScoped<ReadinessService>();
|
||||
builder.Services.AddScoped<ApplicationBootstrapper>();
|
||||
builder.Services.AddSingleton<StartupConfigurationValidator>();
|
||||
builder.Services.AddScoped<IAuthorizationHandler, ActiveMemberAuthorizationHandler>();
|
||||
|
||||
builder.Services
|
||||
|
|
@ -107,12 +130,21 @@ builder.Services.AddAuthorization(options =>
|
|||
|
||||
var app = builder.Build();
|
||||
|
||||
app.Services.GetRequiredService<StartupConfigurationValidator>().Validate();
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<ApplicationBootstrapper>()
|
||||
.RunAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Error");
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseHttpLogging();
|
||||
app.UseHttpsRedirection();
|
||||
app.UseStaticFiles();
|
||||
app.UseRouting();
|
||||
|
|
@ -120,6 +152,49 @@ app.UseAuthentication();
|
|||
app.UseMiddleware<GitHubMembershipSynchronizationMiddleware>();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapGet("/healthz", () => Results.Text("ok")).AllowAnonymous();
|
||||
|
||||
app.MapGet("/readyz", async (ReadinessService readinessService, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var report = await readinessService.GetReportAsync(cancellationToken);
|
||||
return report.IsReady
|
||||
? Results.Text("ready")
|
||||
: Results.Text(
|
||||
string.Join(Environment.NewLine, report.Failures),
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapPost("/github/webhooks", async (
|
||||
HttpRequest request,
|
||||
GitHubWebhookService gitHubWebhookService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
using var reader = new StreamReader(request.Body);
|
||||
var payload = await reader.ReadToEndAsync(cancellationToken);
|
||||
var eventName = request.Headers["X-GitHub-Event"].ToString();
|
||||
var deliveryId = request.Headers["X-GitHub-Delivery"].ToString();
|
||||
var signature = request.Headers["X-Hub-Signature-256"].ToString();
|
||||
|
||||
var result = await gitHubWebhookService.ProcessAsync(
|
||||
eventName,
|
||||
deliveryId,
|
||||
signature,
|
||||
payload,
|
||||
cancellationToken);
|
||||
|
||||
return result.Status switch
|
||||
{
|
||||
GitHubWebhookProcessStatus.Processed => Results.Text(result.Message, statusCode: StatusCodes.Status202Accepted),
|
||||
GitHubWebhookProcessStatus.Ignored => Results.Text(result.Message),
|
||||
GitHubWebhookProcessStatus.NotConfigured => Results.Text(
|
||||
result.Message,
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable),
|
||||
_ => Results.Text(
|
||||
result.Message,
|
||||
statusCode: StatusCodes.Status401Unauthorized)
|
||||
};
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions<GitHubOAuthOptions> gitHubOptions) =>
|
||||
{
|
||||
if (!gitHubOptions.Value.IsConfigured)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,14 @@
|
|||
"ConnectionStrings": {
|
||||
"Bifrost": "Host=127.0.0.1;Database=bifrost_dev;Username=postgres;Password=postgres"
|
||||
},
|
||||
"Bootstrap": {
|
||||
"ApplyMigrationsOnStartup": true
|
||||
},
|
||||
"Host": {
|
||||
"PublicBaseUrl": "https://localhost:5001",
|
||||
"ExpectedHost": "localhost",
|
||||
"RequireStrictHostValidation": false
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
|
|
|||
|
|
@ -7,8 +7,22 @@
|
|||
"ClientSecret": "",
|
||||
"CallbackPath": "/signin-github"
|
||||
},
|
||||
"GitHubApp": {
|
||||
"AppId": "",
|
||||
"ClientId": "",
|
||||
"ClientSecret": "",
|
||||
"PrivateKeyPem": "",
|
||||
"WebhookSecret": "",
|
||||
"EnableWebhookSync": true
|
||||
},
|
||||
"Bootstrap": {
|
||||
"AdminGitHubLogins": []
|
||||
"AdminGitHubLogins": [],
|
||||
"ApplyMigrationsOnStartup": false
|
||||
},
|
||||
"Host": {
|
||||
"PublicBaseUrl": "https://bifrost.gamecult.org",
|
||||
"ExpectedHost": "bifrost.gamecult.org",
|
||||
"RequireStrictHostValidation": true
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
|
|
@ -16,5 +30,5 @@
|
|||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "localhost"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,6 +191,16 @@ code {
|
|||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stack-form {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.stack-form form {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -393,6 +403,17 @@ tbody tr:hover {
|
|||
resize: vertical;
|
||||
}
|
||||
|
||||
.stack-form input,
|
||||
.stack-form select {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(38, 34, 27, 0.14);
|
||||
border-radius: 14px;
|
||||
padding: 0.75rem 0.85rem;
|
||||
background: #fffdf8;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.validation-summary-errors,
|
||||
.field-validation-error {
|
||||
color: var(--danger);
|
||||
|
|
|
|||
87
tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs
Normal file
87
tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private readonly TestWebApplicationFactory _factory;
|
||||
|
||||
public PlatformEndpointsTests(TestWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Health_endpoint_returns_ok()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var response = await client.GetAsync("/healthz");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ready_endpoint_returns_ok_for_test_configuration()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var response = await client.GetAsync("/readyz");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GitHub_issue_webhook_creates_work_item()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
_ = await client.GetAsync("/App");
|
||||
|
||||
const string payload = """
|
||||
{
|
||||
"action": "opened",
|
||||
"repository": { "full_name": "GameCult/Bifrost" },
|
||||
"issue": {
|
||||
"number": 42,
|
||||
"state": "open",
|
||||
"html_url": "https://github.com/GameCult/Bifrost/issues/42",
|
||||
"title": "Sync issue into Bifrost",
|
||||
"body": "Track this from GitHub"
|
||||
},
|
||||
"sender": { "login": "test-admin" }
|
||||
}
|
||||
""";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/github/webhooks");
|
||||
request.Headers.Add("X-GitHub-Event", "issues");
|
||||
request.Headers.Add("X-GitHub-Delivery", Guid.NewGuid().ToString("N"));
|
||||
request.Headers.Add("X-Hub-Signature-256", ComputeSignature("test-webhook-secret", payload));
|
||||
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var workItem = dbContext.WorkItems.Single();
|
||||
var issueLink = dbContext.GitHubIssueLinks.Single();
|
||||
var project = dbContext.Projects.Single(x => x.Id == workItem.ProjectId);
|
||||
|
||||
Assert.Equal(WorkItemSourceType.GitHubIssue, workItem.SourceType);
|
||||
Assert.Equal(42, issueLink.IssueNumber);
|
||||
Assert.Equal("bifrost", project.Slug);
|
||||
Assert.Equal("GameCult/Bifrost".ToLowerInvariant(), project.GitHubRepository);
|
||||
}
|
||||
|
||||
private static string ComputeSignature(string secret, string payload)
|
||||
{
|
||||
using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||
var hash = hasher.ComputeHash(Encoding.UTF8.GetBytes(payload));
|
||||
return $"sha256={Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authentication.Cookies;
|
|||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
|
@ -12,6 +13,9 @@ namespace Bifrost.Web.Tests.Support;
|
|||
|
||||
public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly InMemoryDatabaseRoot _databaseRoot = new();
|
||||
private readonly string _databaseName = $"bifrost-tests-{Guid.NewGuid():N}";
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
|
@ -22,7 +26,13 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
|
|||
{
|
||||
["Bootstrap:AdminGitHubLogins:0"] = "test-admin",
|
||||
["GitHubOAuth:ClientId"] = "configured-for-tests",
|
||||
["GitHubOAuth:ClientSecret"] = "configured-for-tests"
|
||||
["GitHubOAuth:ClientSecret"] = "configured-for-tests",
|
||||
["GitHubApp:WebhookSecret"] = "test-webhook-secret",
|
||||
["GitHubApp:PrivateKeyPem"] = "test-private-key",
|
||||
["GitHubApp:AppId"] = "1",
|
||||
["Host:PublicBaseUrl"] = "https://localhost",
|
||||
["Host:ExpectedHost"] = "localhost",
|
||||
["Host:RequireStrictHostValidation"] = "false"
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -33,7 +43,7 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
|
|||
|
||||
services.AddDbContext<BifrostDbContext>(options =>
|
||||
{
|
||||
options.UseInMemoryDatabase($"bifrost-tests-{Guid.NewGuid():N}");
|
||||
options.UseInMemoryDatabase(_databaseName, _databaseRoot);
|
||||
});
|
||||
|
||||
services.AddAuthentication(options =>
|
||||
|
|
|
|||
Loading…
Reference in a new issue