diff --git a/Apps.GitLab/Actions/BranchActions.cs b/Apps.GitLab/Actions/BranchActions.cs index 1ddd2f3..22f87d2 100644 --- a/Apps.GitLab/Actions/BranchActions.cs +++ b/Apps.GitLab/Actions/BranchActions.cs @@ -6,7 +6,6 @@ using Blackbird.Applications.Sdk.Common; using Blackbird.Applications.Sdk.Common.Actions; using Blackbird.Applications.Sdk.Common.Invocation; -using Blackbird.Applications.SDK.Extensions.FileManagement.Interfaces; using GitLabApiClient.Models.Branches.Responses; using RestSharp; @@ -17,7 +16,7 @@ public class BranchActions(InvocationContext invocationContext) : GitLabActions(invocationContext) { - [Action("List branches", Description = "List respository branches")] + [Action("Search branches", Description = "Search repository branches")] public async Task ListRepositoryBranches([ActionParameter] GetRepositoryRequest input) { var projectId = ParseProjectId(input.RepositoryId); @@ -30,7 +29,7 @@ public async Task ListRepositoryBranches([Action }; } - [Action("Get branch", Description = "Get branch by name")] + [Action("Get branch", Description = "Get branch details by name")] public async Task GetBranch( [ActionParameter] GetRepositoryRequest repositoryRequest, [ActionParameter] GetBranchRequest input) @@ -44,7 +43,7 @@ public async Task GetBranch( return new BranchDto(branch); } - [Action("Create branch", Description = "Create branch")] + [Action("Create branch", Description = "Create branch from a base branch")] public async Task CreateBranch( [ActionParameter] GetRepositoryRequest repositoryRequest, [ActionParameter] Models.Branch.Requests.CreateBranchRequest input) diff --git a/Apps.GitLab/Actions/CommitActions.cs b/Apps.GitLab/Actions/CommitActions.cs index d693816..837a8d4 100644 --- a/Apps.GitLab/Actions/CommitActions.cs +++ b/Apps.GitLab/Actions/CommitActions.cs @@ -22,6 +22,7 @@ namespace Apps.Gitlab.Actions; [ActionList("Commit")] public class CommitActions : GitLabActions { + private const int CommitsPageSize = 100; private readonly IFileManagementClient _fileManagementClient; public CommitActions(InvocationContext invocationContext, IFileManagementClient fileManagementClient) @@ -30,26 +31,124 @@ public CommitActions(InvocationContext invocationContext, IFileManagementClient _fileManagementClient = fileManagementClient; } - [Action("List commits", Description = "List respository commits")] + [Action("Search commits", Description = "Search commits in a repository")] public async Task ListRepositoryCommits( [ActionParameter] GetRepositoryRequest repositoryRequest, - [ActionParameter] GetOptionalBranchRequest branchRequest) + [ActionParameter] GetOptionalBranchRequest branchRequest, + [ActionParameter] ListCommitsRequest searchRequest) + { + var commits = await SearchRepositoryCommits(repositoryRequest, branchRequest, searchRequest); + + return new() + { + Count = commits.Count, + Commits = commits.Select(commit => new CommitResponse(commit)) + }; + } + + [Action("Find commit", Description = "Find first commit that matches search filters in a repository")] + public async Task FindCommit( + [ActionParameter] GetRepositoryRequest repositoryRequest, + [ActionParameter] GetOptionalBranchRequest branchRequest, + [ActionParameter] SearchCommitsRequest searchRequest) + { + var commit = await FindRepositoryCommit(repositoryRequest, branchRequest, searchRequest) + ?? throw new PluginApplicationException("No matching commit was found."); + + return new(commit); + } + + private async Task> SearchRepositoryCommits( + GetRepositoryRequest repositoryRequest, + GetOptionalBranchRequest branchRequest, + ListCommitsRequest searchRequest) { var projectId = ParseProjectId(repositoryRequest.RepositoryId); + var includedAuthors = NormalizeFilterValues(searchRequest.AuthorsToInclude).ToList(); + var maximumResults = GetMaximumResults(searchRequest); + var commits = new List(); + var page = 1; + + while (true) + { + var pageCommits = await GetRepositoryCommitsPage(projectId, branchRequest, searchRequest, includedAuthors, page); + if (pageCommits.Count == 0) + break; + + var matchingCommits = FilterCommits(pageCommits, searchRequest, includedAuthors) + .Take(maximumResults - commits.Count) + .ToList(); + + commits.AddRange(matchingCommits); + if (commits.Count >= maximumResults) + break; + + if (pageCommits.Count < CommitsPageSize) + break; + + page++; + } + + return commits; + } + + private async Task FindRepositoryCommit( + GetRepositoryRequest repositoryRequest, + GetOptionalBranchRequest branchRequest, + SearchCommitsRequest searchRequest) + { + var projectId = ParseProjectId(repositoryRequest.RepositoryId); + var includedAuthors = NormalizeFilterValues(searchRequest.AuthorsToInclude).ToList(); + var page = 1; + + while (true) + { + var pageCommits = await GetRepositoryCommitsPage(projectId, branchRequest, searchRequest, includedAuthors, page); + if (pageCommits.Count == 0) + return null; + + var commit = FilterCommits(pageCommits, searchRequest, includedAuthors).FirstOrDefault(); + if (commit is not null) + return commit; + + if (pageCommits.Count < CommitsPageSize) + return null; + + page++; + } + } + + private async Task> GetRepositoryCommitsPage( + int projectId, + GetOptionalBranchRequest branchRequest, + SearchCommitsRequest searchRequest, + IReadOnlyCollection includedAuthors, + int page) + { var request = RestClient.CreateRequest($"/projects/{projectId}/repository/commits", Method.Get); + request.AddQueryParameter("per_page", CommitsPageSize.ToString()); + request.AddQueryParameter("page", page.ToString()); if (!string.IsNullOrWhiteSpace(branchRequest.Name)) request.AddQueryParameter("ref_name", branchRequest.Name); - var commits = await RestClient.ExecuteWithErrorHandling>(request); - return new() - { - Commits = commits - }; + if (searchRequest.CommitAfter.HasValue) + request.AddQueryParameter("since", FormatGitLabDate(searchRequest.CommitAfter.Value)); + + if (searchRequest.CommitBefore.HasValue) + request.AddQueryParameter("until", FormatGitLabDate(searchRequest.CommitBefore.Value)); + + if (!string.IsNullOrWhiteSpace(searchRequest.FilePath)) + request.AddQueryParameter("path", searchRequest.FilePath); + + if (includedAuthors.Count == 1) + request.AddQueryParameter("author", includedAuthors.First()); + + return await RestClient.ExecuteWithErrorHandling>(request); } - [Action("Get commit", Description = "Get commit by id")] - public async Task GetCommit( + [Action("Get commit", Description = "Get commit details by commit ID")] + public async Task GetCommit( [ActionParameter] GetRepositoryRequest repositoryRequest, [ActionParameter] GetCommitRequest input) { @@ -58,10 +157,10 @@ public async Task GetCommit( $"/projects/{projectId}/repository/commits/{Uri.EscapeDataString(input.CommitId)}", Method.Get); - return await RestClient.ExecuteWithErrorHandling(request); + return new(await RestClient.ExecuteWithErrorHandling(request)); } - [Action("List added or modified files in X hours", Description = "List added or modified files in X hours")] + [Action("Search added or modified files in X hours", Description = "Search files added or modified during specified number of hours")] public async Task ListAddedOrModifiedInHours( [ActionParameter] GetRepositoryRequest repositoryRequest, [ActionParameter] GetOptionalBranchRequest branchRequest, @@ -103,7 +202,7 @@ public async Task ListAddedOrModifiedInHours }; } - [Action("Create or update file", Description = "Create or update file")] + [Action("Create or update file", Description = "Create file or update existing file in a repository")] public async Task PushFile( [ActionParameter] GetRepositoryRequest repositoryRequest, [ActionParameter] GetOptionalBranchRequest branchRequest, @@ -142,7 +241,7 @@ public async Task PushFile( return new(pushFileResult); } - [Action("Update file", Description = "Update file in repository")] + [Action("Update file", Description = "Update existing file in a repository")] public async Task UpdateFile( [ActionParameter] GetRepositoryRequest repositoryRequest, [ActionParameter] GetOptionalBranchRequest branchRequest, @@ -166,7 +265,7 @@ public async Task UpdateFile( return new(fileUpload); } - [Action("Delete file", Description = "Delete file from repository")] + [Action("Delete file", Description = "Delete file from a repository")] public async Task DeleteFile( [ActionParameter] GetRepositoryRequest repositoryRequest, [ActionParameter] GetOptionalBranchRequest branchRequest, @@ -183,4 +282,53 @@ public async Task DeleteFile( Message = fileDelete.Message }; } + + private static IEnumerable FilterCommits( + IEnumerable commits, + SearchCommitsRequest searchRequest, + IReadOnlyCollection includedAuthors) + { + var excludedAuthors = NormalizeFilterValues(searchRequest.AuthorsToExclude).ToList(); + var messageFilter = searchRequest.CommitMessageContains?.Trim(); + + return commits + .Where(commit => !searchRequest.CommitAfter.HasValue || + commit.CreatedAt.ToUniversalTime() > searchRequest.CommitAfter.Value.ToUniversalTime()) + .Where(commit => !searchRequest.CommitBefore.HasValue || + commit.CreatedAt.ToUniversalTime() < searchRequest.CommitBefore.Value.ToUniversalTime()) + .Where(commit => includedAuthors.Count == 0 || AuthorMatches(commit, includedAuthors)) + .Where(commit => excludedAuthors.Count == 0 || !AuthorMatches(commit, excludedAuthors)) + .Where(commit => string.IsNullOrWhiteSpace(messageFilter) || CommitMessageMatches(commit, messageFilter)); + } + + private static IEnumerable NormalizeFilterValues(IEnumerable? values) + => values? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim()) + ?? Enumerable.Empty(); + + private static int GetMaximumResults(ListCommitsRequest searchRequest) + { + var maximumResults = searchRequest.MaximumResults ?? 100; + if (maximumResults <= 0) + throw new PluginMisconfigurationException("Maximum results must be greater than 0."); + + return maximumResults; + } + + private static bool AuthorMatches(Commit commit, IEnumerable authors) + => authors.Any(author => + ContainsIgnoreCase(commit.AuthorName, author) || + ContainsIgnoreCase(commit.AuthorEmail, author)); + + private static bool CommitMessageMatches(Commit commit, string messageFilter) + => ContainsIgnoreCase(commit.Message, messageFilter) || + ContainsIgnoreCase(commit.Title, messageFilter); + + private static bool ContainsIgnoreCase(string? value, string searchValue) + => !string.IsNullOrWhiteSpace(value) && + value.Contains(searchValue, StringComparison.OrdinalIgnoreCase); + + private static string FormatGitLabDate(DateTime date) + => date.ToUniversalTime().ToString("O"); } diff --git a/Apps.GitLab/Actions/PullRequestActions.cs b/Apps.GitLab/Actions/PullRequestActions.cs index 4fa3530..bc380bb 100644 --- a/Apps.GitLab/Actions/PullRequestActions.cs +++ b/Apps.GitLab/Actions/PullRequestActions.cs @@ -11,12 +11,12 @@ namespace Apps.Gitlab.Actions; -[ActionList("Pull request")] +[ActionList("Merge request")] public class PullRequestActions(InvocationContext invocationContext) : GitLabActions(invocationContext) { - [Action("List merge requests", Description = "List merge requests")] + [Action("Search merge requests", Description = "Search merge requests in a repository")] public async Task ListPullRequests( [ActionParameter] GetRepositoryRequest repositoryRequest) { @@ -30,7 +30,7 @@ public async Task ListPullRequests( }; } - [Action("Get merge request", Description = "Get merge request")] + [Action("Get merge request", Description = "Get merge request details")] public async Task GetPullRequest( [ActionParameter] GetRepositoryRequest repositoryRequest, [ActionParameter] GetPullRequest input) @@ -62,7 +62,7 @@ public async Task CreatePullRequest( return await RestClient.ExecuteWithErrorHandling(request); } - [Action("Complete merge request", Description = "Complete merge request")] + [Action("Complete merge request", Description = "Complete merge request by merging it")] public async Task MergePullRequest( [ActionParameter] GetRepositoryRequest repositoryRequest, [ActionParameter] GetPullRequest mergeRequest, diff --git a/Apps.GitLab/Actions/RepositoryActions.cs b/Apps.GitLab/Actions/RepositoryActions.cs index c4c777c..74510be 100644 --- a/Apps.GitLab/Actions/RepositoryActions.cs +++ b/Apps.GitLab/Actions/RepositoryActions.cs @@ -35,7 +35,7 @@ public RepositoryActions(InvocationContext invocationContext, IFileManagementCli _fileManagementClient = fileManagementClient; } - [Action("Create new repository", Description = "Create new repository")] + [Action("Create new repository", Description = "Create repository with selected settings")] public async Task CreateRepository([ActionParameter] CreateRepositoryInput input) { var endpoint = "/projects"; @@ -50,7 +50,7 @@ public async Task CreateRepository([ActionParameter] CreateR return RepositoryResponse.FromProject(project); } - [Action("Get repository file", Description = "Get repository file by path")] + [Action("Get repository file", Description = "Get file from a repository by file path")] public async Task GetFile( [ActionParameter] GetRepositoryRequest repositoryRequest, [ActionParameter] GetOptionalBranchRequest branchRequest, @@ -87,7 +87,7 @@ public async Task GetFile( }; } - [Action("Get all files in folder", Description = "Get all files in folder")] + [Action("Get all files in folder", Description = "Get files from a repository folder")] public async Task GetAllFilesInFolder( [ActionParameter] GetRepositoryRequest repositoryRequest, [ActionParameter] GetOptionalBranchRequest branchRequest, @@ -143,14 +143,14 @@ public async Task GetAllFilesInFolder( return new GetRepositoryFilesFromFilepathsResponse { Files = resultFiles }; } - [Action("Get repository", Description = "Get repository info")] + [Action("Get repository", Description = "Get repository details")] public async Task GetRepositoryById([ActionParameter] GetRepositoryRequest input) { var project = await GetProject(ParseProjectId(input.RepositoryId)); return RepositoryResponse.FromProject(project); } - [Action("Get repository issues", Description = "Get opened issues against repository")] + [Action("Search repository issues", Description = "Get open issues in a repository")] public async Task GetIssuesInRepository([ActionParameter] RepositoryRequest input) { var projectId = ParseProjectId(input.RepositoryId); @@ -163,7 +163,7 @@ public async Task GetIssuesInRepository([ActionParameter] Rep }; } - [Action("Get repository merge requests", Description = "Get opened merge requests in a repository")] + [Action("Search repository merge requests", Description = "Get open merge requests in a repository")] public async Task GetPullRequestsInRepository([ActionParameter] RepositoryRequest input) { var projectId = ParseProjectId(input.RepositoryId); @@ -176,7 +176,7 @@ public async Task GetPullRequestsInRepository([ActionPa }; } - [Action("List repository folder content", Description = "List repository folder content")] + [Action("Search repository folder content", Description = "Search folder content in a repository")] public async Task ListRepositoryContent( [ActionParameter] GetRepositoryRequest repositoryRequest, [ActionParameter] GetOptionalBranchRequest branchRequest, @@ -200,7 +200,7 @@ public async Task ListRepositoryContent( }; } - [Action("List repositories", Description = "List all repositories")] + [Action("Search repositories", Description = "Search repositories available to connection")] public async Task ListRepositories() { var request = RestClient.CreateRequest("/projects", Method.Get); @@ -210,7 +210,7 @@ public async Task ListRepositories() return new(projects.ToArray()); } - [Action("Get files by filepaths", Description = "Get files by filepaths from webhooks")] + [Action("Search files by filepaths", Description = "Get files from a repository by file paths")] public async Task GetRepositoryFilesFromFilepaths( [ActionParameter] GetRepositoryRequest repositoryRequest, [ActionParameter] GetOptionalBranchRequest branchRequest, @@ -237,7 +237,7 @@ public async Task GetRepositoryFilesFro }; } - [Action("Branch exists", Description = "Branch exists in specified repository")] + [Action("Check if branch exists", Description = "Check whether branch exists in a repository")] public async Task BranchExists( [ActionParameter] GetRepositoryRequest repositoryRequest, [ActionParameter][Display("Branch name")] string branchNameRequest) diff --git a/Apps.GitLab/Apps.GitLab.csproj b/Apps.GitLab/Apps.GitLab.csproj index 912a6a7..274984d 100644 --- a/Apps.GitLab/Apps.GitLab.csproj +++ b/Apps.GitLab/Apps.GitLab.csproj @@ -5,8 +5,8 @@ enable enable GitLab - Software development & version control - 1.0.13 + GitLab is a developer platform that allows developers to create, store, and manage their code. This app focuses on connecting repository events and file actions into the Blackbird ecosystem. + 1.0.14 Apps.GitLab @@ -25,5 +25,6 @@ + diff --git a/Apps.GitLab/Models/Commit/Requests/AddedOrModifiedHoursRequest.cs b/Apps.GitLab/Models/Commit/Requests/AddedOrModifiedHoursRequest.cs index f4a328a..b7ae0fd 100644 --- a/Apps.GitLab/Models/Commit/Requests/AddedOrModifiedHoursRequest.cs +++ b/Apps.GitLab/Models/Commit/Requests/AddedOrModifiedHoursRequest.cs @@ -4,6 +4,6 @@ namespace Apps.GitLab.Models.Commit.Requests; public class AddedOrModifiedHoursRequest { - [Display("Last X hours", Description = "List changes in specified hours amount")] + [Display("Last X hours", Description = "Number of hours to search for changes")] public int Hours { get; set; } -} \ No newline at end of file +} diff --git a/Apps.GitLab/Models/Commit/Requests/GetCommitRequest.cs b/Apps.GitLab/Models/Commit/Requests/GetCommitRequest.cs index 03c032f..33eb5c3 100644 --- a/Apps.GitLab/Models/Commit/Requests/GetCommitRequest.cs +++ b/Apps.GitLab/Models/Commit/Requests/GetCommitRequest.cs @@ -4,6 +4,6 @@ namespace Apps.Gitlab.Models.Commit.Requests; public class GetCommitRequest { - [Display("Commit ID (Sha)")] + [Display("Commit ID (SHA)")] public string CommitId { get; set; } -} \ No newline at end of file +} diff --git a/Apps.GitLab/Models/Commit/Requests/ListCommitsRequest.cs b/Apps.GitLab/Models/Commit/Requests/ListCommitsRequest.cs new file mode 100644 index 0000000..0f3e084 --- /dev/null +++ b/Apps.GitLab/Models/Commit/Requests/ListCommitsRequest.cs @@ -0,0 +1,9 @@ +using Blackbird.Applications.Sdk.Common; + +namespace Apps.GitLab.Models.Commit.Requests; + +public class ListCommitsRequest : SearchCommitsRequest +{ + [Display("Maximum results", Description = "Maximum number of matching commits to return")] + public int? MaximumResults { get; set; } = 100; +} diff --git a/Apps.GitLab/Models/Commit/Requests/SearchCommitsRequest.cs b/Apps.GitLab/Models/Commit/Requests/SearchCommitsRequest.cs new file mode 100644 index 0000000..3667816 --- /dev/null +++ b/Apps.GitLab/Models/Commit/Requests/SearchCommitsRequest.cs @@ -0,0 +1,24 @@ +using Blackbird.Applications.Sdk.Common; + +namespace Apps.GitLab.Models.Commit.Requests; + +public class SearchCommitsRequest +{ + [Display("Authors to include", Description = "Author names or emails to include")] + public List? AuthorsToInclude { get; set; } + + [Display("Authors to exclude", Description = "Author names or emails to exclude")] + public List? AuthorsToExclude { get; set; } + + [Display("Commit after", Description = "Only commits after this date")] + public DateTime? CommitAfter { get; set; } + + [Display("Commit before", Description = "Only commits before this date")] + public DateTime? CommitBefore { get; set; } + + [Display("Commit message contains")] + public string? CommitMessageContains { get; set; } + + [Display("File path", Description = "Only commits touching this file path")] + public string? FilePath { get; set; } +} diff --git a/Apps.GitLab/Models/Commit/Responses/CommitResponse.cs b/Apps.GitLab/Models/Commit/Responses/CommitResponse.cs new file mode 100644 index 0000000..4fab772 --- /dev/null +++ b/Apps.GitLab/Models/Commit/Responses/CommitResponse.cs @@ -0,0 +1,90 @@ +using Blackbird.Applications.Sdk.Common; +using GitLabCommit = GitLabApiClient.Models.Commits.Responses.Commit; +using GitLabCommitStats = GitLabApiClient.Models.Commits.Responses.CommitStats; + +namespace Apps.GitLab.Models.Commit.Responses; + +public class CommitResponse +{ + [Display("Commit ID")] + public string Id { get; set; } = string.Empty; + + [Display("Short commit ID")] + public string ShortId { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + [Display("Author name")] + public string AuthorName { get; set; } = string.Empty; + + [Display("Author email")] + public string AuthorEmail { get; set; } = string.Empty; + + [Display("Authored date")] + public DateTime AuthoredDate { get; set; } + + [Display("Committer name")] + public string CommitterName { get; set; } = string.Empty; + + [Display("Committer email")] + public string CommitterEmail { get; set; } = string.Empty; + + [Display("Committed date")] + public DateTime CommittedDate { get; set; } + + [Display("Created at")] + public DateTime CreatedAt { get; set; } + + public string Message { get; set; } = string.Empty; + + [Display("Parent commit IDs")] + public List ParentIds { get; set; } = []; + + [Display("Web URL")] + public string WebUrl { get; set; } = string.Empty; + + [Display("Commit stats")] + public CommitStatsResponse? CommitStats { get; set; } + + public CommitResponse() + { + } + + public CommitResponse(GitLabCommit commit) + { + Id = commit.Id; + ShortId = commit.ShortId; + Title = commit.Title; + AuthorName = commit.AuthorName; + AuthorEmail = commit.AuthorEmail; + AuthoredDate = commit.AuthoredDate; + CommitterName = commit.CommitterName; + CommitterEmail = commit.CommitterEmail; + CommittedDate = commit.CommittedDate; + CreatedAt = commit.CreatedAt; + Message = commit.Message; + ParentIds = commit.ParentIds ?? []; + WebUrl = commit.WebUrl; + CommitStats = commit.CommitStats is null ? null : new(commit.CommitStats); + } +} + +public class CommitStatsResponse +{ + public int Additions { get; set; } + + public int Deletions { get; set; } + + public int Total { get; set; } + + public CommitStatsResponse() + { + } + + public CommitStatsResponse(GitLabCommitStats commitStats) + { + Additions = commitStats.Additions; + Deletions = commitStats.Deletions; + Total = commitStats.Total; + } +} diff --git a/Apps.GitLab/Models/Commit/Responses/ListRepositoryCommitsResponse.cs b/Apps.GitLab/Models/Commit/Responses/ListRepositoryCommitsResponse.cs index 02f5acf..8af96c8 100644 --- a/Apps.GitLab/Models/Commit/Responses/ListRepositoryCommitsResponse.cs +++ b/Apps.GitLab/Models/Commit/Responses/ListRepositoryCommitsResponse.cs @@ -1,7 +1,12 @@ - -using GitLabApiClient.Models.Commits.Responses; +using Blackbird.Applications.Sdk.Common; + +namespace Apps.GitLab.Models.Commit.Responses; public class ListRepositoryCommitsResponse { - public IEnumerable Commits { get; set; } -} \ No newline at end of file + [Display("Count")] + public int Count { get; set; } + + [Display("Commits")] + public IEnumerable Commits { get; set; } = []; +} diff --git a/Apps.GitLab/Models/Respository/Requests/CreateRepositoryInput.cs b/Apps.GitLab/Models/Respository/Requests/CreateRepositoryInput.cs index cbc2b96..0b8875b 100644 --- a/Apps.GitLab/Models/Respository/Requests/CreateRepositoryInput.cs +++ b/Apps.GitLab/Models/Respository/Requests/CreateRepositoryInput.cs @@ -50,7 +50,7 @@ public class CreateRepositoryInput [StaticDataSource(typeof(RepoVisibilityDataHandler))] public string? Visibility { get; set; } - [Display("Import url")] + [Display("Import URL")] public string? ImportUrl { get; set; } [Display("Public jobs")] @@ -62,7 +62,7 @@ public class CreateRepositoryInput [Display("Only allow merge if all discussions are resolved")] public bool? OnlyAllowMergeIfAllDiscussionsAreResolved { get; set; } - [Display("Enable lfs")] + [Display("Enable LFS")] public bool? EnableLfs { get; set; } [Display("Enable request access")] @@ -74,7 +74,7 @@ public class CreateRepositoryInput [Display("Enable printing merge request link")] public bool? EnablePrintingMergeRequestLink { get; set; } - [Display("Ci config path")] + [Display("CI config path")] public string? CiConfigPath { get; set; } [Display("Initialize with README")] diff --git a/Apps.GitLab/Models/User/Responses/UserDataResponse.cs b/Apps.GitLab/Models/User/Responses/UserDataResponse.cs index abaf970..b24817d 100644 --- a/Apps.GitLab/Models/User/Responses/UserDataResponse.cs +++ b/Apps.GitLab/Models/User/Responses/UserDataResponse.cs @@ -31,7 +31,7 @@ public class UserDataResponse //} public string Name { get; set; } - [Display("Avatar url")] + [Display("Avatar URL")] public string AvatarUrl { get; set; } [Display("Account's bio")] @@ -57,7 +57,7 @@ public class UserDataResponse public bool? Hireable { get; set; } - [Display("HTML url")] + [Display("HTML URL")] public string HtmlUrl { get; set; } public int Id { get; set; } @@ -85,4 +85,4 @@ public class UserDataResponse [Display("The account's API URL")] public string Url { get; set; } -} \ No newline at end of file +} diff --git a/Apps.GitLab/Webhooks/Payloads/PushPayload.cs b/Apps.GitLab/Webhooks/Payloads/PushPayload.cs index 5c72221..0b89229 100644 --- a/Apps.GitLab/Webhooks/Payloads/PushPayload.cs +++ b/Apps.GitLab/Webhooks/Payloads/PushPayload.cs @@ -27,7 +27,7 @@ public class PushPayload public bool RefProtected { get; set; } [JsonProperty("checkout_sha")] - [Display("Checkout sha")] + [Display("Checkout SHA")] public string CheckoutSha { get; set; } [JsonProperty("user_id")] @@ -119,19 +119,19 @@ public class Project public string Description { get; set; } [JsonProperty("web_url")] - [Display("Web url")] + [Display("Web URL")] public string WebUrl { get; set; } [JsonProperty("avatar_url")] - [Display("Avatar url")] + [Display("Avatar URL")] public string AvatarUrl { get; set; } [JsonProperty("git_ssh_url")] - [Display("Git ssh url")] + [Display("Git SSH URL")] public string GitSshUrl { get; set; } [JsonProperty("git_http_url")] - [Display("Git http url")] + [Display("Git HTTP URL")] public string GitHttpUrl { get; set; } [JsonProperty("namespace")] @@ -150,7 +150,7 @@ public class Project public string DefaultBranch { get; set; } [JsonProperty("ci_config_path")] - [Display("Ci config path")] + [Display("CI config path")] public string CiConfigPath { get; set; } [JsonProperty("homepage")] @@ -160,11 +160,11 @@ public class Project public string Url { get; set; } [JsonProperty("ssh_url")] - [Display("Ssh url")] + [Display("SSH URL")] public string SshUrl { get; set; } [JsonProperty("http_url")] - [Display("Http url")] + [Display("HTTP URL")] public string HttpUrl { get; set; } } @@ -187,15 +187,14 @@ public class Repository public string Homepage { get; set; } [JsonProperty("git_http_url")] - [Display("Git http url")] + [Display("Git HTTP URL")] public string GitHttpUrl { get; set; } [JsonProperty("git_ssh_url")] - [Display("Git ssh url")] + [Display("Git SSH URL")] public string GitSshUrl { get; set; } [JsonProperty("visibility_level")] [Display("Visibility level")] public int VisibilityLevel { get; set; } } - diff --git a/Apps.GitLab/Webhooks/PushWebhooks.cs b/Apps.GitLab/Webhooks/PushWebhooks.cs index caaa342..3cbdc02 100644 --- a/Apps.GitLab/Webhooks/PushWebhooks.cs +++ b/Apps.GitLab/Webhooks/PushWebhooks.cs @@ -10,7 +10,7 @@ namespace Apps.Gitlab.Webhooks; [WebhookList] public class PushWebhooks { - [Webhook("On commit pushed", typeof(PushEventHandler), Description = "On commit pushed")] + [Webhook("On commit pushed", typeof(PushEventHandler), Description = "On commit pushed to a repository branch")] public async Task> CommitPushedHandler(WebhookRequest webhookRequest) { var data = JsonConvert.DeserializeObject(webhookRequest.Body.ToString()); @@ -23,7 +23,7 @@ public async Task> CommitPushedHandler(WebhookReque }; } - [Webhook("On files added", typeof(PushEventHandler), Description = "On files added")] + [Webhook("On files added", typeof(PushEventHandler), Description = "On files added by new commits")] public async Task> FilesAddedHandler(WebhookRequest webhookRequest, [WebhookParameter] FolderInput input) { @@ -47,7 +47,7 @@ public async Task> FilesAddedHandler(WebhookR return GeneratePreflight(); } - [Webhook("On files modified", typeof(PushEventHandler), Description = "On files modified")] + [Webhook("On files modified", typeof(PushEventHandler), Description = "On files modified by new commits")] public async Task> FilesModifiedHandler(WebhookRequest webhookRequest, [WebhookParameter] FolderInput input) { @@ -71,7 +71,7 @@ public async Task> FilesModifiedHandler(Webho return GeneratePreflight(); } - [Webhook("On files added or modified", typeof(PushEventHandler), Description = "On files added or modified")] + [Webhook("On files added or modified", typeof(PushEventHandler), Description = "On files added or modified by new commits")] public async Task> FilesAddedAndModifiedHandler(WebhookRequest webhookRequest, [WebhookParameter] FolderInput input) { @@ -100,7 +100,7 @@ public async Task> FilesAddedAndModifiedHandl return GeneratePreflight(); } - [Webhook("On files removed", typeof(PushEventHandler), Description = "On files removed")] + [Webhook("On files removed", typeof(PushEventHandler), Description = "On files removed by new commits")] public async Task> FilesRemovedHandler(WebhookRequest webhookRequest, [WebhookParameter] FolderInput input) { @@ -139,4 +139,4 @@ private WebhookResponse GeneratePreflight() where T : class HttpResponseMessage = new HttpResponseMessage(statusCode: HttpStatusCode.OK) }; } -} \ No newline at end of file +} diff --git a/README.md b/README.md index 222fd8f..55255f9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Blackbird is the new automation backbone for the language technology industry. B -GitLab, is a developer platform that allows developers to create, store, and manage their code. This GitLab app focusses on connecting GitLab events and file actions into the Blackbird ecosystem. +GitLab is a developer platform that allows developers to create, store, and manage their code. This app focuses on connecting repository events and file actions into the Blackbird ecosystem. ## Before setting up @@ -63,68 +63,96 @@ If your GitLab instance is hosted on a custom domain, use the **OAuth Self-manag ## Actions -### Repositories - -- **List repositories** -- **Create new repository** -- **Get repository** -- **Get repository issues** -- **List repository folder content** - -### Branches - -- **List branches** -- **Get branch** - -### Commits - -- **List commits** -- **Get commit** - -### Merge requests - -- **Create merge request** -- **List merge requests** -- **Get merge request** -- **Get repository merge requests** -- **Complete merge request** - -### Files - -- **Get repository file** -- **Get all files in folder** -- **Get files by filepaths** -- **Delete file** -- **Push file** -- **Update file** - -### Users - -- **Get my user data** -- **Get user** -- **Get user by username** - -### Utility - -- **Is file in folder** +### Repository + +- **Create new repository** Create repository with selected settings. + Advanced settings: + - **User ID**: User ID that owns the created repository. + - **Default branch**: Default branch name for the created repository. + - **Namespace ID**: Namespace ID for the created repository. + - **Description**: Repository description. + - **Enable issues**: Option to enable issues. + - **Enable merge requests**: Option to enable merge requests. + - **Enable jobs**: Option to enable jobs. + - **Enable wiki**: Option to enable wiki. + - **Enable snippets**: Option to enable snippets. + - **Enable container registry**: Option to enable the container registry. + - **Enable shared runners**: Option to enable shared runners. + - **Visibility**: Repository visibility. + - **Import URL**: URL to import repository content from. + - **Public jobs**: Option to make jobs public. + - **Only allow merge if pipeline succeeds**: Option to allow merges only after a successful pipeline. + - **Only allow merge if all discussions are resolved**: Option to allow merges only after all discussions are resolved. + - **Enable LFS**: Option to enable large file storage. + - **Enable request access**: Option to allow users to request access. + - **Tags**: Multiple tags to assign to the repository. + - **Enable printing merge request link**: Option to print merge request links. + - **CI config path**: CI config file path. + - **Initialize with README**: Option to create an initial README file. +- **Get repository file** Get file from a repository by file path. + Advanced settings: + - **Branch name**: Branch to use instead of the default branch. +- **Get all files in folder** Get files from a repository folder. + Advanced settings: + - **Folder path (e.g. "Folder1/Folder2")**: Folder path to get files from. + - **Include subfolders**: Option to include files in nested folders. +- **Get repository** Get repository details. +- **Search repository issues** Get open issues in a repository. +- **Search repository merge requests** Get open merge requests in a repository. +- **Search repository folder content** Search folder content in a repository. + Advanced settings: + - **Content type**: Content type to include. +- **Search repositories** Search repositories available to connection. +- **Search files by filepaths** Get files from a repository by file paths. +- **Check if branch exists** Check whether branch exists in a repository. + +### Commit + +- **Search commits** Search commits in a repository. + Advanced settings: + - **Authors to include**: Multiple author names or emails to include. + - **Authors to exclude**: Multiple author names or emails to exclude. + - **Commit after**: Only commits after this date. + - **Commit before**: Only commits before this date. + - **Commit message contains**: Text that commit message must contain. + - **File path**: Only commits touching this file path. +- **Find commit** Find first commit that matches search filters in a repository. +- **Get commit** Get commit details by commit ID. +- **List added or modified files in X hours** Search files added or modified during specified number of hours. + Advanced settings: + - **Path pattern**: Use forward slash '/' to represent directory separator. Use '*' to represent wildcards in file and directory names. Use '**' to represent arbitrary directory depth. +- **Create or update file** Create file or update existing file in a repository. +- **Update file** Update existing file in a repository. +- **Delete file** Delete file from a repository. + +### Merge request + +- **Search merge requests** Search merge requests in a repository. +- **Get merge request** Get merge request details. +- **Create merge request** Create merge request. +- **Complete merge request** Complete merge request by merging it. + Advanced settings: + - **Merge commit message**: Text for the merge commit message. + +### Branch + +- **Search branches** Search repository branches. +- **Get branch** Get branch details by name. +- **Create branch** Create branch from a base branch. ## Events -### Pulls - -= **On pull request action** occurs when there is activity on a pull request. See [this page](https://docs.GitLab.com/en/webhooks/webhook-events-and-payloads#pull_request) for more info. - ### Pushes -- **On commit pushed** occurs when there is a push to a repository branch. This includes when a commit is pushed, when a commit tag is pushed, when a branch is deleted, when a tag is deleted, or when a repository is created from a template. -- **On files added** is triggered when new commits have new files. It returns the paths to all added files. -- **On files modified** is triggered when new commits modify files. It returns paths to all modified files. -- **On files added or modified** is triggered when new commits add new files or modify files. It returns paths to all these files. -- **On files removed** is triggered when new commits remove files. It returns paths to all deleted files. +- **On commit pushed** On commit pushed to a repository branch. +- **On files added** On files added by new commits. Outputs paths to added files. +- **On files modified** On files modified by new commits. Outputs paths to modified files. +- **On files added or modified** On files added or modified by new commits. Outputs paths to added or modified files. +- **On files removed** On files removed by new commits. Outputs paths to removed files. For the file specific events, a path parameter can be specified in order to narrow down the event to only files in specific folders or files that have certain extensions. Use the forward slash '/' to represent directory separator. Use '\*' to represent wildcards in file and directory names. Use '\*\*' to represent arbitrary directory depth. -For example: when you want to create an event that triggers only when .html files are modified in a folder called _locales_. Then the path of the **On files added or modified** event should be: _/locales/\*.html_ +For example: when you want to create an event that triggers only when .html files are modified in a folder called _locales_. Then the path pattern should be: _/locales/\*.html_ ![1705407685118](image/README/1705407685118.png) diff --git a/Tests.GitLab/CommitActionTests.cs b/Tests.GitLab/CommitActionTests.cs new file mode 100644 index 0000000..ff34a13 --- /dev/null +++ b/Tests.GitLab/CommitActionTests.cs @@ -0,0 +1,198 @@ +using Apps.Gitlab.Actions; +using Apps.Gitlab.Models.Branch.Requests; +using Apps.Gitlab.Models.Respository.Requests; +using Apps.GitLab.Constants; +using Apps.GitLab.Models.Commit.Requests; +using Blackbird.Applications.Sdk.Common.Invocation; +using Tests.GitLab.Base; + +namespace Tests.GitLab; + +[TestClass] +public class CommitActionTests : TestBaseWithContext +{ + private const string CollectingReferencesDemoRepositoryId = "83936767"; + + [TestMethod, ContextDataSource(ConnectionTypes.PersonalAccessToken, ConnectionTypes.OAuth)] + public async Task SearchCommits_WithRepository_ReturnsCommits(InvocationContext context) + { + var action = new CommitActions(context, FileManagementClient); + var result = await action.ListRepositoryCommits( + new GetRepositoryRequest { RepositoryId = CollectingReferencesDemoRepositoryId }, + new GetOptionalBranchRequest { Name = "main" }, + new ListCommitsRequest()); + + Assert.IsTrue(result.Commits.Any()); + Assert.AreEqual(result.Commits.Count(), result.Count); + } + + [TestMethod, ContextDataSource(ConnectionTypes.PersonalAccessToken, ConnectionTypes.OAuth)] + public async Task SearchCommits_WithAuthorInclude_ReturnsMatchingAuthor(InvocationContext context) + { + var action = new CommitActions(context, FileManagementClient); + var result = await action.ListRepositoryCommits( + new GetRepositoryRequest { RepositoryId = CollectingReferencesDemoRepositoryId }, + new GetOptionalBranchRequest { Name = "main" }, + new ListCommitsRequest + { + AuthorsToInclude = ["Localization Blackbird"] + }); + + Assert.IsTrue(result.Commits.Any()); + Assert.IsTrue(result.Commits.All(commit => + (!string.IsNullOrWhiteSpace(commit.AuthorName) && + commit.AuthorName.Contains("Localization Blackbird", StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrWhiteSpace(commit.AuthorEmail) && + commit.AuthorEmail.Contains("Localization Blackbird", StringComparison.OrdinalIgnoreCase)))); + } + + [TestMethod, ContextDataSource(ConnectionTypes.PersonalAccessToken, ConnectionTypes.OAuth)] + public async Task SearchCommits_WithMaximumResults_ReturnsRequestedNumberOfMatches(InvocationContext context) + { + var action = new CommitActions(context, FileManagementClient); + var result = await action.ListRepositoryCommits( + new GetRepositoryRequest { RepositoryId = CollectingReferencesDemoRepositoryId }, + new GetOptionalBranchRequest { Name = "main" }, + new ListCommitsRequest + { + MaximumResults = 2 + }); + + Assert.AreEqual(2, result.Count); + Assert.AreEqual(2, result.Commits.Count()); + } + + [TestMethod, ContextDataSource(ConnectionTypes.PersonalAccessToken, ConnectionTypes.OAuth)] + public async Task SearchCommits_WithDateRange_ReturnsCommitsInRange(InvocationContext context) + { + var start = new DateTime(2026, 6, 30, 0, 0, 0, DateTimeKind.Utc); + var end = new DateTime(2026, 6, 30, 23, 59, 59, DateTimeKind.Utc); + var action = new CommitActions(context, FileManagementClient); + var result = await action.ListRepositoryCommits( + new GetRepositoryRequest { RepositoryId = CollectingReferencesDemoRepositoryId }, + new GetOptionalBranchRequest { Name = "main" }, + new ListCommitsRequest + { + CommitAfter = start, + CommitBefore = end + }); + + Assert.IsTrue(result.Commits.Any()); + Assert.IsTrue(result.Commits.All(commit => commit.CreatedAt >= start && commit.CreatedAt <= end)); + } + + [TestMethod, ContextDataSource(ConnectionTypes.PersonalAccessToken, ConnectionTypes.OAuth)] + public async Task SearchCommits_WithMessage_ReturnsMatchingMessage(InvocationContext context) + { + var action = new CommitActions(context, FileManagementClient); + var result = await action.ListRepositoryCommits( + new GetRepositoryRequest { RepositoryId = CollectingReferencesDemoRepositoryId }, + new GetOptionalBranchRequest { Name = "main" }, + new ListCommitsRequest + { + CommitMessageContains = "Chore:" + }); + + Assert.IsTrue(result.Commits.Any()); + Assert.IsTrue(result.Commits.All(commit => + (!string.IsNullOrWhiteSpace(commit.Message) && + commit.Message.Contains("Chore:", StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrWhiteSpace(commit.Title) && + commit.Title.Contains("Chore:", StringComparison.OrdinalIgnoreCase)))); + } + + [TestMethod, ContextDataSource(ConnectionTypes.PersonalAccessToken, ConnectionTypes.OAuth)] + public async Task SearchCommits_WithAuthorExclude_RemovesMatchingAuthor(InvocationContext context) + { + var action = new CommitActions(context, FileManagementClient); + var result = await action.ListRepositoryCommits( + new GetRepositoryRequest { RepositoryId = CollectingReferencesDemoRepositoryId }, + new GetOptionalBranchRequest { Name = "main" }, + new ListCommitsRequest + { + AuthorsToExclude = ["Localization Blackbird"] + }); + + Assert.IsTrue(result.Commits.Any()); + Assert.IsTrue(result.Commits.All(commit => + (string.IsNullOrWhiteSpace(commit.AuthorName) || + !commit.AuthorName.Contains("Localization Blackbird", StringComparison.OrdinalIgnoreCase)) && + (string.IsNullOrWhiteSpace(commit.AuthorEmail) || + !commit.AuthorEmail.Contains("Localization Blackbird", StringComparison.OrdinalIgnoreCase)))); + } + + [TestMethod, ContextDataSource(ConnectionTypes.PersonalAccessToken, ConnectionTypes.OAuth)] + public async Task SearchCommits_WithFilePath_ReturnsCommits(InvocationContext context) + { + var action = new CommitActions(context, FileManagementClient); + var result = await action.ListRepositoryCommits( + new GetRepositoryRequest { RepositoryId = CollectingReferencesDemoRepositoryId }, + new GetOptionalBranchRequest { Name = "main" }, + new ListCommitsRequest + { + FilePath = "changelog.md" + }); + + Assert.IsTrue(result.Commits.Any()); + } + + [TestMethod, ContextDataSource(ConnectionTypes.PersonalAccessToken, ConnectionTypes.OAuth)] + public async Task FindCommit_WithMessage_ReturnsFirstMatchingCommit(InvocationContext context) + { + var action = new CommitActions(context, FileManagementClient); + var result = await action.FindCommit( + new GetRepositoryRequest { RepositoryId = CollectingReferencesDemoRepositoryId }, + new GetOptionalBranchRequest { Name = "main" }, + new SearchCommitsRequest + { + CommitMessageContains = "Chore:" + }); + + Assert.IsNotNull(result); + Assert.IsTrue( + (!string.IsNullOrWhiteSpace(result.Message) && + result.Message.Contains("Chore:", StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrWhiteSpace(result.Title) && + result.Title.Contains("Chore:", StringComparison.OrdinalIgnoreCase))); + } + + [TestMethod, ContextDataSource(ConnectionTypes.PersonalAccessToken, ConnectionTypes.OAuth)] + public async Task FindCommit_WithSameFilters_ReturnsFirstSearchResult(InvocationContext context) + { + var action = new CommitActions(context, FileManagementClient); + var searchRequest = new ListCommitsRequest + { + AuthorsToInclude = ["Localization Blackbird"], + CommitMessageContains = "Chore:" + }; + + var searchResult = await action.ListRepositoryCommits( + new GetRepositoryRequest { RepositoryId = CollectingReferencesDemoRepositoryId }, + new GetOptionalBranchRequest { Name = "main" }, + searchRequest); + var findResult = await action.FindCommit( + new GetRepositoryRequest { RepositoryId = CollectingReferencesDemoRepositoryId }, + new GetOptionalBranchRequest { Name = "main" }, + searchRequest); + + Assert.IsTrue(searchResult.Commits.Any()); + Assert.AreEqual(searchResult.Commits.First().Id, findResult.Id); + } + + [TestMethod, ContextDataSource(ConnectionTypes.PersonalAccessToken, ConnectionTypes.OAuth)] + public async Task FindCommit_WithCommitBeforeExactBoundary_ExcludesBoundaryCommit(InvocationContext context) + { + var action = new CommitActions(context, FileManagementClient); + var result = await action.FindCommit( + new GetRepositoryRequest { RepositoryId = CollectingReferencesDemoRepositoryId }, + new GetOptionalBranchRequest { Name = "main" }, + new SearchCommitsRequest + { + CommitBefore = new DateTime(2024, 1, 15, 9, 0, 0, DateTimeKind.Utc), + CommitMessageContains = "Sync source", + FilePath = "locales/en-US/messages.po" + }); + + Assert.AreEqual("9e41fdbe88e1a2099db6dd31c769267a2bf420f3", result.Id); + } +}