Skip to content
Merged
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
89 changes: 89 additions & 0 deletions src/main/java/org/keycloak/gh/bot/LinkBackportPRs.java
Original file line number Diff line number Diff line change
@@ -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<Integer> 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);
}
}
}
39 changes: 39 additions & 0 deletions src/main/java/org/keycloak/gh/bot/utils/PullRequestParser.java
Original file line number Diff line number Diff line change
@@ -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<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()) {
// 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;
}
}
126 changes: 126 additions & 0 deletions src/test/java/org/keycloak/gh/bot/LinkBackportPRsTest.java
Original file line number Diff line number Diff line change
@@ -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));
}
}
59 changes: 59 additions & 0 deletions src/test/java/org/keycloak/gh/bot/utils/PullRequestParserTest.java
Original file line number Diff line number Diff line change
@@ -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"));
}
}
Loading