diff --git a/infrastructure/lib/infrastructure-stack.ts b/infrastructure/lib/infrastructure-stack.ts index c081e91..74f10af 100644 --- a/infrastructure/lib/infrastructure-stack.ts +++ b/infrastructure/lib/infrastructure-stack.ts @@ -23,6 +23,7 @@ import { GitHubAutomationApp } from "./stacks/gitHubAutomationApp"; import { OpenSearchS3 } from "./stacks/s3"; import { GitHubWorkflowMonitorAlarms } from "./stacks/gitHubWorkflowMonitorAlarms"; import {OpenSearchS3EventIndexWorkflowStack} from "./stacks/s3EventIndexWorkflow"; +import {OpenSearchMaintainerInactivityWorkflowStack} from "./stacks/maintainerInactivityWorkflow"; export class InfrastructureStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { @@ -98,6 +99,14 @@ export class InfrastructureStack extends Stack { }) openSearchS3EventIndexWorkflowStack.node.addDependency(vpcStack, openSearchDomainStack); + // Create OpenSearch Maintainer Inactivity Lambda setup + const openSearchMaintainerInactivityWorkflowStack = new OpenSearchMaintainerInactivityWorkflowStack(app, 'OpenSearchMaintainerInactivity-Workflow', { + opensearchDomainStack: openSearchDomainStack, + vpcStack: vpcStack, + lambdaPackage: Project.LAMBDA_PACKAGE, + }) + openSearchMaintainerInactivityWorkflowStack.node.addDependency(vpcStack, openSearchDomainStack); + // Create Secret Manager for the metrics project const openSearchMetricsSecretsStack = new OpenSearchMetricsSecretsStack(app, "OpenSearchMetrics-Secrets", { secretName: 'metrics-creds' @@ -110,7 +119,8 @@ export class InfrastructureStack extends Stack { account: Project.AWS_ACCOUNT, workflowComponent: { opensearchMetricsWorkflowStateMachineName: openSearchMetricsWorkflowStack.workflowComponent.opensearchMetricsWorkflowStateMachineName, - opensearchS3EventIndexWorkflowStateMachineName: openSearchS3EventIndexWorkflowStack.workflowComponent.opensearchS3EventIndexWorkflowStateMachineName + opensearchMaintainerInactivityWorkflowStateMachineName: openSearchMaintainerInactivityWorkflowStack.workflowComponent.opensearchMaintainerInactivityWorkflowStateMachineName, + opensearchS3EventIndexWorkflowStateMachineName: openSearchS3EventIndexWorkflowStack.workflowComponent.opensearchS3EventIndexWorkflowStateMachineName, }, lambdaPackage: Project.LAMBDA_PACKAGE, secrets: openSearchMetricsSecretsStack.secret, diff --git a/infrastructure/lib/stacks/maintainerInactivityWorkflow.ts b/infrastructure/lib/stacks/maintainerInactivityWorkflow.ts new file mode 100644 index 0000000..cb871a3 --- /dev/null +++ b/infrastructure/lib/stacks/maintainerInactivityWorkflow.ts @@ -0,0 +1,78 @@ +/** + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +import { Duration, Stack, StackProps } from "aws-cdk-lib"; +import { Rule, Schedule } from "aws-cdk-lib/aws-events"; +import { SfnStateMachine } from "aws-cdk-lib/aws-events-targets"; +import { JsonPath, StateMachine } from "aws-cdk-lib/aws-stepfunctions"; +import { LambdaInvoke } from "aws-cdk-lib/aws-stepfunctions-tasks"; +import { Construct } from 'constructs'; +import { OpenSearchLambda } from "../constructs/lambda"; +import { OpenSearchDomainStack } from "./opensearch"; +import { VpcStack } from "./vpc"; + +export interface OpenSearchMaintainerInactivityWorkflowStackProps extends StackProps { + readonly opensearchDomainStack: OpenSearchDomainStack; + readonly vpcStack: VpcStack; + readonly lambdaPackage: string +} + +export interface WorkflowComponent { + opensearchMaintainerInactivityWorkflowStateMachineName: string +} + +export class OpenSearchMaintainerInactivityWorkflowStack extends Stack { + public readonly workflowComponent: WorkflowComponent; + constructor(scope: Construct, id: string, props: OpenSearchMaintainerInactivityWorkflowStackProps) { + super(scope, id, props); + + const maintainerInactivityTask = this.createMaintainerInactivityTask( + this, + props.opensearchDomainStack, + props.vpcStack, + props.lambdaPackage + ); + const opensearchMaintainerInactivityWorkflow = new StateMachine(this, 'OpenSearchMaintainerInactivityWorkflow', { + definition: maintainerInactivityTask, + timeout: Duration.minutes(15), + stateMachineName: 'OpenSearchMaintainerInactivityWorkflow' + }) + + new Rule(this, 'MaintainerInactivityWorkflow-Every-Day', { + schedule: Schedule.expression('cron(15 0 * * ? *)'), + targets: [new SfnStateMachine(opensearchMaintainerInactivityWorkflow)], + }); + + this.workflowComponent = { + opensearchMaintainerInactivityWorkflowStateMachineName: opensearchMaintainerInactivityWorkflow.stateMachineName + } + } + + private createMaintainerInactivityTask(scope: Construct, opensearchDomainStack: OpenSearchDomainStack, + vpcStack: VpcStack, lambdaPackage: string) { + const openSearchDomain = opensearchDomainStack.domain; + const maintainerInactivityLambda = new OpenSearchLambda(scope, "OpenSearchMetricsMaintainerInactivityLambdaFunction", { + lambdaNameBase: "OpenSearchMetricsMaintainerInactivity", + handler: "org.opensearchmetrics.lambda.MaintainerInactivityLambda", + lambdaZipPath: `../../../build/distributions/${lambdaPackage}`, + vpc: vpcStack.vpc, + securityGroup: vpcStack.securityGroup, + role: opensearchDomainStack.openSearchMetricsLambdaRole, + environment: { + OPENSEARCH_DOMAIN_ENDPOINT: openSearchDomain.domainEndpoint, + OPENSEARCH_DOMAIN_REGION: openSearchDomain.env.region, + OPENSEARCH_DOMAIN_ROLE: opensearchDomainStack.fullAccessRole.roleArn, + }, + }).lambda; + return new LambdaInvoke(scope, 'Maintainer Inactivity Lambda', { + lambdaFunction: maintainerInactivityLambda, + resultPath: JsonPath.DISCARD, + timeout: Duration.minutes(15) + }).addRetry(); + } +} diff --git a/infrastructure/lib/stacks/monitoringDashboard.ts b/infrastructure/lib/stacks/monitoringDashboard.ts index 1f72356..647dc08 100644 --- a/infrastructure/lib/stacks/monitoringDashboard.ts +++ b/infrastructure/lib/stacks/monitoringDashboard.ts @@ -69,6 +69,7 @@ export class OpenSearchMetricsMonitoringStack extends Stack { private snsMonitorStepFunctionExecutionsFailed(): void { const stepFunctionSnsAlarms = [ { alertName: 'StepFunction_execution_errors_MetricsWorkflow', stateMachineName: this.props.workflowComponent.opensearchMetricsWorkflowStateMachineName }, + { alertName: 'StepFunction_execution_errors_MaintainerInactivityWorkflow', stateMachineName: this.props.workflowComponent.opensearchMaintainerInactivityWorkflowStateMachineName }, { alertName: 'StepFunction_execution_errors_S3EventIndexWorkflow', stateMachineName: this.props.workflowComponent.opensearchS3EventIndexWorkflowStateMachineName }, ]; diff --git a/infrastructure/test/maintainer-inactivity-workflow-stack.test.ts b/infrastructure/test/maintainer-inactivity-workflow-stack.test.ts new file mode 100644 index 0000000..7890af1 --- /dev/null +++ b/infrastructure/test/maintainer-inactivity-workflow-stack.test.ts @@ -0,0 +1,78 @@ +/** + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +import { App } from "aws-cdk-lib"; +import { Template } from "aws-cdk-lib/assertions"; +import { OpenSearchMetricsWorkflowStack } from "../lib/stacks/metricsWorkflow"; +import Project from "../lib/enums/project"; +import { OpenSearchDomainStack } from "../lib/stacks/opensearch"; +import { VpcStack } from "../lib/stacks/vpc"; +import { ArnPrincipal } from "aws-cdk-lib/aws-iam"; +import {OpenSearchS3} from "../lib/stacks/s3"; +import {OpenSearchMaintainerInactivityWorkflowStack} from "../lib/stacks/maintainerInactivityWorkflow"; + +test('Maintainer Inactivity Workflow Stack Test', () => { + const app = new App(); + const vpcStack = new VpcStack(app, 'Test-OpenSearchHealth-VPC', {}); + const s3Stack = new OpenSearchS3(app, "Test-OpenSearchMetrics-GitHubAutomationAppEvents-S3"); + const openSearchDomainStack = new OpenSearchDomainStack(app, 'OpenSearchHealth-OpenSearch', { + region: "us-east-1", + account: "test-account", + vpcStack: new VpcStack(app, 'OpenSearchHealth-VPC', {}), + enableNginxCognito: true, + jenkinsAccess: { + jenkinsAccountRoles: [ + new ArnPrincipal(Project.JENKINS_MASTER_ROLE), + new ArnPrincipal(Project.JENKINS_AGENT_ROLE) + ] + }, + githubAutomationAppAccess: "sample-role-arn", + githubEventsBucket: s3Stack.bucket, + }); + const openSearchMaintainerInactivityWorkflowStack = new OpenSearchMaintainerInactivityWorkflowStack(app, 'Test-OpenSearchMaintainerInactivity-Workflow', { + opensearchDomainStack: openSearchDomainStack, + vpcStack: vpcStack, + lambdaPackage: Project.LAMBDA_PACKAGE, + }); + const template = Template.fromStack(openSearchMaintainerInactivityWorkflowStack); + template.resourceCountIs('AWS::IAM::Role', 2); + template.resourceCountIs('AWS::Lambda::Function', 1); + template.hasResourceProperties('AWS::Lambda::Function', { + "FunctionName": "OpenSearchMetricsMaintainerInactivityLambda", + "Handler": "org.opensearchmetrics.lambda.MaintainerInactivityLambda" + }); + template.resourceCountIs('AWS::StepFunctions::StateMachine', 1); + template.hasResourceProperties('AWS::StepFunctions::StateMachine', { + "DefinitionString": { + "Fn::Join": [ + "", + [ + "{\"StartAt\":\"Maintainer Inactivity Lambda\",\"States\":{\"Maintainer Inactivity Lambda\":{\"End\":true,\"Retry\":[{\"ErrorEquals\":[\"Lambda.ClientExecutionTimeoutException\",\"Lambda.ServiceException\",\"Lambda.AWSLambdaException\",\"Lambda.SdkClientException\"],\"IntervalSeconds\":2,\"MaxAttempts\":6,\"BackoffRate\":2},{\"ErrorEquals\":[\"States.ALL\"]}],\"Type\":\"Task\",\"TimeoutSeconds\":900,\"ResultPath\":null,\"Resource\":\"arn:", + { + "Ref": "AWS::Partition" + }, + ":states:::lambda:invoke\",\"Parameters\":{\"FunctionName\":\"", + { + "Fn::GetAtt": [ + "OpenSearchMetricsMaintainerInactivityLambdaCB6D4475", + "Arn" + ] + }, + "\",\"Payload.$\":\"$\"}}},\"TimeoutSeconds\":900}" + ] + ] + }, + "RoleArn": { + "Fn::GetAtt": [ + "OpenSearchMaintainerInactivityWorkflowRoleF9A5E625", + "Arn" + ] + }, + "StateMachineName": "OpenSearchMaintainerInactivityWorkflow" + }); +}); diff --git a/infrastructure/test/monitoring-stack.test.ts b/infrastructure/test/monitoring-stack.test.ts index ed770a0..eff8b87 100644 --- a/infrastructure/test/monitoring-stack.test.ts +++ b/infrastructure/test/monitoring-stack.test.ts @@ -17,6 +17,7 @@ import { OpenSearchMetricsMonitoringStack } from "../lib/stacks/monitoringDashbo import { OpenSearchMetricsSecretsStack } from "../lib/stacks/secrets"; import {OpenSearchS3} from "../lib/stacks/s3"; import {OpenSearchS3EventIndexWorkflowStack} from "../lib/stacks/s3EventIndexWorkflow"; +import {OpenSearchMaintainerInactivityWorkflowStack} from "../lib/stacks/maintainerInactivityWorkflow"; test('Monitoring Stack Test', () => { const app = new App(); @@ -40,6 +41,11 @@ test('Monitoring Stack Test', () => { vpcStack: vpcStack, lambdaPackage: Project.LAMBDA_PACKAGE, }); + const openSearchMaintainerInactivityWorkflowStack = new OpenSearchMaintainerInactivityWorkflowStack(app, 'Test-OpenSearchMaintainerInactivity-Workflow', { + opensearchDomainStack: opensearchDomainStack, + vpcStack: vpcStack, + lambdaPackage: Project.LAMBDA_PACKAGE, + }); const openSearchS3EventIndexWorkflowStack = new OpenSearchS3EventIndexWorkflowStack(app, 'Test-OpenSearchS3EventIndex-Workflow', { region: Project.REGION, opensearchDomainStack: opensearchDomainStack, @@ -55,6 +61,7 @@ test('Monitoring Stack Test', () => { account: Project.AWS_ACCOUNT, workflowComponent: { opensearchMetricsWorkflowStateMachineName: openSearchMetricsWorkflowStack.workflowComponent.opensearchMetricsWorkflowStateMachineName, + opensearchMaintainerInactivityWorkflowStateMachineName: openSearchMaintainerInactivityWorkflowStack.workflowComponent.opensearchMaintainerInactivityWorkflowStateMachineName, opensearchS3EventIndexWorkflowStateMachineName: openSearchS3EventIndexWorkflowStack.workflowComponent.opensearchS3EventIndexWorkflowStateMachineName }, lambdaPackage: Project.LAMBDA_PACKAGE, @@ -64,7 +71,7 @@ test('Monitoring Stack Test', () => { const template = Template.fromStack(openSearchMetricsMonitoringStack); template.resourceCountIs('AWS::IAM::Role', 2); template.resourceCountIs('AWS::IAM::Policy', 1); - template.resourceCountIs('AWS::CloudWatch::Alarm', 3); + template.resourceCountIs('AWS::CloudWatch::Alarm', 4); template.resourceCountIs('AWS::SNS::Topic', 2); template.resourceCountIs('AWS::Synthetics::Canary', 1); template.hasResourceProperties('AWS::IAM::Role', { @@ -171,6 +178,42 @@ test('Monitoring Stack Test', () => { "Threshold": 1, "TreatMissingData": "notBreaching" }); + + template.hasResourceProperties('AWS::CloudWatch::Alarm', { + "AlarmActions": [ + { + "Ref": "SnsMonitorsStepFunctionExecutionsFailedOpenSearchMetricsAlarmStepFunctionExecutionsFailed0B259DBC" + } + ], + "AlarmDescription": "Detect SF execution failure", + "AlarmName": "StepFunction_execution_errors_MaintainerInactivityWorkflow", + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "DatapointsToAlarm": 1, + "Dimensions": [ + { + "Name": "StateMachineArn", + "Value": { + "Fn::Join": [ + "", + [ + "arn:aws:states:::stateMachine:", + { + "Fn::ImportValue": "Test-OpenSearchMaintainerInactivity-Workflow:ExportsOutputFnGetAttOpenSearchMaintainerInactivityWorkflowE07E380BName0C54300B" + } + ] + ] + } + } + ], + "EvaluationPeriods": 1, + "MetricName": "ExecutionsFailed", + "Namespace": "AWS/States", + "Period": 300, + "Statistic": "Sum", + "Threshold": 1, + "TreatMissingData": "notBreaching" + }); + template.hasResourceProperties('AWS::CloudWatch::Alarm', { "AlarmActions": [ { diff --git a/src/main/java/org/opensearchmetrics/dagger/CommonModule.java b/src/main/java/org/opensearchmetrics/dagger/CommonModule.java index 0929b17..277684c 100644 --- a/src/main/java/org/opensearchmetrics/dagger/CommonModule.java +++ b/src/main/java/org/opensearchmetrics/dagger/CommonModule.java @@ -16,6 +16,7 @@ import org.opensearchmetrics.metrics.MetricsCalculation; import org.opensearchmetrics.metrics.general.*; import org.opensearchmetrics.metrics.label.LabelMetrics; +import org.opensearchmetrics.metrics.maintainer.MaintainerMetrics; import org.opensearchmetrics.metrics.release.ReleaseMetrics; import org.opensearchmetrics.util.OpenSearchUtil; import com.amazonaws.services.secretsmanager.AWSSecretsManager; @@ -107,7 +108,7 @@ public MetricsCalculation getMetricsCalculation(OpenSearchUtil openSearchUtil, O CreatedIssues createdIssues, IssueComments issueComments, PullComments pullComments, IssuePositiveReactions issuePositiveReactions, IssueNegativeReactions issueNegativeReactions, LabelMetrics labelMetrics, - ReleaseMetrics releaseMetrics) { + ReleaseMetrics releaseMetrics, MaintainerMetrics maintainerMetrics) { return new MetricsCalculation(openSearchUtil, objectMapper, untriagedIssues, uncommentedPullRequests, unlabelledPullRequests, unlabelledIssues, @@ -115,7 +116,7 @@ public MetricsCalculation getMetricsCalculation(OpenSearchUtil openSearchUtil, O openIssues, closedIssues, createdIssues, issueComments, pullComments, issuePositiveReactions, issueNegativeReactions, - labelMetrics, releaseMetrics); + labelMetrics, releaseMetrics, maintainerMetrics); } @Provides diff --git a/src/main/java/org/opensearchmetrics/lambda/MaintainerInactivityLambda.java b/src/main/java/org/opensearchmetrics/lambda/MaintainerInactivityLambda.java new file mode 100644 index 0000000..8aa344d --- /dev/null +++ b/src/main/java/org/opensearchmetrics/lambda/MaintainerInactivityLambda.java @@ -0,0 +1,61 @@ +package org.opensearchmetrics.lambda; + +import com.amazonaws.services.lambda.runtime.Context; +import com.google.common.annotations.VisibleForTesting; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.search.aggregations.AggregationBuilders; +import org.opensearch.search.aggregations.bucket.terms.ParsedStringTerms; +import org.opensearch.search.aggregations.bucket.terms.TermsAggregationBuilder; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearchmetrics.dagger.DaggerServiceComponent; +import org.opensearchmetrics.dagger.ServiceComponent; +import org.opensearchmetrics.metrics.MetricsCalculation; +import org.opensearchmetrics.util.OpenSearchUtil; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +public class MaintainerInactivityLambda extends AbstractBaseLambda { + private static final ServiceComponent COMPONENT = DaggerServiceComponent.create(); + private final OpenSearchUtil openSearchUtil; + + private final MetricsCalculation metricsCalculation; + + public MaintainerInactivityLambda() { + this(COMPONENT.getOpenSearchUtil(), COMPONENT.getMetricsCalculation()); + } + + @VisibleForTesting + MaintainerInactivityLambda(@NonNull OpenSearchUtil openSearchUtil, @NonNull MetricsCalculation metricsCalculation) { + this.openSearchUtil = openSearchUtil; + this.metricsCalculation = metricsCalculation; + } + + @Override + public Void handleRequest(Void input, Context context) { + SearchRequest searchRequest = new SearchRequest("github_repos"); + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); + searchSourceBuilder.size(0); + TermsAggregationBuilder aggregation = AggregationBuilders.terms("repos") + .field("repository.keyword").size(500); + searchSourceBuilder.aggregation(aggregation); + searchRequest.source(searchSourceBuilder); + SearchResponse searchResponse = null; + searchResponse = openSearchUtil.search(searchRequest); + ParsedStringTerms termsAggregation = searchResponse.getAggregations().get("repos"); + List keys = termsAggregation.getBuckets().stream() + .map(bucket -> bucket.getKeyAsString()) + .collect(Collectors.toList()); + try { + metricsCalculation.generateMaintainerMetrics(keys); + } catch (Exception e) { + throw new RuntimeException("Error running Maintainer Inactivity Calculation", e); + } + return input; + } +} + diff --git a/src/main/java/org/opensearchmetrics/metrics/MetricsCalculation.java b/src/main/java/org/opensearchmetrics/metrics/MetricsCalculation.java index b713adb..3487c26 100644 --- a/src/main/java/org/opensearchmetrics/metrics/MetricsCalculation.java +++ b/src/main/java/org/opensearchmetrics/metrics/MetricsCalculation.java @@ -16,26 +16,25 @@ import org.opensearchmetrics.metrics.general.*; import org.opensearchmetrics.metrics.label.LabelMetrics; import org.opensearchmetrics.metrics.release.CodeCoverage; +import org.opensearchmetrics.metrics.maintainer.MaintainerMetrics; import org.opensearchmetrics.metrics.release.ReleaseInputs; import org.opensearchmetrics.metrics.release.ReleaseMetrics; import org.opensearchmetrics.model.codecov.CodeCovResponse; import org.opensearchmetrics.model.codecov.CodeCovResult; import org.opensearchmetrics.model.label.LabelData; import org.opensearchmetrics.model.general.MetricsData; +import org.opensearchmetrics.model.label.LabelData; +import org.opensearchmetrics.model.maintainer.LatestEventData; +import org.opensearchmetrics.model.maintainer.MaintainerData; import org.opensearchmetrics.model.release.ReleaseMetricsData; import org.opensearchmetrics.util.OpenSearchUtil; import java.io.IOException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.time.LocalDateTime; -import java.time.ZoneId; +import java.time.*; import java.time.format.DateTimeFormatter; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; +import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -60,6 +59,7 @@ public class MetricsCalculation { private final IssueNegativeReactions issueNegativeReactions; private final LabelMetrics labelMetrics; private final ReleaseMetrics releaseMetrics; + private final MaintainerMetrics maintainerMetrics; public MetricsCalculation(OpenSearchUtil openSearchUtil, ObjectMapper objectMapper, @@ -70,7 +70,7 @@ public MetricsCalculation(OpenSearchUtil openSearchUtil, ObjectMapper objectMapp CreatedIssues createdIssues, IssueComments issueComments, PullComments pullComments, IssuePositiveReactions issuePositiveReactions, IssueNegativeReactions issueNegativeReactions, LabelMetrics labelMetrics, - ReleaseMetrics releaseMetrics) { + ReleaseMetrics releaseMetrics, MaintainerMetrics maintainerMetrics) { this.unlabelledPullRequests = unlabelledPullRequests; this.unlabelledIssues = unlabelledIssues; this.mergedPullRequests = mergedPullRequests; @@ -89,6 +89,7 @@ public MetricsCalculation(OpenSearchUtil openSearchUtil, ObjectMapper objectMapp this.uncommentedPullRequests = uncommentedPullRequests; this.labelMetrics = labelMetrics; this.releaseMetrics = releaseMetrics; + this.maintainerMetrics = maintainerMetrics; } @@ -249,4 +250,85 @@ public void generateCodeCovMetrics() { openSearchUtil.bulkIndex(codeCovIndexName, metricFinalData); } + public void generateMaintainerMetrics(List repositories) { + long[] mostAndLeastRepoEventCounts = maintainerMetrics.mostAndLeastRepoEventCounts(openSearchUtil); + final double mostRepoEventCount = (double) mostAndLeastRepoEventCounts[0]; + final double leastRepoEventCount = (double) mostAndLeastRepoEventCounts[1]; + final double higherBoundDays = 365; // 1 year + final double lowerBoundDays = 90; // 3 months + + // Slope and intercept for linear equation: + // x = number of events + // y = time maintainer is inactive until they are flagged as inactive + final double[] slopeAndIntercept = maintainerMetrics.getSlopeAndIntercept(leastRepoEventCount, higherBoundDays, mostRepoEventCount, lowerBoundDays); + + List eventTypes = maintainerMetrics.getEventTypes(openSearchUtil); + + Map metricFinalData = repositories.stream() + .flatMap(repo -> { + long currentRepoEventCount = maintainerMetrics.repoEventCount(repo, openSearchUtil); + return maintainerMetrics.repoMaintainers(repo).stream() + .flatMap(maintainerData -> { + LatestEventData latestEvent = null; + List individualEvents = new ArrayList<>(); + for (String eventType : eventTypes) { + MaintainerData maintainerEvent = new MaintainerData(); + try { + maintainerEvent.setId(String.valueOf(UUID.nameUUIDFromBytes(MessageDigest.getInstance("SHA-1") + .digest(("maintainer-inactivity-" + eventType + "-" + maintainerData.getGithubLogin() + "-" + currentDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + "-" + repo) + .getBytes())))); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + maintainerEvent.setCurrentDate(currentDate.toString()); + maintainerEvent.setEventType(eventType); + maintainerEvent.setRepository(repo); + maintainerEvent.setName(maintainerData.getName()); + maintainerEvent.setGithubLogin((maintainerData.getGithubLogin())); + maintainerEvent.setAffiliation(maintainerData.getAffiliation()); + Optional latestEventDataOpt = maintainerMetrics.queryLatestEvent(repo, maintainerData.getGithubLogin(), eventType, openSearchUtil); + if (latestEventDataOpt.isPresent()) { + LatestEventData currentLatestEvent = latestEventDataOpt.get(); + currentLatestEvent.setInactive(maintainerMetrics.calculateInactivity(currentRepoEventCount, slopeAndIntercept, lowerBoundDays, currentLatestEvent)); + if (latestEvent != null) { + if (currentLatestEvent.getTimeLastEngaged().isAfter(latestEvent.getTimeLastEngaged())) { + latestEvent = currentLatestEvent; + } + } else { + latestEvent = currentLatestEvent; + } + maintainerEvent.setEventAction(currentLatestEvent.getEventAction()); + maintainerEvent.setTimeLastEngaged(currentLatestEvent.getTimeLastEngaged().toString()); + maintainerEvent.setInactive(currentLatestEvent.isInactive()); + } else { + // If the Optional has no value, then leave event action and inactive empty + maintainerEvent.setInactive(true); + } + individualEvents.add(maintainerEvent); + } + maintainerData.setEventType("Any"); + try { + maintainerData.setId(String.valueOf(UUID.nameUUIDFromBytes(MessageDigest.getInstance("SHA-1") + .digest(("maintainer-inactivity-" + maintainerData.getEventType() + "-" + maintainerData.getGithubLogin() + "-" + currentDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + "-" + repo) + .getBytes())))); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + maintainerData.setCurrentDate(currentDate.toString()); + if (latestEvent != null) { + maintainerData.setEventAction(latestEvent.getEventType() + "." + latestEvent.getEventAction()); // e.g. issues.opened + maintainerData.setTimeLastEngaged(latestEvent.getTimeLastEngaged().toString()); + maintainerData.setInactive(latestEvent.isInactive()); + } else { + maintainerData.setInactive(true); + } + Stream compositeEvent = Stream.of(maintainerData); + return Stream.concat(individualEvents.stream(), compositeEvent); + }); + }) + .collect(Collectors.toMap(MaintainerData::getId, maintainerData -> maintainerData.getJson(maintainerData, objectMapper))); + String indexName = "maintainer-inactivity-" + currentDate.format(DateTimeFormatter.ofPattern("MM-yyyy")); + openSearchUtil.createIndexIfNotExists(indexName); + openSearchUtil.bulkIndex(indexName, metricFinalData); + } } diff --git a/src/main/java/org/opensearchmetrics/metrics/maintainer/MaintainerMetrics.java b/src/main/java/org/opensearchmetrics/metrics/maintainer/MaintainerMetrics.java new file mode 100644 index 0000000..2254c73 --- /dev/null +++ b/src/main/java/org/opensearchmetrics/metrics/maintainer/MaintainerMetrics.java @@ -0,0 +1,218 @@ +package org.opensearchmetrics.metrics.maintainer; + +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.index.query.BoolQueryBuilder; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.search.aggregations.AggregationBuilders; +import org.opensearch.search.aggregations.BucketOrder; +import org.opensearch.search.aggregations.bucket.terms.ParsedStringTerms; +import org.opensearch.search.aggregations.bucket.terms.Terms; +import org.opensearch.search.aggregations.bucket.terms.TermsAggregationBuilder; +import org.opensearch.search.aggregations.metrics.TopHits; +import org.opensearch.search.aggregations.metrics.TopHitsAggregationBuilder; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.search.sort.SortOrder; +import org.opensearchmetrics.model.maintainer.LatestEventData; +import org.opensearchmetrics.model.maintainer.MaintainerData; +import org.opensearchmetrics.util.OpenSearchUtil; + +import javax.inject.Inject; +import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public class MaintainerMetrics { + private final String GITHUB_EVENTS_INDEX = "github-user-activity-events-*"; + private final String NUM_EVENTS_SINCE = "now-6M"; + + @Inject + public MaintainerMetrics() { + } + + public List getEventTypes(OpenSearchUtil openSearchUtil) { + SearchRequest searchRequest = new SearchRequest(GITHUB_EVENTS_INDEX); + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); + searchSourceBuilder.size(0); + TermsAggregationBuilder aggregation = AggregationBuilders.terms("event_types") + .field("type.keyword").size(500); + searchSourceBuilder.aggregation(aggregation); + searchRequest.source(searchSourceBuilder); + SearchResponse searchResponse = openSearchUtil.search(searchRequest); + ParsedStringTerms termsAggregation = searchResponse.getAggregations().get("event_types"); + List eventTypes = termsAggregation.getBuckets().stream() + .map(bucket -> bucket.getKeyAsString()) + .collect(Collectors.toList()); + return eventTypes; + } + + public Optional queryLatestEvent(String repo, String userLogin, String eventType, OpenSearchUtil openSearchUtil) { + BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery(); + boolQueryBuilder.must(QueryBuilders.matchQuery("repository.keyword", repo)); + boolQueryBuilder.must(QueryBuilders.matchQuery("sender.keyword", userLogin)); + boolQueryBuilder.must(QueryBuilders.matchQuery("type.keyword", eventType)); + SearchRequest searchRequest = new SearchRequest(GITHUB_EVENTS_INDEX); + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); + searchSourceBuilder.query(boolQueryBuilder); + searchSourceBuilder.size(0); + TopHitsAggregationBuilder aggregation = AggregationBuilders.topHits("latest_event") + .size(1).sort("created_at", SortOrder.DESC); + searchSourceBuilder.aggregation(aggregation); + searchRequest.source(searchSourceBuilder); + SearchResponse searchResponse = openSearchUtil.search(searchRequest); + RestStatus status = searchResponse.status(); + if (status == RestStatus.OK) { + TopHits topHits = searchResponse.getAggregations().get("latest_event"); + if (topHits.getHits().getHits().length > 0) { + Map latestDocument = topHits.getHits().getHits()[0].getSourceAsMap(); + LatestEventData latestEventData = new LatestEventData(); + latestEventData.setEventType(eventType); + latestEventData.setEventAction(latestDocument.get("action").toString()); + latestEventData.setTimeLastEngaged(Instant.parse(latestDocument.get("created_at").toString())); + return Optional.of(latestEventData); + } else { + return Optional.empty(); + } + } else { + throw new RuntimeException("Error connecting to the cluster"); + } + + } + + public long[] mostAndLeastRepoEventCounts(OpenSearchUtil openSearchUtil) { + SearchRequest searchRequest = new SearchRequest(GITHUB_EVENTS_INDEX); + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); + BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery(); + boolQueryBuilder.must(QueryBuilders.rangeQuery("created_at").gte(NUM_EVENTS_SINCE)); + TermsAggregationBuilder mostCommonTerms = AggregationBuilders + .terms("most_event_count") + .field("repository.keyword") + .size(1) + .order(BucketOrder.count(false)); + + TermsAggregationBuilder leastCommonTerms = AggregationBuilders + .terms("least_event_count") + .field("repository.keyword") + .size(1) + .order(BucketOrder.count(true)); + + searchSourceBuilder.query(boolQueryBuilder); + searchSourceBuilder.aggregation(mostCommonTerms); + searchSourceBuilder.aggregation(leastCommonTerms); + searchSourceBuilder.size(0); + searchRequest.source(searchSourceBuilder); + SearchResponse searchResponse = openSearchUtil.search(searchRequest); + RestStatus status = searchResponse.status(); + if (status == RestStatus.OK) { + Terms leastEventCount = searchResponse.getAggregations().get("least_event_count"); + Terms mostEventCount = searchResponse.getAggregations().get("most_event_count"); + for (Terms.Bucket leastBucket : leastEventCount.getBuckets()) { + for (Terms.Bucket mostBucket : mostEventCount.getBuckets()) { + return new long[]{mostBucket.getDocCount(), leastBucket.getDocCount()}; + } + } + throw new RuntimeException("Error retrieving event counts"); + } else { + throw new RuntimeException("Error connecting to the cluster"); + } + } + + public long repoEventCount(String repo, OpenSearchUtil openSearchUtil) { + BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery(); + boolQueryBuilder.must(QueryBuilders.matchQuery("repository.keyword", repo)) + .must(QueryBuilders.rangeQuery("created_at").gte(NUM_EVENTS_SINCE)); + SearchRequest searchRequest = new SearchRequest(GITHUB_EVENTS_INDEX); + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); + searchSourceBuilder.query(boolQueryBuilder); + searchSourceBuilder.size(0); + searchRequest.source(searchSourceBuilder); + SearchResponse searchResponse = openSearchUtil.search(searchRequest); + RestStatus status = searchResponse.status(); + if (status == RestStatus.OK) { + return searchResponse.getHits().getTotalHits().value; + } else { + throw new RuntimeException("Error connecting to the cluster"); + } + } + + public double[] getSlopeAndIntercept(double x0, double y0, double x1, double y1) { + if (x1 - x0 != 0) { + double m = (y1 - y0) / (x1 - x0); + double b = y1 - m * x1; + return new double[]{m, b}; + } + return null; + } + + public long inactivityLinEq(double[] slopeAndIntercept, double x, double lowerBound) { + if (slopeAndIntercept == null) { + return Math.round(lowerBound); + } else if (slopeAndIntercept.length == 2) { + double m = slopeAndIntercept[0]; + double b = slopeAndIntercept[1]; + return Math.round(m * x + b); + } + throw new RuntimeException("Slope and Intercept set wrong"); + } + + public boolean calculateInactivity(long currentRepoEventCount, double[] slopeAndIntercept, double lowerBound, LatestEventData latestEventData) { + long daysUntilInactive = inactivityLinEq(slopeAndIntercept, (double) currentRepoEventCount, lowerBound); + Duration durationUntilInactive = Duration.ofDays(daysUntilInactive); + Instant benchmark = Instant.now().minus(durationUntilInactive); + + return latestEventData.getTimeLastEngaged().isBefore(benchmark); + } + + public List repoMaintainers(String repo) { + String rawMaintainersFile = String.format("https://raw.githubusercontent.com/opensearch-project/%s/main/MAINTAINERS.md", repo); + boolean isEmeritusSection = false; + List maintainersList = new ArrayList<>(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(new URL(rawMaintainersFile).openStream(), + StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.startsWith("|")) { + String[] columns = line.split("\\|"); + if (columns.length >= 4) { + String maintainer = columns[1].trim(); + Pattern pattern = Pattern.compile("\\[(.*?)\\]"); + Matcher matcher = pattern.matcher(columns[2]); + String githubId = matcher.find() ? matcher.group(1) : ""; + String affiliation = columns[3].trim(); + if (!isEmeritusSection && !maintainer.toLowerCase().contains("emeritus") && !githubId.isEmpty()) { + MaintainerData maintainerData = new MaintainerData(); + maintainerData.setRepository(repo); + maintainerData.setName(maintainer); + maintainerData.setGithubLogin(githubId); + maintainerData.setAffiliation(affiliation); + maintainersList.add(maintainerData); + } + } + } else if (line.contains("Emeritus")) { + isEmeritusSection = true; + } else if (!line.isEmpty() && isEmeritusSection) { + isEmeritusSection = false; + } + } + } catch (FileNotFoundException e) { + return maintainersList; + } catch (IOException e) { + e.printStackTrace(); + } + return maintainersList; + } +} diff --git a/src/main/java/org/opensearchmetrics/model/maintainer/LatestEventData.java b/src/main/java/org/opensearchmetrics/model/maintainer/LatestEventData.java new file mode 100644 index 0000000..4b60b12 --- /dev/null +++ b/src/main/java/org/opensearchmetrics/model/maintainer/LatestEventData.java @@ -0,0 +1,13 @@ +package org.opensearchmetrics.model.maintainer; + +import lombok.Data; + +import java.time.Instant; + +@Data +public class LatestEventData { + private String eventType; + private String eventAction; + private Instant timeLastEngaged; + private boolean inactive; +} diff --git a/src/main/java/org/opensearchmetrics/model/maintainer/MaintainerData.java b/src/main/java/org/opensearchmetrics/model/maintainer/MaintainerData.java new file mode 100644 index 0000000..8a5dfae --- /dev/null +++ b/src/main/java/org/opensearchmetrics/model/maintainer/MaintainerData.java @@ -0,0 +1,69 @@ +package org.opensearchmetrics.model.maintainer; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Data; + +import java.util.HashMap; +import java.util.Map; + +@Data +public class MaintainerData { + + @JsonProperty("id") + private String id; + + @JsonProperty("current_date") + private String currentDate; + + @JsonProperty("repository") + private String repository; + + @JsonProperty("name") + private String name; + + @JsonProperty("github_login") + private String githubLogin; + + @JsonProperty("affiliation") + private String affiliation; + + @JsonProperty("event_type") + private String eventType; + + @JsonProperty("event_action") + private String eventAction; + + @JsonProperty("time_last_engaged") + private String timeLastEngaged; + + @JsonProperty("inactive") + private boolean inactive; + + public String toJson(ObjectMapper mapper) throws JsonProcessingException { + Map data = new HashMap<>(); + data.put("id", id); + data.put("current_date", currentDate); + data.put("repository", repository); + data.put("name", name); + data.put("github_login", githubLogin); + data.put("affiliation", affiliation); + data.put("event_type", eventType); + data.put("event_action", eventAction); + data.put("time_last_engaged", timeLastEngaged); + data.put("inactive", inactive); + return mapper.writeValueAsString(data); + } + + public String getJson(MaintainerData maintainerData, ObjectMapper objectMapper) { + try { + return maintainerData.toJson(objectMapper); + } catch (JsonProcessingException e) { + System.out.println("Error while serializing ReportDataRow to JSON " + e); + throw new RuntimeException(e); + } + } +} + + diff --git a/src/test/java/org/opensearchmetrics/lambda/GithubEventsLambdaTest.java b/src/test/java/org/opensearchmetrics/lambda/GithubEventsLambdaTest.java index f99f5ca..737caab 100644 --- a/src/test/java/org/opensearchmetrics/lambda/GithubEventsLambdaTest.java +++ b/src/test/java/org/opensearchmetrics/lambda/GithubEventsLambdaTest.java @@ -80,8 +80,8 @@ public void testHandleRequestMonthAgo() { when(s3Util.getObjectInputStream(anyString())).thenReturn(new ResponseInputStream<>(getObjectResponse, new ByteArrayInputStream(eventJson.getBytes()))); Map input = new HashMap<>(); - LocalDate today = LocalDate.now(ZoneOffset.UTC); - LocalDate lastMonth = today.minus(1, ChronoUnit.MONTHS); + LocalDate yesterday = LocalDate.now(ZoneOffset.UTC).minus(1, ChronoUnit.DAYS); + LocalDate lastMonth = yesterday.minus(1, ChronoUnit.MONTHS); input.put("collectionStartDate", lastMonth.toString()); // Act @@ -92,7 +92,7 @@ public void testHandleRequestMonthAgo() { verify(openSearchUtil, atLeastOnce()).createIndexIfNotExists(indexNameLastMonth); verify(openSearchUtil, atLeastOnce()).bulkIndex(eq(indexNameLastMonth), any(Map.class)); - String indexNameThisMonth = "github-user-activity-events-" + today.format(DateTimeFormatter.ofPattern("MM-yyyy")); + String indexNameThisMonth = "github-user-activity-events-" + yesterday.format(DateTimeFormatter.ofPattern("MM-yyyy")); verify(openSearchUtil, atLeastOnce()).createIndexIfNotExists(indexNameThisMonth); verify(openSearchUtil, atLeastOnce()).bulkIndex(eq(indexNameThisMonth), any(Map.class)); } diff --git a/src/test/java/org/opensearchmetrics/lambda/MaintainerInactivityLambdaTest.java b/src/test/java/org/opensearchmetrics/lambda/MaintainerInactivityLambdaTest.java new file mode 100644 index 0000000..9f65cde --- /dev/null +++ b/src/test/java/org/opensearchmetrics/lambda/MaintainerInactivityLambdaTest.java @@ -0,0 +1,50 @@ +package org.opensearchmetrics.lambda; + +import com.amazonaws.services.lambda.runtime.Context; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.search.aggregations.Aggregations; +import org.opensearch.search.aggregations.bucket.terms.ParsedStringTerms; +import org.opensearchmetrics.metrics.MetricsCalculation; +import org.opensearchmetrics.util.OpenSearchUtil; + +import java.util.Collections; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.times; + +public class MaintainerInactivityLambdaTest { + @Mock + private OpenSearchUtil openSearchUtil; + + @Mock + private MetricsCalculation metricsCalculation; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + public void testHandleRequest(){ + MaintainerInactivityLambda maintainerInactivityLambda = new MaintainerInactivityLambda(openSearchUtil, metricsCalculation); + SearchResponse searchResponse = mock(SearchResponse.class); + Context context = mock(Context.class); + when(openSearchUtil.search(any(SearchRequest.class))).thenReturn(searchResponse); + Aggregations aggregations = mock(Aggregations.class); + when(searchResponse.getAggregations()).thenReturn(aggregations); + ParsedStringTerms termsAggregation = mock(ParsedStringTerms.class); + when(aggregations.get("repos")).thenReturn(termsAggregation); + when(termsAggregation.getBuckets()).thenReturn(Collections.emptyList()); + maintainerInactivityLambda.handleRequest(null, context); + verify(openSearchUtil, times(1)).search(any(SearchRequest.class)); + verify(metricsCalculation, times(1)).generateMaintainerMetrics(anyList()); + } + +} diff --git a/src/test/java/org/opensearchmetrics/lambda/SlackLambdaTest.java b/src/test/java/org/opensearchmetrics/lambda/SlackLambdaTest.java index 7c749b1..ef65364 100644 --- a/src/test/java/org/opensearchmetrics/lambda/SlackLambdaTest.java +++ b/src/test/java/org/opensearchmetrics/lambda/SlackLambdaTest.java @@ -139,7 +139,7 @@ public void testHandleRequestWithHttpClientException() throws IOException{ private SNSEvent getSNSEventFromMessage(String message) throws IOException { SNSEvent event = new SNSEvent(); SNSEvent.SNSRecord record = new SNSEvent.SNSRecord(); - record.setSns(new SNSEvent.SNS().withMessage(message)); + record.setSns(new SNS().withMessage(message)); event.setRecords(List.of(record)); return event; } diff --git a/src/test/java/org/opensearchmetrics/metrics/MetricsCalculationTest.java b/src/test/java/org/opensearchmetrics/metrics/MetricsCalculationTest.java index 9967985..725e3b6 100644 --- a/src/test/java/org/opensearchmetrics/metrics/MetricsCalculationTest.java +++ b/src/test/java/org/opensearchmetrics/metrics/MetricsCalculationTest.java @@ -24,19 +24,24 @@ import org.opensearch.index.query.BoolQueryBuilder; import org.opensearchmetrics.metrics.general.*; import org.opensearchmetrics.metrics.label.LabelMetrics; +import org.opensearchmetrics.metrics.maintainer.MaintainerMetrics; import org.opensearchmetrics.metrics.release.ReleaseInputs; import org.opensearchmetrics.metrics.release.ReleaseMetrics; import org.opensearchmetrics.model.codecov.CodeCovResponse; import org.opensearchmetrics.model.label.LabelData; import org.opensearchmetrics.model.general.MetricsData; +import org.opensearchmetrics.model.maintainer.LatestEventData; +import org.opensearchmetrics.model.maintainer.MaintainerData; import org.opensearchmetrics.model.release.ReleaseMetricsData; import org.opensearchmetrics.util.OpenSearchUtil; import java.io.IOException; import java.security.NoSuchAlgorithmException; import java.time.LocalDate; +import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; +import java.time.temporal.ChronoUnit; import java.util.*; import static org.junit.jupiter.api.Assertions.assertArrayEquals; @@ -81,6 +86,9 @@ public class MetricsCalculationTest { private LabelMetrics labelMetrics; @Mock private ReleaseMetrics releaseMetrics; + @Mock + private MaintainerMetrics maintainerMetrics; + @InjectMocks private MetricsCalculation metricsCalculation; @@ -93,7 +101,7 @@ void setUp() { untriagedIssues, uncommentedPullRequests, unlabelledPullRequests, unlabelledIssues, mergedPullRequests, openPullRequests, openIssues, closedIssues, createdIssues, issueComments, pullComments, issuePositiveReactions, issueNegativeReactions, - labelMetrics, releaseMetrics); + labelMetrics, releaseMetrics, maintainerMetrics); } @Test @@ -155,7 +163,6 @@ void testGenerateReleaseMetrics() { verify(openSearchUtil, times(1)).createIndexIfNotExists("opensearch_release_metrics"); } - @Test void testGenerateCodeCovMetrics() { try (MockedStatic mockedReleaseInputs = Mockito.mockStatic(ReleaseInputs.class)) { @@ -187,4 +194,35 @@ void testGenerateCodeCovMetrics() { verify(releaseMetrics).getReleaseRepos("2.18.0"); } } + + @Test + void testGenerateMaintainerMetrics() throws IOException{ + List repositories = Arrays.asList("repo1", "repo2"); + List eventList = Arrays.asList("event1", "event2"); + List maintainersList = new ArrayList<>(); + MaintainerData maintainerData = new MaintainerData(); + maintainerData.setRepository("repo1"); + maintainerData.setName("maintainer1"); + maintainerData.setGithubLogin("githubId"); + maintainerData.setAffiliation("affiliation1"); + maintainersList.add(maintainerData); + LatestEventData latestEventData = new LatestEventData(); + latestEventData.setEventType("eventType"); + latestEventData.setEventAction("eventAction"); + latestEventData.setTimeLastEngaged(Instant.now().minus(7, ChronoUnit.DAYS)); + double[] slopeAndIntercept = {-1.0, 368.0}; + double upperBound = 365; + double lowerBound = 90; + when(maintainerMetrics.mostAndLeastRepoEventCounts(any())).thenReturn(new long[]{100L, 10L}); + when(maintainerMetrics.getSlopeAndIntercept(10, upperBound, 100, lowerBound)).thenReturn(slopeAndIntercept); + when(maintainerMetrics.getEventTypes(any())).thenReturn(eventList); + when(maintainerMetrics.repoEventCount(any(), any())).thenReturn(50L); + when(maintainerMetrics.repoMaintainers(any())).thenReturn(maintainersList); + when(maintainerMetrics.queryLatestEvent(any(), any(), any(), any())).thenReturn(Optional.of(latestEventData)); + when(maintainerMetrics.calculateInactivity(50L, slopeAndIntercept, lowerBound, latestEventData)).thenReturn(false); + when(objectMapper.writeValueAsString(any())).thenReturn("json"); + metricsCalculation.generateMaintainerMetrics(repositories); + verify(openSearchUtil).createIndexIfNotExists(matches("maintainer-inactivity-\\d{2}-\\d{4}")); + verify(openSearchUtil).bulkIndex(matches("maintainer-inactivity-\\d{2}-\\d{4}"), argThat(map -> !map.isEmpty())); + } } diff --git a/src/test/java/org/opensearchmetrics/metrics/maintainer/MaintainerMetricsTest.java b/src/test/java/org/opensearchmetrics/metrics/maintainer/MaintainerMetricsTest.java new file mode 100644 index 0000000..7b774a5 --- /dev/null +++ b/src/test/java/org/opensearchmetrics/metrics/maintainer/MaintainerMetricsTest.java @@ -0,0 +1,268 @@ +package org.opensearchmetrics.metrics.maintainer; + +import org.apache.lucene.search.TotalHits; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.search.SearchHit; +import org.opensearch.search.SearchHits; +import org.opensearch.search.aggregations.Aggregations; +import org.opensearch.search.aggregations.bucket.terms.ParsedStringTerms; +import org.opensearch.search.aggregations.bucket.terms.Terms; +import org.opensearch.search.aggregations.metrics.TopHits; +import org.opensearchmetrics.model.maintainer.LatestEventData; +import org.opensearchmetrics.model.maintainer.MaintainerData; +import org.opensearchmetrics.util.OpenSearchUtil; + +import javax.naming.Context; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +public class MaintainerMetricsTest { + + @Test + public void testGetEventTypes() { + // Mock OpenSearchUtil + OpenSearchUtil openSearchUtil = Mockito.mock(OpenSearchUtil.class); + + // Mock search responses + SearchResponse eventsResponse = Mockito.mock(SearchResponse.class); + when(eventsResponse.status()).thenReturn(RestStatus.OK); + + // Mock issues aggregation + ParsedStringTerms eventTerms = Mockito.mock(ParsedStringTerms.class); + when(eventTerms.getBuckets()).thenReturn(new ArrayList<>()); + Aggregations aggregations = Mockito.mock(Aggregations.class); + when(aggregations.get("event_types")).thenReturn(eventTerms); + when(eventsResponse.getAggregations()).thenReturn(aggregations); + + // Mock search request + when(openSearchUtil.search(any(SearchRequest.class))).thenReturn(eventsResponse); + + // Instantiate MaintainerMetrics + MaintainerMetrics maintainerMetrics = new MaintainerMetrics(); + + // Call method under test + List eventTypes = maintainerMetrics.getEventTypes(openSearchUtil); + + // Assertions + assertEquals(new ArrayList<>(), eventTypes); // Modify expected result according to your logic + } + + @Test + public void testQueryLatestEvent() { + // Mock + OpenSearchUtil openSearchUtil = Mockito.mock(OpenSearchUtil.class); + SearchResponse eventsResponse = Mockito.mock(SearchResponse.class); + SearchHit searchHit = Mockito.mock(SearchHit.class); + SearchHits searchHits = Mockito.mock(SearchHits.class); + TopHits topHits = Mockito.mock(TopHits.class); + Aggregations aggregations = Mockito.mock(Aggregations.class); + + when(openSearchUtil.search(any(SearchRequest.class))).thenReturn(eventsResponse); + when(eventsResponse.status()).thenReturn(RestStatus.OK); + when(eventsResponse.getAggregations()).thenReturn(aggregations); + when(aggregations.get("latest_event")).thenReturn(topHits); + when(topHits.getHits()).thenReturn(searchHits); + when(searchHits.getHits()).thenReturn(new SearchHit[]{searchHit}); + + Map sourceMap = new HashMap<>(); + sourceMap.put("action", "some_action"); + sourceMap.put("created_at", "2023-06-15T10:00:00Z"); + when(searchHit.getSourceAsMap()).thenReturn(sourceMap); + + MaintainerMetrics maintainerMetrics = new MaintainerMetrics(); + + // Call method under test + String testRepo = "testRepo"; + String testUserLogin = "testUserLogin"; + String testEventType = "testEventType"; + Optional latestEvent = maintainerMetrics.queryLatestEvent(testRepo, testUserLogin, testEventType, openSearchUtil); + LatestEventData testEvent = new LatestEventData(); + testEvent.setEventType("testEventType"); + testEvent.setEventAction("some_action"); + testEvent.setTimeLastEngaged(Instant.parse("2023-06-15T10:00:00Z")); + Optional testEventOpt = Optional.of(testEvent); + + + // Assertions + assertEquals(testEventOpt, latestEvent); // Modify expected result according to your logic + } + + @Test + public void testQueryLatestEventEmpty() { + // Mock + OpenSearchUtil openSearchUtil = Mockito.mock(OpenSearchUtil.class); + SearchResponse eventsResponse = Mockito.mock(SearchResponse.class); + SearchHits searchHits = Mockito.mock(SearchHits.class); + TopHits topHits = Mockito.mock(TopHits.class); + Aggregations aggregations = Mockito.mock(Aggregations.class); + + when(openSearchUtil.search(any(SearchRequest.class))).thenReturn(eventsResponse); + when(eventsResponse.status()).thenReturn(RestStatus.OK); + when(eventsResponse.getAggregations()).thenReturn(aggregations); + when(aggregations.get("latest_event")).thenReturn(topHits); + when(topHits.getHits()).thenReturn(searchHits); + when(searchHits.getHits()).thenReturn(new SearchHit[0]); + + MaintainerMetrics maintainerMetrics = new MaintainerMetrics(); + + // Call method under test + String testRepo = "testRepo"; + String testUserLogin = "testUserLogin"; + String testEventType = "testEventType"; + Optional latestEvent = maintainerMetrics.queryLatestEvent(testRepo, testUserLogin, testEventType, openSearchUtil); + + // Assertions + assertEquals(Optional.empty(), latestEvent); // Modify expected result according to your logic + } + + @Test + public void testMostAndLeastRepoEventCounts() { + // Mock + OpenSearchUtil openSearchUtil = Mockito.mock(OpenSearchUtil.class); + SearchResponse eventsResponse = Mockito.mock(SearchResponse.class); + Aggregations aggregations = Mockito.mock(Aggregations.class); + Terms termsLeast = Mockito.mock(Terms.class); + Terms termsMost = Mockito.mock(Terms.class); + Terms.Bucket leastBucket = Mockito.mock(Terms.Bucket.class); + Terms.Bucket mostBucket = Mockito.mock(Terms.Bucket.class); + List leastBuckets = Arrays.asList(leastBucket); + List mostBuckets = Arrays.asList(mostBucket); + + + when(openSearchUtil.search(any(SearchRequest.class))).thenReturn(eventsResponse); + when(eventsResponse.status()).thenReturn(RestStatus.OK); + when(eventsResponse.getAggregations()).thenReturn(aggregations); + when(aggregations.get("least_event_count")).thenReturn(termsLeast); + when(aggregations.get("most_event_count")).thenReturn(termsMost); + when(termsLeast.getBuckets()).thenAnswer(invocation -> leastBuckets); + when(termsMost.getBuckets()).thenAnswer(invocation -> mostBuckets); + when(leastBucket.getDocCount()).thenReturn(10L); + when(mostBucket.getDocCount()).thenReturn(100L); + + + MaintainerMetrics maintainerMetrics = new MaintainerMetrics(); + + // Call method under test + long[] mostAndLeastRepoEventCount = maintainerMetrics.mostAndLeastRepoEventCounts(openSearchUtil); + + // Assertions + assertArrayEquals(new long[]{100L, 10L}, mostAndLeastRepoEventCount); // Modify expected result according to your logic + } + + @Test + public void testRepoEventCount() { + // Mock + OpenSearchUtil openSearchUtil = Mockito.mock(OpenSearchUtil.class); + SearchResponse eventsResponse = Mockito.mock(SearchResponse.class); + SearchHits searchHits = Mockito.mock(SearchHits.class); + + when(openSearchUtil.search(any(SearchRequest.class))).thenReturn(eventsResponse); + when(eventsResponse.status()).thenReturn(RestStatus.OK); + when(eventsResponse.getHits()).thenReturn(searchHits); + when(searchHits.getTotalHits()).thenReturn(new TotalHits(5L, TotalHits.Relation.EQUAL_TO)); + + MaintainerMetrics maintainerMetrics = new MaintainerMetrics(); + + // Call method under test + String testRepo = "testRepo"; + long repoEventCount = maintainerMetrics.repoEventCount(testRepo, openSearchUtil); + + // Assertions + assertEquals(5L, repoEventCount); // Modify expected result according to your logic + } + + @Test + public void testGetSlopeAndIntercept() { + MaintainerMetrics maintainerMetrics = new MaintainerMetrics(); + double[] slopeAndIntercept = maintainerMetrics.getSlopeAndIntercept(0, 0, 1, 5); + assertArrayEquals(new double[]{5, 0}, slopeAndIntercept); + } + + @Test + public void testGetSlopeAndInterceptNull() { + MaintainerMetrics maintainerMetrics = new MaintainerMetrics(); + double[] slopeAndIntercept = maintainerMetrics.getSlopeAndIntercept(0, 0, 0, 5); + assertNull(slopeAndIntercept); + } + + @Test + public void testInactivityLinEq() { + MaintainerMetrics maintainerMetrics = new MaintainerMetrics(); + long y = maintainerMetrics.inactivityLinEq(new double[]{-1, 376}, 50, 90); + assertEquals(326L, y); + } + + @Test + public void testInactivityLinEqNull() { + MaintainerMetrics maintainerMetrics = new MaintainerMetrics(); + long y = maintainerMetrics.inactivityLinEq(null, 50, 90); + assertEquals(90L, y); + } + + @Test + public void testInactivityLinEqSlopeInterceptWrong() { + MaintainerMetrics maintainerMetrics = new MaintainerMetrics(); + assertThrows(RuntimeException.class, () -> maintainerMetrics.inactivityLinEq(new double[]{-1, 376, 928}, 50, 90)); + } + + @Test + public void testCalculateInactivity() { + MaintainerMetrics maintainerMetrics = new MaintainerMetrics(); + MaintainerMetrics mockMaintainerMetrics = Mockito.mock(MaintainerMetrics.class); + LatestEventData latestEventData = new LatestEventData(); + latestEventData.setTimeLastEngaged(Instant.now().minus(7, ChronoUnit.DAYS)); + double[] slopeAndIntercept = {-1, 376}; + when(mockMaintainerMetrics.inactivityLinEq(slopeAndIntercept, 50, 90)).thenReturn(326L); + boolean y = maintainerMetrics.calculateInactivity(50L, slopeAndIntercept, 90, latestEventData); + assertFalse(y); + } + + @Test + public void testRepoMaintainers() { + MaintainerMetrics maintainerMetrics = new MaintainerMetrics(); + String expectedContent = "test content\n" + + "| Maintainer | GitHub ID | Affiliation |\n" + + "| maintainer | [githubId](https://github.com/githubId) | affiliation |\n" + + "## Emeritus Maintainers" + + "| maintainer | [githubId](https://github.com/githubId) | affiliation |\n" + + "\n"; + ByteArrayInputStream inputStream = new ByteArrayInputStream( + expectedContent.getBytes(StandardCharsets.UTF_8) + ); + + try (MockedConstruction mocked = mockConstruction(URL.class, (mockUrl, context) -> + when(mockUrl.openStream()).thenReturn(inputStream))) { + + List maintainerDataList = maintainerMetrics.repoMaintainers("repo"); + + List expectedList = new ArrayList<>(); + MaintainerData expectedMaintainer = new MaintainerData(); + expectedMaintainer.setRepository("repo"); + expectedMaintainer.setName("maintainer"); + expectedMaintainer.setGithubLogin("githubId"); + expectedMaintainer.setAffiliation("affiliation"); + expectedList.add(expectedMaintainer); + + assertEquals(expectedList, maintainerDataList); + } + } +} diff --git a/src/test/java/org/opensearchmetrics/model/maintainer/LatestEventDataTest.java b/src/test/java/org/opensearchmetrics/model/maintainer/LatestEventDataTest.java new file mode 100644 index 0000000..3fa9f35 --- /dev/null +++ b/src/test/java/org/opensearchmetrics/model/maintainer/LatestEventDataTest.java @@ -0,0 +1,43 @@ +package org.opensearchmetrics.model.maintainer; + +import java.time.Instant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockitoAnnotations; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class LatestEventDataTest { + private LatestEventData latestEventData; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + latestEventData = new LatestEventData(); + } + + @Test + public void testEventType() { + latestEventData.setEventType("testType"); + assertEquals("testType", latestEventData.getEventType()); + } + + @Test + public void testEventAction() { + latestEventData.setEventAction("testAction"); + assertEquals("testAction", latestEventData.getEventAction()); + } + + @Test + public void testTimeLastEngaged() { + Instant testInstant = Instant.parse("2021-02-09T11:19:42.12Z"); + latestEventData.setTimeLastEngaged(testInstant); + assertEquals(testInstant, latestEventData.getTimeLastEngaged()); + } + + @Test + public void testInactive() { + latestEventData.setInactive(true); + assertEquals(true, latestEventData.isInactive()); + } +} diff --git a/src/test/java/org/opensearchmetrics/model/maintainer/MaintainerDataTest.java b/src/test/java/org/opensearchmetrics/model/maintainer/MaintainerDataTest.java new file mode 100644 index 0000000..d06f063 --- /dev/null +++ b/src/test/java/org/opensearchmetrics/model/maintainer/MaintainerDataTest.java @@ -0,0 +1,171 @@ +package org.opensearchmetrics.model.maintainer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.Mockito.when; + +public class MaintainerDataTest { + + @Mock + ObjectMapper objectMapper; + + private MaintainerData maintainerData; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + maintainerData = new MaintainerData(); + } + + @Test + public void testId() { + maintainerData.setId("123"); + assertEquals("123", maintainerData.getId()); + } + + @Test + public void testCurrentDate() { + maintainerData.setCurrentDate("2024-03-20"); + assertEquals("2024-03-20", maintainerData.getCurrentDate()); + } + + @Test + public void testRepository() { + maintainerData.setRepository("exampleRepo"); + assertEquals("exampleRepo", maintainerData.getRepository()); + } + + @Test + public void testName() { + maintainerData.setName("Alejandro Rosalez"); + assertEquals("Alejandro Rosalez", maintainerData.getName()); + } + + @Test + public void testGithubLogin() { + maintainerData.setGithubLogin("alejandro_rosalez"); + assertEquals("alejandro_rosalez", maintainerData.getGithubLogin()); + } + + @Test + public void testAffiliation() { + maintainerData.setAffiliation("Amazon"); + assertEquals("Amazon", maintainerData.getAffiliation()); + } + + @Test + public void testEventType() { + maintainerData.setEventType("IssuesEvent"); + assertEquals("IssuesEvent", maintainerData.getEventType()); + } + + @Test + public void testEventAction() { + maintainerData.setEventAction("edited"); + assertEquals("edited", maintainerData.getEventAction()); + } + + @Test + public void testTimeLastEngaged() { + maintainerData.setTimeLastEngaged("2024-09-23T20:14:08.346Z"); + assertEquals("2024-09-23T20:14:08.346Z", maintainerData.getTimeLastEngaged()); + } + + @Test + public void testInactive() { + maintainerData.setInactive(true); + assertEquals(true, maintainerData.isInactive()); + } + + @Test + void toJson() throws JsonProcessingException { + // Arrange + MaintainerData maintainerData = new MaintainerData(); + maintainerData.setId("1"); + maintainerData.setCurrentDate("2024-03-15"); + maintainerData.setRepository("test-repo"); + maintainerData.setName("Alejandro Rosalez"); + maintainerData.setGithubLogin("alejandro_rosalez"); + maintainerData.setAffiliation("Amazon"); + maintainerData.setEventType("IssuesEvent"); + maintainerData.setEventAction("edited"); + maintainerData.setTimeLastEngaged("2024-09-23T20:14:08.346Z"); + maintainerData.setInactive(true); + + Map expectedData = new HashMap<>(); + expectedData.put("id", "1"); + expectedData.put("current_date", "2024-03-15"); + expectedData.put("repository", "test-repo"); + expectedData.put("name", "Alejandro Rosalez"); + expectedData.put("github_login", "alejandro_rosalez"); + expectedData.put("affiliation", "Amazon"); + expectedData.put("event_type", "IssuesEvent"); + expectedData.put("event_action", "edited"); + expectedData.put("time_last_engaged", "2024-09-23T20:14:08.346Z"); + expectedData.put("inactive", true); + + when(objectMapper.writeValueAsString(expectedData)).thenReturn("expectedJson"); + + // Act + String actualJson = maintainerData.toJson(objectMapper); + + // Assert + assertEquals("expectedJson", actualJson); + } + + @Test + void getJson() throws JsonProcessingException { + // Arrange + MaintainerData maintainerData = new MaintainerData(); + maintainerData.setId("1"); + maintainerData.setCurrentDate("2024-03-15"); + maintainerData.setRepository("test-repo"); + maintainerData.setName("Alejandro Rosalez"); + maintainerData.setGithubLogin("alejandro_rosalez"); + maintainerData.setAffiliation("Amazon"); + maintainerData.setEventType("IssuesEvent"); + maintainerData.setEventAction("edited"); + maintainerData.setTimeLastEngaged("2024-09-23T20:14:08.346Z"); + maintainerData.setInactive(true); + + when(objectMapper.writeValueAsString(anyMap())).thenReturn("expectedJson"); + + // Act + String actualJson = maintainerData.getJson(maintainerData, objectMapper); + + // Assert + assertEquals("expectedJson", actualJson); + } + + @Test + void getJson_WithJsonProcessingException() throws JsonProcessingException { + // Arrange + MaintainerData maintainerData = new MaintainerData(); + maintainerData.setId("1"); + maintainerData.setCurrentDate("2024-03-15"); + maintainerData.setRepository("test-repo"); + maintainerData.setName("Alejandro Rosalez"); + maintainerData.setGithubLogin("alejandro_rosalez"); + maintainerData.setAffiliation("Amazon"); + maintainerData.setEventType("IssuesEvent"); + maintainerData.setEventAction("edited"); + maintainerData.setTimeLastEngaged("2024-09-23T20:14:08.346Z"); + maintainerData.setInactive(true); + + when(objectMapper.writeValueAsString(anyMap())).thenThrow(JsonProcessingException.class); + + // Act and Assert + assertThrows(RuntimeException.class, () -> maintainerData.getJson(maintainerData, objectMapper)); + } +}