Skip to content
Open
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ References can use `#123`, `owner/repo#123`, or `https://github.com/owner/repo/i
but must identify issues in the PR's repository.
Linking works for open and closed issues without requiring backport labels.

### Add release label to issues when a PR is merged

When a PR is merged into `main` or a `release/*` branch, the issues it closes (from the PR description or the merge commit message)
get the next `release/` label and lose the `backport/` label of the target branch.

## Testing changes to the application

To test changes to the application you need:
Expand Down
91 changes: 91 additions & 0 deletions src/main/java/org/keycloak/gh/bot/AddReleaseLabelOnMerge.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package org.keycloak.gh.bot;

import io.quarkiverse.githubapp.event.PullRequest;
import org.jboss.logging.Logger;
import org.keycloak.gh.bot.utils.Labels;
import org.keycloak.gh.bot.utils.ReleaseLabels;
import org.kohsuke.github.GHEventPayload;
import org.kohsuke.github.GHFileNotFoundException;
import org.kohsuke.github.GHIssue;
import org.kohsuke.github.GHPullRequest;
import org.kohsuke.github.GHRepository;
import org.kohsuke.github.GHTag;

import java.io.IOException;
import java.util.List;
import java.util.Set;

/**
* Replaces the labeller GitHub workflow of keycloak/keycloak, which relied on 'pull_request_target'.
* When a PR is merged, the issues it closes get the 'release/...' label and lose the 'backport/...' label for the target branch.
*/
public class AddReleaseLabelOnMerge {

private static final Logger logger = Logger.getLogger(AddReleaseLabelOnMerge.class);

void onClose(@PullRequest.Closed GHEventPayload.PullRequest payload) throws IOException {
GHPullRequest pullRequest = payload.getPullRequest();
if (!pullRequest.isMerged()) {
return;
}

GHRepository repository = payload.getRepository();
String base = pullRequest.getBase().getRef();

// Only fetch what is needed for the target branch
List<String> branches = base.equals("main") ? repository.getBranches().keySet().stream().toList() : List.of();
List<String> tags = base.startsWith("release/") ? repository.listTags().toList().stream().map(GHTag::getName).toList() : List.of();

ReleaseLabels.Result labels = ReleaseLabels.forBaseBranch(base, branches, tags);
if (labels == null) {
logger.infof("PR %s merged into %s: no release label applicable", pullRequest.getNumber(), base);
return;
}

Set<Integer> issueNumbers = ReleaseLabels.findIssues(repository.getFullName(), pullRequest.getBody(), null);
String mergeCommitSha = pullRequest.getMergeCommitSha();
if (issueNumbers.isEmpty() && mergeCommitSha != null) {
String commitMessage = repository.getCommit(mergeCommitSha).getCommitShortInfo().getMessage();
issueNumbers = ReleaseLabels.findIssues(repository.getFullName(), pullRequest.getBody(), commitMessage);
}
if (issueNumbers.isEmpty()) {
logger.infof("PR %s merged into %s: no linked issues found", pullRequest.getNumber(), base);
return;
}

createLabelIfMissing(repository, labels.releaseLabel());

for (int issueNumber : issueNumbers) {
GHIssue issue;
try {
issue = repository.getIssue(issueNumber);
} catch (GHFileNotFoundException e) {
logger.errorf(e, "PR %s merged into %s: referenced issue %s not found", pullRequest.getNumber(), base, issueNumber);
continue;
}
if (issue.isPullRequest()) {
continue;
}
issue.addLabels(labels.releaseLabel());
if (issue.getLabels().stream().anyMatch(l -> l.getName().equals(labels.backportLabel()))) {
issue.removeLabel(labels.backportLabel());
}
logger.infof("PR %s merged into %s: labelled issue %s with %s", pullRequest.getNumber(), base, issueNumber, labels.releaseLabel());
}
}

private void createLabelIfMissing(GHRepository repository, String label) throws IOException {
if (Labels.hasLabel(repository, label)) {
return;
}
try {
repository.createLabel(label, "0E8A16");
} catch (IOException e) {
// A concurrent merge for the same release might have created the label in the meantime
if (!Labels.hasLabel(repository, label)) {
throw e;
}
}
}

}
2 changes: 1 addition & 1 deletion src/main/java/org/keycloak/gh/bot/utils/Labels.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public static void removeLabel(GHIssue issue, String label) throws IOException {
}
}

private static boolean hasLabel(GHRepository repository, String label) throws IOException {
public static boolean hasLabel(GHRepository repository, String label) throws IOException {
PagedIterator<GHLabel> itr = repository.listLabels().withPageSize(100).iterator();
while (itr.hasNext()) {
if (itr.next().getName().equals(label)) {
Expand Down
65 changes: 65 additions & 0 deletions src/main/java/org/keycloak/gh/bot/utils/ReleaseLabels.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package org.keycloak.gh.bot.utils;

import java.util.Collection;
import java.util.Comparator;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class ReleaseLabels {

public record Result(String releaseLabel, String backportLabel) {
}

private static final Pattern RELEASE_BRANCH = Pattern.compile("release/(\\d+)\\.(\\d+)");

/**
* @return the labels for a PR merged into the given branch, or null if the branch is neither main nor a release branch
*/
public static Result forBaseBranch(String base, Collection<String> branches, Collection<String> tags) {
if (base.equals("main")) {
int[] last = branches.stream()
.map(RELEASE_BRANCH::matcher)
.filter(Matcher::matches)
.map(m -> new int[]{Integer.parseInt(m.group(1)), Integer.parseInt(m.group(2))})
.max(Comparator.<int[]>comparingInt(v -> v[0]).thenComparingInt(v -> v[1]))
.orElse(null);
if (last == null) {
return null;
}
String label = "release/" + last[0] + "." + (last[1] + 1) + ".0";
// There will be no more minor release on the 26.x line; 26.9.0 is actually released as the major bump 27.0.0.
if (label.equals("release/26.9.0")) {
label = "release/27.0.0";
}
return new Result(label, "backport/main");
}

Matcher m = RELEASE_BRANCH.matcher(base);
if (m.matches()) {
String majorMinor = m.group(1) + "." + m.group(2);
Pattern tagPattern = Pattern.compile(Pattern.quote(majorMinor) + "\\.(\\d+)");
int lastMicro = tags.stream()
.map(tagPattern::matcher)
.filter(Matcher::matches)
.mapToInt(t -> Integer.parseInt(t.group(1)))
.max()
.orElse(-1);
return new Result("release/" + majorMinor + "." + (lastMicro + 1), "backport/" + majorMinor);
}

return null;
}

/**
* Finds issues closed by the PR description, falling back to the merge commit message if the description has none.
*/
public static Set<Integer> findIssues(String repository, String prBody, String commitMessage) {
Set<Integer> issues = PullRequestParser.closingIssues(prBody, repository);
if (issues.isEmpty()) {
issues = PullRequestParser.closingIssues(commitMessage, repository);
}
return issues;
}

}
Loading
Loading