diff --git a/Documentation/user-named-destinations.txt b/Documentation/user-named-destinations.txt new file mode 100644 index 0000000000..1b6f143352 --- /dev/null +++ b/Documentation/user-named-destinations.txt @@ -0,0 +1,32 @@ += Gerrit Code Review - Named Destinations + +[[user-named-destinations]] +== User Named Destinations +It is possible to define named destination sets on a user level. +To do this, define the named destination sets in files named after +each destination set in the `destinations` directory of the user's +account ref in the `All-Users` project. The user's account ref is +based on the user's account id which is an integer. The account +refs are sharded by the last two digits (`+nn+`) in the refname, +leading to refs of the format `+refs/users/nn/accountid+`. The +user's destination files are a 2 column tab delimited file. Each +row in a destination file represents a single destination in the +named set. The left column represents the ref of the destination, +and the right column represents the project of the destination. + +Example destination file named `destinations/myreviews`: + +---- +# Ref Project +# +refs/heads/master gerrit +refs/heads/stable-2.11 gerrit +refs/heads/master plugins/cookbook-plugin +---- + +GERRIT +------ +Part of link:index.html[Gerrit Code Review] + +SEARCHBOX +--------- diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt index bb60a7e131..05480794ef 100644 --- a/Documentation/user-search.txt +++ b/Documentation/user-search.txt @@ -88,6 +88,12 @@ Changes that conflict with change 'ID'. Change 'ID' can be specified as a legacy numerical 'ID' such as 15183, or a newer style Change-Id that was scraped out of the commit message. +[[destination]] +destination:'NAME':: ++ +Changes which match the current user's destination named 'NAME'. +(see link:user-named-destinations.html[Named Destinations]). + [[owner]] owner:'USER', o:'USER':: + diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountDestinations.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountDestinations.java new file mode 100644 index 0000000000..d928becb7b --- /dev/null +++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountDestinations.java @@ -0,0 +1,80 @@ +// Copyright (C) 2015 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 com.google.gerrit.reviewdb.client.Account; +import com.google.gerrit.reviewdb.client.RefNames; +import com.google.gerrit.server.git.DestinationList; +import com.google.gerrit.server.git.ValidationError; +import com.google.gerrit.server.git.VersionedMetaData; + +import org.eclipse.jgit.errors.ConfigInvalidException; +import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.lib.FileMode; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +/** Preferences for user accounts. */ +public class VersionedAccountDestinations extends VersionedMetaData { + private static final Logger log = LoggerFactory.getLogger(VersionedAccountDestinations.class); + + public static VersionedAccountDestinations forUser(Account.Id id) { + return new VersionedAccountDestinations(RefNames.refsUsers(id)); + } + + private final String ref; + private final DestinationList destinations = new DestinationList(); + + private VersionedAccountDestinations(String ref) { + this.ref = ref; + } + + @Override + protected String getRefName() { + return ref; + } + + public DestinationList getDestinationList() { + return destinations; + } + + @Override + protected void onLoad() throws IOException, ConfigInvalidException { + String prefix = DestinationList.DIR_NAME + "/"; + for (PathInfo p : getPathInfos(true)) { + if (p.fileMode == FileMode.REGULAR_FILE) { + String path = p.path; + if (path.startsWith(prefix)) { + String label = path.substring(prefix.length()); + ValidationError.Sink errors = destinations.createLoggerSink(path, log); + destinations.parseLabel(label, readUTF8(path), errors); + } + } + } + } + + public ValidationError.Sink createSink(String file) { + return ValidationError.createLoggerSink(file, log); + } + + @Override + protected boolean onSave(CommitBuilder commit) throws IOException, + ConfigInvalidException { + throw new UnsupportedOperationException("Cannot yet save destinations"); + } +} diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/DestinationList.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/DestinationList.java new file mode 100644 index 0000000000..ca1f705c99 --- /dev/null +++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/DestinationList.java @@ -0,0 +1,61 @@ +// Copyright (C) 2015 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.git; + +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Lists; +import com.google.common.collect.SetMultimap; +import com.google.common.collect.Sets; +import com.google.gerrit.reviewdb.client.Branch; +import com.google.gerrit.reviewdb.client.Project; + +import java.io.IOException; +import java.util.List; +import java.util.Set; + +public class DestinationList extends TabFile { + public static final String DIR_NAME = "destinations"; + private SetMultimap destinations = HashMultimap.create(); + + public Set getDestinations(String label) { + return destinations.get(label); + } + + public void parseLabel(String label, String text, + ValidationError.Sink errors) throws IOException { + destinations.replaceValues(label, + toSet(parse(text, DIR_NAME + label, TRIM, null, errors))); + } + + public String asText(String label) { + Set dests = destinations.get(label); + if (dests == null) { + return null; + } + List rows = Lists.newArrayListWithCapacity(dests.size()); + for (Branch.NameKey dest : sort(dests)) { + rows.add(new Row(dest.get(), dest.getParentKey().get())); + } + return asText("Ref", "Project", rows); + } + + protected static Set toSet(List destRows) { + Set dests = Sets.newHashSetWithExpectedSize(destRows.size()); + for(Row row : destRows) { + dests.add(new Branch.NameKey(new Project.NameKey(row.right), row.left)); + } + return dests; + } +} diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java index d07572bfac..1477f6a335 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupList.java @@ -36,7 +36,7 @@ public class GroupList extends TabFile { public static GroupList parse(String text, ValidationError.Sink errors) throws IOException { - List rows = parse(text, FILE_NAME, errors); + List rows = parse(text, FILE_NAME, TRIM, TRIM, errors); Map groupsByUUID = new HashMap<>(rows.size()); for(Row row : rows) { diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/QueryList.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/QueryList.java index 0df866dbd0..dffb18a969 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/git/QueryList.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/QueryList.java @@ -28,7 +28,7 @@ public class QueryList extends TabFile { public static QueryList parse(String text, ValidationError.Sink errors) throws IOException { - return new QueryList(parse(text, FILE_NAME, errors)); + return new QueryList(parse(text, FILE_NAME, TRIM, TRIM, errors)); } public String getQuery(String name) { diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java index 13d2b1efc4..87f9a23879 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/TabFile.java @@ -27,6 +27,17 @@ import java.util.List; import java.util.Map; public class TabFile { + public interface Parser { + public String parse(String str); + } + + public static Parser TRIM = new Parser() { + public String parse(String str) { + return str.trim(); + } + }; + + protected static class Row { public String left; public String right; @@ -37,9 +48,9 @@ public class TabFile { } } - protected static List parse(String text, String filename, - ValidationError.Sink errors) throws IOException { - List rows = new ArrayList<>(); + protected static List parse(String text, String filename, Parser left, + Parser right, ValidationError.Sink errors) throws IOException { + List rows = new ArrayList(); BufferedReader br = new BufferedReader(new StringReader(text)); String s; for (int lineNumber = 1; (s = br.readLine()) != null; lineNumber++) { @@ -54,8 +65,15 @@ public class TabFile { continue; } - rows.add(new Row(s.substring(0, tab).trim(), - s.substring(tab + 1).trim())); + Row row = new Row(s.substring(0, tab), s.substring(tab + 1)); + rows.add(row); + + if (left != null) { + row.left = left.parse(row.left); + } + if (right != null) { + row.right = right.parse(row.right); + } } return rows; } diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java index 17f51ef7b6..dfde5d5844 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java @@ -15,6 +15,7 @@ package com.google.gerrit.server.git; import com.google.common.base.MoreObjects; +import com.google.common.collect.Lists; import org.eclipse.jgit.dircache.DirCache; import org.eclipse.jgit.dircache.DirCacheBuilder; @@ -50,6 +51,7 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.StringReader; import java.util.Objects; +import java.util.List; /** * Support for metadata stored within a version controlled branch. @@ -59,6 +61,23 @@ import java.util.Objects; * later be written back to the repository. */ public abstract class VersionedMetaData { + /** + * Path information that does not hold references to any repository + * data structures, allowing the application to retain this object + * for long periods of time. + */ + public static class PathInfo { + public final FileMode fileMode; + public final String path; + public final ObjectId objectId; + + protected PathInfo(TreeWalk tw) { + fileMode = tw.getFileMode(0); + path = tw.getPathString(); + objectId = tw.getObjectId(0); + } + } + private RevCommit revision; protected ObjectReader reader; protected ObjectInserter inserter; @@ -439,6 +458,17 @@ public abstract class VersionedMetaData { return null; } + public List getPathInfos(boolean recursive) throws IOException { + TreeWalk tw = new TreeWalk(reader); + tw.addTree(revision.getTree()); + tw.setRecursive(recursive); + List paths = Lists.newArrayList(); + while (tw.next()) { + paths.add(new PathInfo(tw)); + } + return paths; + } + protected static void set(Config rc, String section, String subsection, String name, String value) { if (value != null) { diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java index 88bb942ec7..c6ffc27b97 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java @@ -23,6 +23,7 @@ import com.google.gerrit.common.data.GroupReference; import com.google.gerrit.common.errors.NotSignedInException; import com.google.gerrit.reviewdb.client.Account; import com.google.gerrit.reviewdb.client.AccountGroup; +import com.google.gerrit.reviewdb.client.Branch; import com.google.gerrit.reviewdb.client.Change; import com.google.gerrit.reviewdb.client.RefNames; import com.google.gerrit.reviewdb.server.ReviewDb; @@ -34,6 +35,7 @@ import com.google.gerrit.server.account.CapabilityControl; import com.google.gerrit.server.account.GroupBackend; import com.google.gerrit.server.account.GroupBackends; import com.google.gerrit.server.account.VersionedAccountQueries; +import com.google.gerrit.server.account.VersionedAccountDestinations; import com.google.gerrit.server.change.ChangeTriplet; import com.google.gerrit.server.config.AllProjectsName; import com.google.gerrit.server.config.AllUsersName; @@ -98,6 +100,7 @@ public class ChangeQueryBuilder extends QueryBuilder { public static final String FIELD_CONFLICTS = "conflicts"; public static final String FIELD_DELETED = "deleted"; public static final String FIELD_DELTA = "delta"; + public static final String FIELD_DESTINATION = "destination"; public static final String FIELD_DRAFTBY = "draftby"; public static final String FIELD_EDITBY = "editby"; public static final String FIELD_FILE = "file"; @@ -818,6 +821,28 @@ public class ChangeQueryBuilder extends QueryBuilder { return IsReviewedPredicate.create(args.getSchema(), parseAccount(who)); } + @Operator + public Predicate destination(String name) + throws QueryParseException { + AllUsersName allUsers = args.allUsersName.get(); + try (Repository git = args.repoManager.openRepository(allUsers)) { + VersionedAccountDestinations d = + VersionedAccountDestinations.forUser(self()); + d.load(git); + Set destinations = + d.getDestinationList().getDestinations(name); + if (destinations != null) { + return new DestinationPredicate(destinations, name); + } + } catch (RepositoryNotFoundException e) { + throw new QueryParseException("Unknown named destination (no " + + allUsers.get() +" repo): " + name, e); + } catch (IOException | ConfigInvalidException e) { + throw new QueryParseException("Error parsing named destination: " + name, e); + } + throw new QueryParseException("Unknown named destination: " + name); + } + @Override protected Predicate defaultField(String query) throws QueryParseException { if (query.startsWith("refs/")) { diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java new file mode 100644 index 0000000000..25fa09f71b --- /dev/null +++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java @@ -0,0 +1,45 @@ +// Copyright (C) 2015 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.query.change; + +import com.google.gerrit.reviewdb.client.Branch; +import com.google.gerrit.reviewdb.client.Change; +import com.google.gerrit.server.query.OperatorPredicate; +import com.google.gwtorm.server.OrmException; + +import java.util.Set; + +class DestinationPredicate extends OperatorPredicate { + Set destinations; + + DestinationPredicate(Set destinations, String value) { + super(ChangeQueryBuilder.FIELD_DESTINATION, value); + this.destinations = destinations; + } + + @Override + public boolean match(final ChangeData object) throws OrmException { + Change change = object.change(); + if (change == null) { + return false; + } + return destinations.contains(change.getDest()); + } + + @Override + public int getCost() { + return 1; + } +} diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/DestinationListTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/DestinationListTest.java new file mode 100644 index 0000000000..2304ecefd2 --- /dev/null +++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/DestinationListTest.java @@ -0,0 +1,164 @@ +// Copyright (C) 2015 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.git; + +import static com.google.common.truth.Truth.assertThat; +import static org.easymock.EasyMock.createNiceMock; +import static org.easymock.EasyMock.replay; + +import com.google.common.collect.Sets; +import com.google.gerrit.reviewdb.client.Branch; +import com.google.gerrit.reviewdb.client.Project; + +import junit.framework.TestCase; + +import org.junit.Test; + +import java.io.IOException; +import java.util.Set; + +public class DestinationListTest extends TestCase { + public static final String R_FOO = "refs/heads/foo"; + public static final String R_BAR = "refs/heads/bar"; + + public static final String P_MY = "myproject"; + public static final String P_SLASH = "my/project/with/slashes"; + public static final String P_COMPLEX = " a/project/with spaces and \ttabs "; + + public static final String L_FOO = R_FOO + "\t" + P_MY + "\n"; + public static final String L_BAR = R_BAR + "\t" + P_SLASH + "\n"; + public static final String L_FOO_PAD_F = " " + R_FOO + "\t" + P_MY + "\n"; + public static final String L_FOO_PAD_E = R_FOO + " \t" + P_MY + "\n"; + public static final String L_COMPLEX = R_FOO + "\t" + P_COMPLEX + "\n"; + public static final String L_BAD = R_FOO + "\n"; + + public static final String HEADER = "# Ref\tProject\n"; + public static final String HEADER_PROPER = "# Ref \tProject\n"; + public static final String C1 = "# A Simple Comment\n"; + public static final String C2 = "# Comment with a tab\t and multi # # #\n"; + + public static final String F_SIMPLE = L_FOO + L_BAR; + public static final String F_PROPER = L_BAR + L_FOO; // alpha order + public static final String F_PAD_F = L_FOO_PAD_F + L_BAR; + public static final String F_PAD_E = L_FOO_PAD_E + L_BAR; + + public static final String LABEL = "label"; + public static final String LABEL2 = "another"; + + public static final Branch.NameKey B_FOO = dest(P_MY, R_FOO); + public static final Branch.NameKey B_BAR = dest(P_SLASH, R_BAR); + public static final Branch.NameKey B_COMPLEX = dest(P_COMPLEX, R_FOO); + + public static final Set D_SIMPLE = Sets.newHashSet(); + static { + D_SIMPLE.clear(); + D_SIMPLE.add(B_FOO); + D_SIMPLE.add(B_BAR); + } + + private static Branch.NameKey dest(String project, String ref) { + return new Branch.NameKey(new Project.NameKey(project), ref); + } + + @Test + public void testParseSimple() throws Exception { + DestinationList dl = new DestinationList(); + dl.parseLabel(LABEL, F_SIMPLE, null); + Set branches = dl.getDestinations(LABEL); + assertThat(branches).containsExactlyElementsIn(D_SIMPLE); + } + + @Test + public void testParseWHeader() throws Exception { + DestinationList dl = new DestinationList(); + dl.parseLabel(LABEL, HEADER + F_SIMPLE, null); + Set branches = dl.getDestinations(LABEL); + assertThat(branches).containsExactlyElementsIn(D_SIMPLE); + } + + @Test + public void testParseWComments() throws Exception { + DestinationList dl = new DestinationList(); + dl.parseLabel(LABEL, C1 + F_SIMPLE + C2, null); + Set branches = dl.getDestinations(LABEL); + assertThat(branches).containsExactlyElementsIn(D_SIMPLE); + } + + @Test + public void testParseFooComment() throws Exception { + DestinationList dl = new DestinationList(); + dl.parseLabel(LABEL, "#" + L_FOO + L_BAR, null); + Set branches = dl.getDestinations(LABEL); + assertThat(branches).doesNotContain(B_FOO); + assertThat(branches).contains(B_BAR); + } + + @Test + public void testParsePaddedFronts() throws Exception { + DestinationList dl = new DestinationList(); + dl.parseLabel(LABEL, F_PAD_F, null); + Set branches = dl.getDestinations(LABEL); + assertThat(branches).containsExactlyElementsIn(D_SIMPLE); + } + + @Test + public void testParsePaddedEnds() throws Exception { + DestinationList dl = new DestinationList(); + dl.parseLabel(LABEL, F_PAD_E, null); + Set branches = dl.getDestinations(LABEL); + assertThat(branches).containsExactlyElementsIn(D_SIMPLE); + } + + @Test + public void testParseComplex() throws Exception { + DestinationList dl = new DestinationList(); + dl.parseLabel(LABEL, L_COMPLEX, null); + Set branches = dl.getDestinations(LABEL); + assertThat(branches).contains(B_COMPLEX); + } + + @Test(expected = IOException.class) + public void testParseBad() throws IOException { + ValidationError.Sink sink = createNiceMock(ValidationError.Sink.class); + replay(sink); + new DestinationList().parseLabel(LABEL, L_BAD, sink); + } + + @Test + public void testParse2Labels() throws Exception { + DestinationList dl = new DestinationList(); + dl.parseLabel(LABEL, F_SIMPLE, null); + Set branches = dl.getDestinations(LABEL); + assertThat(branches).containsExactlyElementsIn(D_SIMPLE); + + dl.parseLabel(LABEL2, L_COMPLEX, null); + branches = dl.getDestinations(LABEL); + assertThat(branches).containsExactlyElementsIn(D_SIMPLE); + branches = dl.getDestinations(LABEL2); + assertThat(branches).contains(B_COMPLEX); + } + + @Test + public void testAsText() throws Exception { + String text = HEADER_PROPER + "#\n" + F_PROPER; + DestinationList dl = new DestinationList(); + dl.parseLabel(LABEL, F_SIMPLE, null); + String asText = dl.asText(LABEL); + assertThat(text).isEqualTo(asText); + + dl.parseLabel(LABEL2, asText, null); + assertThat(text).isEqualTo(dl.asText(LABEL2)); + } +}