Add workflow for ActionController ActionController's workflow gets split into three components: * PropertyExtractor (extracts properties from an event), * RuleBase (assigns actions to properties), and * ActionExecutor (executes matched actions). Change-Id: Ideb81b242a0f097c94c76bd90afff4bdb438609a
diff --git a/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/ItsHookModule.java b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/ItsHookModule.java index 86097ef..de7709d 100644 --- a/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/ItsHookModule.java +++ b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/ItsHookModule.java
@@ -16,17 +16,19 @@ import com.google.gerrit.common.ChangeListener; import com.google.gerrit.extensions.registration.DynamicSet; +import com.google.gerrit.server.config.FactoryModule; import com.google.gerrit.server.git.validators.CommitValidationListener; -import com.google.inject.AbstractModule; import com.googlesource.gerrit.plugins.hooks.its.ItsName; import com.googlesource.gerrit.plugins.hooks.validation.ItsValidateComment; +import com.googlesource.gerrit.plugins.hooks.workflow.ActionRequest; import com.googlesource.gerrit.plugins.hooks.workflow.GerritHookFilterAddComment; import com.googlesource.gerrit.plugins.hooks.workflow.GerritHookFilterAddRelatedLinkToChangeId; import com.googlesource.gerrit.plugins.hooks.workflow.GerritHookFilterAddRelatedLinkToGitWeb; import com.googlesource.gerrit.plugins.hooks.workflow.GerritHookFilterChangeState; import com.googlesource.gerrit.plugins.hooks.workflow.ActionController; +import com.googlesource.gerrit.plugins.hooks.workflow.Property; -public class ItsHookModule extends AbstractModule { +public class ItsHookModule extends FactoryModule { private String itsName; @@ -49,5 +51,7 @@ ItsValidateComment.class); DynamicSet.bind(binder(), ChangeListener.class).to( ActionController.class); + factory(ActionRequest.Factory.class); + factory(Property.Factory.class); } }
diff --git a/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/util/PropertyExtractor.java b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/util/PropertyExtractor.java new file mode 100644 index 0000000..9cf514e --- /dev/null +++ b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/util/PropertyExtractor.java
@@ -0,0 +1,73 @@ +//Copyright (C) 2013 The Android Open Source Project +// +//Licensed 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 com.googlesource.gerrit.plugins.hooks.util; + +import java.util.Set; + +import com.google.gerrit.server.events.ChangeEvent; + +import com.googlesource.gerrit.plugins.hooks.workflow.Property; + +/** + * Extractor to translate an {@link ChangeEvent} to + * {@link Property Properties}. + */ +public class PropertyExtractor { + /** + * A set of property sets extracted from an event. + * + * As events may relate to more that a single issue, and properties sets are + * should be tied to a single issue, returning {@code Collection<Property>} + * is not sufficient, and we need to return + * {@code Collection<Collection<Property>>}. Using this approach, a + * PatchSetCreatedEvent for a patch set with commit message: + * + * <pre> + * (bug 4711) Fix treatment of special characters in title + * + * This commit mitigates the effects of bug 42, but does not fix them. + * + * Change-Id: I1234567891123456789212345678931234567894 + * </pre> + * + * may return both + * + * <pre> + * issue: 4711 + * association: subject + * event: PatchSetCreatedEvent + * </pre> + * + * and + * + * <pre> + * issue: 42 + * association: body + * event: PatchSetCreatedEvent + * </pre> + * + * Thereby, sites can choose to to cause different actions for different + * issues associated to the same event. So in the above example, a comment + * "mentioned in change 123" may be added for issue 42, and a comment + * "fixed by change 123” may be added for issue 4711. + * + * @param event The event to extract property sets from. + * @return sets of property sets extracted from the event. + */ + public Set<Set<Property>> extractFrom(ChangeEvent event) { + // TODO implement + throw new RuntimeException("unimplemented"); + } +}
diff --git a/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionController.java b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionController.java index 9d6ad99..e5c4728 100644 --- a/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionController.java +++ b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionController.java
@@ -14,19 +14,48 @@ package com.googlesource.gerrit.plugins.hooks.workflow; +import java.util.Collection; +import java.util.Set; + import com.google.gerrit.common.ChangeListener; import com.google.gerrit.server.events.ChangeEvent; +import com.google.inject.Inject; +import com.googlesource.gerrit.plugins.hooks.util.PropertyExtractor; +/** + * Controller that takes actions according to {@code ChangeEvents@}. + * + * The taken actions are typically Its related (e.g.: adding an Its comment, or + * changing an issue's status). + */ public class ActionController implements ChangeListener { - public ActionController() { - // TODO construct rule base + private final PropertyExtractor propertyExtractor; + private final RuleBase ruleBase; + private final ActionExecutor actionExecutor; + + @Inject + public ActionController(PropertyExtractor propertyExtractor, + RuleBase ruleBase, ActionExecutor actionExecutor) { + this.propertyExtractor = propertyExtractor; + this.ruleBase = ruleBase; + this.actionExecutor = actionExecutor; } @Override public void onChangeEvent(ChangeEvent event) { - // TODO extract conditions from event - // TODO find rules in rule base that match the extracted conditions - // TODO fire actions for matched rules + Set<Set<Property>> propertiesCollections = + propertyExtractor.extractFrom(event); + for (Set<Property> properties : propertiesCollections) { + Collection<ActionRequest> actions = + ruleBase.actionRequestsFor(properties); + if (!actions.isEmpty()) { + for (Property property : properties) { + if ("issue".equals(property.getKey())) { + String issue = property.getValue(); + actionExecutor.execute(issue, actions); + } + } + } + } } - }
diff --git a/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionExecutor.java b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionExecutor.java new file mode 100644 index 0000000..13ab6de --- /dev/null +++ b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionExecutor.java
@@ -0,0 +1,25 @@ +//Copyright (C) 2013 The Android Open Source Project +// +//Licensed 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 com.googlesource.gerrit.plugins.hooks.workflow; + +/** + * Executes an {@link ActionRequest} + */ +public class ActionExecutor { + public void execute(String issue, Iterable<ActionRequest> actions) { + // TODO implement + throw new RuntimeException("unimplemented"); + } +}
diff --git a/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionRequest.java b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionRequest.java new file mode 100644 index 0000000..bbf2690 --- /dev/null +++ b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionRequest.java
@@ -0,0 +1,37 @@ +// Copyright (C) 2013 The Android Open Source Project +// +// Licensed 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 com.googlesource.gerrit.plugins.hooks.workflow; + +import javax.annotation.Nullable; + +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; + +/** + * An action to take for an {@code ChangeEvent}. + * + * Actions are typically related to an Its (e.g.:adding an Its comment, or + * changing an issue's status). + */ +public class ActionRequest { + + public interface Factory { + ActionRequest create(String specification); + } + + @Inject + public ActionRequest(@Nullable @Assisted String specification) { + } +}
diff --git a/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/Property.java b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/Property.java new file mode 100644 index 0000000..e8cc634 --- /dev/null +++ b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/Property.java
@@ -0,0 +1,83 @@ +// Copyright (C) 2013 The Android Open Source Project +// +// Licensed 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 com.googlesource.gerrit.plugins.hooks.workflow; + +import javax.annotation.Nullable; + +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; + +/** + * A property to match against {@code Condition}s. + * + * A property is a simple key value pair. + */ +public class Property { + public interface Factory { + Property create(@Assisted("key") String key, + @Assisted("value") String value); + } + + private final String key; + private final String value; + + @Inject + public Property(@Assisted("key") String key, + @Nullable @Assisted("value") String value) { + this.key = key; + this.value = value; + } + + public String getKey() { + return key; + } + + public String getValue() { + return value; + } + + @Override + public boolean equals(Object other) { + boolean ret = false; + if (other != null && other instanceof Property) { + Property otherProperty = (Property) other; + ret = true; + + if (key == null) { + ret &= otherProperty.getKey() == null; + } else { + ret &= key.equals(otherProperty.getKey()); + } + + if (value == null) { + ret &= otherProperty.getValue() == null; + } else { + ret &= value.equals(otherProperty.getValue()); + } + } + return ret; + } + + @Override + public int hashCode() { + return (key == null ? 0 : key.hashCode()) * 31 + + (value == null ? 0 : value.hashCode()); + } + + @Override + public String toString() { + return "[" + key + " = " + value + "]"; + } +}
diff --git a/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/RuleBase.java b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/RuleBase.java new file mode 100644 index 0000000..e7ef399 --- /dev/null +++ b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/RuleBase.java
@@ -0,0 +1,38 @@ +// Copyright (C) 2013 The Android Open Source Project +// +// Licensed 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 com.googlesource.gerrit.plugins.hooks.workflow; + +import java.util.Collection; + +/** + * Collection and matcher agains {@link Rule}s. + */ +public class RuleBase { + public RuleBase() { + // TODO construct rule base + } + + /** + * Gets the action requests for a set of properties. + * + * @param properties The properties to search actions for. + * @return Requests for the actions that should be fired. + */ + public Collection<ActionRequest> actionRequestsFor( + Iterable<Property> properties) { + // TODO implement + throw new RuntimeException("unimplemented"); + } +}
diff --git a/hooks-its/src/test/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionControllerTest.java b/hooks-its/src/test/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionControllerTest.java new file mode 100644 index 0000000..926fe8b --- /dev/null +++ b/hooks-its/src/test/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionControllerTest.java
@@ -0,0 +1,200 @@ +// Copyright (C) 2013 The Android Open Source Project +// +// Licensed 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 com.googlesource.gerrit.plugins.hooks.workflow; + +import static org.easymock.EasyMock.expect; + +import java.util.Collection; +import java.util.Collections; +import java.util.Set; + +import com.google.common.collect.Lists; +import com.google.common.collect.Sets; +import com.google.gerrit.server.config.FactoryModule; +import com.google.gerrit.server.events.ChangeEvent; +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.googlesource.gerrit.plugins.hooks.testutil.LoggingMockingTestCase; +import com.googlesource.gerrit.plugins.hooks.util.PropertyExtractor; + +public class ActionControllerTest extends LoggingMockingTestCase { + private Injector injector; + + private PropertyExtractor propertyExtractor; + private RuleBase ruleBase; + private ActionExecutor actionExecutor; + + public void testNoPropertySets() { + ActionController actionController = createActionController(); + + ChangeEvent event = createMock(ChangeEvent.class); + + Set<Set<Property>> propertySets = Collections.emptySet(); + expect(propertyExtractor.extractFrom(event)).andReturn(propertySets) + .anyTimes(); + + replayMocks(); + + actionController.onChangeEvent(event); + } + + public void testNoActions() { + ActionController actionController = createActionController(); + + ChangeEvent event = createMock(ChangeEvent.class); + + Set<Set<Property>> propertySets = Sets.newHashSet(); + Set<Property> propertySet = Collections.emptySet(); + propertySets.add(propertySet); + + expect(propertyExtractor.extractFrom(event)).andReturn(propertySets) + .anyTimes(); + + Collection<ActionRequest> actions = Collections.emptySet(); + expect(ruleBase.actionRequestsFor(propertySet)).andReturn(actions).once(); + + replayMocks(); + + actionController.onChangeEvent(event); + } + + public void testNoIssues() { + ActionController actionController = createActionController(); + + ChangeEvent event = createMock(ChangeEvent.class); + + Set<Set<Property>> propertySets = Sets.newHashSet(); + Set<Property> propertySet = Collections.emptySet(); + propertySets.add(propertySet); + + expect(propertyExtractor.extractFrom(event)).andReturn(propertySets) + .anyTimes(); + + Collection<ActionRequest> actions = Lists.newArrayListWithCapacity(1); + ActionRequest action1 = createMock(ActionRequest.class); + actions.add(action1); + expect(ruleBase.actionRequestsFor(propertySet)).andReturn(actions).once(); + + replayMocks(); + + actionController.onChangeEvent(event); + } + + public void testSinglePropertySetSingleActionSingleIssue() { + ActionController actionController = createActionController(); + + ChangeEvent event = createMock(ChangeEvent.class); + + Property propertyIssue1 = createMock(Property.class); + expect(propertyIssue1.getKey()).andReturn("issue").anyTimes(); + expect(propertyIssue1.getValue()).andReturn("testIssue").anyTimes(); + + Set<Property> propertySet = Sets.newHashSet(); + propertySet.add(propertyIssue1); + + Set<Set<Property>> propertySets = Sets.newHashSet(); + propertySets.add(propertySet); + + expect(propertyExtractor.extractFrom(event)).andReturn(propertySets) + .anyTimes(); + + Collection<ActionRequest> actionRequests = + Lists.newArrayListWithCapacity(1); + ActionRequest actionRequest1 = createMock(ActionRequest.class); + actionRequests.add(actionRequest1); + expect(ruleBase.actionRequestsFor(propertySet)).andReturn(actionRequests) + .once(); + + actionExecutor.execute("testIssue", actionRequests); + + replayMocks(); + + actionController.onChangeEvent(event); + } + + public void testMultiplePropertySetsMultipleActionMultipleIssue() { + ActionController actionController = createActionController(); + + ChangeEvent event = createMock(ChangeEvent.class); + + Property propertyIssue1 = createMock(Property.class); + expect(propertyIssue1.getKey()).andReturn("issue").anyTimes(); + expect(propertyIssue1.getValue()).andReturn("testIssue").anyTimes(); + + Property propertyIssue2 = createMock(Property.class); + expect(propertyIssue2.getKey()).andReturn("issue").anyTimes(); + expect(propertyIssue2.getValue()).andReturn("testIssue2").anyTimes(); + + Set<Property> propertySet1 = Sets.newHashSet(); + propertySet1.add(propertyIssue1); + + Set<Property> propertySet2 = Sets.newHashSet(); + propertySet2.add(propertyIssue1); + propertySet2.add(propertyIssue2); + + Set<Set<Property>> propertySets = Sets.newHashSet(); + propertySets.add(propertySet1); + propertySets.add(propertySet2); + + expect(propertyExtractor.extractFrom(event)).andReturn(propertySets) + .anyTimes(); + + Collection<ActionRequest> actionRequests1 = + Lists.newArrayListWithCapacity(1); + ActionRequest actionRequest1 = createMock(ActionRequest.class); + actionRequests1.add(actionRequest1); + + Collection<ActionRequest> actionRequests2 = + Lists.newArrayListWithCapacity(2); + ActionRequest actionRequest2 = createMock(ActionRequest.class); + actionRequests2.add(actionRequest2); + ActionRequest actionRequest3 = createMock(ActionRequest.class); + actionRequests2.add(actionRequest3); + + expect(ruleBase.actionRequestsFor(propertySet1)).andReturn(actionRequests1) + .once(); + expect(ruleBase.actionRequestsFor(propertySet2)).andReturn(actionRequests2) + .once(); + + actionExecutor.execute("testIssue", actionRequests1); + actionExecutor.execute("testIssue", actionRequests2); + actionExecutor.execute("testIssue2", actionRequests2); + + replayMocks(); + + actionController.onChangeEvent(event); + } + private ActionController createActionController() { + return injector.getInstance(ActionController.class); + } + + public void setUp() throws Exception { + super.setUp(); + injector = Guice.createInjector(new TestModule()); + } + + private class TestModule extends FactoryModule { + @Override + protected void configure() { + propertyExtractor = createMock(PropertyExtractor.class); + bind(PropertyExtractor.class).toInstance(propertyExtractor); + + ruleBase = createMock(RuleBase.class); + bind(RuleBase.class).toInstance(ruleBase); + + actionExecutor = createMock(ActionExecutor.class); + bind(ActionExecutor.class).toInstance(actionExecutor); + } + } +} \ No newline at end of file
diff --git a/hooks-its/src/test/java/com/googlesource/gerrit/plugins/hooks/workflow/PropertyTest.java b/hooks-its/src/test/java/com/googlesource/gerrit/plugins/hooks/workflow/PropertyTest.java new file mode 100644 index 0000000..c977208 --- /dev/null +++ b/hooks-its/src/test/java/com/googlesource/gerrit/plugins/hooks/workflow/PropertyTest.java
@@ -0,0 +1,127 @@ +// Copyright (C) 2013 The Android Open Source Project +// +// Licensed 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 com.googlesource.gerrit.plugins.hooks.workflow; + +import com.google.gerrit.server.config.FactoryModule; +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.googlesource.gerrit.plugins.hooks.testutil.LoggingMockingTestCase; + +public class PropertyTest extends LoggingMockingTestCase { + private Injector injector; + + public void testGetKeyNull() { + Property property = new Property(null, "testValue"); + assertNull("Key is not null", property.getKey()); + } + + public void testGetKeyNonNull() { + Property property = createProperty("testKey", "testValue"); + assertEquals("Key does not match", "testKey", property.getKey()); + } + + public void testGetValueNull() { + Property property = createProperty("testKey", null); + assertNull("Value is not null", property.getValue()); + } + + public void testGetValueNonNull() { + Property property = createProperty("testKey", "testValue"); + assertEquals("Value does not match", "testValue", property.getValue()); + } + + public void testEqualsSelf() { + Property property = createProperty("testKey", "testValue"); + assertTrue("Property not equal to itself", property.equals(property)); + } + + public void testEqualsSimilar() { + Property propertyA = createProperty("testKey", "testValue"); + Property propertyB = createProperty("testKey", "testValue"); + assertTrue("Property is equal to similar", propertyA.equals(propertyB)); + } + + public void testEqualsNull() { + Property property = createProperty("testKey", "testValue"); + assertFalse("Property is equal to null", property.equals(null)); + } + + public void testEqualsNull2() { + Property property = new Property(null, null); + assertFalse("Property is equal to null", property.equals(null)); + } + + public void testEqualsNulledKey() { + Property propertyA = new Property(null, "testValue"); + Property propertyB = createProperty("testKey", "testValue"); + assertFalse("Single nulled key does match", + propertyA.equals(propertyB)); + } + + public void testEqualsNulledKey2() { + Property propertyA = createProperty("testKey", "testValue"); + Property propertyB = new Property(null, "testValue"); + assertFalse("Single nulled key does match", + propertyA.equals(propertyB)); + } + + public void testEqualsNulledValue() { + Property propertyA = createProperty("testKey", "testValue"); + Property propertyB = createProperty("testKey", null); + assertFalse("Single nulled value does match", + propertyA.equals(propertyB)); + } + + public void testEqualsNulledValue2() { + Property propertyA = createProperty("testKey", null); + Property propertyB = createProperty("testKey", "testValue"); + assertFalse("Single nulled value does match", + propertyA.equals(propertyB)); + } + + public void testHashCodeEquals() { + Property propertyA = createProperty("testKey", "testValue"); + Property propertyB = createProperty("testKey", "testValue"); + assertEquals("Hash codes do not match", propertyA.hashCode(), + propertyB.hashCode()); + } + + public void testHashCodeNullKey() { + Property property = new Property(null, "testValue"); + property.hashCode(); + } + + public void testHashCodeNullValue() { + Property property = createProperty("testKey", null); + property.hashCode(); + } + + private Property createProperty(String key, String value) { + Property.Factory factory = injector.getInstance(Property.Factory.class); + return factory.create(key, value); + } + + public void setUp() throws Exception { + super.setUp(); + + injector = Guice.createInjector(new TestModule()); + } + + private class TestModule extends FactoryModule { + @Override + protected void configure() { + factory(Property.Factory.class); + } + } +} \ No newline at end of file