From e030191a3f33021392bb0b2ebb4f77b4a400efc9 Mon Sep 17 00:00:00 2001 From: Alexander Schwartz Date: Thu, 24 Sep 2026 08:38:24 +0200 Subject: [PATCH] Detect issue type from template for API generated issues without labels Closes #84 Signed-off-by: Alexander Schwartz --- .../keycloak/gh/bot/AddAreaLabelToBugs.java | 36 ++++- .../keycloak/gh/bot/utils/IssueParser.java | 55 +++++++ .../org/keycloak/gh/bot/utils/Labels.java | 17 ++- .../gh/bot/AddAreaLabelToBugsTest.java | 143 ++++++++++++++++++ .../gh/bot/utils/IssueParserTest.java | 56 ++++++- .../keycloak/gh/bot/utils/issue-body-bug-v2 | 35 +++++ .../gh/bot/utils/issue-body-enhancement | 25 +++ .../gh/bot/utils/issue-body-enhancement-v2 | 25 +++ .../gh/bot/utils/issue-body-feature-v2 | 25 +++ .../keycloak/gh/bot/utils/issue-body-plain | 5 + .../keycloak/gh/bot/utils/issue-body-task-v2 | 3 + 11 files changed, 414 insertions(+), 11 deletions(-) create mode 100644 src/test/java/org/keycloak/gh/bot/AddAreaLabelToBugsTest.java create mode 100644 src/test/resources/org/keycloak/gh/bot/utils/issue-body-bug-v2 create mode 100644 src/test/resources/org/keycloak/gh/bot/utils/issue-body-enhancement create mode 100644 src/test/resources/org/keycloak/gh/bot/utils/issue-body-enhancement-v2 create mode 100644 src/test/resources/org/keycloak/gh/bot/utils/issue-body-feature-v2 create mode 100644 src/test/resources/org/keycloak/gh/bot/utils/issue-body-plain create mode 100644 src/test/resources/org/keycloak/gh/bot/utils/issue-body-task-v2 diff --git a/src/main/java/org/keycloak/gh/bot/AddAreaLabelToBugs.java b/src/main/java/org/keycloak/gh/bot/AddAreaLabelToBugs.java index cc09b8c..0ec3d14 100644 --- a/src/main/java/org/keycloak/gh/bot/AddAreaLabelToBugs.java +++ b/src/main/java/org/keycloak/gh/bot/AddAreaLabelToBugs.java @@ -9,15 +9,45 @@ 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 and feature: adds the kind label and status/triage. + * For tasks: adds kind/task. */ 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 != null) { + switch (template) { + case BUG -> { + Labels.addLabelIfExists(issue, Labels.KIND_BUG); + Labels.addLabelIfExists(issue, Labels.STATUS_TRIAGE); + isBug = true; + } + case ENHANCEMENT -> { + Labels.addLabelIfExists(issue, Labels.KIND_ENHANCEMENT); + Labels.addLabelIfExists(issue, Labels.STATUS_TRIAGE); + } + case FEATURE -> { + Labels.addLabelIfExists(issue, Labels.KIND_FEATURE); + Labels.addLabelIfExists(issue, Labels.STATUS_TRIAGE); + } + case TASK -> Labels.addLabelIfExists(issue, Labels.KIND_TASK); + } + } + } + + 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..00a15e1 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,13 @@ public class IssueParser { static final Pattern AREA_PATTERN = Pattern.compile("### Area\\W*([a-z/-]+)"); + public enum TemplateType { + BUG, + ENHANCEMENT, + FEATURE, + TASK + } + public static String getAreaFromBody(String body) { Matcher matcher = AREA_PATTERN.matcher(body); if (matcher.find()) { @@ -18,4 +25,52 @@ public static String getAreaFromBody(String body) { } } + /** + * Detects the issue template type from the body structure. + * Supports the templates from before and after keycloak/keycloak#55144 (merged 2026-11-13). + */ + public static TemplateType detectTemplateType(String body) { + if (body == null) { + return null; + } + + // Templates after keycloak/keycloak#55144: each type has its own description header. + if (hasHeading(body, "Bug Description")) { + return TemplateType.BUG; + } + if (hasHeading(body, "Enhancement Description")) { + return TemplateType.ENHANCEMENT; + } + if (hasHeading(body, "Feature Request Description")) { + return TemplateType.FEATURE; + } + if (hasHeading(body, "Task Description")) { + return TemplateType.TASK; + } + + // Templates before keycloak/keycloak#55144: the bug template used different headers, + // and the enhancement and feature templates were identical, so they can't be told apart. + // Assume an enhancement, the triager can change this to a feature later. + // Agents working on old forks might still use the old way. + if (hasHeading(body, "Describe the bug") + && hasHeading(body, "Expected behavior") + && hasHeading(body, "Actual behavior")) { + return TemplateType.BUG; + } + if (hasHeading(body, "Value Proposition") + && hasHeading(body, "Goals") + && hasHeading(body, "Non-Goals")) { + return TemplateType.ENHANCEMENT; + } + return null; + } + + /** + * Matches only a heading on a line of its own, so text that mentions or quotes a heading is ignored. + * Headings inside fenced code blocks still match; this is an accepted limitation. + */ + private static boolean hasHeading(String body, String title) { + return body.lines().anyMatch(line -> line.strip().equals("### " + title)); + } + } 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..fcb1f9a 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,12 @@ public class Labels { public static final String KIND_BUG = "kind/bug"; + public static final String KIND_ENHANCEMENT = "kind/enhancement"; + + public static final String KIND_FEATURE = "kind/feature"; + + public static final String KIND_TASK = "kind/task"; + public static final String STATUS_TRIAGE = "status/triage"; public static final String AREA_CI = "area/ci"; @@ -25,7 +31,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 +57,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..3591865 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,59 @@ 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 testDetectLegacyEnhancementTemplate() throws IOException { + String body = getResource("issue-body-enhancement"); + + assertEquals(IssueParser.TemplateType.ENHANCEMENT, IssueParser.detectTemplateType(body)); + } + + @Test + public void testDetectCurrentTemplates() throws IOException { + assertEquals(IssueParser.TemplateType.BUG, IssueParser.detectTemplateType(getResource("issue-body-bug-v2"))); + assertEquals(IssueParser.TemplateType.ENHANCEMENT, IssueParser.detectTemplateType(getResource("issue-body-enhancement-v2"))); + assertEquals(IssueParser.TemplateType.FEATURE, IssueParser.detectTemplateType(getResource("issue-body-feature-v2"))); + assertEquals(IssueParser.TemplateType.TASK, IssueParser.detectTemplateType(getResource("issue-body-task-v2"))); + assertEquals("area/token-exchange", IssueParser.getAreaFromBody(getResource("issue-body-bug-v2"))); + } + + @Test + public void testDetectIgnoresHeadingMentionedInText() { + assertNull(IssueParser.detectTemplateType("Please add a ### Bug Description section to the docs.")); + assertNull(IssueParser.detectTemplateType("> ### Bug Description\n> quoted from another issue")); + assertEquals(IssueParser.TemplateType.TASK, + IssueParser.detectTemplateType("### Task Description\n\nThe template contains\n> ### Bug Description\n")); + } + + @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-bug-v2 b/src/test/resources/org/keycloak/gh/bot/utils/issue-body-bug-v2 new file mode 100644 index 0000000..9f17091 --- /dev/null +++ b/src/test/resources/org/keycloak/gh/bot/utils/issue-body-bug-v2 @@ -0,0 +1,35 @@ +### Before reporting an issue + +- [X] I have read and understood the above terms for submitting issues, and I understand that my issue may be closed without action if I do not follow them. + +### Area + +token-exchange + +### Bug Description + +Blah blah blah + +### Version + +26.0.0 + +### Regression + +- [ ] The issue is a regression + +### Expected behavior + +It works + +### Actual behavior + +It doesn't work + +### How to Reproduce? + +Do stuff + +### Anything else? + +_No response_ 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-enhancement-v2 b/src/test/resources/org/keycloak/gh/bot/utils/issue-body-enhancement-v2 new file mode 100644 index 0000000..ef55e7a --- /dev/null +++ b/src/test/resources/org/keycloak/gh/bot/utils/issue-body-enhancement-v2 @@ -0,0 +1,25 @@ +### Enhancement 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-feature-v2 b/src/test/resources/org/keycloak/gh/bot/utils/issue-body-feature-v2 new file mode 100644 index 0000000..abf5bcf --- /dev/null +++ b/src/test/resources/org/keycloak/gh/bot/utils/issue-body-feature-v2 @@ -0,0 +1,25 @@ +### Feature Request 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. diff --git a/src/test/resources/org/keycloak/gh/bot/utils/issue-body-task-v2 b/src/test/resources/org/keycloak/gh/bot/utils/issue-body-task-v2 new file mode 100644 index 0000000..9d97aca --- /dev/null +++ b/src/test/resources/org/keycloak/gh/bot/utils/issue-body-task-v2 @@ -0,0 +1,3 @@ +### Task Description + +Clean up the build scripts.