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
27 changes: 24 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,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);
}
Expand Down
26 changes: 26 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,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()) {
Expand All @@ -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;
}

}
13 changes: 9 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,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";
Expand All @@ -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 {
Expand All @@ -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<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);
}
}

}
39 changes: 35 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,56 @@
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 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);
}
}

}
Original file line number Diff line number Diff line change
@@ -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_
5 changes: 5 additions & 0 deletions src/test/resources/org/keycloak/gh/bot/utils/issue-body-plain
Original file line number Diff line number Diff line change
@@ -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.
Loading