diff --git a/README.md b/README.md index 1e85d7c..e3cfa70 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,14 @@ 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. +### Link backport PRs to issues + +When a PR targeting `release/*` is opened, edited, or reopened, +the bot adds it to the Development section of issues referenced by closing keywords in its description, such as `Closes #123`. +References can use `#123`, `owner/repo#123`, or `https://github.com/owner/repo/issues/123`, +but must identify issues in the PR's repository. +Linking works for open and closed issues without requiring backport labels. + ## Testing changes to the application To test changes to the application you need: 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..ad3e4ed --- /dev/null +++ b/src/main/java/org/keycloak/gh/bot/LinkBackportPRs.java @@ -0,0 +1,89 @@ +package org.keycloak.gh.bot; + +import io.quarkiverse.githubapp.event.PullRequest; +import io.smallrye.graphql.client.Response; +import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient; +import org.jboss.logging.Logger; +import org.keycloak.gh.bot.utils.PullRequestParser; +import org.kohsuke.github.GHEventPayload; +import org.kohsuke.github.GHFileNotFoundException; +import org.kohsuke.github.GHIssue; +import org.kohsuke.github.GHIssueState; +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 java.util.concurrent.ExecutionException; + +public class LinkBackportPRs { + + private static final Logger logger = Logger.getLogger(LinkBackportPRs.class); + + private static final String LINK_PR = """ + mutation($issueId: ID!, $pullRequestIds: [ID!]!) { + addCloseIssueReferences(input: {issueId: $issueId, pullRequestIds: $pullRequestIds}) { + issue { id } + } + } + """; + + void onOpen(@PullRequest.Opened GHEventPayload.PullRequest payload, DynamicGraphQLClient client) throws IOException { + link(payload, client); + } + + void onEdit(@PullRequest.Edited GHEventPayload.PullRequest payload, DynamicGraphQLClient client) throws IOException { + link(payload, client); + } + + void onReopen(@PullRequest.Reopened GHEventPayload.PullRequest payload, DynamicGraphQLClient client) throws IOException { + link(payload, client); + } + + private void link(GHEventPayload.PullRequest payload, DynamicGraphQLClient client) throws IOException { + GHRepository repository = payload.getRepository(); + GHPullRequest pr = payload.getPullRequest(); + try { + String branch = pr.getBase().getRef(); + if (pr.getState() != GHIssueState.OPEN || !branch.startsWith("release/")) { + return; + } + Set issueNumbers = PullRequestParser.closingIssues(pr.getBody(), repository.getFullName()); + for (int issueNumber : issueNumbers) { + GHIssue issue; + try { + issue = repository.getIssue(issueNumber); + } catch (GHFileNotFoundException e) { + logger.errorv(e, "Referenced issue not found while linking backport PR {0}#{1} to issue #{2}", + repository.getFullName(), pr.getNumber(), issueNumber); + continue; + } + if (!issue.isPullRequest()) { + link(client, issue.getNodeId(), pr.getNodeId()); + logger.infov("Linked backport PR {0}#{1} to issue {0}#{2}", + repository.getFullName(), pr.getNumber(), issueNumber); + } + } + } catch (Exception e) { + logger.errorv(e, "Failed to link backport PR {0}#{1} to referenced issues", + repository.getFullName(), pr.getNumber()); + throw e; + } + } + + private void link(DynamicGraphQLClient client, String issueId, String prId) throws IOException { + try { + Response response = client.executeSync(LINK_PR, Map.of("issueId", issueId, "pullRequestIds", List.of(prId))); + if (response.hasError() || !response.hasData()) { + throw new IOException("Backport GraphQL request failed: " + response.getErrors()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Backport linking interrupted", e); + } catch (ExecutionException e) { + throw new IOException("Backport GraphQL request failed", e); + } + } +} diff --git a/src/main/java/org/keycloak/gh/bot/utils/PullRequestParser.java b/src/main/java/org/keycloak/gh/bot/utils/PullRequestParser.java new file mode 100644 index 0000000..e427224 --- /dev/null +++ b/src/main/java/org/keycloak/gh/bot/utils/PullRequestParser.java @@ -0,0 +1,39 @@ +package org.keycloak.gh.bot.utils; + +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class PullRequestParser { + + 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/])"); + + public 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()) { + // Groups 1/2 are shorthand references; groups 3/4 are issue URLs. + String referenceRepository = matcher.group(1) != null ? matcher.group(1) : matcher.group(3); + if (referenceRepository != null && !referenceRepository.equalsIgnoreCase(repository)) { + continue; + } + String number = matcher.group(2) != null ? matcher.group(2) : matcher.group(4); + 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; + } +} 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..e1786d6 --- /dev/null +++ b/src/test/java/org/keycloak/gh/bot/LinkBackportPRsTest.java @@ -0,0 +1,126 @@ +package org.keycloak.gh.bot; + +import io.smallrye.graphql.client.Response; +import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +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.GHPullRequest; +import org.kohsuke.github.GHRepository; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class LinkBackportPRsTest { + + private final LinkBackportPRs handler = new LinkBackportPRs(); + private final DynamicGraphQLClient client = mock(DynamicGraphQLClient.class); + private final GHEventPayload.PullRequest payload = mock(GHEventPayload.PullRequest.class); + private final GHRepository repository = mock(GHRepository.class); + private final GHPullRequest pr = mock(GHPullRequest.class); + private final GHCommitPointer base = mock(GHCommitPointer.class); + private final GHIssue issue = mock(GHIssue.class); + + @BeforeEach + void setUp() throws Exception { + when(payload.getRepository()).thenReturn(repository); + when(repository.getFullName()).thenReturn("keycloak/keycloak"); + when(payload.getPullRequest()).thenReturn(pr); + when(pr.getState()).thenReturn(GHIssueState.OPEN); + when(pr.getBody()).thenReturn("Closes #123"); + when(pr.getBase()).thenReturn(base); + when(base.getRef()).thenReturn("release/26.8"); + when(pr.getNodeId()).thenReturn("backport"); + when(repository.getIssue(123)).thenReturn(issue); + when(issue.getNodeId()).thenReturn("issue"); + Response response = mock(Response.class); + when(response.hasData()).thenReturn(true); + when(client.executeSync(contains("mutation"), anyMap())).thenReturn(response); + } + + @Test + void linksOnOpenEditAndReopen() throws Exception { + handler.onOpen(payload, client); + handler.onEdit(payload, client); + handler.onReopen(payload, client); + verify(client, times(3)).executeSync(contains("mutation"), + eq(Map.of("issueId", "issue", "pullRequestIds", List.of("backport")))); + } + + @ParameterizedTest + @CsvSource({"main,OPEN", "release/26.8,CLOSED"}) + void skipsUnrelatedOrClosedPRs(String branch, GHIssueState state) throws Exception { + when(base.getRef()).thenReturn(branch); + when(pr.getState()).thenReturn(state); + handler.onOpen(payload, client); + verifyNoInteractions(client); + } + + @Test + void linksIssuesInAnyRepository() throws Exception { + when(repository.getFullName()).thenReturn("other/repo"); + handler.onOpen(payload, client); + verify(repository).getIssue(123); + verify(client).executeSync(contains("mutation"), + eq(Map.of("issueId", "issue", "pullRequestIds", List.of("backport")))); + } + + @ParameterizedTest + @CsvSource({"Closes other/repo#123", "Resolves https://github.com/other/repo/issues/123"}) + void skipsCrossRepositoryReferences(String body) throws Exception { + when(pr.getBody()).thenReturn(body); + handler.onOpen(payload, client); + verify(repository, times(0)).getIssue(123); + verifyNoInteractions(client); + } + + @ParameterizedTest + @CsvSource({"keycloak/keycloak,Fixes keycloak/keycloak#123", + "keycloak/keycloak,Resolves https://github.com/keycloak/keycloak/issues/123", + "other/repo,Fixes other/repo#123", + "other/repo,Closes https://github.com/other/repo/issues/123"}) + void linksQualifiedReferencesInCurrentRepository(String repo, String body) throws Exception { + when(repository.getFullName()).thenReturn(repo); + when(pr.getBody()).thenReturn(body); + handler.onOpen(payload, client); + verify(repository).getIssue(123); + verify(client).executeSync(contains("mutation"), + eq(Map.of("issueId", "issue", "pullRequestIds", List.of("backport")))); + } + + @Test + void skipsMissingIssuesAndPullRequestReferences() throws Exception { + when(pr.getBody()).thenReturn("Closes #999\nCloses #123"); + when(repository.getIssue(999)).thenThrow(GHFileNotFoundException.class); + handler.onOpen(payload, client); + verify(client).executeSync(contains("mutation"), anyMap()); + when(issue.isPullRequest()).thenReturn(true); + handler.onEdit(payload, client); + verify(client, times(1)).executeSync(contains("mutation"), anyMap()); + } + + @Test + void propagatesGraphQLErrors() throws Exception { + Response error = mock(Response.class); + when(error.hasError()).thenReturn(true); + when(client.executeSync(contains("mutation"), anyMap())).thenReturn(error); + assertThrows(IOException.class, () -> handler.onOpen(payload, client)); + } +} diff --git a/src/test/java/org/keycloak/gh/bot/utils/PullRequestParserTest.java b/src/test/java/org/keycloak/gh/bot/utils/PullRequestParserTest.java new file mode 100644 index 0000000..417e9a2 --- /dev/null +++ b/src/test/java/org/keycloak/gh/bot/utils/PullRequestParserTest.java @@ -0,0 +1,59 @@ +package org.keycloak.gh.bot.utils; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class PullRequestParserTest { + + @ParameterizedTest + @CsvSource({"Close #123,123", "Closes #123,123", "Closed #123,123", "Fix #123,123", + "FIXES: #123,123", "Fixed #123,123", "Resolve #123,123", "Resolves #123,123", + "Resolved #123,123", "Closes #1234,1234", "Fixes #2147483647,2147483647", + "Closes keycloak/keycloak#123,123", "Fixes KEYCLOAK/Keycloak#123,123", + "Closes: https://github.com/keycloak/keycloak/issues/53957,53957", + "Closes https://github.com/keycloak/keycloak/issues/53210.,53210"}) + void parsesClosingReferences(String body, int number) { + assertEquals(Set.of(number), PullRequestParser.closingIssues(body, "keycloak/keycloak")); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"Related to #123", "Closes other/repo#123", + "Closes https://github.com/keycloak/keycloak/pull/123", + "Fixes https://example.com/keycloak/keycloak/issues/123", + "Fixes https://github.com/other/repo/issues/123", "Closes #0", "Closes #-123", + "Closes #999999999999999999999", "Closes #2147483648", "Closes #123abc", + "Closes #123/456", "Discloses #123"}) + void ignoresUnsupportedReferences(String body) { + assertEquals(Set.of(), PullRequestParser.closingIssues(body, "keycloak/keycloak")); + } + + @Test + void deduplicatesReferences() { + assertEquals(Set.of(123, 456), PullRequestParser.closingIssues( + "Closes #123\nFixes #456\nResolves keycloak/keycloak#123\n" + + "Closes https://github.com/keycloak/keycloak/issues/456", "keycloak/keycloak")); + } + + @Test + void parsesLocalReferencesAlongsideUnsupportedReferences() { + assertEquals(Set.of(123, 456), PullRequestParser.closingIssues( + "Closes other/repo#789\nFixes #123\nCloses #999999999999999999999\nResolves #456", + "keycloak/keycloak")); + } + + @Test + void usesCurrentRepositoryRatherThanHardcodedRepository() { + assertEquals(Set.of(123, 456), PullRequestParser.closingIssues( + "Closes other/repo#123\nFixes https://github.com/other/repo/issues/456\n" + + "Closes keycloak/keycloak#789\nFixes https://github.com/keycloak/keycloak/issues/789", + "other/repo")); + } +}