Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add license check rule / Add PR tasks depending on a Jira issue type #297

Merged
merged 5 commits into from
Jan 9, 2025
Merged
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
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ links to JIRA tickets will be appended at the bottom of the PR body.
Optionally, the bot can be configured to automatically create a GitHub check listing Develocity build scans
for every commit that has completed checks related to CI (GitHub Actions or Jenkins).

### License agreement check

Optionally, the bot can be configured to check that the pull request body includes the license agreement text from the template.

### Pull request tasks

Optionally, the bot can also add a list of tasks to the pull request as a reminder of the steps to complete before
the pull request can be merged. This can be enabled per repository. Once enabled, the list of tasks per
issue type should be configured. If the Jira issue type doesn't have a list of tasks configured the default tasks will be used.

## Configuration

### Enabling the bot in a new repository
Expand Down Expand Up @@ -100,15 +110,37 @@ develocity:
replacement: "$0"
- pattern: "hibernate.search|elasticsearch|opensearch|main|\\d+.\\d+|PR-\\d+"
replacement: "" # Just remove these tags
licenseAgreement:
enabled: true
# Optionally provide the pattern to use for extracting the license text from the `PULL_REQUEST_TEMPLATE.md`
# Keep in mind that the bot expects that the license text to check is matched by the 1st group:
pullRequestTemplatePattern: .+(---.+---).+
pullRequestTasks:
# Make the bot add list of tasks to the pull requests and enable the check that makes sure all tasks are completed:
enabled: true
# Tasks for a particular issue type, with `default` being a "unique" category:
tasks:
# List of tasks for commits without a Jira ID
# or for those with Jira ID but that don't have a specific configuration for a corresponding issue type:
default:
- task1
- task2
# Tasks specific to the bug issue type:
bug:
- bug task1
- bug task2
# Tasks specific to the improvement issue type:
improvement:
- improvement task1
```

### Altering the infrastructure

This should only be needed very rarely, so think twice before trying this.

You will need admin rights in the Hibernate organization.
You will need admin rights in the Hibernate organization and access to the OpenShift cluster.

The infrastructure configuration can be found [here](https://github.com/hibernate/ci.hibernate.org).
The infrastructure configuration can be found [here](src/main/resources/application.properties) under "#Deployment configuration".

The GitHub registration of this bot can be found [here](https://github.com/organizations/hibernate/settings/apps/hibernate-github-bot).

Expand Down
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5-mockito</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import jakarta.inject.Inject;
Expand Down Expand Up @@ -55,35 +56,37 @@ public class CheckPullRequestContributionRules {
void pullRequestChanged(
@PullRequest.Opened @PullRequest.Reopened @PullRequest.Edited @PullRequest.Synchronize
GHEventPayload.PullRequest payload,
@ConfigFile("hibernate-github-bot.yml") RepositoryConfig repositoryConfig) throws IOException {
checkPullRequestContributionRules( payload.getRepository(), repositoryConfig,
payload.getPullRequest()
);
@ConfigFile("hibernate-github-bot.yml") RepositoryConfig repositoryConfig,
@ConfigFile("PULL_REQUEST_TEMPLATE.md") String pullRequestTemplate) throws IOException {
checkPullRequestContributionRules( payload.getRepository(), repositoryConfig, pullRequestTemplate, payload.getPullRequest() );
}

void checkRunRequested(@CheckRun.Rerequested GHEventPayload.CheckRun payload,
@ConfigFile("hibernate-github-bot.yml") RepositoryConfig repositoryConfig) throws IOException {
@ConfigFile("hibernate-github-bot.yml") RepositoryConfig repositoryConfig,
@ConfigFile("PULL_REQUEST_TEMPLATE.md") String pullRequestTemplate) throws IOException {
for ( GHPullRequest pullRequest : payload.getCheckRun().getPullRequests() ) {
checkPullRequestContributionRules( payload.getRepository(), repositoryConfig, pullRequest );
checkPullRequestContributionRules( payload.getRepository(), repositoryConfig, pullRequestTemplate, pullRequest );
}
}

void checkSuiteRequested(@CheckSuite.Requested @CheckSuite.Rerequested GHEventPayload.CheckSuite payload,
@ConfigFile("hibernate-github-bot.yml") RepositoryConfig repositoryConfig) throws IOException {
@ConfigFile("hibernate-github-bot.yml") RepositoryConfig repositoryConfig,
@ConfigFile("PULL_REQUEST_TEMPLATE.md") String pullRequestTemplate) throws IOException {
for ( GHPullRequest pullRequest : payload.getCheckSuite().getPullRequests() ) {
checkPullRequestContributionRules( payload.getRepository(), repositoryConfig, pullRequest );
checkPullRequestContributionRules( payload.getRepository(), repositoryConfig, pullRequestTemplate, pullRequest );
}
}

private void checkPullRequestContributionRules(GHRepository repository, RepositoryConfig repositoryConfig,
String pullRequestTemplate,
GHPullRequest pullRequest)
throws IOException {
if ( !shouldCheck( repository, pullRequest ) ) {
return;
}

PullRequestCheckRunContext context = new PullRequestCheckRunContext( deploymentConfig, repository, repositoryConfig, pullRequest );
List<PullRequestCheck> checks = createChecks( repositoryConfig );
List<PullRequestCheck> checks = createChecks( repositoryConfig, pullRequestTemplate );
List<PullRequestCheckRunOutput> outputs = new ArrayList<>();
for ( PullRequestCheck check : checks ) {
outputs.add( PullRequestCheck.run( context, check ) );
Expand Down Expand Up @@ -133,7 +136,7 @@ private GHIssueComment findExistingComment(GHPullRequest pullRequest) throws IOE
return null;
}

private List<PullRequestCheck> createChecks(RepositoryConfig repositoryConfig) {
private List<PullRequestCheck> createChecks(RepositoryConfig repositoryConfig, String pullRequestTemplate) {
List<PullRequestCheck> checks = new ArrayList<>();
checks.add( new TitleCheck() );

Expand All @@ -150,6 +153,24 @@ private List<PullRequestCheck> createChecks(RepositoryConfig repositoryConfig) {
) ) );
}

if ( repositoryConfig != null
&& repositoryConfig.licenseAgreement != null
&& repositoryConfig.licenseAgreement.getEnabled().orElse( Boolean.FALSE ) ) {
Matcher matcher = repositoryConfig.licenseAgreement.getPullRequestTemplatePattern().matcher( pullRequestTemplate );
if ( matcher.matches()
&& matcher.groupCount() == 1 ) {
checks.add( new LicenseCheck( matcher.group( 1 ).trim() ) );
}
else {
throw new IllegalArgumentException( "Misconfigured license agreement check. Pattern should contain exactly 1 match group. Pattern: %s. Fetched Pull Request template: %s".formatted( repositoryConfig.licenseAgreement.getPullRequestTemplatePattern(), pullRequestTemplate ) );
}
}

if ( repositoryConfig != null && repositoryConfig.pullRequestTasks != null
&& repositoryConfig.pullRequestTasks.getEnabled().orElse( Boolean.FALSE ) ) {
checks.add( new TasksCompletedCheck() );
}

return checks;
}

Expand Down Expand Up @@ -256,4 +277,45 @@ private boolean shouldCheckPullRequest(PullRequestCheckRunContext context) throw
}
}

static class LicenseCheck extends PullRequestCheck {

private final String agreementText;

protected LicenseCheck(String agreementText) {
super( "Contribution — License agreement" );
this.agreementText = agreementText;
}

@Override
public void perform(PullRequestCheckRunContext context, PullRequestCheckRunOutput output) {
String body = context.pullRequest.getBody();
PullRequestCheckRunRule rule = output.rule( "The pull request description must contain the license agreement text." );
if ( body != null && body.contains( agreementText ) ) {
rule.passed();
}
else {
rule.failed( """
The description of this pull request must contain the following license agreement text:
```
%s
```
""".formatted( agreementText ) );
}
}
}

static class TasksCompletedCheck extends PullRequestCheck {

protected TasksCompletedCheck() {
super( "Contribution — Review tasks" );
}

@Override
public void perform(PullRequestCheckRunContext context, PullRequestCheckRunOutput output) {
String body = context.pullRequest.getBody();
output.rule( "All pull request tasks should be completed." )
.result( !EditPullRequestBodyAddTaskList.containsUnfinishedTasks( body ) );
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package org.hibernate.infra.bot;

import java.io.IOException;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;

import org.hibernate.infra.bot.config.DeploymentConfig;
import org.hibernate.infra.bot.config.RepositoryConfig;
import org.hibernate.infra.bot.jira.JiraIssue;
import org.hibernate.infra.bot.jira.JiraIssues;
import org.hibernate.infra.bot.jira.JiraRestClient;
import org.hibernate.infra.bot.util.CommitMessages;
import org.hibernate.infra.bot.util.Patterns;

import org.jboss.logging.Logger;

import io.quarkiverse.githubapp.ConfigFile;
import io.quarkiverse.githubapp.event.PullRequest;
import jakarta.inject.Inject;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.kohsuke.github.GHEventPayload;
import org.kohsuke.github.GHIssueState;
import org.kohsuke.github.GHPullRequest;
import org.kohsuke.github.GHPullRequestCommitDetail;
import org.kohsuke.github.GHRepository;

public class EditPullRequestBodyAddTaskList {
private static final Logger LOG = Logger.getLogger( EditPullRequestBodyAddTaskList.class );

private static final String START_MARKER = "<!-- Hibernate GitHub Bot task list start -->";

private static final String END_MARKER = "<!-- Hibernate GitHub Bot task list end -->";
private static final Set<Character> REGEX_ESCAPE_CHARS = Set.of( '(', ')', '[', ']', '{', '}', '\\', '.', '?', '*', '+' );

@Inject
DeploymentConfig deploymentConfig;
@RestClient
JiraRestClient jiraRestClient;

void pullRequestChanged(
@PullRequest.Opened @PullRequest.Reopened @PullRequest.Edited @PullRequest.Synchronize
GHEventPayload.PullRequest payload,
@ConfigFile("hibernate-github-bot.yml") RepositoryConfig repositoryConfig
) throws IOException {
addUpdateTaskList( payload.getRepository(), repositoryConfig, payload.getPullRequest() );
}

private void addUpdateTaskList(
GHRepository repository,
RepositoryConfig repositoryConfig,
GHPullRequest pullRequest
) throws IOException {
if ( repositoryConfig == null || repositoryConfig.pullRequestTasks == null
|| !repositoryConfig.pullRequestTasks.getEnabled().orElse( Boolean.FALSE ) ) {
return;
}

if ( !shouldCheck( repository, pullRequest ) ) {
return;
}

final Set<String> issueKeys = new HashSet<>();
boolean genericTasksRequired = false;
if ( repositoryConfig.jira.getIssueKeyPattern().isPresent() ) {
Pattern issueKeyPattern = repositoryConfig.jira.getIssueKeyPattern().get();

for ( GHPullRequestCommitDetail commitDetails : pullRequest.listCommits() ) {
final GHPullRequestCommitDetail.Commit commit = commitDetails.getCommit();
final List<String> commitIssueKeys = CommitMessages.extractIssueKeys(
issueKeyPattern,
commit.getMessage()
);
issueKeys.addAll( commitIssueKeys );
genericTasksRequired = genericTasksRequired || commitIssueKeys.isEmpty();
}
}
else {
genericTasksRequired = true;
}

final String originalBody = Objects.toString( pullRequest.getBody(), "" );
final String currentTasks = currentTaskBody( originalBody );
final String tasks = generateTaskList( repositoryConfig.pullRequestTasks, genericTasksRequired, issueKeys );

String body;

if ( currentTasks == null && tasks == null ) {
return;
}

if ( tasks == null ) {
body = originalBody.replace( currentTasks, "" );
}
else if ( currentTasks != null ) {
if (tasksAreTheSame( currentTasks, tasks ) ) {
return;
}
body = originalBody.replace( currentTasks, tasks );
}
else {
body = "%s\n\n---\n%s\n%s\n%s".formatted( originalBody, START_MARKER, tasks, END_MARKER );
}

if ( !deploymentConfig.isDryRun() ) {
pullRequest.setBody( body );
}
else {
LOG.info( "Pull request #" + pullRequest.getNumber() + " - Updated PR body: " + body );
}
}

private boolean tasksAreTheSame(String currentTasks, String tasks) {
StringBuilder sb = new StringBuilder();
for ( char c : tasks.trim().toCharArray() ) {
if ( REGEX_ESCAPE_CHARS.contains( c ) ) {
sb.append( '\\' );
}
if ( c == '\n' ) {
sb.append( '\\' ).append( 'n' );
}
else {
sb.append( c );
}
}

return Patterns.compile( sb.toString().replace( "- \\[ \\]", "- \\[.\\]" ) )
.matcher( currentTasks.trim() )
.matches();
}

private String generateTaskList(RepositoryConfig.TaskList taskListConfiguration, boolean genericTasksRequired, Set<String> issueKeys) {
if ( !genericTasksRequired && issueKeys.isEmpty() ) {
return null;
}
StringBuilder taskList = new StringBuilder();
taskList.append( "Please make sure that the following tasks are completed:\n" );
if ( genericTasksRequired ) {
addTasks( taskList, taskListConfiguration.defaultTasks() );
}
if ( !issueKeys.isEmpty() ) {
JiraIssues issues = jiraRestClient.find( "key IN (" + String.join( ",", issueKeys ) + ") ORDER BY KEY DESC", "issuetype,key" );
for ( JiraIssue issue : issues.issues ) {
taskList.append( "Tasks specific to " )
.append( issue.key )
.append( " (" )
.append( issue.fields.issuetype.name )
.append( "):\n" );
addTasks( taskList, taskListConfiguration.getTasks().getOrDefault( issue.fields.issuetype.name.toLowerCase( Locale.ROOT ), taskListConfiguration.defaultTasks() ) );
}
}

return taskList.toString();
}

private void addTasks(StringBuilder sb, List<String> tasks) {
for ( String task : tasks ) {
sb.append( "- [ ] " )
.append( task )
.append( "\n" );
}
sb.append( "\n" );
}

public static boolean containsUnfinishedTasks(String body) {
if ( body == null || body.isEmpty() ) {
return false;
}
String taskBody = currentTaskBody( body );
if ( taskBody == null ) {
return false;
}
return taskBody.contains( "- [ ]" );
}

private static String currentTaskBody(String originalBody) {
// Check if the body already contains the tasks
final int startIndex = originalBody.indexOf( START_MARKER );
final int endIndex = startIndex > -1 ? originalBody.indexOf( END_MARKER ) : -1;
if ( startIndex > -1 && endIndex > -1 ) {
return originalBody.substring( startIndex + START_MARKER.length() + 1, endIndex - 1 );
}
else {
return null;
}
}

private boolean shouldCheck(GHRepository repository, GHPullRequest pullRequest) {
return !GHIssueState.CLOSED.equals( pullRequest.getState() )
&& repository.getId() == pullRequest.getBase().getRepository().getId();
}
}
Loading
Loading