diff --git a/.github/workflows/pruebas.yml b/.github/workflows/pruebas.yml index 2db842e3..f7ae993e 100644 --- a/.github/workflows/pruebas.yml +++ b/.github/workflows/pruebas.yml @@ -12,8 +12,8 @@ jobs: runs-on: ubuntu-latest env: GITHUB_LOGIN: ${{ github.actor }} - GITHUB_PACKAGES: ${{ secrets.GHTOKEN }} - GITHUB_OAUTH: ${{ secrets.GHTOKEN }} + GITHUB_PACKAGES: ${{ secrets.GITHUB_TOKEN }} + GITHUB_OAUTH: ${{ secrets.GITHUB_TOKEN }} steps: - name: Clonando el repositorio y estableciendo el espacio de trabajo uses: actions/checkout@v3 diff --git a/build.gradle b/build.gradle index 95067239..ae91eda0 100644 --- a/build.gradle +++ b/build.gradle @@ -142,3 +142,4 @@ test { + diff --git a/src/main/java/us/muit/fs/a4i/control/IndicatorCalidadIssues.java b/src/main/java/us/muit/fs/a4i/control/IndicatorCalidadIssues.java new file mode 100644 index 00000000..cc4e5048 --- /dev/null +++ b/src/main/java/us/muit/fs/a4i/control/IndicatorCalidadIssues.java @@ -0,0 +1,83 @@ +package us.muit.fs.a4i.control; + +import java.util.List; +import java.util.Arrays; + +import us.muit.fs.a4i.exceptions.NotAvailableMetricException; +import us.muit.fs.a4i.exceptions.ReportItemException; +import us.muit.fs.a4i.model.entities.ReportItem; +import us.muit.fs.a4i.model.entities.ReportItemI; + +/** + * Estrategia para calcular el indicador de calidad de resolución. + */ +public class IndicatorCalidadIssues { + + private static final String MRI = "reopenedIssuesAvg"; + private static final String TRPI = "firstTryResolutionRate"; + private static final String IAPC = "postClosureActivityRate"; + private static final String RESULT_NAME = "calidadResolucion"; + + /** + * Calcula el indicador a partir de una lista de métricas. + * + * @param metrics Lista de métricas + * @return ReportItemI con el valor del indicador + * @throws NotAvailableMetricException si faltan métricas necesarias + */ + public ReportItemI calcIndicator(List> metrics) throws NotAvailableMetricException { + Double mriValue = null; + Double trpiValue = null; + Double iapcValue = null; + + for (ReportItemI item : metrics) { + switch (item.getName()) { + case MRI: + mriValue = item.getValue(); + break; + case TRPI: + trpiValue = item.getValue(); + break; + case IAPC: + iapcValue = item.getValue(); + break; + } + } + + if (mriValue == null || trpiValue == null || iapcValue == null) { + throw new NotAvailableMetricException("Faltan métricas requeridas para calcular el indicador."); + } + + // Normalización del valor MRI + double percMRI; + if (mriValue <= 0.3) { + percMRI = 0.0; + } else if (mriValue >= 2.0) { + percMRI = 100.0; + } else { + percMRI = (mriValue - 0.3) / 1.7 * 100.0; + } + + // Cálculo de la calidad + double quality = 0.3 * (100-percMRI) + 0.4 * trpiValue + 0.3 * (100.0 - iapcValue); + + + try { + return new ReportItem.ReportItemBuilder<>(RESULT_NAME, quality) + .source("auto") + .unit("%") + .build(); + } catch (ReportItemException e) { + throw new RuntimeException("Error al construir ReportItem: " + e.getMessage(), e); + } + } + + /** + * Devuelve la lista de métricas requeridas por este indicador. + * + * @return lista de nombres de métricas requeridas + */ + public List requiredMetrics() { + return Arrays.asList(MRI, TRPI, IAPC); + } +} diff --git a/src/main/java/us/muit/fs/a4i/control/IndicatorStrategy.java b/src/main/java/us/muit/fs/a4i/control/IndicatorStrategy.java index 608406fb..a9b80b31 100644 --- a/src/main/java/us/muit/fs/a4i/control/IndicatorStrategy.java +++ b/src/main/java/us/muit/fs/a4i/control/IndicatorStrategy.java @@ -34,4 +34,4 @@ public interface IndicatorStrategy { */ public List requiredMetrics(); -} +} \ No newline at end of file diff --git a/src/main/java/us/muit/fs/a4i/model/remote/GitHubRemoteEnquirer.java b/src/main/java/us/muit/fs/a4i/model/remote/GitHubRemoteEnquirer.java new file mode 100644 index 00000000..d87b90c8 --- /dev/null +++ b/src/main/java/us/muit/fs/a4i/model/remote/GitHubRemoteEnquirer.java @@ -0,0 +1,127 @@ +package us.muit.fs.a4i.model.remote; + +import us.muit.fs.a4i.model.remote.RemoteEnquirer; +import us.muit.fs.a4i.exceptions.MetricException; +import us.muit.fs.a4i.exceptions.ReportItemException; +import us.muit.fs.a4i.model.entities.Report; +import us.muit.fs.a4i.model.entities.ReportI; +import us.muit.fs.a4i.model.entities.ReportItem.ReportItemBuilder; +import us.muit.fs.a4i.model.entities.ReportItemI; + + +import org.kohsuke.github.*; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class GitHubRemoteEnquirer implements RemoteEnquirer { + + private static final List AVAILABLE_METRICS = List.of( + "reopenedIssuesAvg", + "firstTryResolutionRate", + "postClosureActivityRate" + ); + + private final GitHub github; + + public GitHubRemoteEnquirer() throws IOException { + String token = System.getenv("GITHUB_OAUTH"); + if (token == null || token.isEmpty()) { + token = System.getenv("GITHUB_TOKEN"); + } + if (token == null || token.isEmpty()) { + throw new IllegalStateException("No GitHub token provided."); + } + this.github = new GitHubBuilder().withOAuthToken(token).build(); + + } + + @Override + public ReportI buildReport(String repoFullName) { + Report report = new Report(repoFullName); + for (String metric : AVAILABLE_METRICS) { + try { + report.addMetric(getMetric(metric, repoFullName)); + } catch (MetricException e) { + System.err.println("Error al obtener la métrica: " + metric); + } + } + return report; + } + + @Override + public ReportItemI getMetric(String metricName, String repoFullName) throws MetricException { + try { + GHRepository repo = github.getRepository(repoFullName); + List issues = repo.getIssues(GHIssueState.CLOSED); + int reopenedCount = 0; + int firstTrySuccess = 0; + int postClosureActivity = 0; + + for (GHIssue issue : issues) { + boolean reopened = false; + for (GHIssueEvent event : issue.listEvents()) { + if (event.getEvent().equals("reopened")) { + reopened = true; + reopenedCount++; + break; + } + } + + if (!reopened) { + firstTrySuccess++; + } + + if (issue.getUpdatedAt().after(issue.getClosedAt())) { + postClosureActivity++; + } + } + + int totalIssues = issues.size(); + if (totalIssues == 0) throw new MetricException("No hay issues cerrados para analizar."); + + double avgReopened = (double) reopenedCount / totalIssues; + double trpi = ((double) firstTrySuccess / totalIssues) * 100; + double pcap = ((double) postClosureActivity / totalIssues) * 100; + + try { + switch (metricName) { + case "reopenedIssuesAvg": + return new ReportItemBuilder(metricName, avgReopened) + .source("GitHub") + .build(); + + case "firstTryResolutionRate": + return new ReportItemBuilder(metricName, trpi) + .source("GitHub") + .build(); + + case "postClosureActivityRate": + return new ReportItemBuilder(metricName, pcap) + .source("GitHub") + .build(); + + default: + throw new MetricException("Métrica no definida: " + metricName); + } + + } catch (ReportItemException e) { + throw new MetricException("Error al construir el ReportItem para " + metricName + ": " + e.getMessage()); + } + + } catch (IOException e) { + throw new MetricException("Error al procesar la métrica: " + e.getMessage()); + } + } + + @Override + public List getAvailableMetrics() { + return new ArrayList<>(AVAILABLE_METRICS); + } + + @Override + public RemoteType getRemoteType() { + return RemoteType.GITHUB; + } +} diff --git a/src/main/resources/a4iDefault.json b/src/main/resources/a4iDefault.json index 33cee922..bd956438 100644 --- a/src/main/resources/a4iDefault.json +++ b/src/main/resources/a4iDefault.json @@ -233,7 +233,26 @@ "type": "java.lang.Integer", "description": "Balance de equipos y open issues", "unit": "ratio" - } + }, + { + "name": "reopenedIssuesAvg", + "type": "java.lang.Double", + "description": "Media de issues reabiertos en el ultimo sprint", + "unit": "issues/sprint" + }, + { + "name": "reopenedIssuesResolutionRate", + "type": "java.lang.Double", + "description": "Porcentaje de issues que se resuelven en el primer intento sin necesidad de ser reabiertos", + "unit": "ratio" + }, + + { + "name": "postClosureActivityRate", + "type": "java.lang.Double", + "description": "Porcentaje de issues cerrados que reciben comentarios o actividades adicionales", + "unit": "ratio" + } ], "indicators": [ { @@ -310,6 +329,20 @@ "type": "java.lang.Double", "description": "Tiempo para arreglos", "unit": "ratio" + }, + { + "name": "calidadResolucion", + "type": "java.lang.Double", + "description": "Indicador que mide la calidad en la resolución de issues considerando su reapertura, resolución inicial y actividad posterior al cierre.", + "unit": "%", + "limits": { + "ok": 90, + "warning": 65, + "critical": 30 + } + } - ] + + ] + } \ No newline at end of file diff --git a/src/test/java/us/muit/fs/a4i/model/remote/RemoteEnquirerTest.java b/src/test/java/us/muit/fs/a4i/model/remote/RemoteEnquirerTest.java new file mode 100644 index 00000000..f6a4c1a3 --- /dev/null +++ b/src/test/java/us/muit/fs/a4i/model/remote/RemoteEnquirerTest.java @@ -0,0 +1,56 @@ +/** + * + */ +package us.muit.fs.a4i.model.remote; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import us.muit.fs.a4i.exceptions.MetricException; +import us.muit.fs.a4i.model.entities.ReportI; +import us.muit.fs.a4i.model.entities.ReportItemI; +import us.muit.fs.a4i.model.remote.RemoteEnquirer; + +class RemoteEnquirerTest { + + private GitHubRemoteEnquirer enquirer; + + @BeforeEach + void setUp() throws MetricException, IOException { + enquirer = new GitHubRemoteEnquirer(); + } + + @Test + void testBuildReport() { + ReportI report = enquirer.buildReport("MIT-FS/Audit4Improve-API"); + assertNotNull(report); + } + + @Test + void testGetMetric() throws MetricException { + ReportItemI metric = enquirer.getMetric("reopenedIssuesAvg", "MIT-FS/Audit4Improve-API"); + assertNotNull(metric); + } + + + @Test + void testGetAvailableMetrics() { + List metrics = enquirer.getAvailableMetrics(); + assertEquals(3, metrics.size()); + assertTrue(metrics.contains("reopenedIssuesAvg")); + assertTrue(metrics.contains("firstTryResolutionRate")); + assertTrue(metrics.contains("postClosureActivityRate")); + } + + @Test + void testGetRemoteType() { + assertEquals(RemoteEnquirer.RemoteType.GITHUB, enquirer.getRemoteType()); + } +} diff --git a/src/test/java/us/muit/fs/a4i/test/control/IndicatorStrategyTest.java b/src/test/java/us/muit/fs/a4i/test/control/IndicatorStrategyTest.java new file mode 100644 index 00000000..6ee902b7 --- /dev/null +++ b/src/test/java/us/muit/fs/a4i/test/control/IndicatorStrategyTest.java @@ -0,0 +1,123 @@ +package us.muit.fs.a4i.test.control; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import java.util.logging.Logger; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import static org.mockito.Mockito.*; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import us.muit.fs.a4i.control.IndicatorCalidadIssues; +import us.muit.fs.a4i.control.IndicatorStrategy; +import us.muit.fs.a4i.exceptions.NotAvailableMetricException; +import us.muit.fs.a4i.model.entities.ReportItemI; + +/** + * + */ + +public class IndicatorStrategyTest { + + private static Logger log = Logger.getLogger(IndicatorStrategyTest.class.getName()); + + @Test + public void testCalcIndicator() throws NotAvailableMetricException { + // Creamos los mocks necesarios + ReportItemI mockMRI = Mockito.mock(ReportItemI.class); + ReportItemI mockTRPI = Mockito.mock(ReportItemI.class); + ReportItemI mockIAPC = Mockito.mock(ReportItemI.class); + + // Configuramos los mocks para devolver valores predefinidos + Mockito.when(mockMRI.getName()).thenReturn("reopenedIssuesAvg"); + Mockito.when(mockMRI.getValue()).thenReturn(0.5); // Media Reapertura Issues + + Mockito.when(mockTRPI.getName()).thenReturn("firstTryResolutionRate"); + Mockito.when(mockTRPI.getValue()).thenReturn(80.0); // Tasa Resolución Primer Intento + + Mockito.when(mockIAPC.getName()).thenReturn("postClosureActivityRate"); + Mockito.when(mockIAPC.getValue()).thenReturn(20.0); // Issues con Actividad Posterior al Cierre + + // Creamos una instancia de IndicatorStrategy + IndicatorCalidadIssues indicator = new IndicatorCalidadIssues(); + + // Ejecutamos el método que queremos probar con los mocks como argumentos + List> metrics = Arrays.asList(mockMRI, mockTRPI, mockIAPC); + ReportItemI result = indicator.calcIndicator(metrics); + + // Calidad esperada para los valores del Mock + Assertions.assertEquals("calidadResolucion", result.getName()); + Assertions.assertEquals(82.47058823529412, result.getValue(), 0.5); // Con margen de error para la comparación + Assertions.assertDoesNotThrow(() -> indicator.calcIndicator(metrics)); + } + + @Test + public void testCalcIndicatorThrowsNotAvailableMetricException() { + // Creamos los mocks necesarios + ReportItemI mockMRI = Mockito.mock(ReportItemI.class); + + // Configuramos el mock para devolver un valor predefinido + Mockito.when(mockMRI.getName()).thenReturn("reopenedIssuesAvg"); + Mockito.when(mockMRI.getValue()).thenReturn(0.5); + + // Creamos una instancia de IndicatorStrategy + IndicatorCalidadIssues indicator = new IndicatorCalidadIssues(); + + // Ejecutamos el método que queremos probar con métricas insuficientes + List> metrics = Arrays.asList(mockMRI); + + // Comprobamos que se lanza la excepción adecuada + NotAvailableMetricException exception = Assertions.assertThrows(NotAvailableMetricException.class, + () -> indicator.calcIndicator(metrics)); + } + + @Test + public void testRequiredMetrics() { + // Creamos una instancia de IndicatorStrategy + IndicatorCalidadIssues indicatorStrategy = new IndicatorCalidadIssues(); + + // Ejecutamos el método que queremos probar + List requiredMetrics = indicatorStrategy.requiredMetrics(); + + // Comprobamos que el resultado es el esperado + List expectedMetrics = Arrays.asList("reopenedIssuesAvg", "firstTryResolutionRate", "postClosureActivityRate"); + Assertions.assertEquals(expectedMetrics, requiredMetrics); + } + + @Test + public void testCalcIndicatorWithExtremeValues() throws NotAvailableMetricException { + ReportItemI mri = Mockito.mock(ReportItemI.class); + ReportItemI trpi = Mockito.mock(ReportItemI.class); + ReportItemI iapc = Mockito.mock(ReportItemI.class); + + Mockito.when(mri.getName()).thenReturn("reopenedIssuesAvg"); + Mockito.when(mri.getValue()).thenReturn(0.0); + + Mockito.when(trpi.getName()).thenReturn("firstTryResolutionRate"); + Mockito.when(trpi.getValue()).thenReturn(100.0); + + Mockito.when(iapc.getName()).thenReturn("postClosureActivityRate"); + Mockito.when(iapc.getValue()).thenReturn(0.0); + + IndicatorCalidadIssues indicator = new IndicatorCalidadIssues(); + List> metrics = Arrays.asList(mri, trpi, iapc); + ReportItemI result = indicator.calcIndicator(metrics); + + // Aquí el resultado será alto (cerca de 100) + Assertions.assertTrue(result.getValue() > 90.0); + + + } + +} \ No newline at end of file diff --git a/src/test/resources/appConfTest.json b/src/test/resources/appConfTest.json index ae5c655c..42ba8a15 100644 --- a/src/test/resources/appConfTest.json +++ b/src/test/resources/appConfTest.json @@ -11,7 +11,25 @@ "type": "java.lang.Integer", "description": "Número de comentarios", "unit": "comments" - } + }, + { + "name": "reopenedIssuesAvg", + "type": "java.lang.Double", + "description": "Media de issues reabiertos en el ultimo sprint", + "unit": "issues/sprint" + }, + { + "name": "reopenedIssuesResolutionRate", + "type": "java.lang.Double", + "description": "Porcentaje de issues que se resuelven en el primer intento sin necesidad de ser reabiertos", + "unit": "ratio" + }, + { + "name": "postClosureActivityRate", + "type": "java.lang.Double", + "description": "Porcentaje de issues cerrados que reciben comentarios o actividades adicionales", + "unit": "ratio" + } ], "indicators": [ {