Skip to content

Commit 6f28c54

Browse files
konardclaude
andcommitted
Implement user commit links collector for GitHub bot
Added a new trigger that collects all commit links for a specific user in chronological order across all organization repositories. This addresses GitHub issue #90. Features: - Responds to issues titled "Collect commits for user <username>" and similar patterns - Extracts username from issue title or @username mentions in issue body - Collects commits from all organization repositories - Sorts commits chronologically (oldest first) - Groups commits by repository - Generates formatted markdown report with direct links to commits - Automatically closes the issue when complete - Includes comprehensive error handling and user feedback Implementation details: - Added UserCommitLinksCollectorTrigger.cs with full functionality - Integrated trigger into main Program.cs issue tracker - Added documentation to README.md explaining usage - Follows existing codebase patterns and conventions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent be61ea4 commit 6f28c54

File tree

3 files changed

+325
-1
lines changed

3 files changed

+325
-1
lines changed

csharp/Platform.Bot/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ private static async Task<int> Main(string[] args)
9595
var dbContext = new FileStorage(databaseFilePath?.FullName ?? new TemporaryFile().Filename);
9696
Console.WriteLine($"Bot has been started. {Environment.NewLine}Press CTRL+C to close");
9797
var githubStorage = new GitHubStorage(githubUserName, githubApiToken, githubApplicationName);
98-
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));
98+
var issueTracker = new IssueTracker(githubStorage, new HelloWorldTrigger(githubStorage, dbContext, fileSetName), new OrganizationLastMonthActivityTrigger(githubStorage), new LastCommitActivityTrigger(githubStorage), new UserCommitLinksCollectorTrigger(githubStorage), new AdminAuthorIssueTriggerDecorator(new ProtectDefaultBranchTrigger(githubStorage), githubStorage), new AdminAuthorIssueTriggerDecorator(new ChangeOrganizationRepositoriesDefaultBranchTrigger(githubStorage, dbContext), githubStorage), new AdminAuthorIssueTriggerDecorator(new ChangeOrganizationPullRequestsBaseBranchTrigger(githubStorage, dbContext), githubStorage));
9999
var pullRequenstTracker = new PullRequestTracker(githubStorage, new MergeDependabotBumpsTrigger(githubStorage));
100100
var timestampTracker = new DateTimeTracker(githubStorage, new CreateAndSaveOrganizationRepositoriesMigrationTrigger(githubStorage, dbContext, Path.Combine(Directory.GetCurrentDirectory(), "/github-migrations")));
101101
var cancellation = new CancellationTokenSource();

csharp/Platform.Bot/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,32 @@ dotnet run MyNickname ghp_123 MyAppName db.links HelloWorldSet
4040
```shell
4141
./run.sh NICKNAME TOKEN APP_NAME
4242
```
43+
44+
## Features
45+
46+
The bot supports several triggers that respond to specific GitHub issues:
47+
48+
### Commit Collection Feature
49+
50+
To collect all commit links for a specific user in chronological order, create an issue with one of these titles:
51+
- `Collect commits for user <username>`
52+
- `User commits <username>`
53+
- `Collect user commits`
54+
- `Collect all user commits`
55+
56+
You can also specify the username in the issue body using `@username` syntax.
57+
58+
**Example:**
59+
1. Create an issue titled: "Collect commits for user konard"
60+
2. The bot will automatically:
61+
- Find all commits by that user across all organization repositories
62+
- Sort them chronologically (oldest first)
63+
- Generate a detailed report with links to all commits
64+
- Post the results as a comment on the issue
65+
- Close the issue when complete
66+
67+
The output includes:
68+
- Total number of commits found
69+
- Commits grouped by repository
70+
- Each commit with timestamp and direct link to GitHub
71+
- Formatted as markdown for easy reading
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
using Interfaces;
2+
using Octokit;
3+
using Storage.Remote.GitHub;
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Linq;
7+
using System.Text;
8+
using System.Threading.Tasks;
9+
10+
namespace Platform.Bot.Triggers
11+
{
12+
using TContext = Issue;
13+
14+
/// <summary>
15+
/// <para>
16+
/// Represents a trigger that collects all links to all user's commits in chronological order.
17+
/// </para>
18+
/// <para></para>
19+
/// </summary>
20+
internal class UserCommitLinksCollectorTrigger : ITrigger<TContext>
21+
{
22+
private readonly GitHubStorage _githubStorage;
23+
24+
/// <summary>
25+
/// <para>
26+
/// Initializes a new <see cref="UserCommitLinksCollectorTrigger"/> instance.
27+
/// </para>
28+
/// <para></para>
29+
/// </summary>
30+
/// <param name="storage">
31+
/// <para>The GitHub storage instance.</para>
32+
/// <para></para>
33+
/// </param>
34+
public UserCommitLinksCollectorTrigger(GitHubStorage storage) => _githubStorage = storage;
35+
36+
/// <summary>
37+
/// <para>
38+
/// Determines whether this trigger should be activated for the given issue.
39+
/// </para>
40+
/// <para></para>
41+
/// </summary>
42+
/// <param name="issue">
43+
/// <para>The issue context.</para>
44+
/// <para></para>
45+
/// </param>
46+
/// <returns>
47+
/// <para>True if the issue title matches the expected pattern.</para>
48+
/// <para></para>
49+
/// </returns>
50+
public Task<bool> Condition(TContext issue)
51+
{
52+
var title = issue.Title.ToLower().Trim();
53+
return Task.FromResult(title.StartsWith("collect commits for user") ||
54+
title.StartsWith("user commits") ||
55+
title == "collect user commits" ||
56+
title == "collect all user commits");
57+
}
58+
59+
/// <summary>
60+
/// <para>
61+
/// Collects all commit links for the specified user in chronological order.
62+
/// </para>
63+
/// <para></para>
64+
/// </summary>
65+
/// <param name="issue">
66+
/// <para>The issue context.</para>
67+
/// <para></para>
68+
/// </param>
69+
public async Task Action(TContext issue)
70+
{
71+
try
72+
{
73+
var organizationName = issue.Repository.Owner.Login;
74+
var userName = ExtractUserNameFromIssue(issue);
75+
76+
if (string.IsNullOrEmpty(userName))
77+
{
78+
await _githubStorage.CreateIssueComment(issue.Repository.Id, issue.Number,
79+
"❌ Could not extract username from issue. Please specify the username in the issue title or body.\n\n" +
80+
"Example: `Collect commits for user konard` or add `@username` in the issue body.");
81+
return;
82+
}
83+
84+
await _githubStorage.CreateIssueComment(issue.Repository.Id, issue.Number,
85+
$"🔍 Starting to collect commit links for user `{userName}` in chronological order...");
86+
87+
var userCommitLinks = await CollectUserCommitLinksChronologically(organizationName, userName);
88+
89+
if (!userCommitLinks.Any())
90+
{
91+
await _githubStorage.CreateIssueComment(issue.Repository.Id, issue.Number,
92+
$"ℹ️ No commits found for user `{userName}` in organization `{organizationName}`.");
93+
}
94+
else
95+
{
96+
var message = BuildCommitLinksMessage(userName, userCommitLinks);
97+
await _githubStorage.CreateIssueComment(issue.Repository.Id, issue.Number, message);
98+
}
99+
100+
Console.WriteLine($"Issue {issue.Title} is processed: {issue.HtmlUrl}");
101+
await _githubStorage.Client.Issue.Update(issue.Repository.Owner.Login, issue.Repository.Name, issue.Number,
102+
new IssueUpdate() { State = ItemState.Closed });
103+
}
104+
catch (Exception ex)
105+
{
106+
await _githubStorage.CreateIssueComment(issue.Repository.Id, issue.Number,
107+
$"❌ Error occurred while collecting commits: {ex.Message}");
108+
Console.WriteLine($"Error processing issue {issue.Title}: {ex.Message}");
109+
}
110+
}
111+
112+
/// <summary>
113+
/// <para>
114+
/// Extracts the username from the issue title or body.
115+
/// </para>
116+
/// <para></para>
117+
/// </summary>
118+
/// <param name="issue">
119+
/// <para>The issue to extract username from.</para>
120+
/// <para></para>
121+
/// </param>
122+
/// <returns>
123+
/// <para>The extracted username or null if not found.</para>
124+
/// <para></para>
125+
/// </returns>
126+
private string ExtractUserNameFromIssue(Issue issue)
127+
{
128+
var title = issue.Title.ToLower();
129+
var body = issue.Body?.ToLower() ?? string.Empty;
130+
131+
// Try to extract from title patterns like "collect commits for user konard"
132+
var titleWords = title.Split(' ');
133+
for (int i = 0; i < titleWords.Length - 1; i++)
134+
{
135+
if (titleWords[i] == "user" && i + 1 < titleWords.Length)
136+
{
137+
return titleWords[i + 1];
138+
}
139+
}
140+
141+
// Try to extract @username from body
142+
if (body.Contains("@"))
143+
{
144+
var bodyWords = body.Split(' ', '\n', '\r');
145+
foreach (var word in bodyWords)
146+
{
147+
if (word.StartsWith("@") && word.Length > 1)
148+
{
149+
return word.Substring(1);
150+
}
151+
}
152+
}
153+
154+
return null;
155+
}
156+
157+
/// <summary>
158+
/// <para>
159+
/// Collects all commit links for a specific user across all organization repositories in chronological order.
160+
/// </para>
161+
/// <para></para>
162+
/// </summary>
163+
/// <param name="organizationName">
164+
/// <para>The organization name.</para>
165+
/// <para></para>
166+
/// </param>
167+
/// <param name="userName">
168+
/// <para>The username to collect commits for.</para>
169+
/// <para></para>
170+
/// </param>
171+
/// <returns>
172+
/// <para>List of commit information sorted chronologically (oldest first).</para>
173+
/// <para></para>
174+
/// </returns>
175+
private async Task<List<CommitInfo>> CollectUserCommitLinksChronologically(string organizationName, string userName)
176+
{
177+
var allCommitInfos = new List<CommitInfo>();
178+
var allRepositories = await _githubStorage.GetAllRepositories(organizationName);
179+
180+
if (!allRepositories.Any())
181+
{
182+
return allCommitInfos;
183+
}
184+
185+
foreach (var repository in allRepositories)
186+
{
187+
try
188+
{
189+
// Check if repository has any branches
190+
var branches = await _githubStorage.Client.Repository.Branch.GetAll(repository.Id);
191+
if (!branches.Any())
192+
{
193+
continue;
194+
}
195+
196+
// Get commits from all time for this repository
197+
var commits = await _githubStorage.GetCommits(repository.Id, new CommitRequest
198+
{
199+
Author = userName
200+
});
201+
202+
foreach (var commit in commits)
203+
{
204+
if (commit.Author?.Login?.ToLower() == userName.ToLower())
205+
{
206+
allCommitInfos.Add(new CommitInfo
207+
{
208+
Commit = commit,
209+
Repository = repository,
210+
CommittedDate = commit.Commit.Author.Date
211+
});
212+
}
213+
}
214+
}
215+
catch (Exception ex)
216+
{
217+
Console.WriteLine($"Warning: Could not get commits from repository {repository.Name}: {ex.Message}");
218+
// Continue with other repositories
219+
}
220+
}
221+
222+
// Sort chronologically (oldest first)
223+
return allCommitInfos.OrderBy(c => c.CommittedDate).ToList();
224+
}
225+
226+
/// <summary>
227+
/// <para>
228+
/// Builds a formatted message with all commit links.
229+
/// </para>
230+
/// <para></para>
231+
/// </summary>
232+
/// <param name="userName">
233+
/// <para>The username.</para>
234+
/// <para></para>
235+
/// </param>
236+
/// <param name="commitInfos">
237+
/// <para>List of commit information.</para>
238+
/// <para></para>
239+
/// </param>
240+
/// <returns>
241+
/// <para>Formatted markdown message.</para>
242+
/// <para></para>
243+
/// </returns>
244+
private string BuildCommitLinksMessage(string userName, List<CommitInfo> commitInfos)
245+
{
246+
var stringBuilder = new StringBuilder();
247+
stringBuilder.AppendLine($"# 📝 All Commit Links for User: `{userName}` (Chronological Order)");
248+
stringBuilder.AppendLine();
249+
stringBuilder.AppendLine($"**Total commits found:** {commitInfos.Count}");
250+
stringBuilder.AppendLine();
251+
252+
var currentRepository = string.Empty;
253+
foreach (var commitInfo in commitInfos)
254+
{
255+
// Add repository header when we switch to a new repository
256+
if (currentRepository != commitInfo.Repository.Name)
257+
{
258+
currentRepository = commitInfo.Repository.Name;
259+
stringBuilder.AppendLine($"## 📁 Repository: [{commitInfo.Repository.Name}]({commitInfo.Repository.HtmlUrl})");
260+
stringBuilder.AppendLine();
261+
}
262+
263+
// Format commit message (remove newlines and limit length)
264+
var commitMessage = commitInfo.Commit.Commit.Message.Replace('\n', ' ').Replace('\r', ' ');
265+
if (commitMessage.Length > 100)
266+
{
267+
commitMessage = commitMessage.Substring(0, 97) + "...";
268+
}
269+
270+
// Add commit link with date
271+
var commitDate = commitInfo.CommittedDate.ToString("yyyy-MM-dd HH:mm:ss");
272+
stringBuilder.AppendLine($"- **{commitDate}** - [{commitMessage}]({commitInfo.Commit.HtmlUrl})");
273+
}
274+
275+
stringBuilder.AppendLine();
276+
stringBuilder.AppendLine("---");
277+
stringBuilder.AppendLine("✅ **Collection completed successfully!**");
278+
279+
return stringBuilder.ToString();
280+
}
281+
}
282+
283+
/// <summary>
284+
/// <para>
285+
/// Helper class to store commit information with repository context.
286+
/// </para>
287+
/// <para></para>
288+
/// </summary>
289+
internal class CommitInfo
290+
{
291+
public GitHubCommit Commit { get; set; } = null!;
292+
public Repository Repository { get; set; } = null!;
293+
public DateTimeOffset CommittedDate { get; set; }
294+
}
295+
}

0 commit comments

Comments
 (0)