// 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(); } } }