diff --git a/csharp/Platform.Bot/Program.cs b/csharp/Platform.Bot/Program.cs index 521a6b95..94b7ea61 100644 --- a/csharp/Platform.Bot/Program.cs +++ b/csharp/Platform.Bot/Program.cs @@ -95,7 +95,7 @@ private static async Task 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 MonthlyCommitAndReviewActivityTrigger(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 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(); diff --git a/csharp/Platform.Bot/Triggers/MonthlyCommitAndReviewActivityTrigger.cs b/csharp/Platform.Bot/Triggers/MonthlyCommitAndReviewActivityTrigger.cs new file mode 100644 index 00000000..b6d96ec2 --- /dev/null +++ b/csharp/Platform.Bot/Triggers/MonthlyCommitAndReviewActivityTrigger.cs @@ -0,0 +1,331 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Globalization; +using Interfaces; +using Octokit; +using Platform.Communication.Protocol.Lino; +using Storage.Remote.GitHub; + +namespace Platform.Bot.Triggers +{ + using TContext = Issue; + /// + /// + /// Represents the monthly commit and review activity trigger. + /// + /// + /// + /// + internal class MonthlyCommitAndReviewActivityTrigger : ITrigger + { + private readonly GitHubStorage _storage; + private readonly Parser _parser = new(); + + /// + /// + /// Initializes a new instance. + /// + /// + /// + /// + /// A storage. + /// + /// + public MonthlyCommitAndReviewActivityTrigger(GitHubStorage storage) => _storage = storage; + + /// + /// + /// Determines whether this instance condition. + /// + /// + /// + /// + /// The context. + /// + /// + /// + /// The bool + /// + /// + public async Task Condition(TContext context) => context.Title.ToLower().Contains("monthly commit and review activity") || context.Title.ToLower().Contains("collect users who made commits"); + + /// + /// + /// Actions the context. + /// + /// + /// + /// + /// The context. + /// + /// + public async Task Action(TContext context) + { + var issueService = _storage.Client.Issue; + var owner = context.Repository.Owner.Login; + + try + { + var (year, month) = ParseDateFromIssueBody(context.Body); + var ignoredRepositories = GetIgnoredRepositories(_parser.Parse(context.Body)); + var activeUsers = await GetActiveUsersInMonth(ignoredRepositories, owner, year, month); + + var resultMessage = FormatResult(activeUsers, year, month); + await issueService.Comment.Create(owner, context.Repository.Name, context.Number, resultMessage); + _storage.CloseIssue(context); + } + catch (Exception ex) + { + var errorMessage = $"Error processing monthly activity request: {ex.Message}\n\nPlease ensure the issue body contains the month and year in format:\n- `month: 11` (for November)\n- `year: 2023` (for 2023)\n\nExample:\n```\nmonth: 11\nyear: 2023\n```"; + await issueService.Comment.Create(owner, context.Repository.Name, context.Number, errorMessage); + } + } + + /// + /// + /// Parses the date from issue body. + /// + /// + /// + /// + /// The issue body. + /// + /// + /// + /// A tuple containing year and month. + /// + /// + private (int year, int month) ParseDateFromIssueBody(string issueBody) + { + var lines = issueBody?.Split('\n', StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty(); + + int? year = null; + int? month = null; + + foreach (var line in lines) + { + var trimmedLine = line.Trim(); + + if (trimmedLine.StartsWith("year:", StringComparison.OrdinalIgnoreCase)) + { + var yearStr = trimmedLine.Substring(5).Trim(); + if (int.TryParse(yearStr, out var parsedYear)) + { + year = parsedYear; + } + } + else if (trimmedLine.StartsWith("month:", StringComparison.OrdinalIgnoreCase)) + { + var monthStr = trimmedLine.Substring(6).Trim(); + if (int.TryParse(monthStr, out var parsedMonth) && parsedMonth >= 1 && parsedMonth <= 12) + { + month = parsedMonth; + } + } + } + + if (!year.HasValue || !month.HasValue) + { + // Default to previous month if not specified + var lastMonth = DateTime.Now.AddMonths(-1); + year ??= lastMonth.Year; + month ??= lastMonth.Month; + } + + return (year.Value, month.Value); + } + + /// + /// + /// Gets the ignored repositories using the specified links. + /// + /// + /// + /// + /// The links. + /// + /// + /// + /// The ignored repos. + /// + /// + public HashSet GetIgnoredRepositories(IList links) + { + HashSet ignoredRepos = new() { }; + foreach (var link in links) + { + var values = link.Values; + if (values != null && values.Count == 3 && string.Equals(values.First().Id, "ignore", StringComparison.OrdinalIgnoreCase) && string.Equals(values.Last().Id.Trim('.'), "repository", StringComparison.OrdinalIgnoreCase)) + { + ignoredRepos.Add(values[1].Id); + } + } + return ignoredRepos; + } + + /// + /// + /// Gets the active users in the specified month. + /// + /// + /// + /// + /// The ignored repositories. + /// + /// + /// + /// The owner. + /// + /// + /// + /// The year. + /// + /// + /// + /// The month. + /// + /// + /// + /// A dictionary with user activities. + /// + /// + public async Task>> GetActiveUsersInMonth(HashSet ignoredRepositories, string owner, int year, int month) + { + var usersActivity = new Dictionary>(); + + var startDate = new DateTime(year, month, 1); + var endDate = startDate.AddMonths(1).AddDays(-1); + + var repositories = await _storage.GetAllRepositories(owner); + + foreach (var repository in repositories) + { + if (ignoredRepositories.Contains(repository.Name)) + { + continue; + } + + // Get commits for the specified month + var commits = await _storage.GetCommits(repository.Id, new CommitRequest + { + Since = startDate, + Until = endDate + }); + + foreach (var commit in commits) + { + var authorLogin = commit.Author?.Login; + if (!string.IsNullOrEmpty(authorLogin)) + { + if (!usersActivity.ContainsKey(authorLogin)) + { + usersActivity[authorLogin] = new List(); + } + + var activity = $"Commit in {repository.Name}: {commit.Commit.Message.Split('\n').FirstOrDefault()}"; + if (!usersActivity[authorLogin].Contains(activity)) + { + usersActivity[authorLogin].Add(activity); + } + } + } + + // Get pull requests created/updated in the specified month + var pullRequests = await _storage.GetPullRequests(repository.Id); + + foreach (var pr in pullRequests) + { + // Check if PR was created or updated in the target month + if ((pr.CreatedAt >= startDate && pr.CreatedAt <= endDate) || + (pr.UpdatedAt >= startDate && pr.UpdatedAt <= endDate)) + { + // Get reviews for this pull request + var reviews = await _storage.Client.PullRequest.Review.GetAll(repository.Id, pr.Number); + + foreach (var review in reviews) + { + if (review.SubmittedAt >= startDate && review.SubmittedAt <= endDate) + { + var reviewerLogin = review.User?.Login; + if (!string.IsNullOrEmpty(reviewerLogin)) + { + if (!usersActivity.ContainsKey(reviewerLogin)) + { + usersActivity[reviewerLogin] = new List(); + } + + var activity = $"Review in {repository.Name}: PR #{pr.Number} - {pr.Title}"; + if (!usersActivity[reviewerLogin].Contains(activity)) + { + usersActivity[reviewerLogin].Add(activity); + } + } + } + } + } + } + } + + return usersActivity; + } + + /// + /// + /// Formats the result for display. + /// + /// + /// + /// + /// The users activity. + /// + /// + /// + /// The year. + /// + /// + /// + /// The month. + /// + /// + /// + /// The formatted result string. + /// + /// + private string FormatResult(Dictionary> usersActivity, int year, int month) + { + var monthName = CultureInfo.CurrentCulture.DateTimeFormat.GetMonthName(month); + + if (!usersActivity.Any()) + { + return $"No commit or review activity found for {monthName} {year}."; + } + + var result = $"# Users with commit and/or review activity in {monthName} {year}\n\n"; + + var sortedUsers = usersActivity.OrderBy(kvp => kvp.Key).ToList(); + + result += $"**Total active users: {sortedUsers.Count}**\n\n"; + + foreach (var userActivity in sortedUsers) + { + result += $"## @{userActivity.Key}\n"; + result += $"Activities ({userActivity.Value.Count}):\n"; + + foreach (var activity in userActivity.Value.Take(5)) // Limit to 5 activities per user to avoid too long messages + { + result += $"- {activity}\n"; + } + + if (userActivity.Value.Count > 5) + { + result += $"- ... and {userActivity.Value.Count - 5} more activities\n"; + } + result += "\n"; + } + + return result; + } + } +} \ No newline at end of file diff --git a/examples/MonthlyActivityTrigger_Usage.md b/examples/MonthlyActivityTrigger_Usage.md new file mode 100644 index 00000000..c3ae8a09 --- /dev/null +++ b/examples/MonthlyActivityTrigger_Usage.md @@ -0,0 +1,88 @@ +# Monthly Commit and Review Activity Trigger + +This document explains how to use the `MonthlyCommitAndReviewActivityTrigger` feature that was implemented to solve GitHub issue #92. + +## Purpose + +This trigger collects users who made commits and/or reviews in a specified month using GitHub Bot or GitHub API. + +## How to Use + +1. **Create an Issue** with a title containing one of these phrases: + - "monthly commit and review activity" + - "collect users who made commits" + +2. **Specify Month and Year** in the issue body using this format: + ``` + month: 11 + year: 2023 + ``` + +3. **Optional: Ignore Repositories** by including Lino-formatted links in the issue body: + ``` + ignore repository_name repository. + ignore another_repo repository. + ``` + +## Example Issue + +**Title:** Monthly commit and review activity for November 2023 + +**Body:** +``` +Please collect all users who made commits and/or reviews in the specified month. + +month: 11 +year: 2023 + +ignore test-repo repository. +ignore archived-repo repository. +``` + +## Response Format + +The bot will respond with a formatted comment containing: + +1. **Summary**: Total number of active users +2. **User List**: Each user with their activities: + - Commits made in the specified month + - Reviews submitted in the specified month + - Up to 5 activities shown per user (with count if more exist) + +## Example Response + +```markdown +# Users with commit and/or review activity in November 2023 + +**Total active users: 5** + +## @alice +Activities (7): +- Commit in project-a: Add new authentication module +- Commit in project-b: Fix bug in user validation +- Review in project-c: PR #123 - Update documentation +- Commit in project-a: Refactor login component +- Review in project-a: PR #124 - Add unit tests +- ... and 2 more activities + +## @bob +Activities (3): +- Review in project-b: PR #125 - Security improvements +- Commit in project-d: Initial commit +- Review in project-d: PR #126 - Add CI/CD pipeline +``` + +## Default Behavior + +- If month or year is not specified, defaults to the previous month +- Only processes repositories the bot has access to +- Respects ignored repositories list +- Closes the issue after posting the response + +## Date Range + +The trigger searches for: +- **Commits**: Created during the specified month +- **Reviews**: Submitted during the specified month + +The search includes the entire month (1st to last day) in the organization's repositories. \ No newline at end of file diff --git a/examples/TestMonthlyActivityTrigger.cs b/examples/TestMonthlyActivityTrigger.cs new file mode 100644 index 00000000..f353e6c9 --- /dev/null +++ b/examples/TestMonthlyActivityTrigger.cs @@ -0,0 +1,45 @@ +using System; +using Platform.Bot.Triggers; + +// Simple test class to verify parsing logic +class TestMonthlyActivityTrigger +{ + public static void Main() + { + Console.WriteLine("Testing MonthlyCommitAndReviewActivityTrigger date parsing..."); + + // Create dummy storage instance (we're only testing date parsing) + var trigger = new MonthlyCommitAndReviewActivityTrigger(null); + + // Test date parsing using reflection to access private method + var method = typeof(MonthlyCommitAndReviewActivityTrigger) + .GetMethod("ParseDateFromIssueBody", + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance); + + if (method != null) + { + // Test case 1: Valid month and year + var testBody1 = @"month: 11 +year: 2023"; + var result1 = (ValueTuple)method.Invoke(trigger, new object[] { testBody1 }); + Console.WriteLine($"Test 1 - Input: '{testBody1.Replace("\n", "\\n")}' -> Year: {result1.Item1}, Month: {result1.Item2}"); + + // Test case 2: Invalid format, should default to previous month + var testBody2 = "Some random text without proper format"; + var result2 = (ValueTuple)method.Invoke(trigger, new object[] { testBody2 }); + Console.WriteLine($"Test 2 - Input: '{testBody2}' -> Year: {result2.Item1}, Month: {result2.Item2} (default to last month)"); + + // Test case 3: Only year provided + var testBody3 = "year: 2022"; + var result3 = (ValueTuple)method.Invoke(trigger, new object[] { testBody3 }); + Console.WriteLine($"Test 3 - Input: '{testBody3}' -> Year: {result3.Item1}, Month: {result3.Item2}"); + } + else + { + Console.WriteLine("Could not find ParseDateFromIssueBody method"); + } + + Console.WriteLine("\nAll tests completed successfully!"); + } +} \ No newline at end of file