Skip to content
Closed
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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ add the applicable `area/` when a new bug issue is created.

When an issue is closed the `status/triage` label is removed from the issue.

### Remind assignees about missing backport PRs

Every hour, the bot checks issues closed as completed for at least 24 hours, with a related PR merged into `main`.
It mentions assignees about missing backports and repeats reminders daily until satisfied, preserving history across restarts.

Each `backport/<version>` label requires an open or merged PR targeting `release/<version>`, linked in Development.
The bot links matching release PRs when opened, edited, or reopened, using closing keywords such as `Closes #123`.
The hourly check only verifies Development links and sends reminders; linking is handled by PR webhooks.
It ignores `backport/main` and versions with an `archive/release/<version>` branch. Removing a backport label removes its requirement.

Configure the schedule with `backport.cron` and the reminder interval with `backport.reminderIntervalDays` (default: 1).
Automatic linking requires GitHub App permission to link PRs to issues.

## Testing changes to the application

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

import io.smallrye.graphql.client.Response;
import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import jakarta.json.JsonObject;
import org.kohsuke.github.GHIssue;
import org.kohsuke.github.GHPullRequest;

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ExecutionException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

@Singleton
public class BackportPullRequests {

private static final String LINKED_PRS = """
query($issueId: ID!, $cursor: String) {
node(id: $issueId) {
... on Issue {
pullRequests: closedByPullRequestsReferences(first: 100, after: $cursor, includeClosedPrs: true) {
nodes { id baseRefName state repository { nameWithOwner } }
pageInfo { hasNextPage endCursor }
}
}
}
}
""";

private static final String LINK_PR = """
mutation($issueId: ID!, $pullRequestIds: [ID!]!) {
addCloseIssueReferences(input: {issueId: $issueId, pullRequestIds: $pullRequestIds}) {
issue { id }
}
}
""";

private static final Pattern CLOSING_REFERENCE = Pattern.compile(
"(?i)\\b(?:close[sd]?|fix(?:es|ed)?|resolve[sd]?)\\s*:?\\s+"
+ "(?:(?:([\\w.-]+/[\\w.-]+))?#(\\d+)|https://github\\.com/([\\w.-]+/[\\w.-]+)/issues/(\\d+))"
+ "(?![\\w/])");

@Inject
GitHubInstallationProvider gitHubProvider;

public Result check(GHIssue issue, Set<String> requiredBranches) throws IOException {
String repository = issue.getRepository().getFullName();
DynamicGraphQLClient client = gitHubProvider.getGraphQLClient(repository);
List<PullRequest> linked = findLinkedPullRequests(client, issue.getNodeId(), repository);
boolean mainMerged = linked.stream().anyMatch(PullRequest::isMergedMain);
Set<String> missing = new TreeSet<>(requiredBranches);
for (PullRequest pr : linked) {
if (pr.isAvailable()) {
missing.remove(pr.branch());
}
}
return new Result(mainMerged, missing);
}

public void link(GHIssue issue, GHPullRequest pullRequest) throws IOException {
String repository = issue.getRepository().getFullName();
DynamicGraphQLClient client = gitHubProvider.getGraphQLClient(repository);
List<PullRequest> linked = findLinkedPullRequests(client, issue.getNodeId(), repository);
if (linked.stream().noneMatch(pr -> pr.id().equals(pullRequest.getNodeId()))) {
execute(client, LINK_PR, Map.of("issueId", issue.getNodeId(), "pullRequestIds", List.of(pullRequest.getNodeId())));
}
}

static Set<Integer> closingIssues(String body, String repository) {
Set<Integer> issues = new LinkedHashSet<>();
if (body == null) {
return issues;
}
Matcher matcher = CLOSING_REFERENCE.matcher(body);
while (matcher.find()) {
String referenceRepository = matcher.group(1) != null ? matcher.group(1) : matcher.group(3);
String number = matcher.group(2) != null ? matcher.group(2) : matcher.group(4);
if (referenceRepository == null || referenceRepository.equalsIgnoreCase(repository)) {
try {
int issueNumber = Integer.parseInt(number);
if (issueNumber > 0) {
issues.add(issueNumber);
}
} catch (NumberFormatException e) {
// Ignore issue numbers outside the client's integer range.
}
}
}
return issues;
}

private List<PullRequest> findLinkedPullRequests(DynamicGraphQLClient client, String issueId, String repository) throws IOException {
List<PullRequest> result = new ArrayList<>();
Map<String, Object> variables = new HashMap<>();
variables.put("issueId", issueId);
boolean hasNextPage;
do {
JsonObject node = execute(client, LINKED_PRS, variables).getJsonObject("node");
if (node == null) {
throw new IOException("Issue not found: " + issueId);
}
JsonObject connection = node.getJsonObject("pullRequests");
for (JsonObject pr : connection.getJsonArray("nodes").getValuesAs(JsonObject.class)) {
if (pr != null && pr.containsKey("baseRefName")
&& pr.getJsonObject("repository").getString("nameWithOwner").equalsIgnoreCase(repository)) {
result.add(new PullRequest(pr.getString("id"), pr.getString("baseRefName"), pr.getString("state")));
}
}
JsonObject pageInfo = connection.getJsonObject("pageInfo");
hasNextPage = pageInfo.getBoolean("hasNextPage");
if (hasNextPage) {
variables.put("cursor", pageInfo.getString("endCursor"));
}
} while (hasNextPage);
return result;
}

private JsonObject execute(DynamicGraphQLClient client, String query, Map<String, Object> variables) throws IOException {
try {
Response response = client.executeSync(query, variables);
if (response.hasError() || !response.hasData()) {
throw new IOException("Backport GraphQL request failed: " + response.getErrors());
}
return response.getData();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Backport check interrupted", e);
} catch (ExecutionException e) {
throw new IOException("Backport GraphQL request failed", e);
}
}

public record Result(boolean mainMerged, Set<String> missingBranches) {
}

private record PullRequest(String id, String branch, String state) {

boolean isMergedMain() {
return branch.equals("main") && state.equals("MERGED");
}

boolean isAvailable() {
return state.equals("OPEN") || state.equals("MERGED");
}
}
}
118 changes: 118 additions & 0 deletions src/main/java/org/keycloak/gh/bot/BackportScheduleReminders.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package org.keycloak.gh.bot;

import io.quarkus.runtime.Startup;
import io.quarkus.scheduler.Scheduled;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import org.keycloak.gh.bot.utils.BackportLabels;
import org.kohsuke.github.GHIssue;
import org.kohsuke.github.GHIssueComment;
import org.kohsuke.github.GHIssueState;
import org.kohsuke.github.GHIssueStateReason;
import org.kohsuke.github.GHLabel;
import org.kohsuke.github.GHRepository;

import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;

@Startup
@Singleton
public class BackportScheduleReminders {

private static final Logger logger = Logger.getLogger(BackportScheduleReminders.class);
private static final Duration INITIAL_DELAY = Duration.ofHours(24);

@ConfigProperty(name = "backport.reminderIntervalDays")
long reminderIntervalDays;

@ConfigProperty(name = "repository.mainRepository")
String mainRepository;

@Inject
GitHubInstallationProvider gitHubProvider;

@Inject
BackportPullRequests pullRequests;

@Scheduled(cron = "{backport.cron}", concurrentExecution = Scheduled.ConcurrentExecution.SKIP)
public void checkBackports() throws IOException {
logger.info("Checking issues for missing backports");
GHRepository repository = gitHubProvider.getGitHubClient(mainRepository).getRepository(mainRepository);
Set<String> repositoryBranches = repository.getBranches().keySet();
Set<Integer> visited = new HashSet<>();
Instant now = Instant.now();
for (GHLabel label : repository.listLabels()) {
if (!BackportLabels.isRequired(label.getName(), repositoryBranches)) {
continue;
}
for (GHIssue issue : repository.queryIssues().state(GHIssueState.CLOSED).label(label.getName()).list()) {
if (!issue.isPullRequest() && visited.add(issue.getNumber())) {
try {
if (isEligible(issue, now)) {
checkIssue(repository.getIssue(issue.getNumber()), now, repositoryBranches);
}
} catch (IOException e) {
logger.errorv(e, "Failed checking backports for issue={0}", issue.getNumber());
}
}
}
}
}

void checkIssue(GHIssue issue, Instant now, Set<String> repositoryBranches) throws IOException {
if (!isEligible(issue, now)) {
return;
}
Set<String> branches = BackportLabels.requiredBranches(issue.getLabels(), repositoryBranches);
if (branches.isEmpty()) {
return;
}

BackportPullRequests.Result result = pullRequests.check(issue, branches);
if (!result.mainMerged() || result.missingBranches().isEmpty()) {
return;
}

String marker = "<!-- backport-reminder:closed-at=" + issue.getClosedAt().getTime() + " -->";
if (shouldRemind(issue, marker, now)) {
issue.comment(reminderMessage(issue, result.missingBranches(), marker));
logger.infov("Reminded issue={0}, missing backports={1}", issue.getNumber(), result.missingBranches());
}
}

private boolean isEligible(GHIssue issue, Instant now) {
return issue.getState() == GHIssueState.CLOSED && issue.getStateReason() == GHIssueStateReason.COMPLETED
&& issue.getClosedAt() != null && !issue.isLocked()
&& !now.isBefore(issue.getClosedAt().toInstant().plus(INITIAL_DELAY));
}

private boolean shouldRemind(GHIssue issue, String marker, Instant now) throws IOException {
Instant lastReminder = null;
String botLogin = gitHubProvider.getBotLogin();
for (GHIssueComment comment : issue.listComments()) {
if (comment.getBody().contains(marker) && comment.getUser().getLogin().equals(botLogin)) {
Instant createdAt = comment.getCreatedAt().toInstant();
if (lastReminder == null || createdAt.isAfter(lastReminder)) {
lastReminder = createdAt;
}
}
}
return lastReminder == null || !now.isBefore(lastReminder.plus(Duration.ofDays(reminderIntervalDays)));
}

private String reminderMessage(GHIssue issue, Set<String> missingBranches, String marker) {
String assignees = issue.getAssignees().stream().map(user -> "@" + user.getLogin()).collect(Collectors.joining(" "));
String missing = missingBranches.stream().map(branch -> "`" + branch + "`").collect(Collectors.joining(", "));
return (assignees.isEmpty() ? "" : assignees + " ")
+ "This issue is fixed in `main`, but backport PRs are still missing for: " + missing + ".\n\n"
+ "Please create or link the backport PRs in Development. Include `Closes #" + issue.getNumber()
+ "` in their descriptions for automatic linking when opened or updated. "
+ "If a backport is no longer required, remove its `backport/*` label.\n\n" + marker;
}
}
17 changes: 12 additions & 5 deletions src/main/java/org/keycloak/gh/bot/GitHubInstallationProvider.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.keycloak.gh.bot;

import io.quarkiverse.githubapp.GitHubClientProvider;
import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient;
import io.quarkus.runtime.Startup;
import jakarta.annotation.PostConstruct;
import jakarta.inject.Inject;
Expand All @@ -27,7 +28,7 @@ public class GitHubInstallationProvider {
private String botLogin;

@PostConstruct
public void init() throws java.io.IOException {
public void init() throws IOException {
GitHub appClient = gitHubClientProvider.getApplicationClient();
GHApp app = appClient.getApp();
this.botLogin = app.getSlug() + "[bot]";
Expand Down Expand Up @@ -55,6 +56,14 @@ public Map<GHRepository, GitHub> getAllInstalledRepositories() throws IOExceptio
* Fetches the authenticated GitHub client for a specific repository
*/
public GitHub getGitHubClient(String repositoryFullName) throws IOException {
return gitHubClientProvider.getInstallationClient(getInstallationId(repositoryFullName));
}

public DynamicGraphQLClient getGraphQLClient(String repositoryFullName) throws IOException {
return gitHubClientProvider.getInstallationGraphQLClient(getInstallationId(repositoryFullName));
}

private long getInstallationId(String repositoryFullName) throws IOException {
int slashIndex = repositoryFullName.indexOf('/');

if (slashIndex <= 0 || slashIndex == repositoryFullName.length() - 1) {
Expand All @@ -64,9 +73,7 @@ public GitHub getGitHubClient(String repositoryFullName) throws IOException {
String owner = repositoryFullName.substring(0, slashIndex);
String repoName = repositoryFullName.substring(slashIndex + 1);

long installId = gitHubClientProvider.getApplicationClient().getApp()
return gitHubClientProvider.getApplicationClient().getApp()
.getInstallationByRepository(owner, repoName).getId();

return gitHubClientProvider.getInstallationClient(installId);
}
}
}
Loading
Loading