Skip to content

Commit a518e0a

Browse files
committed
New action that automatically edits PR bodies adding issue links
1 parent c68d58b commit a518e0a

9 files changed

+1341
-19
lines changed

README.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ This includes:
2121
* Proper formatting of commits: every commit message must start with the key of a JIRA ticket.
2222
* Etc.
2323

24+
The bot can also be configured to automatically add links to JIRA issues in PR descriptions. When this is enabled
25+
links to JIRA tickets will be appended at the bottom of the PR body.
26+
2427
## Configuration
2528

2629
### Enabling the bot in a new repository
@@ -32,12 +35,13 @@ and add your repository under "Repository access".
3235

3336
If you wish to enable the JIRA-related features as well,
3437
create the file `.github/hibernate-github-bot.yml` in default branch of your repository,
35-
will the following content:
38+
with the following content:
3639

3740
```yaml
3841
---
3942
jira:
4043
projectKey: "HSEARCH" # Change to whatever your project key is
44+
insertLinksInPullRequests: true # This is optional and enables automatically adding issue links to PR descriptions
4145
```
4246
4347
### Altering the infrastructure

src/main/java/org/hibernate/infra/bot/CheckPullRequestContributionRules.java

+20-13
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,10 @@ private List<Check> createChecks(RepositoryConfig repositoryConfig) {
135135
checks.add( new TitleCheck() );
136136

137137
if ( repositoryConfig != null && repositoryConfig.jira != null ) {
138+
final boolean checkMentions = repositoryConfig.jira.getInsertLinksInPullRequests().isEmpty()
139+
|| repositoryConfig.jira.getInsertLinksInPullRequests().get().equals( Boolean.FALSE );
138140
repositoryConfig.jira.getIssueKeyPattern()
139-
.ifPresent( issueKeyPattern -> checks.add( new JiraIssuesCheck( issueKeyPattern ) ) );
141+
.ifPresent( issueKeyPattern -> checks.add( new JiraIssuesCheck( issueKeyPattern, checkMentions ) ) );
140142
}
141143

142144
return checks;
@@ -163,9 +165,12 @@ static class JiraIssuesCheck extends Check {
163165

164166
private final Pattern issueKeyPattern;
165167

166-
JiraIssuesCheck(Pattern issueKeyPattern) {
168+
private final boolean checkMentions;
169+
170+
JiraIssuesCheck(Pattern issueKeyPattern, boolean checkMentions) {
167171
super( "Contribution — JIRA issues" );
168172
this.issueKeyPattern = issueKeyPattern;
173+
this.checkMentions = checkMentions;
169174
}
170175

171176
@Override
@@ -196,17 +201,19 @@ public void perform(CheckRunContext context, CheckRunOutput output) throws IOExc
196201
commitRule.failed( "Offending commits: " + commitsWithMessageNotStartingWithIssueKey );
197202
}
198203

199-
CheckRunRule pullRequestRule = output.rule(
200-
"The PR title or body should list the keys of all JIRA issues mentioned in the commits" );
201-
List<String> issueKeysNotMentionedInPullRequest = issueKeys.stream()
202-
.filter( issueKey -> ( title == null || !title.contains( issueKey ) )
203-
&& ( body == null || !body.contains( issueKey ) ) )
204-
.toList();
205-
if ( issueKeysNotMentionedInPullRequest.isEmpty() ) {
206-
pullRequestRule.passed();
207-
}
208-
else {
209-
pullRequestRule.failed( "Issue keys mentioned in commits but missing from the PR title or body: " + issueKeysNotMentionedInPullRequest );
204+
if ( checkMentions ) {
205+
// We only need to check mentions if automatic body editing is disabled
206+
CheckRunRule pullRequestRule = output.rule(
207+
"The PR title or body should list the keys of all JIRA issues mentioned in the commits");
208+
List<String> issueKeysNotMentionedInPullRequest = issueKeys.stream()
209+
.filter(issueKey -> (title == null || !title.contains(issueKey))
210+
&& (body == null || !body.contains(issueKey)))
211+
.toList();
212+
if (issueKeysNotMentionedInPullRequest.isEmpty()) {
213+
pullRequestRule.passed();
214+
} else {
215+
pullRequestRule.failed("Issue keys mentioned in commits but missing from the PR title or body: " + issueKeysNotMentionedInPullRequest);
216+
}
210217
}
211218
}
212219
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package org.hibernate.infra.bot;
2+
3+
import java.io.IOException;
4+
import java.util.HashSet;
5+
import java.util.List;
6+
import java.util.Locale;
7+
import java.util.Set;
8+
9+
import org.hibernate.infra.bot.config.DeploymentConfig;
10+
import org.hibernate.infra.bot.config.RepositoryConfig;
11+
import org.hibernate.infra.bot.util.CommitMessages;
12+
13+
import org.jboss.logging.Logger;
14+
15+
import io.quarkiverse.githubapp.ConfigFile;
16+
import io.quarkiverse.githubapp.event.CheckRun;
17+
import io.quarkiverse.githubapp.event.CheckSuite;
18+
import io.quarkiverse.githubapp.event.PullRequest;
19+
import jakarta.inject.Inject;
20+
import org.kohsuke.github.GHEventPayload;
21+
import org.kohsuke.github.GHIssueState;
22+
import org.kohsuke.github.GHPullRequest;
23+
import org.kohsuke.github.GHPullRequestCommitDetail;
24+
import org.kohsuke.github.GHRepository;
25+
26+
/**
27+
* @author Marco Belladelli
28+
*/
29+
public class EditPullRequestBodyAddIssueLinks {
30+
private static final Logger LOG = Logger.getLogger( EditPullRequestBodyAddIssueLinks.class );
31+
32+
private static final String START_MARKER = "<!-- Hibernate GitHub Bot issue links start -->";
33+
34+
private static final String END_MARKER = "<!-- Hibernate GitHub Bot issue links end -->";
35+
36+
private static final String EDITOR_WARNING = "<!-- THIS SECTION IS AUTOMATICALLY GENERATED, ANY MANUAL CHANGES WILL BE LOST -->\n";
37+
38+
private static final String LINK_TEMPLATE = "https://hibernate.atlassian.net/browse/%s";
39+
40+
@Inject
41+
DeploymentConfig deploymentConfig;
42+
43+
void pullRequestChanged(
44+
@PullRequest.Opened @PullRequest.Reopened @PullRequest.Edited @PullRequest.Synchronize
45+
GHEventPayload.PullRequest payload,
46+
@ConfigFile( "hibernate-github-bot.yml" ) RepositoryConfig repositoryConfig) throws IOException {
47+
editPullRequestBodyAddIssueLinks( payload.getRepository(), repositoryConfig, payload.getPullRequest() );
48+
}
49+
50+
private void editPullRequestBodyAddIssueLinks(
51+
GHRepository repository,
52+
RepositoryConfig repositoryConfig,
53+
GHPullRequest pullRequest) throws IOException {
54+
if ( repositoryConfig == null || repositoryConfig.jira == null
55+
|| repositoryConfig.jira.getIssueKeyPattern().isEmpty()
56+
|| repositoryConfig.jira.getInsertLinksInPullRequests().isEmpty()
57+
|| repositoryConfig.jira.getInsertLinksInPullRequests().get().equals( Boolean.FALSE ) ) {
58+
return;
59+
}
60+
61+
if ( !shouldCheck( repository, pullRequest ) ) {
62+
return;
63+
}
64+
65+
final Set<String> issueKeys = new HashSet<>();
66+
// Collect all issue keys from commit messages
67+
repositoryConfig.jira.getIssueKeyPattern().ifPresent( issueKeyPattern -> {
68+
for ( GHPullRequestCommitDetail commitDetails : pullRequest.listCommits() ) {
69+
final GHPullRequestCommitDetail.Commit commit = commitDetails.getCommit();
70+
final List<String> commitIssueKeys = CommitMessages.extractIssueKeys(
71+
issueKeyPattern,
72+
commit.getMessage()
73+
);
74+
issueKeys.addAll( commitIssueKeys );
75+
}
76+
} );
77+
78+
if ( issueKeys.isEmpty() ) {
79+
LOG.debug( "Found no issue keys in commits, terminating." );
80+
return;
81+
}
82+
83+
final String originalBody = pullRequest.getBody();
84+
final StringBuilder sb = new StringBuilder();
85+
if ( originalBody != null ) {
86+
// Check if the body already contains the link section
87+
final int startIndex = originalBody.indexOf( START_MARKER );
88+
final int endIndex = startIndex > -1 ? originalBody.indexOf( END_MARKER ) : -1;
89+
if ( startIndex > -1 && endIndex > -1 ) {
90+
// Remove the whole section, it will be re-appended at the end of the body
91+
sb.append( originalBody.substring( 0, startIndex ).trim() );
92+
final String following = originalBody.substring( endIndex + END_MARKER.length() ).trim();
93+
if ( following.length() > 0 ) {
94+
sb.append( "\n\n" );
95+
sb.append( following );
96+
}
97+
}
98+
else {
99+
sb.append( originalBody.trim() );
100+
}
101+
}
102+
103+
final String body = sb.toString();
104+
final String linksSection = constructLinksSection( issueKeys, body );
105+
if ( linksSection == null ) {
106+
// All issue links were already found in the request body, nothing to do
107+
return;
108+
}
109+
110+
final String newBody = body.length() == 0
111+
? linksSection
112+
: body + "\n\n" + linksSection;
113+
if ( !deploymentConfig.isDryRun() ) {
114+
pullRequest.setBody( newBody );
115+
}
116+
else {
117+
LOG.info( "Pull request #" + pullRequest.getNumber() + " - Updated PR body: " + newBody );
118+
}
119+
}
120+
121+
private String constructLinksSection(Set<String> issueKeys, String originalBody) {
122+
final String lowerCaseBody = originalBody.toLowerCase( Locale.ROOT );
123+
final StringBuilder sb = new StringBuilder();
124+
for ( String key : issueKeys ) {
125+
if ( !lowerCaseBody.contains( key.toLowerCase( Locale.ROOT ) ) ) {
126+
// Only add links for issue keys that are not already found
127+
// in the original PR body
128+
sb.append( String.format( LINK_TEMPLATE, key ) ).append( '\n' );
129+
}
130+
}
131+
132+
if ( sb.isEmpty() ) {
133+
return null;
134+
}
135+
136+
return START_MARKER + "\n" + EDITOR_WARNING + sb + END_MARKER;
137+
}
138+
139+
private boolean shouldCheck(GHRepository repository, GHPullRequest pullRequest) {
140+
return !GHIssueState.CLOSED.equals( pullRequest.getState() )
141+
&& repository.getId() == pullRequest.getBase().getRepository().getId();
142+
}
143+
}

src/main/java/org/hibernate/infra/bot/config/RepositoryConfig.java

+12
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import java.util.Optional;
44
import java.util.regex.Pattern;
55

6+
import javax.swing.text.html.Option;
7+
68
import org.hibernate.infra.bot.util.Patterns;
79

810
public class RepositoryConfig {
@@ -12,12 +14,22 @@ public class RepositoryConfig {
1214
public static class JiraConfig {
1315
private Optional<Pattern> issueKeyPattern = Optional.empty();
1416

17+
private Optional<Boolean> insertLinksInPullRequests = Optional.empty();
18+
1519
public void setProjectKey(String key) {
1620
issueKeyPattern = Optional.of( Patterns.compile( key + "-\\d+" ) );
1721
}
1822

1923
public Optional<Pattern> getIssueKeyPattern() {
2024
return issueKeyPattern;
2125
}
26+
27+
public void setInsertLinksInPullRequests(boolean insertLinksInPullRequests) {
28+
this.insertLinksInPullRequests = Optional.of( insertLinksInPullRequests );
29+
}
30+
31+
public Optional<Boolean> getInsertLinksInPullRequests() {
32+
return insertLinksInPullRequests;
33+
}
2234
}
2335
}

src/test/java/org/hibernate/infra/bot/tests/ApplicationSanityTest.java

+3-2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import static org.assertj.core.api.Assertions.assertThat;
44

55
import org.hibernate.infra.bot.CheckPullRequestContributionRules;
6+
import org.hibernate.infra.bot.EditPullRequestBodyAddIssueLinks;
67

78
import org.junit.jupiter.api.Test;
89

@@ -14,8 +15,8 @@ public class ApplicationSanityTest {
1415

1516
@Test
1617
void checkApplicationIncludesCheckPullRequestContributionRules() {
17-
assertThat( Arc.container().instance( CheckPullRequestContributionRules.class ) )
18-
.isNotNull();
18+
assertThat( Arc.container().instance( CheckPullRequestContributionRules.class ) ).isNotNull();
19+
assertThat( Arc.container().instance( EditPullRequestBodyAddIssueLinks.class ) ).isNotNull();
1920
}
2021

2122
}

0 commit comments

Comments
 (0)