summaryrefslogtreecommitdiffstats
path: root/controller-api/src
diff options
context:
space:
mode:
Diffstat (limited to 'controller-api/src')
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Contacts.java136
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Issues.java186
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Properties.java16
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/jira/JiraMock.java50
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/DeploymentIssues.java26
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Issue.java68
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/IssueId.java46
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/MockOrganization.java146
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Organization.java117
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/User.java43
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/ContactsMock.java31
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/LoggingDeploymentIssues.java96
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/LoggingIssues.java95
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/PropertiesMock.java26
14 files changed, 542 insertions, 540 deletions
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Contacts.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Contacts.java
deleted file mode 100644
index 329483a85c5..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Contacts.java
+++ /dev/null
@@ -1,136 +0,0 @@
-// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.controller.api.integration;
-
-import org.jetbrains.annotations.NotNull;
-
-import java.net.URI;
-import java.util.Collection;
-import java.util.Objects;
-
-import static com.yahoo.vespa.hosted.controller.api.integration.Contacts.Category.unknown;
-import static java.util.Comparator.reverseOrder;
-
-/**
- * @author jvenstad
- */
-public interface Contacts {
-
- /**
- * Returns the most relevant user of lowest non-empty level above that of @assignee, or, if no such user exists,
- * the @assignee with @Category information.
- */
- static UserContact escalationTargetFrom(Collection<UserContact> userContacts, String assignee) {
- return userContacts.stream()
- .filter(contact -> ! contact.username().isEmpty()) // don't assign to empty names
- .sorted(reverseOrder()).distinct() // Pick out the highest category per user.
- // Keep the assignee, or the last user on the first non-empty level above her.
- .sorted().reduce(new UserContact(assignee, assignee, unknown), (current, next) ->
- next.is(assignee) || (current.is(assignee) ^ current.level() == next.level()) ? next : current);
- }
-
- /**
- * Return a list of all contact entries for property with id @propertyId, where username is set.
- */
- Collection<UserContact> userContactsFor(long propertyId);
-
- /** Returns the URL listing contacts for the given property */
- URI contactsUri(long propertyId);
-
- /**
- * Return a target of escalation above @assignee, from the set of @UserContact entries found for @propertyId.
- */
- default UserContact escalationTargetFor(long propertyId, String assignee) {
- return escalationTargetFrom(userContactsFor(propertyId), assignee);
- }
-
- /**
- * A list of contact roles, in the order in which we look for escalation targets.
- * Categories must be listed in increasing order of relevancy per level, and by increasing level.
- */
- enum Category {
-
- unknown(-1, Level.none, "Unknown"),
- admin(54, Level.grunt, "Administrator"), // TODO: Find more grunts?
- businessOwner(567, Level.owner, "Business Owner"),
- serviceOwner(646, Level.owner, "Service Engineering Owner"),
- engineeringOwner(566, Level.owner, "Engineering Owner"),
- vpBusiness(11, Level.VP, "VP Business"),
- vpService(647, Level.VP, "VP Service Engineering"),
- vpEngineering(9, Level.VP, "VP Engineering");
-
- public final long id;
- public final Level level;
- public final String name;
-
- Category(long id, Level level, String name) {
- this.id = id;
- this.level = level;
- this.name = name;
- }
-
- /** Find the category for the given id, or unknown if the id is unknown. */
- public static Category of(Long id) {
- for (Category category : values())
- if (category.id == id)
- return category;
- return unknown;
- }
-
- public enum Level {
- none,
- grunt,
- owner,
- VP;
- }
-
- }
-
- /** Container class for user contact information; sorts by category and identifies by username. Immutable. */
- class UserContact implements Comparable<UserContact> {
-
- private final String username;
- private final String name;
- private final Category category;
-
- public UserContact(String username, String name, Category category) {
- Objects.requireNonNull(username, "username cannot be null");
- Objects.requireNonNull(name, "name cannot be null");
- Objects.requireNonNull(category, "category cannot be null");
- this.username = username;
- this.name = name;
- this.category = category;
- }
-
- public String username() { return username; }
- public String name() { return name; }
- public Category category() { return category; }
- public Category.Level level() { return category.level; }
-
- public boolean is(String username) { return this.username.equals(username); }
-
- @Override
- public boolean equals(Object o) {
- if (this == o) return true;
- if (o == null || getClass() != o.getClass()) return false;
- UserContact that = (UserContact) o;
- return Objects.equals(username, that.username);
- }
-
- @Override
- public int hashCode() {
- return Objects.hash(username);
- }
-
- @Override
- public int compareTo(@NotNull UserContact other) {
- return category().compareTo(other.category());
- }
-
- @Override
- public String toString() {
- return String.format("%s, %s, %s", username, name, category.name);
- }
-
- }
-
-}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Issues.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Issues.java
deleted file mode 100644
index 8f24b4e3ede..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Issues.java
+++ /dev/null
@@ -1,186 +0,0 @@
-// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.controller.api.integration;
-
-import java.time.Instant;
-import java.util.List;
-import java.util.Optional;
-
-/**
- * @author jvenstad
- */
-public interface Issues {
-
- /**
- * Returns information about an issue.
- * If this issue does not exist this returns an issue id containing the id and default values.
- */
- IssueInfo fetch(String issueId);
-
- /**
- * Returns the @Meta of all unresolved issues which have the same summary (and queue, if present) as @issue.
- */
- List<IssueInfo> fetchSimilarTo(Issue issue);
-
- /**
- * Files the given issue
- *
- * @return the id of the created issue
- */
- String file(Issue issue);
-
- /**
- * Update the description fields of the issue stored with id @issueId to be @description.
- */
- void update(String issueId, String description);
-
- /**
- * Set the assignee of the issue with id @issueId to the user with usename @assignee.
- */
- void reassign(String issueId, String assignee);
-
- /**
- * Add the user with username @watcher to the watcher list of the issue with id @issueId.
- */
- void addWatcher(String issueId, String watcher);
-
- /**
- * Post @comment as a comment to the issue with id @issueId.
- */
- void comment(String issueId, String comment);
-
-
- /** Contains information used to file an issue with the responsible party; only @queue is mandatory. */
- class Classification {
-
- private final String queue;
- private final String component;
- private final String label;
- private final String assignee;
-
- public Classification(String queue, String component, String label, String assignee) {
- if (queue.isEmpty()) throw new IllegalArgumentException("Queue can not be empty!");
-
- this.queue = queue;
- this.component = component;
- this.label = label;
- this.assignee = assignee;
- }
-
- public Classification(String queue) {
- this(queue, null, null, null);
- }
-
- public Classification withComponent(String component) { return new Classification(queue, component, label, assignee); }
- public Classification withLabel(String label) { return new Classification(queue, component, label, assignee); }
- public Classification withAssignee(String assignee) { return new Classification(queue, component, label, assignee); }
-
- public String queue() { return queue; }
- public Optional<String> component() { return Optional.ofNullable(component); }
- public Optional<String> label() { return Optional.ofNullable(label); }
- public Optional<String> assignee() { return Optional.ofNullable(assignee); }
-
- @Override
- public String toString() {
- return "Queue : " + queue() + "\n" +
- "Component : " + component() + "\n" +
- "Label : " + label() + "\n" +
- "Assignee : " + assignee() + "\n";
- }
-
- }
-
-
- /** Information about a stored issue */
- class IssueInfo {
-
- private final String id;
- private final String key;
- private final Instant updated;
- private final Optional<String> assignee;
- private final Status status;
-
- public IssueInfo(String id, String key, Instant updated, Optional<String> assignee, Status status) {
- if (assignee == null || assignee.isPresent() && assignee.get().isEmpty()) // TODO: Throw on these things
- assignee = Optional.empty();
- this.id = id;
- this.key = key;
- this.updated = updated;
- this.assignee = assignee;
- this.status = status;
- }
-
- public IssueInfo withAssignee(Optional<String> assignee) {
- return new IssueInfo(id, key, updated, assignee, status);
- }
-
- public String id() { return id; }
- public String key() { return key; }
- public Instant updated() { return updated; }
- public Optional<String> assignee() { return assignee; }
- public Status status() { return status; }
-
- public enum Status {
-
- toDo("To Do"),
- inProgress("In Progress"),
- done("Done"),
- noCategory("No Category");
-
- private final String value;
-
- Status(String value) { this.value = value; }
-
- public static Status fromValue(String value) {
- for (Status status : Status.values())
- if (status.value.equals(value))
- return status;
- throw new IllegalArgumentException(value + " is not a valid status.");
- }
-
- }
-
- }
-
-
- /**
- * A representation of an issue with a Vespa application which can be reported and escalated through an external issue service.
- * This class is immutable.
- *
- * @author jvenstad
- */
- class Issue {
-
- private final String summary;
- private final String description;
- private final Classification classification;
-
- public Issue(String summary, String description, Classification classification) {
- if (summary.isEmpty()) throw new IllegalArgumentException("Summary can not be empty.");
- if (description.isEmpty()) throw new IllegalArgumentException("Description can not be empty.");
-
- this.summary = summary;
- this.description = description;
- this.classification = classification;
- }
-
- public Issue(String summary, String description) {
- this(summary, description, null);
- }
-
- public Issue with(Classification classification) {
- return new Issue(summary, description, classification);
- }
- public Issue withDescription(String description) { return new Issue(summary, description, classification); }
-
- /** Return a new @Issue with the description of @this, but with @appendage appended. */
- public Issue append(String appendage) {
- return new Issue(summary, description + "\n\n" + appendage, classification);
- }
-
- public String summary() { return summary; }
- public String description() { return description; }
- public Optional<Classification> classification() { return Optional.ofNullable(classification); }
-
- }
-
-}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Properties.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Properties.java
deleted file mode 100644
index 652b5495bc5..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/Properties.java
+++ /dev/null
@@ -1,16 +0,0 @@
-// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.controller.api.integration;
-
-import java.util.Optional;
-
-/**
- * @author jvenstad
- */
-public interface Properties {
-
- /**
- * Return the @Issues.Classification listed for the property with id @propertyId.
- */
- Optional<Issues.Classification> classificationFor(long propertyId);
-
-}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/jira/JiraMock.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/jira/JiraMock.java
deleted file mode 100644
index da653ddd8a8..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/jira/JiraMock.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.controller.api.integration.jira;
-
-import java.time.Instant;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.stream.Collectors;
-
-/**
- * @author jvenstad
- */
-// TODO: Make mock.
-public class JiraMock implements Jira {
-
- public final Map<String, JiraCreateIssue.JiraFields> issues = new HashMap<>();
-
- private Long counter = 0L;
-
- @Override
- public List<JiraIssue> searchByProjectAndSummary(String project, String summary) {
- return issues.entrySet().stream()
- .filter(entry -> entry.getValue().project.key.equals(project))
- .filter(entry -> entry.getValue().summary.contains(summary))
- .map(entry -> new JiraIssue(entry.getKey(), new JiraIssue.Fields(Instant.now())))
- .collect(Collectors.toList());
- }
-
- @Override
- public JiraIssue createIssue(JiraCreateIssue issueData) {
- JiraIssue issue = uniqueKey();
- issues.put(issue.key, issueData.fields);
- return issue;
- }
-
- @Override
- public void commentIssue(JiraIssue issue, JiraComment comment) {
- // Add mock when relevant.
- }
-
- @Override
- public void addAttachment(JiraIssue issue, String filename, String fileContent) {
- // Add mock when relevant.
- }
-
- private JiraIssue uniqueKey() {
- return new JiraIssue((++counter).toString(), new JiraIssue.Fields(Instant.now()));
- }
-
-}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/DeploymentIssues.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/DeploymentIssues.java
new file mode 100644
index 00000000000..7874fcd8c45
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/DeploymentIssues.java
@@ -0,0 +1,26 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.api.integration.organization;
+
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.PropertyId;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Optional;
+
+/**
+ * Represents the people responsible for keeping Vespa up and running in a given organization, etc..
+ *
+ * @author jvenstad
+ */
+public interface DeploymentIssues {
+
+ IssueId fileUnlessOpen(Optional<IssueId> issueId, ApplicationId applicationId, PropertyId propertyId);
+
+ IssueId fileUnlessOpen(Optional<IssueId> issueId, ApplicationId applicationId, User assignee);
+
+ IssueId fileUnlessOpen(Collection<ApplicationId> applicationIds);
+
+ void escalateIfInactive(IssueId issueId, Optional<PropertyId> propertyId, Duration maxInactivity);
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Issue.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Issue.java
new file mode 100644
index 00000000000..74f02172af0
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Issue.java
@@ -0,0 +1,68 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.api.integration.organization;
+
+import com.yahoo.vespa.hosted.controller.api.identifiers.PropertyId;
+
+import java.util.Objects;
+import java.util.Optional;
+
+public class Issue {
+
+ private final String summary;
+ private final String description;
+ private final String label;
+ private final User assignee;
+ private final PropertyId propertyId;
+
+ private Issue(String summary, String description, String label, User assignee, PropertyId propertyId) {
+ if (summary.isEmpty()) throw new IllegalArgumentException("Issue summary can not be empty!");
+ if (description.isEmpty()) throw new IllegalArgumentException("Issue description can not be empty!");
+ Objects.requireNonNull(propertyId, "An issue must belong to a property!");
+
+ this.summary = summary;
+ this.description = description;
+ this.label = label;
+ this.assignee = assignee;
+ this.propertyId = propertyId;
+ }
+
+ public Issue(String summary, String description, PropertyId propertyId) {
+ this(summary, description, null, null, propertyId);
+ }
+
+ public Issue append(String appendage) {
+ return new Issue(summary, description + appendage, label, assignee, propertyId);
+ }
+
+ public Issue withLabel(String label) {
+ return new Issue(summary, description, label, assignee, propertyId);
+ }
+ public Issue withAssignee(User assignee) {
+ return new Issue(summary, description, label, assignee, propertyId);
+ }
+
+ public Issue withPropertyId(PropertyId propertyId) {
+ return new Issue(summary, description, label, assignee, propertyId);
+ }
+
+ public String summary() {
+ return summary;
+ }
+
+ public String description() {
+ return description;
+ }
+
+ public Optional<String> label() {
+ return Optional.ofNullable(label);
+ }
+
+ public Optional<User> assignee() {
+ return Optional.ofNullable(assignee);
+ }
+
+ public PropertyId propertyId() {
+ return propertyId;
+ }
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/IssueId.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/IssueId.java
new file mode 100644
index 00000000000..42524c865c9
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/IssueId.java
@@ -0,0 +1,46 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.api.integration.organization;
+
+import java.util.Objects;
+
+/**
+ * @author jvenstad
+ */
+public class IssueId {
+
+ protected final String id;
+
+ protected IssueId(String id) {
+ this.id = id;
+ }
+
+ public static IssueId from(String value) {
+ if (value.isEmpty())
+ throw new IllegalArgumentException("Can not make an IssueId from an empty value.");
+
+ return new IssueId(value);
+ }
+
+ public String value() {
+ return id;
+ }
+
+ @Override
+ public String toString() {
+ return value();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ IssueId issueId = (IssueId) o;
+ return Objects.equals(id, issueId.id);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id);
+ }
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/MockOrganization.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/MockOrganization.java
new file mode 100644
index 00000000000..51bba7bb52c
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/MockOrganization.java
@@ -0,0 +1,146 @@
+package com.yahoo.vespa.hosted.controller.api.integration.organization;
+
+import com.yahoo.vespa.hosted.controller.api.identifiers.PropertyId;
+
+import java.net.URI;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicLong;
+
+public class MockOrganization implements Organization {
+
+ private final Clock clock;
+ private final AtomicLong counter;
+ private final HashMap<IssueId, WrappedIssue> issues;
+ private final HashMap<PropertyId, PropertyInfo> properties;
+
+ public MockOrganization(Clock clock) {
+ this.clock = clock;
+
+ counter = new AtomicLong();
+ issues = new HashMap<>();
+ properties = new HashMap<>();
+ }
+
+ @Override
+ public IssueId file(Issue issue) {
+ IssueId issueId = IssueId.from("" + counter.incrementAndGet());
+ issues.put(issueId, new WrappedIssue(issue));
+ return issueId;
+ }
+
+ @Override
+ public Optional<IssueId> findBySimilarity(Issue issue) {
+ return issues.entrySet().stream()
+ .filter(entry -> entry.getValue().issue.summary().equals(issue.summary()))
+ .findFirst()
+ .map(Map.Entry::getKey);
+ }
+
+ @Override
+ public void update(IssueId issueId, String description) {
+ touch(issueId);
+ }
+
+ @Override
+ public void commentOn(IssueId issueId, String comment) {
+ touch(issueId);
+ }
+
+ @Override
+ public boolean isOpen(IssueId issueId) {
+ return issues.get(issueId).open;
+ }
+
+ @Override
+ public boolean isActive(IssueId issueId, Duration maxInactivity) {
+ return issues.get(issueId).updated.isAfter(clock.instant().minus(maxInactivity));
+ }
+
+ @Override
+ public Optional<User> assigneeOf(IssueId issueId) {
+ return Optional.ofNullable(issues.get(issueId).assignee);
+ }
+
+ @Override
+ public boolean reassign(IssueId issueId, User assignee) {
+ issues.get(issueId).assignee = assignee;
+ touch(issueId);
+ return true;
+ }
+
+ @Override
+ public List<? extends List<? extends User>> contactsFor(PropertyId propertyId) {
+ return properties.get(propertyId).contacts;
+ }
+
+ @Override
+ public URI issueCreationUri(PropertyId propertyId) {
+ return null;
+ }
+
+ @Override
+ public URI contactsUri(PropertyId propertyId) {
+ return null;
+ }
+
+ @Override
+ public URI propertyUri(PropertyId propertyId) {
+ return null;
+ }
+
+ public void close(IssueId issueId) {
+ issues.get(issueId).open = false;
+ touch(issueId);
+ }
+
+ public void setDefaultAssigneeFor(PropertyId propertyId, User defaultAssignee) {
+ properties.get(propertyId).defaultAssignee = defaultAssignee;
+ }
+
+ public void setContactsFor(PropertyId propertyId, List<List<User>> contacts) {
+ properties.get(propertyId).contacts = contacts;
+ }
+
+ public void addProperty(PropertyId propertyId) {
+ properties.put(propertyId, new PropertyInfo());
+ }
+
+ private void touch(IssueId issueId) {
+ issues.get(issueId).updated = clock.instant();
+ }
+
+
+ private class WrappedIssue {
+
+ private Issue issue;
+ private Instant updated;
+ private boolean open;
+ private User assignee;
+
+ private WrappedIssue(Issue issue) {
+ this.issue = issue;
+
+ updated = clock.instant();
+ open = true;
+ assignee = issue.assignee().orElse(properties.get(issue.propertyId()).defaultAssignee);
+ }
+
+ }
+
+
+ private class PropertyInfo {
+
+ private User defaultAssignee;
+ private List<List<User>> contacts = Collections.emptyList();
+
+ }
+
+}
+
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Organization.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Organization.java
new file mode 100644
index 00000000000..913c2d96a14
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Organization.java
@@ -0,0 +1,117 @@
+package com.yahoo.vespa.hosted.controller.api.integration.organization;
+
+import com.yahoo.vespa.hosted.controller.api.identifiers.PropertyId;
+
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.time.Duration;
+import java.util.List;
+import java.util.Optional;
+
+public interface Organization {
+
+ /**
+ * File an issue with its given property or the default, and with the specific assignee, if present.
+ *
+ * @param issue The issue to file.
+ * @return ID of the created issue.
+ */
+ IssueId file(Issue issue);
+
+ /**
+ * Returns the ID of this issue, if it exists and is open, based on a similarity search.
+ *
+ * @param issue The issue to search for; relevant fields are the summary and the owner (propertyId).
+ * @return ID of the issue, if it is found.
+ */
+ Optional<IssueId> findBySimilarity(Issue issue);
+
+ /**
+ * Update the description of the issue with the given ID.
+ *
+ * @param issueId ID of the issue to comment on.
+ * @param description The updated description.
+ */
+ void update(IssueId issueId, String description);
+
+ /**
+ * Add a comment to the issue with the given ID.
+ *
+ * @param issueId ID of the issue to comment on.
+ * @param comment The comment to add.
+ */
+ void commentOn(IssueId issueId, String comment);
+
+ /**
+ * Returns whether the issue is still under investigation.
+ *
+ * @param issueId ID of the issue to examine.
+ * @return Whether the given issue is under investigation.
+ */
+ boolean isOpen(IssueId issueId);
+
+ /**
+ * Returns whether there has been significant activity on the issue within the given duration.
+ *
+ * @param issueId ID of the issue to examine.
+ * @return Whether the given issue is actively worked on.
+ */
+ boolean isActive(IssueId issueId, Duration maxInactivity);
+
+ /**
+ * Returns the user assigned to the given issue, if any.
+ *
+ * @param issueId ID of the issue for which to find the assignee.
+ * @return The user responsible for fixing the given issue, if found.
+ */
+ Optional<User> assigneeOf(IssueId issueId);
+
+ /**
+ * Reassign the issue with the given ID to the given user, and returns the outcome of this.
+ *
+ * @param issueId ID of the issue to be reassigned.
+ * @param assignee User to which the issue shall be assigned.
+ * @return Whether the reassignment was successful.
+ */
+ boolean reassign(IssueId issueId, User assignee);
+
+ /**
+ * Escalate an issue filed with the given property.
+ *
+ * @param issueId ID of the issue to escalate.
+ * @param propertyId PropertyId of the tenant owning the application for which the issue was filed.
+ */
+ default boolean escalate(IssueId issueId, PropertyId propertyId) {
+ List<? extends List<? extends User>> contacts = contactsFor(propertyId);
+
+ Optional<User> assignee = assigneeOf(issueId);
+ int assigneeLevel = -1;
+ if (assignee.isPresent())
+ for (int level = contacts.size(); --level > assigneeLevel; )
+ if (contacts.get(level).contains(assignee.get()))
+ assigneeLevel = level;
+
+ for (int level = assigneeLevel + 1; level < contacts.size(); level++)
+ for (User target : contacts.get(level))
+ if (reassign(issueId, target))
+ return true;
+
+ return false;
+ }
+
+ /**
+ * Returns a nested list where the entries have increasing rank, and where each entry is
+ * a list of the users of that rank, by decreasing relevance.
+ *
+ * @param propertyId ID of the property for which to list contacts.
+ * @return A sorted, nested, reverse sorted list of contacts.
+ */
+ List<? extends List<? extends User>> contactsFor(PropertyId propertyId);
+
+ URI issueCreationUri(PropertyId propertyId);
+
+ URI contactsUri(PropertyId propertyId);
+
+ URI propertyUri(PropertyId propertyId);
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/User.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/User.java
new file mode 100644
index 00000000000..13141ee2adb
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/User.java
@@ -0,0 +1,43 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.api.integration.organization;
+
+import java.util.Objects;
+
+public class User {
+
+ private final String username;
+
+ protected User(String username) {
+ this.username = username;
+ }
+
+ public String username() {
+ return username;
+ }
+
+ public static User from(String username) {
+ if (username.isEmpty())
+ throw new IllegalArgumentException("Username may not be empty!");
+
+ return new User(username);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if ( ! (o instanceof User)) return false;
+ User that = (User) o;
+ return Objects.equals(username, that.username);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(username);
+ }
+
+ @Override
+ public String toString() {
+ return username();
+ }
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/ContactsMock.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/ContactsMock.java
deleted file mode 100644
index 9114cf20ccc..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/ContactsMock.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.controller.api.integration.stubs;
-
-import com.yahoo.vespa.hosted.controller.api.integration.Contacts;
-
-import java.net.URI;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-/**
- * @author mpolden
- */
-public class ContactsMock implements Contacts {
-
- private final Map<Long, List<UserContact>> userContacts = new HashMap<>();
-
- public void addContact(long propertyId, List<UserContact> contacts) {
- userContacts.put(propertyId, contacts);
- }
-
- public List<UserContact> userContactsFor(long propertyId) {
- return userContacts.get(propertyId);
- }
-
- @Override
- public URI contactsUri(long propertyId) {
- return URI.create("http://contacts.test?propertyId=" + propertyId);
- }
-
-}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/LoggingDeploymentIssues.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/LoggingDeploymentIssues.java
new file mode 100644
index 00000000000..62dde3efe55
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/LoggingDeploymentIssues.java
@@ -0,0 +1,96 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+
+package com.yahoo.vespa.hosted.controller.api.integration.stubs;
+
+import com.google.inject.Inject;
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.PropertyId;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.DeploymentIssues;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.IssueId;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.User;
+import org.jetbrains.annotations.TestOnly;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.logging.Logger;
+
+/**
+ * A memory backed implementation of the Issues API which logs changes and does nothing else.
+ *
+ * @author bratseth, jvenstad
+ */
+public class LoggingDeploymentIssues implements DeploymentIssues {
+
+ private static final Logger log = Logger.getLogger(LoggingDeploymentIssues.class.getName());
+
+ /** Whether the platform is currently broken. */
+ protected final AtomicBoolean platformIssue = new AtomicBoolean(false);
+ /** Last updates for each issue -- used to determine if issues are already logged and when to escalate. */
+ protected final Map<IssueId, Instant> issueUpdates = new HashMap<>();
+
+ /** Used to fabricate unique issue ids. */
+ private final AtomicLong issueIdSequence = new AtomicLong(0);
+
+ private final Clock clock;
+
+ @SuppressWarnings("unused") // Created by dependency injection.
+ @Inject
+ public LoggingDeploymentIssues() {
+ this(Clock.systemUTC());
+ }
+
+ @TestOnly
+ protected LoggingDeploymentIssues(Clock clock) {
+ this.clock = clock;
+ }
+
+ @Override
+ public IssueId fileUnlessOpen(Optional<IssueId> issueId, ApplicationId applicationId, PropertyId propertyId) {
+ return fileUnlessPresent(issueId, applicationId);
+ }
+
+ @Override
+ public IssueId fileUnlessOpen(Optional<IssueId> issueId, ApplicationId applicationId, User assignee) {
+ return fileUnlessPresent(issueId, applicationId);
+ }
+
+ @Override
+ public IssueId fileUnlessOpen(Collection<ApplicationId> applicationIds) {
+ if ( ! platformIssue.get())
+ log.info("These applications are all failing deployment:\n" + applicationIds);
+
+ platformIssue.set(true);
+ return null;
+ }
+
+ @Override
+ public void escalateIfInactive(IssueId issueId, Optional<PropertyId> propertyId, Duration maxInactivity) {
+ if (issueUpdates.containsKey(issueId) && issueUpdates.get(issueId).isBefore(clock.instant().minus(maxInactivity)))
+ escalateIssue(issueId);
+ }
+
+ protected void escalateIssue(IssueId issueId) {
+ issueUpdates.put(issueId, clock.instant());
+ log.info("Deployment issue " + issueId + " should be escalated.");
+ }
+
+ protected IssueId fileIssue(ApplicationId applicationId) {
+ IssueId issueId = IssueId.from("" + issueIdSequence.incrementAndGet());
+ issueUpdates.put(issueId, clock.instant());
+ log.info("Deployment issue " + issueId +": " + applicationId + " has failing deployments.");
+ return issueId;
+ }
+
+ private IssueId fileUnlessPresent(Optional<IssueId> issueId, ApplicationId applicationId) {
+ platformIssue.set(false);
+ return issueId.filter(issueUpdates::containsKey).orElseGet(() -> fileIssue(applicationId));
+ }
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/LoggingIssues.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/LoggingIssues.java
deleted file mode 100644
index 4801f551307..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/LoggingIssues.java
+++ /dev/null
@@ -1,95 +0,0 @@
-// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.controller.api.integration.stubs;
-
-import com.yahoo.vespa.hosted.controller.api.integration.Issues;
-
-import java.time.Instant;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.concurrent.atomic.AtomicLong;
-import java.util.logging.Logger;
-
-/**
- * An memory backed implementation of the Issues API which logs changes and does nothing else.
- *
- * @author bratseth
- */
-@SuppressWarnings("unused") // created by dependency injection
-public class LoggingIssues implements Issues {
-
- private static final Logger log = Logger.getLogger(LoggingIssues.class.getName());
-
- /** Used to fabricate unique issue ids */
- private AtomicLong issueIdSequence = new AtomicLong(0);
-
- // These two maps should have precisely the same keys
- private final Map<String, Issue> issues = new HashMap<>();
- private final Map<String, IssueInfo> issueInfos = new HashMap<>();
-
- @Override
- public IssueInfo fetch(String issueId) {
- return issueInfos.getOrDefault(issueId,
- new IssueInfo(issueId, null, Instant.ofEpochMilli(0), null, IssueInfo.Status.noCategory));
- }
-
- @Override
- public List<IssueInfo> fetchSimilarTo(Issue issue) {
- return Collections.emptyList();
- }
-
- @Override
- public String file(Issue issue) {
- log.info("Want to file " + issue);
- String issueId = "issue-" + issueIdSequence.getAndIncrement();
- file(issueId, issue);
- return issueId;
- }
-
- private IssueInfo file(String issueId, Issue issue) {
- IssueInfo issueInfo = new IssueInfo(issueId, null, Instant.now(), null, IssueInfo.Status.noCategory);
- issues.put(issueId, issue);
- issueInfos.put(issueId, issueInfo);
- return issueInfo;
- }
-
- @Override
- public void update(String issueId, String description) {
- log.info("Want to update " + issueId);
- issues.put(issueId, requireIssue(issueId).withDescription(description));
- }
-
- @Override
- public void reassign(String issueId, String assignee) {
- log.info("Want to reassign issue " + issueId + " to " + assignee);
- issueInfos.put(issueId, requireInfo(issueId).withAssignee(Optional.of(assignee)));
- }
-
- @Override
- public void addWatcher(String issueId, String watcher) {
- log.info("Want to add watcher " + watcher + " to issue " + issueId);
- }
-
- @Override
- public void comment(String issueId, String comment) {
- log.info("Want to comment on issue " + issueId);
- }
-
- private Issue requireIssue(String issueId) {
- Issue issue = issues.get(issueId);
- if (issue == null)
- throw new IllegalArgumentException("No issue with id '" + issueId + "'");
- return issue;
- }
-
- private IssueInfo requireInfo(String issueId) {
- IssueInfo info = issueInfos.get(issueId);
- if (info != null) // we still remember this issue
- return info;
- else // we forgot this issue (due to restart) - recreate it here to avoid log noise
- return file(issueId, new Issue("(Forgotten)", "(Forgotten)"));
- }
-
-}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/PropertiesMock.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/PropertiesMock.java
deleted file mode 100644
index 53a31933e03..00000000000
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/PropertiesMock.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
-package com.yahoo.vespa.hosted.controller.api.integration.stubs;
-
-import com.yahoo.vespa.hosted.controller.api.integration.Issues;
-import com.yahoo.vespa.hosted.controller.api.integration.Properties;
-
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Optional;
-
-/**
- * @author mpolden
- */
-public class PropertiesMock implements Properties {
-
- private final Map<Long, Issues.Classification> projects = new HashMap<>();
-
- public void addClassification(long propertyId, String classification) {
- projects.put(propertyId, new Issues.Classification(classification));
- }
-
- public Optional<Issues.Classification> classificationFor(long propertyId) {
- return Optional.ofNullable(projects.get(propertyId));
- }
-
-}