Skip to content
Open
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
36 changes: 33 additions & 3 deletions src/main/java/org/keycloak/gh/bot/AddAreaLabelToBugs.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
55 changes: 55 additions & 0 deletions src/main/java/org/keycloak/gh/bot/utils/IssueParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand All @@ -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));
}

}
17 changes: 13 additions & 4 deletions src/main/java/org/keycloak/gh/bot/utils/Labels.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand All @@ -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<GHLabel> 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;
}
}
Expand Down
143 changes: 143 additions & 0 deletions src/test/java/org/keycloak/gh/bot/AddAreaLabelToBugsTest.java
Original file line number Diff line number Diff line change
@@ -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<GHLabel> labelIterable = mock(PagedIterable.class);
when(labelIterable.withPageSize(anyInt())).thenReturn(labelIterable);
PagedIterator<GHLabel> 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);
}
}

}
56 changes: 52 additions & 4 deletions src/test/java/org/keycloak/gh/bot/utils/IssueParserTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,73 @@
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);
}

@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);
}
}

}
Loading
Loading