From 46f7b19dc141b6893ecac78036c18dd58c224643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Barto=C5=A1?= Date: Tue, 13 Oct 2026 11:53:08 +0200 Subject: [PATCH] Remind issue assignees about missing backport PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #91 Signed-off-by: Martin Bartoš --- README.md | 13 + .../keycloak/gh/bot/BackportPullRequests.java | 155 ++++++++++ .../gh/bot/BackportScheduleReminders.java | 118 +++++++ .../gh/bot/GitHubInstallationProvider.java | 17 +- .../org/keycloak/gh/bot/LinkBackportPRs.java | 72 +++++ .../keycloak/gh/bot/utils/BackportLabels.java | 34 ++ src/main/resources/application.properties | 3 + .../gh/bot/BackportPullRequestsTest.java | 195 ++++++++++++ .../gh/bot/BackportScheduleRemindersTest.java | 292 ++++++++++++++++++ .../keycloak/gh/bot/LinkBackportPRsTest.java | 161 ++++++++++ 10 files changed, 1055 insertions(+), 5 deletions(-) create mode 100644 src/main/java/org/keycloak/gh/bot/BackportPullRequests.java create mode 100644 src/main/java/org/keycloak/gh/bot/BackportScheduleReminders.java create mode 100644 src/main/java/org/keycloak/gh/bot/LinkBackportPRs.java create mode 100644 src/main/java/org/keycloak/gh/bot/utils/BackportLabels.java create mode 100644 src/test/java/org/keycloak/gh/bot/BackportPullRequestsTest.java create mode 100644 src/test/java/org/keycloak/gh/bot/BackportScheduleRemindersTest.java create mode 100644 src/test/java/org/keycloak/gh/bot/LinkBackportPRsTest.java diff --git a/README.md b/README.md index 1e85d7c..0309968 100644 --- a/README.md +++ b/README.md @@ -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/` label requires an open or merged PR targeting `release/`, 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/` 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: diff --git a/src/main/java/org/keycloak/gh/bot/BackportPullRequests.java b/src/main/java/org/keycloak/gh/bot/BackportPullRequests.java new file mode 100644 index 0000000..2d81eae --- /dev/null +++ b/src/main/java/org/keycloak/gh/bot/BackportPullRequests.java @@ -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 requiredBranches) throws IOException { + String repository = issue.getRepository().getFullName(); + DynamicGraphQLClient client = gitHubProvider.getGraphQLClient(repository); + List linked = findLinkedPullRequests(client, issue.getNodeId(), repository); + boolean mainMerged = linked.stream().anyMatch(PullRequest::isMergedMain); + Set 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 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 closingIssues(String body, String repository) { + Set 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 findLinkedPullRequests(DynamicGraphQLClient client, String issueId, String repository) throws IOException { + List result = new ArrayList<>(); + Map 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 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 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"); + } + } +} diff --git a/src/main/java/org/keycloak/gh/bot/BackportScheduleReminders.java b/src/main/java/org/keycloak/gh/bot/BackportScheduleReminders.java new file mode 100644 index 0000000..99ab3fa --- /dev/null +++ b/src/main/java/org/keycloak/gh/bot/BackportScheduleReminders.java @@ -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 repositoryBranches = repository.getBranches().keySet(); + Set 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 repositoryBranches) throws IOException { + if (!isEligible(issue, now)) { + return; + } + Set 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 = ""; + 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 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; + } +} diff --git a/src/main/java/org/keycloak/gh/bot/GitHubInstallationProvider.java b/src/main/java/org/keycloak/gh/bot/GitHubInstallationProvider.java index 857642e..ebe1a2c 100644 --- a/src/main/java/org/keycloak/gh/bot/GitHubInstallationProvider.java +++ b/src/main/java/org/keycloak/gh/bot/GitHubInstallationProvider.java @@ -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; @@ -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]"; @@ -55,6 +56,14 @@ public Map 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) { @@ -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); } -} \ No newline at end of file +} diff --git a/src/main/java/org/keycloak/gh/bot/LinkBackportPRs.java b/src/main/java/org/keycloak/gh/bot/LinkBackportPRs.java new file mode 100644 index 0000000..cfbff8a --- /dev/null +++ b/src/main/java/org/keycloak/gh/bot/LinkBackportPRs.java @@ -0,0 +1,72 @@ +package org.keycloak.gh.bot; + +import io.quarkiverse.githubapp.event.PullRequest; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; +import org.keycloak.gh.bot.utils.BackportLabels; +import org.kohsuke.github.GHEventPayload; +import org.kohsuke.github.GHFileNotFoundException; +import org.kohsuke.github.GHIssue; +import org.kohsuke.github.GHIssueState; +import org.kohsuke.github.GHIssueStateReason; +import org.kohsuke.github.GHPullRequest; +import org.kohsuke.github.GHRepository; + +import java.io.IOException; +import java.util.Set; + +public class LinkBackportPRs { + + private static final Logger logger = Logger.getLogger(LinkBackportPRs.class); + + @ConfigProperty(name = "repository.mainRepository") + String mainRepository; + + @Inject + BackportPullRequests pullRequests; + + void onOpen(@PullRequest.Opened GHEventPayload.PullRequest payload) throws IOException { + link(payload); + } + + void onEdit(@PullRequest.Edited GHEventPayload.PullRequest payload) throws IOException { + link(payload); + } + + void onReopen(@PullRequest.Reopened GHEventPayload.PullRequest payload) throws IOException { + link(payload); + } + + private void link(GHEventPayload.PullRequest payload) throws IOException { + GHRepository repository = payload.getRepository(); + GHPullRequest pr = payload.getPullRequest(); + String branch = pr.getBase().getRef(); + if (!repository.getFullName().equalsIgnoreCase(mainRepository) || pr.getState() != GHIssueState.OPEN + || !branch.startsWith("release/")) { + return; + } + Set issueNumbers = BackportPullRequests.closingIssues(pr.getBody(), repository.getFullName()); + if (issueNumbers.isEmpty()) { + return; + } + Set repositoryBranches = repository.getBranches().keySet(); + if (repositoryBranches.contains("archive/" + branch)) { + return; + } + for (int issueNumber : issueNumbers) { + GHIssue issue; + try { + issue = repository.getIssue(issueNumber); + } catch (GHFileNotFoundException e) { + logger.warnv("Referenced issue not found: repository={0}, issue={1}", repository.getFullName(), issueNumber); + continue; + } + if (!issue.isPullRequest() && !issue.isLocked() + && issue.getState() == GHIssueState.CLOSED && issue.getStateReason() == GHIssueStateReason.COMPLETED + && BackportLabels.requiredBranches(issue.getLabels(), repositoryBranches).contains(branch)) { + pullRequests.link(issue, pr); + } + } + } +} diff --git a/src/main/java/org/keycloak/gh/bot/utils/BackportLabels.java b/src/main/java/org/keycloak/gh/bot/utils/BackportLabels.java new file mode 100644 index 0000000..9635e4c --- /dev/null +++ b/src/main/java/org/keycloak/gh/bot/utils/BackportLabels.java @@ -0,0 +1,34 @@ +package org.keycloak.gh.bot.utils; + +import org.kohsuke.github.GHLabel; + +import java.util.Collection; +import java.util.Set; +import java.util.TreeSet; + +public final class BackportLabels { + + private static final String PREFIX = "backport/"; + + private BackportLabels() { + } + + public static boolean isRequired(String name, Set repositoryBranches) { + return name.startsWith(PREFIX) && name.length() > PREFIX.length() && !name.equals("backport/main") + && !repositoryBranches.contains("archive/" + targetBranch(name)); + } + + public static Set requiredBranches(Collection labels, Set repositoryBranches) { + Set branches = new TreeSet<>(); + for (GHLabel label : labels) { + if (isRequired(label.getName(), repositoryBranches)) { + branches.add(targetBranch(label.getName())); + } + } + return branches; + } + + private static String targetBranch(String labelName) { + return "release/" + labelName.substring(PREFIX.length()); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index b0889e7..9a1f9c0 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -63,3 +63,6 @@ bot.email.auto-reply.enabled=true jira.url=https://issues.redhat.com jira.pat=${JIRA_PAT} bot.jira.sync.interval=1h + +backport.cron=0 7 * * * ? +backport.reminderIntervalDays=1 diff --git a/src/test/java/org/keycloak/gh/bot/BackportPullRequestsTest.java b/src/test/java/org/keycloak/gh/bot/BackportPullRequestsTest.java new file mode 100644 index 0000000..6724a1c --- /dev/null +++ b/src/test/java/org/keycloak/gh/bot/BackportPullRequestsTest.java @@ -0,0 +1,195 @@ +package org.keycloak.gh.bot; + +import io.smallrye.graphql.client.Response; +import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient; +import jakarta.json.Json; +import jakarta.json.JsonArrayBuilder; +import jakarta.json.JsonObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.kohsuke.github.GHIssue; +import org.kohsuke.github.GHPullRequest; +import org.kohsuke.github.GHRepository; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class BackportPullRequestsTest { + + private DynamicGraphQLClient client; + private BackportPullRequests service; + private GHIssue issue; + + @BeforeEach + void setUp() throws IOException { + client = mock(DynamicGraphQLClient.class); + service = new BackportPullRequests(); + service.gitHubProvider = mock(GitHubInstallationProvider.class); + when(service.gitHubProvider.getGraphQLClient("keycloak/keycloak")).thenReturn(client); + issue = mock(GHIssue.class); + GHRepository repository = mock(GHRepository.class); + when(repository.getFullName()).thenReturn("keycloak/keycloak"); + when(issue.getRepository()).thenReturn(repository); + when(issue.getNodeId()).thenReturn("issue"); + when(issue.getNumber()).thenReturn(123); + } + + @Test + void recognizesClosingReferences() { + assertTrue(BackportPullRequests.closingIssues("Closes #123", "keycloak/keycloak").contains(123)); + assertTrue(BackportPullRequests.closingIssues("FIXES: keycloak/keycloak#123", "keycloak/keycloak").contains(123)); + assertTrue(BackportPullRequests.closingIssues("Resolves https://github.com/keycloak/keycloak/issues/123", "keycloak/keycloak").contains(123)); + assertFalse(BackportPullRequests.closingIssues("Related to #123", "keycloak/keycloak").contains(123)); + assertFalse(BackportPullRequests.closingIssues("Closes other/repo#123", "keycloak/keycloak").contains(123)); + assertFalse(BackportPullRequests.closingIssues("Closes #1234", "keycloak/keycloak").contains(123)); + } + + @Test + void parsesMultipleReferencesAndIgnoresInvalidIssueNumbers() { + assertEquals(Set.of(123, 456), BackportPullRequests.closingIssues( + "Closes #123\nFixes keycloak/keycloak#456\nResolves #123\nCloses other/repo#789\nCloses #0\nCloses #999999999999999999999", + "keycloak/keycloak")); + assertTrue(BackportPullRequests.closingIssues(null, "keycloak/keycloak").isEmpty()); + } + + @Test + void linksWebhookPullRequestDirectlyWithoutMainOrTimelineChecks() throws Exception { + GHPullRequest pr = mock(GHPullRequest.class); + when(pr.getNodeId()).thenReturn("backport"); + Response linked = page(List.of(), false); + Response mutation = mutationResponse(); + when(client.executeSync(anyString(), anyMap())).thenReturn(linked, mutation); + + service.link(issue, pr); + + verify(client).executeSync(contains("mutation"), eq(Map.of("issueId", "issue", "pullRequestIds", List.of("backport")))); + verify(client, never()).executeSync(contains("timelineItems"), anyMap()); + } + + @Test + void doesNotDuplicateExistingDevelopmentLink() throws Exception { + GHPullRequest pr = mock(GHPullRequest.class); + when(pr.getNodeId()).thenReturn("backport"); + Response linked = page(List.of(pr("backport", "release/26.8", "OPEN")), false); + when(client.executeSync(anyString(), anyMap())).thenReturn(linked); + + service.link(issue, pr); + + verify(client, never()).executeSync(contains("mutation"), anyMap()); + } + + @Test + void acceptsOpenLinkedBackportAndRejectsUnmergedClosedBackport() throws Exception { + Response linked = page(List.of(pr("main", "main", "MERGED"), + pr("backport", "release/26.6", "OPEN"), pr("abandoned", "release/26.7", "CLOSED")), false); + when(client.executeSync(anyString(), anyMap())).thenReturn(linked); + + BackportPullRequests.Result result = service.check(issue, Set.of("release/26.6", "release/26.7")); + + assertTrue(result.mainMerged()); + assertEquals(Set.of("release/26.7"), result.missingBranches()); + verify(client, never()).executeSync(contains("mutation"), anyMap()); + } + + @Test + void acceptsMergedLinkedBackportWithoutKeyword() throws Exception { + Response linked = page(List.of(pr("main", "main", "MERGED"), pr("backport", "release/26.8", "MERGED")), false); + when(client.executeSync(anyString(), anyMap())).thenReturn(linked); + assertTrue(service.check(issue, Set.of("release/26.8")).missingBranches().isEmpty()); + verify(client, times(1)).executeSync(anyString(), anyMap()); + verify(client, never()).executeSync(contains("timelineItems"), anyMap()); + } + + @Test + void readsAllPagesOfDevelopmentLinks() throws Exception { + Response firstPage = page(List.of(), true); + Response secondPage = page(List.of(pr("main", "main", "MERGED")), false); + when(client.executeSync(anyString(), anyMap())).thenReturn(firstPage, secondPage); + assertEquals(Set.of("release/26.8"), service.check(issue, Set.of("release/26.8")).missingBranches()); + verify(client, times(2)).executeSync(contains("closedByPullRequestsReferences"), anyMap()); + verify(service.gitHubProvider).getGraphQLClient("keycloak/keycloak"); + verify(client, never()).executeSync(contains("mutation"), anyMap()); + } + + @Test + void failsInsteadOfRemindingAfterApiError() throws Exception { + Response error = mock(Response.class); + when(error.hasError()).thenReturn(true); + when(client.executeSync(anyString(), anyMap())).thenReturn(error); + assertThrows(IOException.class, () -> service.check(issue, Set.of("release/26.8"))); + } + + @Test + void ignoresPullRequestsInOtherRepositories() throws Exception { + JsonObject unrelated = Json.createObjectBuilder(pr("unrelated", "release/26.8", "OPEN")) + .add("repository", Json.createObjectBuilder().add("nameWithOwner", "other/repo")).build(); + Response linked = page(List.of(pr("main", "main", "MERGED"), unrelated), false); + when(client.executeSync(anyString(), anyMap())).thenReturn(linked); + + assertEquals(Set.of("release/26.8"), service.check(issue, Set.of("release/26.8")).missingBranches()); + verify(client, never()).executeSync(contains("mutation"), anyMap()); + } + + @Test + void scheduledCheckOnlyReadsDevelopmentLinks() throws Exception { + Response linked = page(List.of(pr("main", "main", "MERGED")), false); + when(client.executeSync(anyString(), anyMap())).thenReturn(linked); + + BackportPullRequests.Result result = service.check(issue, Set.of("release/26.8")); + + assertTrue(result.mainMerged()); + assertEquals(Set.of("release/26.8"), result.missingBranches()); + verify(client, times(1)).executeSync(contains("closedByPullRequestsReferences"), anyMap()); + verify(client, never()).executeSync(contains("timelineItems"), anyMap()); + verify(client, never()).executeSync(contains("mutation"), anyMap()); + } + + @Test + void requiresMergedMainPullRequest() throws Exception { + Response linked = page(List.of(pr("main", "main", "OPEN")), false); + when(client.executeSync(anyString(), anyMap())).thenReturn(linked); + assertFalse(service.check(issue, Set.of("release/26.8")).mainMerged()); + verify(client, never()).executeSync(contains("mutation"), anyMap()); + } + + private JsonObject pr(String id, String branch, String state) { + return Json.createObjectBuilder().add("id", id).add("baseRefName", branch).add("state", state) + .add("repository", Json.createObjectBuilder().add("nameWithOwner", "keycloak/keycloak")).build(); + } + + private Response page(List prs, boolean hasNextPage) { + JsonArrayBuilder nodes = Json.createArrayBuilder(); + for (JsonObject pr : prs) { + nodes.add(pr); + } + Response response = mock(Response.class); + when(response.hasData()).thenReturn(true); + when(response.getData()).thenReturn(Json.createObjectBuilder().add("node", Json.createObjectBuilder() + .add("pullRequests", Json.createObjectBuilder().add("nodes", nodes) + .add("pageInfo", Json.createObjectBuilder().add("hasNextPage", hasNextPage).add("endCursor", "next")))).build()); + return response; + } + + private Response mutationResponse() { + Response response = mock(Response.class); + when(response.hasData()).thenReturn(true); + when(response.getData()).thenReturn(Json.createObjectBuilder().build()); + return response; + } +} diff --git a/src/test/java/org/keycloak/gh/bot/BackportScheduleRemindersTest.java b/src/test/java/org/keycloak/gh/bot/BackportScheduleRemindersTest.java new file mode 100644 index 0000000..1c6b3bf --- /dev/null +++ b/src/test/java/org/keycloak/gh/bot/BackportScheduleRemindersTest.java @@ -0,0 +1,292 @@ +package org.keycloak.gh.bot; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.kohsuke.github.GHBranch; +import org.kohsuke.github.GHEventPayload; +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 org.kohsuke.github.GHUser; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.PagedIterable; +import org.kohsuke.github.PagedIterator; + +import java.io.IOException; +import java.io.StringReader; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class BackportScheduleRemindersTest { + + private final Instant closedAt = Instant.parse("2026-10-01T12:00:00Z"); + private BackportScheduleReminders schedule; + private GHIssue issue; + private Set repositoryBranches = Set.of(); + + @BeforeEach + void setUp() throws IOException { + schedule = new BackportScheduleReminders(); + schedule.pullRequests = mock(BackportPullRequests.class); + schedule.gitHubProvider = mock(GitHubInstallationProvider.class); + schedule.reminderIntervalDays = 1; + schedule.mainRepository = "keycloak/keycloak"; + when(schedule.gitHubProvider.getBotLogin()).thenReturn("keycloak-bot[bot]"); + + issue = mock(GHIssue.class); + when(issue.getNumber()).thenReturn(123); + when(issue.getState()).thenReturn(GHIssueState.CLOSED); + when(issue.getStateReason()).thenReturn(GHIssueStateReason.COMPLETED); + when(issue.getClosedAt()).thenReturn(Date.from(closedAt)); + List labels = List.of(label("backport/26.6"), label("backport/26.8"), label("backport/main"), label("kind/bug")); + when(issue.getLabels()).thenReturn(labels); + GHUser assignee = mock(GHUser.class); + when(assignee.getLogin()).thenReturn("alice"); + when(issue.getAssignees()).thenReturn(List.of(assignee)); + comments(List.of()); + when(schedule.pullRequests.check(issue, Set.of("release/26.6", "release/26.8"))) + .thenReturn(new BackportPullRequests.Result(true, Set.of("release/26.8"))); + } + + @Test + void waitsFor24Hours() throws IOException { + checkIssue(closedAt.plus(Duration.ofHours(24)).minusSeconds(1)); + verifyNoInteractions(schedule.pullRequests); + verify(issue, never()).comment(anyString()); + } + + @Test + void remindsAt24HoursWithOnlyMissingBranches() throws IOException { + checkIssue(closedAt.plus(Duration.ofHours(24))); + verify(schedule.pullRequests).check(issue, Set.of("release/26.6", "release/26.8")); + verify(issue).comment(contains("@alice This issue is fixed in `main`, but backport PRs are still missing for: `release/26.8`.")); + verify(issue, never()).comment(contains("`release/26.6`")); + } + + @Test + void doesNotRemindWithoutMergedMain() throws IOException { + when(schedule.pullRequests.check(issue, Set.of("release/26.6", "release/26.8"))) + .thenReturn(new BackportPullRequests.Result(false, Set.of("release/26.8"))); + checkIssue(closedAt.plus(Duration.ofDays(2))); + verify(issue, never()).comment(anyString()); + } + + @Test + void doesNotRemindWhenAllBackportsExist() throws IOException { + when(schedule.pullRequests.check(issue, Set.of("release/26.6", "release/26.8"))) + .thenReturn(new BackportPullRequests.Result(true, Set.of())); + checkIssue(closedAt.plus(Duration.ofDays(2))); + verify(issue, never()).comment(anyString()); + } + + @Test + void restartDoesNotDuplicateReminder() throws IOException { + comments(List.of(reminder(closedAt.plus(Duration.ofDays(1))))); + checkIssue(closedAt.plus(Duration.ofDays(1)).plus(Duration.ofHours(1))); + verify(issue, never()).comment(anyString()); + } + + @Test + void sendsFollowUpAfterOneDay() throws IOException { + comments(List.of(reminder(closedAt.plus(Duration.ofDays(1))))); + checkIssue(closedAt.plus(Duration.ofDays(2))); + verify(issue).comment(anyString()); + } + + @Test + void waitsAFullDayBeforeFollowUp() throws IOException { + comments(List.of(reminder(closedAt.plus(Duration.ofDays(1))))); + checkIssue(closedAt.plus(Duration.ofDays(2)).minusSeconds(1)); + verify(issue, never()).comment(anyString()); + } + + @Test + void continuesAfterTwoReminders() throws IOException { + comments(List.of(reminder(closedAt.plus(Duration.ofDays(1))), reminder(closedAt.plus(Duration.ofDays(2))))); + checkIssue(closedAt.plus(Duration.ofDays(3))); + verify(issue).comment(anyString()); + } + + @Test + void usesMostRecentReminderRegardlessOfOrder() throws IOException { + comments(List.of(reminder(closedAt.plus(Duration.ofDays(2))), reminder(closedAt.plus(Duration.ofDays(1))))); + checkIssue(closedAt.plus(Duration.ofDays(2)).plus(Duration.ofHours(1))); + verify(issue, never()).comment(anyString()); + } + + @Test + void oldClosureDoesNotSuppressNewReminder() throws IOException { + GHIssueComment old = reminder(closedAt.minus(Duration.ofDays(1)), closedAt.minusSeconds(1)); + comments(List.of(old)); + checkIssue(closedAt.plus(Duration.ofDays(1))); + verify(issue).comment(anyString()); + } + + @Test + void skipsReopenedIssues() throws IOException { + when(issue.getState()).thenReturn(GHIssueState.OPEN); + checkIssue(closedAt.plus(Duration.ofDays(1))); + verifyNoInteractions(schedule.pullRequests); + } + + @Test + void skipsNotPlannedIssues() throws IOException { + when(issue.getStateReason()).thenReturn(GHIssueStateReason.NOT_PLANNED); + checkIssue(closedAt.plus(Duration.ofDays(1))); + verifyNoInteractions(schedule.pullRequests); + verify(issue, never()).getLabels(); + verify(issue, never()).comment(anyString()); + } + + @Test + void skipsDuplicateIssues() throws IOException { + String payload = """ + { + "issue": { + "number": 123, + "state": "closed", + "state_reason": "duplicate", + "closed_at": "2026-10-01T12:00:00Z", + "labels": [{"name": "backport/26.8"}] + } + } + """; + GHIssue duplicate = GitHub.offline().parseEventPayload(new StringReader(payload), GHEventPayload.Issue.class).getIssue(); + schedule.checkIssue(duplicate, closedAt.plus(Duration.ofDays(1)), repositoryBranches); + verifyNoInteractions(schedule.pullRequests); + } + + @Test + void skipsIssuesWithMissingResolution() throws IOException { + when(issue.getStateReason()).thenReturn(null); + checkIssue(closedAt.plus(Duration.ofDays(1))); + verifyNoInteractions(schedule.pullRequests); + verify(issue, never()).getLabels(); + } + + @Test + void skipsIssuesWithoutBackportLabels() throws IOException { + when(issue.getLabels()).thenReturn(List.of()); + checkIssue(closedAt.plus(Duration.ofDays(1))); + verifyNoInteractions(schedule.pullRequests); + verify(issue, never()).listComments(); + } + + @Test + void skipsIssuesWithOnlyArchivedBackports() throws IOException { + List labels = List.of(label("backport/26.2")); + when(issue.getLabels()).thenReturn(labels); + repositoryBranches = Set.of("archive/release/26.2"); + + checkIssue(closedAt.plus(Duration.ofDays(1))); + + verifyNoInteractions(schedule.pullRequests); + verify(issue, never()).listComments(); + verify(issue, never()).comment(anyString()); + } + + @Test + void remindsOnlyForActiveReleaseInMixedLabels() throws IOException { + List labels = List.of(label("backport/26.2"), label("backport/26.8")); + when(issue.getLabels()).thenReturn(labels); + repositoryBranches = Set.of("archive/release/26.2", "release/26.8"); + when(schedule.pullRequests.check(issue, Set.of("release/26.8"))) + .thenReturn(new BackportPullRequests.Result(true, Set.of("release/26.8"))); + + checkIssue(closedAt.plus(Duration.ofDays(1))); + + verify(schedule.pullRequests).check(issue, Set.of("release/26.8")); + verify(issue).comment(contains("`release/26.8`")); + verify(issue, never()).comment(contains("`release/26.2`")); + } + + @Test + void archiveTakesPrecedenceOverReleaseBranch() throws IOException { + repositoryBranches = Set.of("archive/release/26.6", "release/26.6", "archive/release/26.8", "release/26.8"); + + checkIssue(closedAt.plus(Duration.ofDays(1))); + + verifyNoInteractions(schedule.pullRequests); + verify(issue, never()).comment(anyString()); + } + + @Test + void skipsIssueQueriesForArchivedLabels() throws IOException { + schedule.mainRepository = "owner/test"; + GHRepository repository = mock(GHRepository.class); + GitHub gitHub = mock(GitHub.class); + when(schedule.gitHubProvider.getGitHubClient("owner/test")).thenReturn(gitHub); + when(gitHub.getRepository("owner/test")).thenReturn(repository); + GHBranch archivedBranch = mock(GHBranch.class); + when(repository.getBranches()).thenReturn(Map.of("archive/release/26.2", archivedBranch)); + PagedIterable labels = paged(List.of(label("backport/26.2"))); + when(repository.listLabels()).thenReturn(labels); + + schedule.checkBackports(); + + verify(schedule.gitHubProvider).getGitHubClient("owner/test"); + verify(repository).getBranches(); + verify(repository, never()).queryIssues(); + verifyNoInteractions(schedule.pullRequests); + } + + private void checkIssue(Instant now) throws IOException { + schedule.checkIssue(issue, now, repositoryBranches); + } + + private GHLabel label(String name) { + GHLabel label = mock(GHLabel.class); + when(label.getName()).thenReturn(name); + return label; + } + + private GHIssueComment reminder(Instant createdAt) throws IOException { + return reminder(createdAt, closedAt); + } + + private GHIssueComment reminder(Instant createdAt, Instant issueClosedAt) throws IOException { + String payload = """ + { + "issue": {"number": 123}, + "comment": { + "body": "", + "created_at": "%s", + "user": {"login": "keycloak-bot[bot]"} + } + } + """.formatted(issueClosedAt.toEpochMilli(), createdAt); + return GitHub.offline().parseEventPayload(new StringReader(payload), GHEventPayload.IssueComment.class).getComment(); + } + + private void comments(List comments) throws IOException { + PagedIterable iterable = paged(comments); + when(issue.listComments()).thenReturn(iterable); + } + + @SuppressWarnings("unchecked") + private PagedIterable paged(List values) { + PagedIterable iterable = mock(PagedIterable.class); + PagedIterator pagedIterator = mock(PagedIterator.class); + Iterator iterator = values.iterator(); + when(pagedIterator.hasNext()).thenAnswer(invocation -> iterator.hasNext()); + when(pagedIterator.next()).thenAnswer(invocation -> iterator.next()); + when(iterable.iterator()).thenReturn(pagedIterator); + return iterable; + } +} diff --git a/src/test/java/org/keycloak/gh/bot/LinkBackportPRsTest.java b/src/test/java/org/keycloak/gh/bot/LinkBackportPRsTest.java new file mode 100644 index 0000000..cf651eb --- /dev/null +++ b/src/test/java/org/keycloak/gh/bot/LinkBackportPRsTest.java @@ -0,0 +1,161 @@ +package org.keycloak.gh.bot; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.kohsuke.github.GHBranch; +import org.kohsuke.github.GHCommitPointer; +import org.kohsuke.github.GHEventPayload; +import org.kohsuke.github.GHFileNotFoundException; +import org.kohsuke.github.GHIssue; +import org.kohsuke.github.GHIssueState; +import org.kohsuke.github.GHIssueStateReason; +import org.kohsuke.github.GHLabel; +import org.kohsuke.github.GHPullRequest; +import org.kohsuke.github.GHRepository; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class LinkBackportPRsTest { + + private LinkBackportPRs handler; + private GHEventPayload.PullRequest payload; + private GHRepository repository; + private GHPullRequest pr; + private GHCommitPointer base; + private GHIssue issue; + + @BeforeEach + void setUp() throws IOException { + handler = new LinkBackportPRs(); + handler.mainRepository = "keycloak/keycloak"; + handler.pullRequests = mock(BackportPullRequests.class); + payload = mock(GHEventPayload.PullRequest.class); + repository = mock(GHRepository.class); + when(payload.getRepository()).thenReturn(repository); + when(repository.getFullName()).thenReturn("keycloak/keycloak"); + when(repository.getBranches()).thenReturn(Map.of()); + pr = mock(GHPullRequest.class); + when(payload.getPullRequest()).thenReturn(pr); + when(pr.getState()).thenReturn(GHIssueState.OPEN); + when(pr.getBody()).thenReturn("Closes #123"); + base = mock(GHCommitPointer.class); + when(pr.getBase()).thenReturn(base); + when(base.getRef()).thenReturn("release/26.8"); + issue = mock(GHIssue.class); + when(repository.getIssue(123)).thenReturn(issue); + when(issue.getState()).thenReturn(GHIssueState.CLOSED); + when(issue.getStateReason()).thenReturn(GHIssueStateReason.COMPLETED); + GHLabel label = mock(GHLabel.class); + when(label.getName()).thenReturn("backport/26.8"); + when(issue.getLabels()).thenReturn(List.of(label)); + } + + @Test + void linksOnOpenWithoutWaitingForClosureAge() throws IOException { + handler.onOpen(payload); + verify(handler.pullRequests).link(issue, pr); + verify(issue, never()).getClosedAt(); + } + + @Test + void linksWhenDescriptionIsEditedToReferenceIssue() throws IOException { + when(pr.getBody()).thenReturn("Backport of the fix"); + handler.onOpen(payload); + verifyNoInteractions(handler.pullRequests); + + when(pr.getBody()).thenReturn("Fixes keycloak/keycloak#123"); + handler.onEdit(payload); + verify(handler.pullRequests).link(issue, pr); + } + + @Test + void linksWhenBaseBranchChangesToRelease() throws IOException { + when(base.getRef()).thenReturn("main"); + handler.onOpen(payload); + verifyNoInteractions(handler.pullRequests); + + when(base.getRef()).thenReturn("release/26.8"); + handler.onEdit(payload); + verify(handler.pullRequests).link(issue, pr); + } + + @Test + void linksOnReopen() throws IOException { + handler.onReopen(payload); + verify(handler.pullRequests).link(issue, pr); + } + + @Test + void skipsOtherRepositories() throws IOException { + when(repository.getFullName()).thenReturn("other/repo"); + handler.onOpen(payload); + verifyNoInteractions(handler.pullRequests); + verify(repository, never()).getBranches(); + } + + @Test + void skipsArchivedReleaseBeforeIssueLookups() throws IOException { + GHBranch archived = mock(GHBranch.class); + when(repository.getBranches()).thenReturn(Map.of("archive/release/26.8", archived)); + handler.onOpen(payload); + verifyNoInteractions(handler.pullRequests); + verify(repository, never()).getIssue(anyInt()); + } + + @Test + void skipsMissingOrForeignClosingReferencesBeforeBranchLookups() throws IOException { + for (String body : new String[] {null, "Related to #123", "Closes other/repo#123"}) { + when(pr.getBody()).thenReturn(body); + handler.onOpen(payload); + } + verifyNoInteractions(handler.pullRequests); + verify(repository, never()).getBranches(); + } + + @Test + void requiresMatchingBackportLabel() throws IOException { + when(issue.getLabels()).thenReturn(List.of()); + handler.onOpen(payload); + verifyNoInteractions(handler.pullRequests); + } + + @Test + void skipsNotPlannedIssues() throws IOException { + when(issue.getStateReason()).thenReturn(GHIssueStateReason.NOT_PLANNED); + handler.onOpen(payload); + verifyNoInteractions(handler.pullRequests); + } + + @Test + void skipsOpenIssues() throws IOException { + when(issue.getState()).thenReturn(GHIssueState.OPEN); + handler.onOpen(payload); + verifyNoInteractions(handler.pullRequests); + } + + @Test + void skipsClosedPullRequests() throws IOException { + when(pr.getState()).thenReturn(GHIssueState.CLOSED); + handler.onEdit(payload); + verifyNoInteractions(handler.pullRequests); + verify(repository, never()).getBranches(); + } + + @Test + void linksValidReferencesWhenAnotherIssueDoesNotExist() throws IOException { + when(pr.getBody()).thenReturn("Closes #999\nCloses #123\nFixes #123"); + when(repository.getIssue(999)).thenThrow(GHFileNotFoundException.class); + handler.onOpen(payload); + verify(handler.pullRequests).link(issue, pr); + verify(repository).getIssue(123); + } +}