Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion csharp/Platform.Bot/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ private static async Task<int> Main(string[] args)
var dbContext = new FileStorage(databaseFilePath?.FullName ?? new TemporaryFile().Filename);
Console.WriteLine($"Bot has been started. {Environment.NewLine}Press CTRL+C to close");
var githubStorage = new GitHubStorage(githubUserName, githubApiToken, githubApplicationName);
var issueTracker = new IssueTracker(githubStorage, new HelloWorldTrigger(githubStorage, dbContext, fileSetName), new OrganizationLastMonthActivityTrigger(githubStorage), new LastCommitActivityTrigger(githubStorage), new AdminAuthorIssueTriggerDecorator(new ProtectDefaultBranchTrigger(githubStorage), githubStorage), new AdminAuthorIssueTriggerDecorator(new ChangeOrganizationRepositoriesDefaultBranchTrigger(githubStorage, dbContext), githubStorage), new AdminAuthorIssueTriggerDecorator(new ChangeOrganizationPullRequestsBaseBranchTrigger(githubStorage, dbContext), githubStorage));
var issueTracker = new IssueTracker(githubStorage, new HelloWorldTrigger(githubStorage, dbContext, fileSetName), new OrganizationLastMonthActivityTrigger(githubStorage), new LastCommitActivityTrigger(githubStorage), new AdminAuthorIssueTriggerDecorator(new ProtectDefaultBranchTrigger(githubStorage), githubStorage), new AdminAuthorIssueTriggerDecorator(new ChangeOrganizationRepositoriesDefaultBranchTrigger(githubStorage, dbContext), githubStorage), new AdminAuthorIssueTriggerDecorator(new ChangeOrganizationPullRequestsBaseBranchTrigger(githubStorage, dbContext), githubStorage), new VotingCountdownTrigger(githubStorage, dbContext));
var pullRequenstTracker = new PullRequestTracker(githubStorage, new MergeDependabotBumpsTrigger(githubStorage));
var timestampTracker = new DateTimeTracker(githubStorage, new CreateAndSaveOrganizationRepositoriesMigrationTrigger(githubStorage, dbContext, Path.Combine(Directory.GetCurrentDirectory(), "/github-migrations")));
var cancellation = new CancellationTokenSource();
Expand Down
224 changes: 224 additions & 0 deletions csharp/Platform.Bot/Triggers/VotingCountdownTrigger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Interfaces;
using Octokit;
using Storage.Local;
using Storage.Remote.GitHub;
using System.Numerics;

namespace Platform.Bot.Triggers
{
using TContext = Issue;

/// <summary>
/// <para>
/// Represents the voting countdown trigger that prevents users from responding with "-" to another "-" comment within a specified time period.
/// </para>
/// <para></para>
/// </summary>
/// <seealso cref="ITrigger{TContext}"/>
internal class VotingCountdownTrigger : ITrigger<TContext>
{
private readonly GitHubStorage _storage;
private readonly FileStorage _fileStorage;
private readonly TimeSpan _countdownPeriod;
private readonly string _votingTrackingKey = "voting_countdown_tracking";

/// <summary>
/// <para>
/// Initializes a new <see cref="VotingCountdownTrigger"/> instance.
/// </para>
/// <para></para>
/// </summary>
/// <param name="storage">
/// <para>A GitHub storage instance.</para>
/// <para></para>
/// </param>
/// <param name="fileStorage">
/// <para>A file storage instance.</para>
/// <para></para>
/// </param>
/// <param name="countdownMinutes">
/// <para>The countdown period in minutes (default: 5 minutes).</para>
/// <para></para>
/// </param>
public VotingCountdownTrigger(GitHubStorage storage, FileStorage fileStorage, int countdownMinutes = 5)
{
_storage = storage;
_fileStorage = fileStorage;
_countdownPeriod = TimeSpan.FromMinutes(countdownMinutes);
}

/// <summary>
/// <para>
/// Determines whether this instance should process the issue for voting countdown enforcement.
/// </para>
/// <para></para>
/// </summary>
/// <param name="context">
/// <para>The issue context.</para>
/// <para></para>
/// </param>
/// <returns>
/// <para>True if the issue has recent "-" comments that need countdown enforcement.</para>
/// <para></para>
/// </returns>
public async Task<bool> Condition(TContext context)
{
try
{
var comments = await _storage.GetIssueComments(context.Repository.Id, context.Number);

// Only process if there are comments
if (!comments.Any()) return false;

// Check if there are any "-" comments in the recent timeframe
var recentComments = comments.Where(c => c.CreatedAt > DateTimeOffset.UtcNow.Subtract(_countdownPeriod)).ToList();
var minusComments = recentComments.Where(c => c.Body.Trim() == "-").ToList();

return minusComments.Any();
}
catch (Exception)
{
// If we can't retrieve comments, don't trigger
return false;
}
}

/// <summary>
/// <para>
/// Enforces the voting countdown rules by deleting or warning about invalid "-" responses.
/// </para>
/// <para></para>
/// </summary>
/// <param name="context">
/// <para>The issue context.</para>
/// <para></para>
/// </param>
public async Task Action(TContext context)
{
try
{
var comments = await _storage.GetIssueComments(context.Repository.Id, context.Number);
var minusComments = comments.Where(c => c.Body.Trim() == "-")
.OrderBy(c => c.CreatedAt)
.ToList();

if (minusComments.Count < 2) return; // Need at least 2 minus comments to check

// Track user voting timestamps
var userVotingData = GetUserVotingData(context.Repository.Id, context.Number);
var now = DateTimeOffset.UtcNow;
bool hasViolations = false;

for (int i = 1; i < minusComments.Count; i++)
{
var currentComment = minusComments[i];
var previousComment = minusComments[i - 1];
var timeDifference = currentComment.CreatedAt - previousComment.CreatedAt;

// Check if this is a response to the previous "-" comment within the countdown period
if (timeDifference < _countdownPeriod)
{
// Check if the user had already voted with "-" recently
var userKey = $"{currentComment.User.Login}_{context.Repository.Id}_{context.Number}";
var lastVoteTime = GetLastVoteTime(userVotingData, userKey);

if (lastVoteTime.HasValue && (currentComment.CreatedAt - lastVoteTime.Value) < _countdownPeriod)
{
// This is a violation - user voted with "-" too soon after another "-"
await _storage.CreateIssueComment(context.Repository.Id, context.Number,
$"@{currentComment.User.Login} Please wait {_countdownPeriod.TotalMinutes} minutes before responding with \"-\" to another \"-\" comment. " +
$"This helps maintain civil discussion. Your comment was posted too quickly after a previous \"-\" vote.");

hasViolations = true;
}

// Update the user's voting timestamp
SetLastVoteTime(userVotingData, userKey, currentComment.CreatedAt);
}
}

if (hasViolations)
{
// Save the updated voting tracking data
SaveUserVotingData(context.Repository.Id, context.Number, userVotingData);
}
}
catch (Exception)
{
// Log error if needed, but don't crash the bot
}
}

private Dictionary<string, DateTimeOffset> GetUserVotingData(long repositoryId, int issueNumber)
{
try
{
var key = $"{_votingTrackingKey}_{repositoryId}_{issueNumber}";
var fileSet = _fileStorage.GetFileSet(key);

if (fileSet != 0) // FileSet exists
{
var files = _fileStorage.GetFilesFromSet(key);
var dataFile = files.FirstOrDefault();
if (dataFile != null)
{
// Parse the stored data (format: "user_repo_issue:timestamp,user_repo_issue:timestamp")
var result = new Dictionary<string, DateTimeOffset>();
var lines = dataFile.Content.Split('\n', StringSplitOptions.RemoveEmptyEntries);

foreach (var line in lines)
{
var parts = line.Split(':', 2);
if (parts.Length == 2 && DateTimeOffset.TryParse(parts[1], out var timestamp))
{
result[parts[0]] = timestamp;
}
}
return result;
}
}
}
catch (Exception)
{
// If parsing fails, return empty dictionary
}

return new Dictionary<string, DateTimeOffset>();
}

private void SaveUserVotingData(long repositoryId, int issueNumber, Dictionary<string, DateTimeOffset> votingData)
{
try
{
var key = $"{_votingTrackingKey}_{repositoryId}_{issueNumber}";

// Convert dictionary to string format
var dataLines = votingData.Select(kvp => $"{kvp.Key}:{kvp.Value:O}").ToArray();
var content = string.Join('\n', dataLines);

// Create or update the file set
var fileSet = _fileStorage.CreateFileSet(key);
var file = _fileStorage.AddFile(content);
_fileStorage.AddFileToSet(fileSet, file, $"{key}_data.txt");
}
catch (Exception)
{
// If saving fails, continue silently
}
}

private DateTimeOffset? GetLastVoteTime(Dictionary<string, DateTimeOffset> votingData, string userKey)
{
return votingData.TryGetValue(userKey, out var timestamp) ? timestamp : null;
}

private void SetLastVoteTime(Dictionary<string, DateTimeOffset> votingData, string userKey, DateTimeOffset timestamp)
{
votingData[userKey] = timestamp;
}
}
}
5 changes: 5 additions & 0 deletions csharp/Storage/RemoteStorage/GitHubStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,11 @@ public Task<IssueComment> CreateIssueComment(long repositoryId, int issueNumber,
return Client.Issue.Comment.Create(repositoryId, issueNumber, message);
}

public Task<IReadOnlyList<IssueComment>> GetIssueComments(long repositoryId, int issueNumber)
{
return Client.Issue.Comment.GetAllForIssue(repositoryId, issueNumber);
}

#endregion

#region Branch
Expand Down
114 changes: 114 additions & 0 deletions experiments/VotingCountdownTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace VotingCountdownExperiments
{
/// <summary>
/// <para>
/// Test simulation to demonstrate the voting countdown logic.
/// This helps verify that our VotingCountdownTrigger logic works correctly.
/// </para>
/// </summary>
public class VotingCountdownTest
{
public static void Main(string[] args)
{
Console.WriteLine("=== Voting Countdown Logic Test ===");
Console.WriteLine("Testing scenarios for preventing rapid '-' responses\n");

// Test Scenario 1: Valid behavior - sufficient time between votes
TestScenario1();

// Test Scenario 2: Invalid behavior - too quick response
TestScenario2();

// Test Scenario 3: Multiple users voting
TestScenario3();

Console.WriteLine("=== All tests completed ===");
}

private static void TestScenario1()
{
Console.WriteLine("Scenario 1: Valid behavior - 6 minutes between '-' votes");

var countdownMinutes = 5;
var comment1Time = DateTimeOffset.UtcNow.AddMinutes(-10);
var comment2Time = DateTimeOffset.UtcNow.AddMinutes(-4); // 6 minutes later

var timeDiff = comment2Time - comment1Time;
var isValid = timeDiff >= TimeSpan.FromMinutes(countdownMinutes);

Console.WriteLine($" First '-' comment: {comment1Time:HH:mm:ss}");
Console.WriteLine($" Second '-' comment: {comment2Time:HH:mm:ss}");
Console.WriteLine($" Time difference: {timeDiff.TotalMinutes:F1} minutes");
Console.WriteLine($" Required minimum: {countdownMinutes} minutes");
Console.WriteLine($" Result: {(isValid ? "ALLOWED" : "BLOCKED")}");
Console.WriteLine($" Expected: ALLOWED\n");
}

private static void TestScenario2()
{
Console.WriteLine("Scenario 2: Invalid behavior - 2 minutes between '-' votes");

var countdownMinutes = 5;
var comment1Time = DateTimeOffset.UtcNow.AddMinutes(-7);
var comment2Time = DateTimeOffset.UtcNow.AddMinutes(-5); // Only 2 minutes later

var timeDiff = comment2Time - comment1Time;
var isValid = timeDiff >= TimeSpan.FromMinutes(countdownMinutes);

Console.WriteLine($" First '-' comment: {comment1Time:HH:mm:ss}");
Console.WriteLine($" Second '-' comment: {comment2Time:HH:mm:ss}");
Console.WriteLine($" Time difference: {timeDiff.TotalMinutes:F1} minutes");
Console.WriteLine($" Required minimum: {countdownMinutes} minutes");
Console.WriteLine($" Result: {(isValid ? "ALLOWED" : "BLOCKED")}");
Console.WriteLine($" Expected: BLOCKED\n");
}

private static void TestScenario3()
{
Console.WriteLine("Scenario 3: Multiple users - different rules for different users");

var countdownMinutes = 5;
var baseTime = DateTimeOffset.UtcNow.AddMinutes(-10);

// Simulate comments from different users
var comments = new List<(string user, DateTimeOffset time, string content)>
{
("user1", baseTime, "-"),
("user2", baseTime.AddMinutes(2), "-"), // 2 minutes later, different user
("user1", baseTime.AddMinutes(3), "-"), // 3 minutes after user1's first vote - should be blocked
("user3", baseTime.AddMinutes(4), "-"), // 4 minutes later, different user
("user1", baseTime.AddMinutes(7), "-") // 7 minutes after user1's first vote - should be allowed
};

var userVoteTimes = new Dictionary<string, DateTimeOffset>();

foreach (var comment in comments)
{
if (comment.content == "-")
{
var shouldBlock = false;
if (userVoteTimes.ContainsKey(comment.user))
{
var timeSinceLastVote = comment.time - userVoteTimes[comment.user];
if (timeSinceLastVote < TimeSpan.FromMinutes(countdownMinutes))
{
shouldBlock = true;
}
}

Console.WriteLine($" {comment.user} votes '-' at {comment.time:HH:mm:ss} -> {(shouldBlock ? "BLOCKED" : "ALLOWED")}");

if (!shouldBlock)
{
userVoteTimes[comment.user] = comment.time;
}
}
}
Console.WriteLine();
}
}
}
Loading
Loading