exceptionfactory commented on code in PR #11670: URL: https://github.com/apache/nifi/pull/11670#discussion_r4184054938
########## nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/processors/tests/system/MigrateToControllerService.java: ########## @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.nifi.processors.tests.system; + +import org.apache.nifi.annotation.behavior.InputRequirement; +import org.apache.nifi.annotation.behavior.InputRequirement.Requirement; +import org.apache.nifi.annotation.documentation.CapabilityDescription; +import org.apache.nifi.components.PropertyDescriptor; +import org.apache.nifi.processor.AbstractProcessor; +import org.apache.nifi.processor.ProcessContext; +import org.apache.nifi.processor.ProcessSession; +import org.apache.nifi.processor.Relationship; +import org.apache.nifi.processor.exception.ProcessException; +import org.apache.nifi.processor.util.StandardValidators; + +import java.util.List; +import java.util.Set; + +@CapabilityDescription(""" + Pre-upgrade shape of a processor that keeps its store location in a plain property. + The post-upgrade shape of the same processor, in the alternate-config extensions bundle, migrates that property into a Controller Service. + """) +@InputRequirement(Requirement.INPUT_FORBIDDEN) +public class MigrateToControllerService extends AbstractProcessor { + + static final PropertyDescriptor STORE_NAME = new PropertyDescriptor.Builder() + .name("store-name") + .displayName("Store Name") + .required(true) Review Comment: The `displayName` is not needed and should be removed. ```suggestion .name("Store Name") ``` ########## nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/processors/tests/system/MigrateToControllerService.java: ########## @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.nifi.processors.tests.system; + +import org.apache.nifi.annotation.behavior.InputRequirement; +import org.apache.nifi.annotation.behavior.InputRequirement.Requirement; +import org.apache.nifi.annotation.documentation.CapabilityDescription; +import org.apache.nifi.components.PropertyDescriptor; +import org.apache.nifi.processor.AbstractProcessor; +import org.apache.nifi.processor.ProcessContext; +import org.apache.nifi.processor.ProcessSession; +import org.apache.nifi.processor.Relationship; +import org.apache.nifi.processor.exception.ProcessException; +import org.apache.nifi.processor.util.StandardValidators; + +import java.util.List; +import java.util.Set; + +@CapabilityDescription(""" + Pre-upgrade shape of a processor that keeps its store location in a plain property. + The post-upgrade shape of the same processor, in the alternate-config extensions bundle, migrates that property into a Controller Service. + """) +@InputRequirement(Requirement.INPUT_FORBIDDEN) +public class MigrateToControllerService extends AbstractProcessor { + + static final PropertyDescriptor STORE_NAME = new PropertyDescriptor.Builder() + .name("store-name") + .displayName("Store Name") + .required(true) + .addValidator(StandardValidators.NON_EMPTY_VALIDATOR) + .build(); + + static final Relationship REL_SUCCESS = new Relationship.Builder().name("success").build(); + + private static final List<PropertyDescriptor> PROPERTIES = List.of(STORE_NAME); + private static final Set<Relationship> RELATIONSHIPS = Set.of(REL_SUCCESS); + + @Override + protected List<PropertyDescriptor> getSupportedPropertyDescriptors() { + return PROPERTIES; + } + + @Override + public Set<Relationship> getRelationships() { + return RELATIONSHIPS; + } + + @Override + public void onTrigger(final ProcessContext context, final ProcessSession session) throws ProcessException { + context.yield(); Review Comment: Is there a reason for calling `yield()` instead of having an empty body? ########## nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizer.java: ########## @@ -1169,9 +1172,228 @@ private void removeMissingRpg(final ProcessGroup group, final VersionedProcessGr removeMissingComponents(group, proposed, rpgsByVersionedId, VersionedProcessGroup::getRemoteProcessGroups, ProcessGroup::removeRemoteProcessGroup); } + /** + * Assigns a proposed versioned id to a Controller Service created by property migration. + * The service must have exactly one referencer. + * The proposed counterpart of that referencer must point at a Controller Service of the same type. + * If those do not hold, the service stays unversioned. + * A proposed id is assigned to at most one local service. + */ + private void assignVersionedIdsToMigrationCreatedControllerServices(final ProcessGroup group, final VersionedProcessGroup proposed) { + final Collection<ControllerServiceNode> groupServices = group.getControllerServices(false); + if (groupServices.isEmpty()) { + return; + } + + final List<ControllerServiceNode> migrationCreatedServices = getUnversionedMigrationCreatedServices(groupServices); + if (migrationCreatedServices.isEmpty()) { + return; + } + + final Set<String> claimedVersionedIds = getClaimedVersionedIds(groupServices); + + final Set<VersionedControllerService> proposedControllerServices = Objects.requireNonNullElse(proposed.getControllerServices(), Set.of()); + final Set<VersionedProcessor> proposedProcessors = Objects.requireNonNullElse(proposed.getProcessors(), Set.of()); + final Map<String, VersionedConfigurableExtension> proposedComponentsByVersionedId = indexByVersionedId(proposedControllerServices, proposedProcessors); + + for (final ControllerServiceNode localService : orderByReferencerChain(migrationCreatedServices)) { + assignMatchingVersionedId(group, localService, claimedVersionedIds, proposedComponentsByVersionedId); + } + } + + private List<ControllerServiceNode> getUnversionedMigrationCreatedServices(final Collection<ControllerServiceNode> groupServices) { + final List<ControllerServiceNode> migrationCreatedServices = new ArrayList<>(); + for (final ControllerServiceNode localService : groupServices) { + if (localService.getVersionedComponentId().isEmpty() && isMigrationCreated(localService)) { + migrationCreatedServices.add(localService); + } + } + return migrationCreatedServices; + } + + private Set<String> getClaimedVersionedIds(final Collection<ControllerServiceNode> groupServices) { + final Set<String> claimedVersionedIds = HashSet.newHashSet(groupServices.size()); + for (final ControllerServiceNode localService : groupServices) { + localService.getVersionedComponentId().ifPresent(claimedVersionedIds::add); + } + return claimedVersionedIds; + } + + private void assignMatchingVersionedId( + final ProcessGroup group, + final ControllerServiceNode localService, + final Set<String> claimedVersionedIds, + final Map<String, VersionedConfigurableExtension> proposedComponentsByVersionedId + ) { + final ComponentNode referencer = getSoleReferencer(localService); + if (referencer == null) { + LOG.debug("Leaving {} in {} unversioned because it is not referenced by exactly one component", localService, group); + return; + } + + final VersionedConfigurableExtension proposedReferencer = getProposedReferencer(referencer, proposedComponentsByVersionedId); + if (proposedReferencer == null) { + LOG.debug("Leaving {} in {} unversioned because its referencer {} has no counterpart in the proposed flow", localService, group, referencer); + return; + } + + final ProposedControllerServiceMatch match = findMatchingProposedControllerService(localService, referencer, proposedReferencer, proposedComponentsByVersionedId, group); + if (match == null) { + LOG.debug("Leaving {} in {} unversioned because no proposed Controller Service matches the referencing property of {}", localService, group, referencer); + return; + } + + if (!claimedVersionedIds.add(match.versionedId())) { + LOG.debug("Leaving {} in {} unversioned because versioned id {} is already used by another Controller Service", localService, group, match.versionedId()); + return; + } + + localService.setVersionedComponentId(match.versionedId()); + updatedVersionedComponentIds.add(match.versionedId()); + LOG.info("Matched {} in {} to the Controller Service with versioned id {} that the proposed flow declares, based on the {} property of {}", + localService, group, match.versionedId(), match.propertyName(), referencer); + } + + private List<ControllerServiceNode> orderByReferencerChain(final List<ControllerServiceNode> migrationCreatedServices) { Review Comment: The reason behind the ordering is not quite clear, it would be helpful to add a comment that explains why the ordering is needed ########## nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizer.java: ########## @@ -1169,9 +1172,228 @@ private void removeMissingRpg(final ProcessGroup group, final VersionedProcessGr removeMissingComponents(group, proposed, rpgsByVersionedId, VersionedProcessGroup::getRemoteProcessGroups, ProcessGroup::removeRemoteProcessGroup); } + /** + * Assigns a proposed versioned id to a Controller Service created by property migration. + * The service must have exactly one referencer. + * The proposed counterpart of that referencer must point at a Controller Service of the same type. + * If those do not hold, the service stays unversioned. + * A proposed id is assigned to at most one local service. + */ + private void assignVersionedIdsToMigrationCreatedControllerServices(final ProcessGroup group, final VersionedProcessGroup proposed) { + final Collection<ControllerServiceNode> groupServices = group.getControllerServices(false); + if (groupServices.isEmpty()) { + return; + } + + final List<ControllerServiceNode> migrationCreatedServices = getUnversionedMigrationCreatedServices(groupServices); + if (migrationCreatedServices.isEmpty()) { + return; + } + + final Set<String> claimedVersionedIds = getClaimedVersionedIds(groupServices); + + final Set<VersionedControllerService> proposedControllerServices = Objects.requireNonNullElse(proposed.getControllerServices(), Set.of()); + final Set<VersionedProcessor> proposedProcessors = Objects.requireNonNullElse(proposed.getProcessors(), Set.of()); + final Map<String, VersionedConfigurableExtension> proposedComponentsByVersionedId = indexByVersionedId(proposedControllerServices, proposedProcessors); + + for (final ControllerServiceNode localService : orderByReferencerChain(migrationCreatedServices)) { + assignMatchingVersionedId(group, localService, claimedVersionedIds, proposedComponentsByVersionedId); + } + } + + private List<ControllerServiceNode> getUnversionedMigrationCreatedServices(final Collection<ControllerServiceNode> groupServices) { + final List<ControllerServiceNode> migrationCreatedServices = new ArrayList<>(); + for (final ControllerServiceNode localService : groupServices) { + if (localService.getVersionedComponentId().isEmpty() && isMigrationCreated(localService)) { + migrationCreatedServices.add(localService); + } + } + return migrationCreatedServices; + } + + private Set<String> getClaimedVersionedIds(final Collection<ControllerServiceNode> groupServices) { + final Set<String> claimedVersionedIds = HashSet.newHashSet(groupServices.size()); + for (final ControllerServiceNode localService : groupServices) { + localService.getVersionedComponentId().ifPresent(claimedVersionedIds::add); + } + return claimedVersionedIds; + } + + private void assignMatchingVersionedId( + final ProcessGroup group, + final ControllerServiceNode localService, + final Set<String> claimedVersionedIds, + final Map<String, VersionedConfigurableExtension> proposedComponentsByVersionedId + ) { + final ComponentNode referencer = getSoleReferencer(localService); + if (referencer == null) { + LOG.debug("Leaving {} in {} unversioned because it is not referenced by exactly one component", localService, group); + return; + } + + final VersionedConfigurableExtension proposedReferencer = getProposedReferencer(referencer, proposedComponentsByVersionedId); + if (proposedReferencer == null) { + LOG.debug("Leaving {} in {} unversioned because its referencer {} has no counterpart in the proposed flow", localService, group, referencer); + return; + } + + final ProposedControllerServiceMatch match = findMatchingProposedControllerService(localService, referencer, proposedReferencer, proposedComponentsByVersionedId, group); + if (match == null) { + LOG.debug("Leaving {} in {} unversioned because no proposed Controller Service matches the referencing property of {}", localService, group, referencer); + return; + } + + if (!claimedVersionedIds.add(match.versionedId())) { + LOG.debug("Leaving {} in {} unversioned because versioned id {} is already used by another Controller Service", localService, group, match.versionedId()); + return; + } + + localService.setVersionedComponentId(match.versionedId()); + updatedVersionedComponentIds.add(match.versionedId()); + LOG.info("Matched {} in {} to the Controller Service with versioned id {} that the proposed flow declares, based on the {} property of {}", + localService, group, match.versionedId(), match.propertyName(), referencer); + } + + private List<ControllerServiceNode> orderByReferencerChain(final List<ControllerServiceNode> migrationCreatedServices) { + final BitSet visited = new BitSet(migrationCreatedServices.size()); + final List<ControllerServiceNode> ordered = new ArrayList<>(migrationCreatedServices.size()); + + for (int i = 0; i < migrationCreatedServices.size(); i++) { + appendReferencerChain(i, migrationCreatedServices, visited, ordered); + } + + return ordered; + } + + private void appendReferencerChain( + final int index, + final List<ControllerServiceNode> migrationCreatedServices, + final BitSet visited, + final List<ControllerServiceNode> ordered + ) { + if (visited.get(index)) { + return; + } + visited.set(index); + + final ControllerServiceNode localService = migrationCreatedServices.get(index); + final ComponentNode referencer = getSoleReferencer(localService); + if (referencer instanceof ControllerServiceNode referencingService && isMigrationCreated(referencingService)) { + final int referencerIndex = migrationCreatedServices.indexOf(referencingService); + if (referencerIndex >= 0) { + // Visit the creator first so we can assign the versioned id to the creator first. + appendReferencerChain(referencerIndex, migrationCreatedServices, visited, ordered); + } + } + + ordered.add(localService); + } + + private boolean isMigrationCreated(final ControllerServiceNode service) { + return StandardControllerServiceFactory.MIGRATION_CREATED_COMMENT.equals(service.getComments()); + } + + private Map<String, VersionedConfigurableExtension> indexByVersionedId( + final Collection<? extends VersionedConfigurableExtension> controllerServices, + final Collection<? extends VersionedConfigurableExtension> processors + ) { + final Map<String, VersionedConfigurableExtension> byVersionedId = HashMap.newHashMap(controllerServices.size() + processors.size()); + addByVersionedId(byVersionedId, controllerServices); + addByVersionedId(byVersionedId, processors); + return byVersionedId; + } + + private void addByVersionedId( + final Map<String, VersionedConfigurableExtension> byVersionedId, + final Collection<? extends VersionedConfigurableExtension> components + ) { + for (final VersionedConfigurableExtension component : components) { + byVersionedId.put(component.getIdentifier(), component); + } + } + + private ComponentNode getSoleReferencer(final ControllerServiceNode localService) { + final Set<ComponentNode> referencers = localService.getReferences().getReferencingComponents(); + if (referencers.size() != 1) { + return null; + } + + return referencers.iterator().next(); + } + + private VersionedConfigurableExtension getProposedReferencer( + final ComponentNode referencer, + final Map<String, VersionedConfigurableExtension> proposedComponentsByVersionedId + ) { + if (!(referencer instanceof org.apache.nifi.components.VersionedComponent versionedReferencer)) { + return null; + } + + return versionedReferencer.getVersionedComponentId() + .map(proposedComponentsByVersionedId::get) + .orElse(null); + } + + private ProposedControllerServiceMatch findMatchingProposedControllerService( + final ControllerServiceNode localService, + final ComponentNode referencer, + final VersionedConfigurableExtension proposedReferencer, + final Map<String, VersionedConfigurableExtension> proposedComponentsByVersionedId, + final ProcessGroup group + ) { + for (final Map.Entry<PropertyDescriptor, String> propertyEntry : referencer.getRawPropertyValues().entrySet()) { + final PropertyDescriptor descriptor = propertyEntry.getKey(); + final String propertyName = descriptor.getName(); + if (descriptor.getControllerServiceDefinition() == null) { + continue; + } + + if (!localService.getIdentifier().equals(propertyEntry.getValue())) { + continue; + } + + final Map<String, String> proposedProperties = proposedReferencer.getProperties(); + final String proposedServiceId = proposedProperties == null ? null : proposedProperties.get(propertyName); + if (proposedServiceId == null) { + LOG.debug("Leaving {} in {} unversioned because the proposed {} does not set the {} property", + localService, group, proposedReferencer, propertyName); + continue; + } + + // In versioned flow, the service identifier is the versioned component id of the service. + final VersionedConfigurableExtension proposedService = proposedComponentsByVersionedId.get(proposedServiceId); + if (proposedService == null || !proposedService.getType().equals(localService.getCanonicalClassName())) { + LOG.debug("Leaving {} in {} unversioned because proposed Controller Service {} is missing or has a different type than {}", + localService, group, proposedServiceId, localService); + continue; + } + + return new ProposedControllerServiceMatch(proposedServiceId, propertyName); + } + + return null; + } + + private record ProposedControllerServiceMatch(String versionedId, String propertyName) { + } Review Comment: I recommend declaring this at the end of the class instead of in between methods ########## nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizer.java: ########## @@ -1169,9 +1172,228 @@ private void removeMissingRpg(final ProcessGroup group, final VersionedProcessGr removeMissingComponents(group, proposed, rpgsByVersionedId, VersionedProcessGroup::getRemoteProcessGroups, ProcessGroup::removeRemoteProcessGroup); } + /** + * Assigns a proposed versioned id to a Controller Service created by property migration. + * The service must have exactly one referencer. + * The proposed counterpart of that referencer must point at a Controller Service of the same type. + * If those do not hold, the service stays unversioned. + * A proposed id is assigned to at most one local service. + */ + private void assignVersionedIdsToMigrationCreatedControllerServices(final ProcessGroup group, final VersionedProcessGroup proposed) { + final Collection<ControllerServiceNode> groupServices = group.getControllerServices(false); + if (groupServices.isEmpty()) { + return; + } + + final List<ControllerServiceNode> migrationCreatedServices = getUnversionedMigrationCreatedServices(groupServices); + if (migrationCreatedServices.isEmpty()) { + return; + } + + final Set<String> claimedVersionedIds = getClaimedVersionedIds(groupServices); + + final Set<VersionedControllerService> proposedControllerServices = Objects.requireNonNullElse(proposed.getControllerServices(), Set.of()); + final Set<VersionedProcessor> proposedProcessors = Objects.requireNonNullElse(proposed.getProcessors(), Set.of()); + final Map<String, VersionedConfigurableExtension> proposedComponentsByVersionedId = indexByVersionedId(proposedControllerServices, proposedProcessors); + + for (final ControllerServiceNode localService : orderByReferencerChain(migrationCreatedServices)) { + assignMatchingVersionedId(group, localService, claimedVersionedIds, proposedComponentsByVersionedId); + } + } + + private List<ControllerServiceNode> getUnversionedMigrationCreatedServices(final Collection<ControllerServiceNode> groupServices) { + final List<ControllerServiceNode> migrationCreatedServices = new ArrayList<>(); + for (final ControllerServiceNode localService : groupServices) { + if (localService.getVersionedComponentId().isEmpty() && isMigrationCreated(localService)) { + migrationCreatedServices.add(localService); + } + } + return migrationCreatedServices; + } + + private Set<String> getClaimedVersionedIds(final Collection<ControllerServiceNode> groupServices) { + final Set<String> claimedVersionedIds = HashSet.newHashSet(groupServices.size()); + for (final ControllerServiceNode localService : groupServices) { + localService.getVersionedComponentId().ifPresent(claimedVersionedIds::add); + } + return claimedVersionedIds; + } + + private void assignMatchingVersionedId( + final ProcessGroup group, + final ControllerServiceNode localService, + final Set<String> claimedVersionedIds, + final Map<String, VersionedConfigurableExtension> proposedComponentsByVersionedId + ) { + final ComponentNode referencer = getSoleReferencer(localService); + if (referencer == null) { + LOG.debug("Leaving {} in {} unversioned because it is not referenced by exactly one component", localService, group); + return; + } + + final VersionedConfigurableExtension proposedReferencer = getProposedReferencer(referencer, proposedComponentsByVersionedId); + if (proposedReferencer == null) { + LOG.debug("Leaving {} in {} unversioned because its referencer {} has no counterpart in the proposed flow", localService, group, referencer); + return; + } + + final ProposedControllerServiceMatch match = findMatchingProposedControllerService(localService, referencer, proposedReferencer, proposedComponentsByVersionedId, group); + if (match == null) { + LOG.debug("Leaving {} in {} unversioned because no proposed Controller Service matches the referencing property of {}", localService, group, referencer); + return; + } + + if (!claimedVersionedIds.add(match.versionedId())) { + LOG.debug("Leaving {} in {} unversioned because versioned id {} is already used by another Controller Service", localService, group, match.versionedId()); + return; + } + + localService.setVersionedComponentId(match.versionedId()); + updatedVersionedComponentIds.add(match.versionedId()); + LOG.info("Matched {} in {} to the Controller Service with versioned id {} that the proposed flow declares, based on the {} property of {}", + localService, group, match.versionedId(), match.propertyName(), referencer); + } + + private List<ControllerServiceNode> orderByReferencerChain(final List<ControllerServiceNode> migrationCreatedServices) { + final BitSet visited = new BitSet(migrationCreatedServices.size()); + final List<ControllerServiceNode> ordered = new ArrayList<>(migrationCreatedServices.size()); + + for (int i = 0; i < migrationCreatedServices.size(); i++) { + appendReferencerChain(i, migrationCreatedServices, visited, ordered); + } + + return ordered; + } + + private void appendReferencerChain( + final int index, + final List<ControllerServiceNode> migrationCreatedServices, + final BitSet visited, + final List<ControllerServiceNode> ordered + ) { + if (visited.get(index)) { + return; + } + visited.set(index); + + final ControllerServiceNode localService = migrationCreatedServices.get(index); + final ComponentNode referencer = getSoleReferencer(localService); + if (referencer instanceof ControllerServiceNode referencingService && isMigrationCreated(referencingService)) { + final int referencerIndex = migrationCreatedServices.indexOf(referencingService); + if (referencerIndex >= 0) { + // Visit the creator first so we can assign the versioned id to the creator first. + appendReferencerChain(referencerIndex, migrationCreatedServices, visited, ordered); + } + } + + ordered.add(localService); + } + + private boolean isMigrationCreated(final ControllerServiceNode service) { + return StandardControllerServiceFactory.MIGRATION_CREATED_COMMENT.equals(service.getComments()); + } + + private Map<String, VersionedConfigurableExtension> indexByVersionedId( + final Collection<? extends VersionedConfigurableExtension> controllerServices, + final Collection<? extends VersionedConfigurableExtension> processors + ) { + final Map<String, VersionedConfigurableExtension> byVersionedId = HashMap.newHashMap(controllerServices.size() + processors.size()); + addByVersionedId(byVersionedId, controllerServices); + addByVersionedId(byVersionedId, processors); + return byVersionedId; + } + + private void addByVersionedId( + final Map<String, VersionedConfigurableExtension> byVersionedId, + final Collection<? extends VersionedConfigurableExtension> components + ) { + for (final VersionedConfigurableExtension component : components) { + byVersionedId.put(component.getIdentifier(), component); + } + } + + private ComponentNode getSoleReferencer(final ControllerServiceNode localService) { + final Set<ComponentNode> referencers = localService.getReferences().getReferencingComponents(); + if (referencers.size() != 1) { + return null; + } + + return referencers.iterator().next(); Review Comment: This assumes there will never be `0` referencing components, which seems unsafe -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
