| // Copyright (C) 2012 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 static com.google.gerrit.common.changes.ListChangesOption.ALL_COMMITS; |
| import static com.google.gerrit.common.changes.ListChangesOption.ALL_FILES; |
| import static com.google.gerrit.common.changes.ListChangesOption.ALL_REVISIONS; |
| import static com.google.gerrit.common.changes.ListChangesOption.CURRENT_COMMIT; |
| import static com.google.gerrit.common.changes.ListChangesOption.CURRENT_FILES; |
| import static com.google.gerrit.common.changes.ListChangesOption.CURRENT_REVISION; |
| import static com.google.gerrit.common.changes.ListChangesOption.LABELS; |
| |
| import com.google.common.base.Strings; |
| import com.google.common.collect.Lists; |
| import com.google.common.collect.Maps; |
| import com.google.gerrit.common.changes.ListChangesOption; |
| import com.google.gerrit.common.data.ApprovalType; |
| import com.google.gerrit.common.data.ApprovalTypes; |
| import com.google.gerrit.common.data.SubmitRecord; |
| import com.google.gerrit.reviewdb.client.Account; |
| import com.google.gerrit.reviewdb.client.Change; |
| import com.google.gerrit.reviewdb.client.ChangeMessage; |
| import com.google.gerrit.reviewdb.client.Patch; |
| import com.google.gerrit.reviewdb.client.PatchSet; |
| import com.google.gerrit.reviewdb.client.PatchSetApproval; |
| import com.google.gerrit.reviewdb.client.PatchSetInfo; |
| import com.google.gerrit.reviewdb.client.PatchSetInfo.ParentInfo; |
| import com.google.gerrit.reviewdb.client.UserIdentity; |
| import com.google.gerrit.reviewdb.server.ReviewDb; |
| import com.google.gerrit.server.AnonymousUser; |
| import com.google.gerrit.server.CurrentUser; |
| import com.google.gerrit.server.IdentifiedUser; |
| import com.google.gerrit.server.OutputFormat; |
| import com.google.gerrit.server.config.CanonicalWebUrl; |
| import com.google.gerrit.server.config.GerritServerConfig; |
| import com.google.gerrit.server.events.AccountAttribute; |
| import com.google.gerrit.server.patch.PatchList; |
| import com.google.gerrit.server.patch.PatchListCache; |
| import com.google.gerrit.server.patch.PatchListEntry; |
| import com.google.gerrit.server.patch.PatchListNotAvailableException; |
| import com.google.gerrit.server.patch.PatchSetInfoFactory; |
| import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException; |
| import com.google.gerrit.server.project.ChangeControl; |
| import com.google.gerrit.server.project.NoSuchChangeException; |
| import com.google.gerrit.server.query.QueryParseException; |
| import com.google.gerrit.server.ssh.SshInfo; |
| import com.google.gson.reflect.TypeToken; |
| import com.google.gwtorm.server.OrmException; |
| import com.google.inject.Inject; |
| import com.google.inject.Provider; |
| import com.google.inject.Singleton; |
| |
| import com.jcraft.jsch.HostKey; |
| |
| import org.eclipse.jgit.lib.Config; |
| import org.kohsuke.args4j.Option; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| import java.io.IOException; |
| import java.io.Writer; |
| import java.sql.Timestamp; |
| import java.util.Collection; |
| import java.util.Collections; |
| import java.util.Comparator; |
| import java.util.EnumSet; |
| import java.util.List; |
| import java.util.Map; |
| |
| public class ListChanges { |
| private static final Logger log = LoggerFactory.getLogger(ListChanges.class); |
| |
| @Singleton |
| static class Urls { |
| final String git; |
| final String http; |
| |
| @Inject |
| Urls(@GerritServerConfig Config cfg) { |
| this.git = ensureSlash(cfg.getString("gerrit", null, "canonicalGitUrl")); |
| this.http = ensureSlash(cfg.getString("gerrit", null, "gitHttpUrl")); |
| } |
| |
| private static String ensureSlash(String in) { |
| if (in != null && !in.endsWith("/")) { |
| return in + "/"; |
| } |
| return in; |
| } |
| } |
| |
| private final QueryProcessor imp; |
| private final Provider<ReviewDb> db; |
| private final ApprovalTypes approvalTypes; |
| private final CurrentUser user; |
| private final AnonymousUser anonymous; |
| private final ChangeControl.Factory changeControlFactory; |
| private final PatchSetInfoFactory patchSetInfoFactory; |
| private final PatchListCache patchListCache; |
| private final SshInfo sshInfo; |
| private final Provider<String> urlProvider; |
| private final Urls urls; |
| private boolean reverse; |
| private Map<Account.Id, AccountAttribute> accounts; |
| private Map<Change.Id, ChangeControl> controls; |
| private EnumSet<ListChangesOption> options; |
| |
| @Option(name = "--format", metaVar = "FMT", usage = "Output display format") |
| private OutputFormat format = OutputFormat.TEXT; |
| |
| @Option(name = "--query", aliases = {"-q"}, metaVar = "QUERY", multiValued = true, usage = "Query string") |
| private List<String> queries; |
| |
| @Option(name = "--limit", aliases = {"-n"}, metaVar = "CNT", usage = "Maximum number of results to return") |
| public void setLimit(int limit) { |
| imp.setLimit(limit); |
| } |
| |
| @Option(name = "-o", multiValued = true, usage = "Output options per change") |
| public void addOption(ListChangesOption o) { |
| options.add(o); |
| } |
| |
| @Option(name = "-O", usage = "Output option flags, in hex") |
| void setOptionFlagsHex(String hex) { |
| options.addAll(ListChangesOption.fromBits(Integer.parseInt(hex, 16))); |
| } |
| |
| @Option(name = "-P", metaVar = "SORTKEY", usage = "Previous changes before SORTKEY") |
| public void setSortKeyAfter(String key) { |
| // Querying for the prior page of changes requires sortkey_after predicate. |
| // Changes are shown most recent->least recent. The previous page of |
| // results contains changes that were updated after the given key. |
| imp.setSortkeyAfter(key); |
| reverse = true; |
| } |
| |
| @Option(name = "-N", metaVar = "SORTKEY", usage = "Next changes after SORTKEY") |
| public void setSortKeyBefore(String key) { |
| // Querying for the next page of changes requires sortkey_before predicate. |
| // Changes are shown most recent->least recent. The next page contains |
| // changes that were updated before the given key. |
| imp.setSortkeyBefore(key); |
| } |
| |
| @Inject |
| ListChanges(QueryProcessor qp, |
| Provider<ReviewDb> db, |
| ApprovalTypes at, |
| CurrentUser u, |
| AnonymousUser au, |
| ChangeControl.Factory cf, |
| PatchSetInfoFactory psi, |
| PatchListCache plc, |
| SshInfo sshInfo, |
| @CanonicalWebUrl Provider<String> curl, |
| Urls urls) { |
| this.imp = qp; |
| this.db = db; |
| this.approvalTypes = at; |
| this.user = u; |
| this.anonymous = au; |
| this.changeControlFactory = cf; |
| this.patchSetInfoFactory = psi; |
| this.patchListCache = plc; |
| this.sshInfo = sshInfo; |
| this.urlProvider = curl; |
| this.urls = urls; |
| |
| accounts = Maps.newHashMap(); |
| controls = Maps.newHashMap(); |
| options = EnumSet.noneOf(ListChangesOption.class); |
| } |
| |
| public OutputFormat getFormat() { |
| return format; |
| } |
| |
| public ListChanges setFormat(OutputFormat fmt) { |
| this.format = fmt; |
| return this; |
| } |
| |
| public ListChanges addQuery(String query) { |
| if (queries == null) { |
| queries = Lists.newArrayList(); |
| } |
| queries.add(query); |
| return this; |
| } |
| |
| public void query(Writer out) |
| throws OrmException, QueryParseException, IOException { |
| if (imp.isDisabled()) { |
| throw new QueryParseException("query disabled"); |
| } |
| if (queries == null || queries.isEmpty()) { |
| queries = Collections.singletonList("status:open"); |
| } else if (queries.size() > 10) { |
| // Hard-code a default maximum number of queries to prevent |
| // users from submitting too much to the server in a single call. |
| throw new QueryParseException("limit of 10 queries"); |
| } |
| |
| List<List<ChangeInfo>> res = Lists.newArrayListWithCapacity(queries.size()); |
| for (String query : queries) { |
| List<ChangeData> changes = imp.queryChanges(query); |
| boolean moreChanges = imp.getLimit() > 0 && changes.size() > imp.getLimit(); |
| if (moreChanges) { |
| if (reverse) { |
| changes = changes.subList(1, changes.size()); |
| } else { |
| changes = changes.subList(0, imp.getLimit()); |
| } |
| } |
| ChangeData.ensureChangeLoaded(db, changes); |
| ChangeData.ensureCurrentPatchSetLoaded(db, changes); |
| ChangeData.ensureCurrentApprovalsLoaded(db, changes); |
| |
| List<ChangeInfo> info = Lists.newArrayListWithCapacity(changes.size()); |
| for (ChangeData cd : changes) { |
| info.add(toChangeInfo(cd)); |
| } |
| if (moreChanges && !info.isEmpty()) { |
| if (reverse) { |
| info.get(0)._moreChanges = true; |
| } else { |
| info.get(info.size() - 1)._moreChanges = true; |
| } |
| } |
| res.add(info); |
| } |
| |
| if (!accounts.isEmpty()) { |
| for (Account account : db.get().accounts().get(accounts.keySet())) { |
| AccountAttribute a = accounts.get(account.getId()); |
| a.name = Strings.emptyToNull(account.getFullName()); |
| } |
| } |
| |
| if (format.isJson()) { |
| format.newGson().toJson( |
| res.size() == 1 ? res.get(0) : res, |
| new TypeToken<List<ChangeInfo>>() {}.getType(), |
| out); |
| out.write('\n'); |
| } else { |
| boolean firstQuery = true; |
| for (List<ChangeInfo> info : res) { |
| if (firstQuery) { |
| firstQuery = false; |
| } else { |
| out.write('\n'); |
| } |
| for (ChangeInfo c : info) { |
| String id = new Change.Key(c.id).abbreviate(); |
| String subject = c.subject; |
| if (subject.length() + id.length() > 80) { |
| subject = subject.substring(0, 80 - id.length()); |
| } |
| out.write(id); |
| out.write(' '); |
| out.write(subject.replace('\n', ' ')); |
| out.write('\n'); |
| } |
| } |
| } |
| } |
| |
| private ChangeInfo toChangeInfo(ChangeData cd) throws OrmException { |
| ChangeInfo out = new ChangeInfo(); |
| Change in = cd.change(db); |
| out.project = in.getProject().get(); |
| out.branch = in.getDest().getShortName(); |
| out.topic = in.getTopic(); |
| out.id = in.getKey().get(); |
| out.subject = in.getSubject(); |
| out.status = in.getStatus(); |
| out.owner = asAccountAttribute(in.getOwner()); |
| out.created = in.getCreatedOn(); |
| out.updated = in.getLastUpdatedOn(); |
| out._number = in.getId().get(); |
| out._sortkey = in.getSortKey(); |
| out.starred = user.getStarredChanges().contains(in.getId()) ? true : null; |
| out.reviewed = in.getStatus().isOpen() && isChangeReviewed(cd) ? true : null; |
| out.labels = options.contains(LABELS) ? labelsFor(cd) : null; |
| |
| if (options.contains(ALL_REVISIONS) || options.contains(CURRENT_REVISION)) { |
| out.revisions = revisions(cd); |
| for (String commit : out.revisions.keySet()) { |
| if (out.revisions.get(commit).isCurrent) { |
| out.current_revision = commit; |
| break; |
| } |
| } |
| } |
| |
| return out; |
| } |
| |
| private AccountAttribute asAccountAttribute(Account.Id user) { |
| if (user == null) { |
| return null; |
| } |
| AccountAttribute a = accounts.get(user); |
| if (a == null) { |
| a = new AccountAttribute(); |
| accounts.put(user, a); |
| } |
| return a; |
| } |
| |
| private ChangeControl control(ChangeData cd) throws OrmException { |
| ChangeControl ctrl = cd.changeControl(); |
| if (ctrl != null && ctrl.getCurrentUser() == user) { |
| return ctrl; |
| } |
| |
| ctrl = controls.get(cd.getId()); |
| if (ctrl != null) { |
| return ctrl; |
| } |
| |
| try { |
| ctrl = changeControlFactory.controlFor(cd.change(db)); |
| } catch (NoSuchChangeException e) { |
| return null; |
| } |
| controls.put(cd.getId(), ctrl); |
| return ctrl; |
| } |
| |
| private Map<String, LabelInfo> labelsFor(ChangeData cd) throws OrmException { |
| ChangeControl ctl = control(cd); |
| if (ctl == null) { |
| return Collections.emptyMap(); |
| } |
| |
| PatchSet ps = cd.currentPatchSet(db); |
| if (ps == null) { |
| return Collections.emptyMap(); |
| } |
| |
| Map<String, LabelInfo> labels = Maps.newLinkedHashMap(); |
| for (SubmitRecord rec : ctl.canSubmit(db.get(), ps, cd, true, false)) { |
| if (rec.labels == null) { |
| continue; |
| } |
| for (SubmitRecord.Label r : rec.labels) { |
| LabelInfo p = labels.get(r.label); |
| if (p == null || p._status.compareTo(r.status) < 0) { |
| LabelInfo n = new LabelInfo(); |
| n._status = r.status; |
| switch (r.status) { |
| case OK: |
| n.approved = asAccountAttribute(r.appliedBy); |
| break; |
| case REJECT: |
| n.rejected = asAccountAttribute(r.appliedBy); |
| break; |
| } |
| n.optional = n._status == SubmitRecord.Label.Status.MAY ? true : null; |
| labels.put(r.label, n); |
| } |
| } |
| } |
| |
| Collection<PatchSetApproval> approvals = null; |
| for (Map.Entry<String, LabelInfo> e : labels.entrySet()) { |
| if (e.getValue().approved != null || e.getValue().rejected != null) { |
| continue; |
| } |
| |
| ApprovalType type = approvalTypes.byLabel(e.getKey()); |
| if (type == null || type.getMin() == null || type.getMax() == null) { |
| // Unknown or misconfigured type can't have intermediate scores. |
| continue; |
| } |
| |
| short min = type.getMin().getValue(); |
| short max = type.getMax().getValue(); |
| if (-1 <= min && max <= 1) { |
| // Types with a range of -1..+1 can't have intermediate scores. |
| continue; |
| } |
| |
| if (approvals == null) { |
| approvals = cd.currentApprovals(db); |
| } |
| for (PatchSetApproval psa : approvals) { |
| short val = psa.getValue(); |
| if (val != 0 && min < val && val < max |
| && psa.getCategoryId().equals(type.getCategory().getId())) { |
| if (0 < val) { |
| e.getValue().recommended = asAccountAttribute(psa.getAccountId()); |
| e.getValue().value = val != 1 ? val : null; |
| } else { |
| e.getValue().disliked = asAccountAttribute(psa.getAccountId()); |
| e.getValue().value = val != -1 ? val : null; |
| } |
| } |
| } |
| } |
| return labels; |
| } |
| |
| private boolean isChangeReviewed(ChangeData cd) throws OrmException { |
| if (user instanceof IdentifiedUser) { |
| PatchSet currentPatchSet = cd.currentPatchSet(db); |
| if (currentPatchSet == null) { |
| return false; |
| } |
| |
| List<ChangeMessage> messages = |
| db.get().changeMessages().byPatchSet(currentPatchSet.getId()).toList(); |
| |
| if (messages.isEmpty()) { |
| return false; |
| } |
| |
| // Sort messages to let the most recent ones at the beginning. |
| Collections.sort(messages, new Comparator<ChangeMessage>() { |
| @Override |
| public int compare(ChangeMessage a, ChangeMessage b) { |
| return b.getWrittenOn().compareTo(a.getWrittenOn()); |
| } |
| }); |
| |
| Account.Id currentUserId = ((IdentifiedUser) user).getAccountId(); |
| Account.Id changeOwnerId = cd.change(db).getOwner(); |
| for (ChangeMessage cm : messages) { |
| if (currentUserId.equals(cm.getAuthor())) { |
| return true; |
| } else if (changeOwnerId.equals(cm.getAuthor())) { |
| return false; |
| } |
| } |
| } |
| return false; |
| } |
| |
| private Map<String, RevisionInfo> revisions(ChangeData cd) throws OrmException { |
| ChangeControl ctl = control(cd); |
| if (ctl == null) { |
| return Collections.emptyMap(); |
| } |
| |
| Collection<PatchSet> src; |
| if (options.contains(ALL_REVISIONS)) { |
| src = cd.patches(db); |
| } else { |
| src = Collections.singletonList(cd.currentPatchSet(db)); |
| } |
| Map<String, RevisionInfo> res = Maps.newLinkedHashMap(); |
| for (PatchSet in : src) { |
| if (ctl.isPatchVisible(in, db.get())) { |
| res.put(in.getRevision().get(), toRevisionInfo(cd, in)); |
| } |
| } |
| return res; |
| } |
| |
| private RevisionInfo toRevisionInfo(ChangeData cd, PatchSet in) |
| throws OrmException { |
| RevisionInfo out = new RevisionInfo(); |
| out.isCurrent = in.getId().equals(cd.change(db).currentPatchSetId()); |
| out._number = in.getId().get(); |
| out.draft = in.isDraft() ? true : null; |
| out.fetch = makeFetchMap(cd, in); |
| |
| if (options.contains(ALL_COMMITS) |
| || (out.isCurrent && options.contains(CURRENT_COMMIT))) { |
| try { |
| PatchSetInfo info = patchSetInfoFactory.get(db.get(), in.getId()); |
| out.commit = new CommitInfo(); |
| out.commit.parents = Lists.newArrayListWithCapacity(info.getParents().size()); |
| out.commit.author = toGitPerson(info.getAuthor()); |
| out.commit.committer = toGitPerson(info.getCommitter()); |
| out.commit.subject = info.getSubject(); |
| out.commit.message = info.getMessage(); |
| |
| for (ParentInfo parent : info.getParents()) { |
| CommitInfo i = new CommitInfo(); |
| i.commit = parent.id.get(); |
| i.subject = parent.shortMessage; |
| out.commit.parents.add(i); |
| } |
| } catch (PatchSetInfoNotAvailableException e) { |
| log.warn("Cannot load PatchSetInfo " + in.getId(), e); |
| } |
| } |
| |
| if (options.contains(ALL_FILES) |
| || (out.isCurrent && options.contains(CURRENT_FILES))) { |
| PatchList list; |
| try { |
| list = patchListCache.get(cd.change(db), in); |
| } catch (PatchListNotAvailableException e) { |
| log.warn("Cannot load PatchList " + in.getId(), e); |
| list = null; |
| } |
| if (list != null) { |
| out.files = Maps.newTreeMap(); |
| for (PatchListEntry e : list.getPatches()) { |
| if (Patch.COMMIT_MSG.equals(e.getNewName())) { |
| continue; |
| } |
| |
| FileInfo d = new FileInfo(); |
| d.status = e.getChangeType() != Patch.ChangeType.MODIFIED |
| ? e.getChangeType().getCode() |
| : null; |
| d.oldPath = e.getOldName(); |
| if (e.getPatchType() == Patch.PatchType.BINARY) { |
| d.binary = true; |
| } else { |
| d.linesInserted = e.getInsertions() > 0 ? e.getInsertions() : null; |
| d.linesDeleted = e.getDeletions() > 0 ? e.getDeletions() : null; |
| } |
| |
| FileInfo o = out.files.put(e.getNewName(), d); |
| if (o != null) { |
| // This should only happen on a delete-add break created by JGit |
| // when the file was rewritten and too little content survived. Write |
| // a single record with data from both sides. |
| d.status = Patch.ChangeType.REWRITE.getCode(); |
| if (o.binary != null && o.binary) { |
| d.binary = true; |
| } |
| if (o.linesInserted != null) { |
| d.linesInserted = o.linesInserted; |
| } |
| if (o.linesDeleted != null) { |
| d.linesDeleted = o.linesDeleted; |
| } |
| } |
| } |
| } |
| } |
| return out; |
| } |
| |
| private Map<String, FetchInfo> makeFetchMap(ChangeData cd, PatchSet in) |
| throws OrmException { |
| Map<String, FetchInfo> r = Maps.newLinkedHashMap(); |
| String refName = in.getRefName(); |
| ChangeControl ctl = control(cd); |
| if (ctl != null && ctl.forUser(anonymous).isPatchVisible(in, db.get())) { |
| if (urls.git != null) { |
| r.put("git", new FetchInfo(urls.git |
| + cd.change(db).getProject().get(), refName)); |
| } |
| } |
| if (urls.http != null) { |
| r.put("http", new FetchInfo(urls.http |
| + cd.change(db).getProject().get(), refName)); |
| } else { |
| String http = urlProvider.get(); |
| if (!Strings.isNullOrEmpty(http)) { |
| r.put("http", new FetchInfo(http |
| + cd.change(db).getProject().get(), refName)); |
| } |
| } |
| if (!sshInfo.getHostKeys().isEmpty()) { |
| HostKey host = sshInfo.getHostKeys().get(0); |
| r.put("ssh", new FetchInfo(String.format( |
| "ssh://%s/%s", |
| host.getHost(), cd.change(db).getProject().get()), |
| refName)); |
| } |
| |
| return r; |
| } |
| |
| private static GitPerson toGitPerson(UserIdentity committer) { |
| GitPerson p = new GitPerson(); |
| p.name = committer.getName(); |
| p.email = committer.getEmail(); |
| p.date = committer.getDate(); |
| p.tz = committer.getTimeZone(); |
| return p; |
| } |
| |
| static class ChangeInfo { |
| String project; |
| String branch; |
| String topic; |
| String id; |
| String subject; |
| Change.Status status; |
| Timestamp created; |
| Timestamp updated; |
| Boolean starred; |
| Boolean reviewed; |
| |
| String _sortkey; |
| int _number; |
| |
| AccountAttribute owner; |
| Map<String, LabelInfo> labels; |
| String current_revision; |
| Map<String, RevisionInfo> revisions; |
| |
| Boolean _moreChanges; |
| } |
| |
| static class RevisionInfo { |
| private transient boolean isCurrent; |
| Boolean draft; |
| int _number; |
| Map<String, FetchInfo> fetch; |
| CommitInfo commit; |
| Map<String, FileInfo> files; |
| } |
| |
| static class FetchInfo { |
| String url; |
| String ref; |
| |
| FetchInfo(String url, String ref) { |
| this.url = url; |
| this.ref = ref; |
| } |
| } |
| |
| static class GitPerson { |
| String name; |
| String email; |
| Timestamp date; |
| int tz; |
| } |
| |
| static class CommitInfo { |
| String commit; |
| List<CommitInfo> parents; |
| GitPerson author; |
| GitPerson committer; |
| String subject; |
| String message; |
| } |
| |
| static class FileInfo { |
| Character status; |
| Boolean binary; |
| String oldPath; |
| Integer linesInserted; |
| Integer linesDeleted; |
| } |
| |
| static class LabelInfo { |
| transient SubmitRecord.Label.Status _status; |
| AccountAttribute approved; |
| AccountAttribute rejected; |
| |
| AccountAttribute recommended; |
| AccountAttribute disliked; |
| Short value; |
| Boolean optional; |
| } |
| } |