diff --git a/src/main/java/org/keycloak/gh/bot/AddAreaLabelToBugs.java b/src/main/java/org/keycloak/gh/bot/AddAreaLabelToBugs.java index cc09b8c..3e2a1e5 100644 --- a/src/main/java/org/keycloak/gh/bot/AddAreaLabelToBugs.java +++ b/src/main/java/org/keycloak/gh/bot/AddAreaLabelToBugs.java @@ -9,15 +9,36 @@ import java.io.IOException; /** - * Adds 'area/...' label to bugs by using the area selected by the reporter in the area dropdown on the bug issue form. + * On issue open, detects issue template structure from the body and applies appropriate labels. + * For bugs: adds kind/bug, status/triage, and area label. + * For enhancement/feature: adds status/triage (template structure is identical for both). */ public class AddAreaLabelToBugs { void onOpen(@Issue.Opened GHEventPayload.Issue issuePayload) throws IOException { GHIssue issue = issuePayload.getIssue(); + String body = issue.getBody(); - if (Labels.hasLabel(issue, Labels.KIND_BUG)) { - String areaLabel = IssueParser.getAreaFromBody(issuePayload.getIssue().getBody()); + boolean isBug = Labels.hasLabel(issue, Labels.KIND_BUG); + + if (!isBug && !Labels.hasAnyLabelWithPrefix(issue, "kind/")) { + // When an issue is created by a user's AI using the API, it will not have a type or labels set + // Derive the necessary information from the template used. + IssueParser.TemplateType template = IssueParser.detectTemplateType(body); + if (template == IssueParser.TemplateType.BUG) { + Labels.addLabelIfExists(issue, Labels.KIND_BUG); + Labels.addLabelIfExists(issue, Labels.STATUS_TRIAGE); + isBug = true; + } else if (template == IssueParser.TemplateType.ENHANCEMENT_OR_FEATURE) { + // Assume it is an enhancement, as we can't distinguish enhancement from feature + // The triager might then change this to a feature later. + Labels.addLabelIfExists(issue, Labels.KIND_ENHANCEMENT); + Labels.addLabelIfExists(issue, Labels.STATUS_TRIAGE); + } + } + + if (isBug) { + String areaLabel = IssueParser.getAreaFromBody(body); if (areaLabel != null) { Labels.addLabelIfExists(issue, areaLabel); } diff --git a/src/main/java/org/keycloak/gh/bot/utils/IssueParser.java b/src/main/java/org/keycloak/gh/bot/utils/IssueParser.java index 18cf8f8..ad67345 100644 --- a/src/main/java/org/keycloak/gh/bot/utils/IssueParser.java +++ b/src/main/java/org/keycloak/gh/bot/utils/IssueParser.java @@ -9,6 +9,11 @@ public class IssueParser { static final Pattern AREA_PATTERN = Pattern.compile("### Area\\W*([a-z/-]+)"); + public enum TemplateType { + BUG, + ENHANCEMENT_OR_FEATURE + } + public static String getAreaFromBody(String body) { Matcher matcher = AREA_PATTERN.matcher(body); if (matcher.find()) { @@ -18,4 +23,25 @@ public static String getAreaFromBody(String body) { } } + /** + * Detects the issue template type from the body structure. + * Enhancement and feature templates share identical headers, so they are grouped together. + */ + public static TemplateType detectTemplateType(String body) { + if (body == null) { + return null; + } + if (body.contains("### Describe the bug") + && body.contains("### Expected behavior") + && body.contains("### Actual behavior")) { + return TemplateType.BUG; + } + if (body.contains("### Value Proposition") + && body.contains("### Goals") + && body.contains("### Non-Goals")) { + return TemplateType.ENHANCEMENT_OR_FEATURE; + } + return null; + } + } diff --git a/src/main/java/org/keycloak/gh/bot/utils/Labels.java b/src/main/java/org/keycloak/gh/bot/utils/Labels.java index eb451c7..d2b4ada 100644 --- a/src/main/java/org/keycloak/gh/bot/utils/Labels.java +++ b/src/main/java/org/keycloak/gh/bot/utils/Labels.java @@ -14,6 +14,8 @@ public class Labels { public static final String KIND_BUG = "kind/bug"; + public static final String KIND_ENHANCEMENT = "kind/enhancement"; + public static final String STATUS_TRIAGE = "status/triage"; public static final String AREA_CI = "area/ci"; @@ -25,7 +27,11 @@ public class Labels { public static final String REOPENED_BY_BOT = "status/re-opened-by-bot"; public static boolean hasLabel(GHIssue issue, String label) { - return issue.getLabels().stream().filter(l -> l.getName().equals(label)).findFirst().isPresent(); + return issue.getLabels().stream().anyMatch(l -> l.getName().equals(label)); + } + + public static boolean hasAnyLabelWithPrefix(GHIssue issue, String prefix) { + return issue.getLabels().stream().anyMatch(l -> l.getName().startsWith(prefix)); } public static void addLabelIfExists(GHIssue issue, String areaLabel) throws IOException { @@ -47,9 +53,8 @@ public static void removeLabel(GHIssue issue, String label) throws IOException { } private static boolean hasLabel(GHRepository repository, String label) throws IOException { - PagedIterator itr = repository.listLabels().withPageSize(100).iterator(); - while (itr.hasNext()) { - if (itr.next().getName().equals(label)) { + for (GHLabel ghLabel : repository.listLabels().withPageSize(100)) { + if (ghLabel.getName().equals(label)) { return true; } } diff --git a/src/test/java/org/keycloak/gh/bot/AddAreaLabelToBugsTest.java b/src/test/java/org/keycloak/gh/bot/AddAreaLabelToBugsTest.java new file mode 100644 index 0000000..cdcedbc --- /dev/null +++ b/src/test/java/org/keycloak/gh/bot/AddAreaLabelToBugsTest.java @@ -0,0 +1,143 @@ +package org.keycloak.gh.bot; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.kohsuke.github.GHEventPayload; +import org.kohsuke.github.GHIssue; +import org.kohsuke.github.GHLabel; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.PagedIterable; +import org.kohsuke.github.PagedIterator; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +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.when; + +public class AddAreaLabelToBugsTest { + + private GHEventPayload.Issue issuePayload; + private GHIssue issue; + + @BeforeEach + @SuppressWarnings("unchecked") + public void setup() throws IOException { + issuePayload = mock(GHEventPayload.Issue.class); + issue = mock(GHIssue.class); + GHRepository repository = mock(GHRepository.class); + + when(issuePayload.getIssue()).thenReturn(issue); + when(issue.getRepository()).thenReturn(repository); + when(issue.getHtmlUrl()).thenReturn(new URL("https://github.com/keycloak/keycloak/issues/1")); + + PagedIterable labelIterable = mock(PagedIterable.class); + when(labelIterable.withPageSize(anyInt())).thenReturn(labelIterable); + PagedIterator labelIterator = mock(PagedIterator.class); + when(labelIterable.iterator()).thenReturn(labelIterator); + + GHLabel kindBugRepoLabel = mockLabel("kind/bug"); + GHLabel kindEnhancementRepoLabel = mockLabel("kind/enhancement"); + GHLabel statusTriageRepoLabel = mockLabel("status/triage"); + GHLabel areaTokenExchangeRepoLabel = mockLabel("area/token-exchange"); + + when(labelIterator.hasNext()).thenReturn(true, true, true, true, false); + when(labelIterator.next()).thenReturn(kindBugRepoLabel, kindEnhancementRepoLabel, statusTriageRepoLabel, areaTokenExchangeRepoLabel); + when(repository.listLabels()).thenReturn(labelIterable); + } + + @Test + public void testBugWithKindLabel() throws IOException { + String body = getResource("utils/issue-body-token-exchange"); + GHLabel kindBugLabel = mockLabel("kind/bug"); + + when(issue.getBody()).thenReturn(body); + when(issue.getLabels()).thenReturn(List.of(kindBugLabel)); + + new AddAreaLabelToBugs().onOpen(issuePayload); + + verify(issue, never()).addLabels("kind/bug"); + verify(issue, never()).addLabels("status/triage"); + verify(issue).addLabels("area/token-exchange"); + } + + @Test + public void testBugTemplateWithoutKindLabel() throws IOException { + String body = getResource("utils/issue-body-token-exchange"); + + when(issue.getBody()).thenReturn(body); + when(issue.getLabels()).thenReturn(Collections.emptyList()); + + new AddAreaLabelToBugs().onOpen(issuePayload); + + verify(issue).addLabels("kind/bug"); + verify(issue).addLabels("status/triage"); + verify(issue).addLabels("area/token-exchange"); + } + + @Test + public void testEnhancementTemplateWithoutKindLabel() throws IOException { + String body = getResource("utils/issue-body-enhancement"); + + when(issue.getBody()).thenReturn(body); + when(issue.getLabels()).thenReturn(Collections.emptyList()); + + new AddAreaLabelToBugs().onOpen(issuePayload); + + verify(issue, never()).addLabels("kind/bug"); + verify(issue).addLabels("kind/enhancement"); + verify(issue).addLabels("status/triage"); + } + + @Test + public void testPlainBodyWithoutKindLabel() throws IOException { + String body = getResource("utils/issue-body-plain"); + + when(issue.getBody()).thenReturn(body); + when(issue.getLabels()).thenReturn(Collections.emptyList()); + + new AddAreaLabelToBugs().onOpen(issuePayload); + + verify(issue, never()).addLabels("kind/bug"); + verify(issue, never()).addLabels("status/triage"); + } + + @Test + public void testBugTemplateWithExistingKindEnhancement() throws IOException { + String body = getResource("utils/issue-body-token-exchange"); + GHLabel kindEnhancementLabel = mockLabel("kind/enhancement"); + + when(issue.getBody()).thenReturn(body); + when(issue.getLabels()).thenReturn(List.of(kindEnhancementLabel)); + + new AddAreaLabelToBugs().onOpen(issuePayload); + + verify(issue, never()).addLabels("kind/bug"); + verify(issue, never()).addLabels("status/triage"); + verify(issue, never()).addLabels("area/token-exchange"); + } + + private GHLabel mockLabel(String name) { + GHLabel label = mock(GHLabel.class); + when(label.getName()).thenReturn(name); + return label; + } + + private String getResource(String name) throws IOException { + try (InputStream is = getClass().getResourceAsStream(name)) { + if (is == null) { + throw new RuntimeException("Resource not found " + name); + } + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } + +} diff --git a/src/test/java/org/keycloak/gh/bot/utils/IssueParserTest.java b/src/test/java/org/keycloak/gh/bot/utils/IssueParserTest.java index 5c193ea..dde3267 100644 --- a/src/test/java/org/keycloak/gh/bot/utils/IssueParserTest.java +++ b/src/test/java/org/keycloak/gh/bot/utils/IssueParserTest.java @@ -7,13 +7,13 @@ import java.nio.charset.StandardCharsets; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; public class IssueParserTest { @Test public void testTokenExchange() throws IOException { - InputStream is = IssueParserTest.class.getResourceAsStream("issue-body-token-exchange"); - String body = new String(is.readAllBytes(), StandardCharsets.UTF_8); + String body = getResource("issue-body-token-exchange"); String area = IssueParser.getAreaFromBody(body); assertEquals("area/token-exchange", area); @@ -21,11 +21,42 @@ public void testTokenExchange() throws IOException { @Test public void testJavaCli() throws IOException { - InputStream is = IssueParserTest.class.getResourceAsStream("issue-body-adapter-java-cli"); - String body = new String(is.readAllBytes(), StandardCharsets.UTF_8); + String body = getResource("issue-body-adapter-java-cli"); String area = IssueParser.getAreaFromBody(body); assertEquals("area/adapter/java-cli", area); } + @Test + public void testDetectBugTemplate() throws IOException { + String body = getResource("issue-body-token-exchange"); + + assertEquals(IssueParser.TemplateType.BUG, IssueParser.detectTemplateType(body)); + } + + @Test + public void testDetectEnhancementOrFeatureTemplate() throws IOException { + String body = getResource("issue-body-enhancement"); + + assertEquals(IssueParser.TemplateType.ENHANCEMENT_OR_FEATURE, IssueParser.detectTemplateType(body)); + } + + @Test + public void testDetectNoTemplate() throws IOException { + String body = getResource("issue-body-plain"); + + assertNull(IssueParser.detectTemplateType(body)); + } + + @Test + public void testDetectNullBody() { + assertNull(IssueParser.detectTemplateType(null)); + } + + private String getResource(String name) throws IOException { + try (InputStream is = IssueParserTest.class.getResourceAsStream(name)) { + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } + } diff --git a/src/test/resources/org/keycloak/gh/bot/utils/issue-body-enhancement b/src/test/resources/org/keycloak/gh/bot/utils/issue-body-enhancement new file mode 100644 index 0000000..29ca402 --- /dev/null +++ b/src/test/resources/org/keycloak/gh/bot/utils/issue-body-enhancement @@ -0,0 +1,25 @@ +### Description + +Add support for configuring custom token mappers via the admin REST API. + +### Value Proposition + +This would allow automation of token mapper configuration, reducing manual setup time. + +### Goals + +- REST API endpoint for CRUD operations on token mappers +- Consistent behavior with admin console + +### Non-Goals + +- UI changes +- Changes to existing mapper implementations + +### Discussion + +_No response_ + +### Notes + +_No response_ diff --git a/src/test/resources/org/keycloak/gh/bot/utils/issue-body-plain b/src/test/resources/org/keycloak/gh/bot/utils/issue-body-plain new file mode 100644 index 0000000..2572a0f --- /dev/null +++ b/src/test/resources/org/keycloak/gh/bot/utils/issue-body-plain @@ -0,0 +1,5 @@ +This is a plain issue without any template structure. + +It just has some text describing a problem but doesn't follow any particular template format. + +Please help fix this.