blob: 667ca3764215900defd158d5b44bd299a36a6122 [file] [log] [blame]
// Copyright (C) 2016 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.google.gerrit.server.account;
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.base.Preconditions.checkState;
import com.google.auto.value.AutoValue;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Enums;
import com.google.common.base.Joiner;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.MultimapBuilder;
import com.google.common.collect.Sets;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.reviewdb.client.RefNames;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.MetaDataUpdate;
import com.google.gerrit.server.git.ValidationError;
import com.google.gerrit.server.git.VersionedMetaData;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.Repository;
/**
* ‘watch.config’ file in the user branch in the All-Users repository that contains the watch
* configuration of the user.
*
* <p>The 'watch.config' file is a git config file that has one 'project' section for all project
* watches of a project.
*
* <p>The project name is used as subsection name and the filters with the notify types that decide
* for which events email notifications should be sent are represented as 'notify' values in the
* subsection. A 'notify' value is formatted as {@code <filter>
* [<comma-separated-list-of-notify-types>]}:
*
* <pre>
* [project "foo"]
* notify = * [ALL_COMMENTS]
* notify = branch:master [ALL_COMMENTS, NEW_PATCHSETS]
* notify = branch:master owner:self [SUBMITTED_CHANGES]
* </pre>
*
* <p>If two notify values in the same subsection have the same filter they are merged on the next
* save, taking the union of the notify types.
*
* <p>For watch configurations that notify on no event the list of notify types is empty:
*
* <pre>
* [project "foo"]
* notify = branch:master []
* </pre>
*
* <p>Unknown notify types are ignored and removed on save.
*/
public class WatchConfig extends VersionedMetaData implements ValidationError.Sink {
@Singleton
public static class Accessor {
private final GitRepositoryManager repoManager;
private final AllUsersName allUsersName;
private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
private final IdentifiedUser.GenericFactory userFactory;
@Inject
Accessor(
GitRepositoryManager repoManager,
AllUsersName allUsersName,
Provider<MetaDataUpdate.User> metaDataUpdateFactory,
IdentifiedUser.GenericFactory userFactory) {
this.repoManager = repoManager;
this.allUsersName = allUsersName;
this.metaDataUpdateFactory = metaDataUpdateFactory;
this.userFactory = userFactory;
}
public Map<ProjectWatchKey, Set<NotifyType>> getProjectWatches(Account.Id accountId)
throws IOException, ConfigInvalidException {
try (Repository git = repoManager.openRepository(allUsersName)) {
WatchConfig watchConfig = new WatchConfig(accountId);
watchConfig.load(git);
return watchConfig.getProjectWatches();
}
}
public synchronized void upsertProjectWatches(
Account.Id accountId, Map<ProjectWatchKey, Set<NotifyType>> newProjectWatches)
throws IOException, ConfigInvalidException {
WatchConfig watchConfig = read(accountId);
Map<ProjectWatchKey, Set<NotifyType>> projectWatches = watchConfig.getProjectWatches();
projectWatches.putAll(newProjectWatches);
commit(watchConfig);
}
public synchronized void deleteProjectWatches(
Account.Id accountId, Collection<ProjectWatchKey> projectWatchKeys)
throws IOException, ConfigInvalidException {
WatchConfig watchConfig = read(accountId);
Map<ProjectWatchKey, Set<NotifyType>> projectWatches = watchConfig.getProjectWatches();
boolean commit = false;
for (ProjectWatchKey key : projectWatchKeys) {
if (projectWatches.remove(key) != null) {
commit = true;
}
}
if (commit) {
commit(watchConfig);
}
}
public synchronized void deleteAllProjectWatches(Account.Id accountId)
throws IOException, ConfigInvalidException {
WatchConfig watchConfig = read(accountId);
boolean commit = false;
if (!watchConfig.getProjectWatches().isEmpty()) {
watchConfig.getProjectWatches().clear();
commit = true;
}
if (commit) {
commit(watchConfig);
}
}
private WatchConfig read(Account.Id accountId) throws IOException, ConfigInvalidException {
try (Repository git = repoManager.openRepository(allUsersName)) {
WatchConfig watchConfig = new WatchConfig(accountId);
watchConfig.load(git);
return watchConfig;
}
}
private void commit(WatchConfig watchConfig) throws IOException {
try (MetaDataUpdate md =
metaDataUpdateFactory
.get()
.create(allUsersName, userFactory.create(watchConfig.accountId))) {
watchConfig.commit(md);
}
}
}
@AutoValue
public abstract static class ProjectWatchKey {
public static ProjectWatchKey create(Project.NameKey project, @Nullable String filter) {
return new AutoValue_WatchConfig_ProjectWatchKey(project, Strings.emptyToNull(filter));
}
public abstract Project.NameKey project();
public abstract @Nullable String filter();
}
public enum NotifyType {
// sort by name, except 'ALL' which should stay last
ABANDONED_CHANGES,
ALL_COMMENTS,
NEW_CHANGES,
NEW_PATCHSETS,
SUBMITTED_CHANGES,
ALL
}
public static final String FILTER_ALL = "*";
public static final String WATCH_CONFIG = "watch.config";
public static final String PROJECT = "project";
public static final String KEY_NOTIFY = "notify";
private final Account.Id accountId;
private final String ref;
private Map<ProjectWatchKey, Set<NotifyType>> projectWatches;
private List<ValidationError> validationErrors;
public WatchConfig(Account.Id accountId) {
this.accountId = accountId;
this.ref = RefNames.refsUsers(accountId);
}
@Override
protected String getRefName() {
return ref;
}
@Override
protected void onLoad() throws IOException, ConfigInvalidException {
Config cfg = readConfig(WATCH_CONFIG);
projectWatches = parse(accountId, cfg, this);
}
@VisibleForTesting
public static Map<ProjectWatchKey, Set<NotifyType>> parse(
Account.Id accountId, Config cfg, ValidationError.Sink validationErrorSink) {
Map<ProjectWatchKey, Set<NotifyType>> projectWatches = new HashMap<>();
for (String projectName : cfg.getSubsections(PROJECT)) {
String[] notifyValues = cfg.getStringList(PROJECT, projectName, KEY_NOTIFY);
for (String nv : notifyValues) {
if (Strings.isNullOrEmpty(nv)) {
continue;
}
NotifyValue notifyValue =
NotifyValue.parse(accountId, projectName, nv, validationErrorSink);
if (notifyValue == null) {
continue;
}
ProjectWatchKey key =
ProjectWatchKey.create(new Project.NameKey(projectName), notifyValue.filter());
if (!projectWatches.containsKey(key)) {
projectWatches.put(key, EnumSet.noneOf(NotifyType.class));
}
projectWatches.get(key).addAll(notifyValue.notifyTypes());
}
}
return projectWatches;
}
Map<ProjectWatchKey, Set<NotifyType>> getProjectWatches() {
checkLoaded();
return projectWatches;
}
public void setProjectWatches(Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
this.projectWatches = projectWatches;
}
@Override
protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
checkLoaded();
if (Strings.isNullOrEmpty(commit.getMessage())) {
commit.setMessage("Updated watch configuration\n");
}
Config cfg = readConfig(WATCH_CONFIG);
for (String projectName : cfg.getSubsections(PROJECT)) {
cfg.unsetSection(PROJECT, projectName);
}
ListMultimap<String, String> notifyValuesByProject =
MultimapBuilder.hashKeys().arrayListValues().build();
for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e : projectWatches.entrySet()) {
NotifyValue notifyValue = NotifyValue.create(e.getKey().filter(), e.getValue());
notifyValuesByProject.put(e.getKey().project().get(), notifyValue.toString());
}
for (Map.Entry<String, Collection<String>> e : notifyValuesByProject.asMap().entrySet()) {
cfg.setStringList(PROJECT, e.getKey(), KEY_NOTIFY, new ArrayList<>(e.getValue()));
}
saveConfig(WATCH_CONFIG, cfg);
return true;
}
private void checkLoaded() {
checkState(projectWatches != null, "project watches not loaded yet");
}
@Override
public void error(ValidationError error) {
if (validationErrors == null) {
validationErrors = new ArrayList<>(4);
}
validationErrors.add(error);
}
/**
* Get the validation errors, if any were discovered during load.
*
* @return list of errors; empty list if there are no errors.
*/
public List<ValidationError> getValidationErrors() {
if (validationErrors != null) {
return ImmutableList.copyOf(validationErrors);
}
return ImmutableList.of();
}
@AutoValue
public abstract static class NotifyValue {
public static NotifyValue parse(
Account.Id accountId,
String project,
String notifyValue,
ValidationError.Sink validationErrorSink) {
notifyValue = notifyValue.trim();
int i = notifyValue.lastIndexOf('[');
if (i < 0 || notifyValue.charAt(notifyValue.length() - 1) != ']') {
validationErrorSink.error(
new ValidationError(
WATCH_CONFIG,
String.format(
"Invalid project watch of account %d for project %s: %s",
accountId.get(), project, notifyValue)));
return null;
}
String filter = notifyValue.substring(0, i).trim();
if (filter.isEmpty() || FILTER_ALL.equals(filter)) {
filter = null;
}
Set<NotifyType> notifyTypes = EnumSet.noneOf(NotifyType.class);
if (i + 1 < notifyValue.length() - 2) {
for (String nt :
Splitter.on(',')
.trimResults()
.splitToList(notifyValue.substring(i + 1, notifyValue.length() - 1))) {
NotifyType notifyType = Enums.getIfPresent(NotifyType.class, nt).orNull();
if (notifyType == null) {
validationErrorSink.error(
new ValidationError(
WATCH_CONFIG,
String.format(
"Invalid notify type %s in project watch "
+ "of account %d for project %s: %s",
nt, accountId.get(), project, notifyValue)));
continue;
}
notifyTypes.add(notifyType);
}
}
return create(filter, notifyTypes);
}
public static NotifyValue create(@Nullable String filter, Set<NotifyType> notifyTypes) {
return new AutoValue_WatchConfig_NotifyValue(
Strings.emptyToNull(filter), Sets.immutableEnumSet(notifyTypes));
}
public abstract @Nullable String filter();
public abstract ImmutableSet<NotifyType> notifyTypes();
@Override
public String toString() {
List<NotifyType> notifyTypes = new ArrayList<>(notifyTypes());
StringBuilder notifyValue = new StringBuilder();
notifyValue.append(firstNonNull(filter(), FILTER_ALL)).append(" [");
Joiner.on(", ").appendTo(notifyValue, notifyTypes);
notifyValue.append("]");
return notifyValue.toString();
}
}
}