| // Copyright (C) 2010 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.common; |
| |
| import com.google.gerrit.common.data.ApprovalType; |
| import com.google.gerrit.common.data.ApprovalTypes; |
| import com.google.gerrit.reviewdb.Account; |
| import com.google.gerrit.reviewdb.ApprovalCategory; |
| import com.google.gerrit.reviewdb.ApprovalCategoryValue; |
| import com.google.gerrit.reviewdb.Change; |
| import com.google.gerrit.reviewdb.PatchSet; |
| import com.google.gerrit.server.IdentifiedUser; |
| import com.google.gerrit.server.account.AccountCache; |
| import com.google.gerrit.server.account.AccountState; |
| import com.google.gerrit.server.config.CanonicalWebUrl; |
| import com.google.gerrit.server.config.GerritServerConfig; |
| import com.google.gerrit.server.config.SitePaths; |
| import com.google.gerrit.server.git.GitRepositoryManager; |
| import com.google.gerrit.server.git.WorkQueue; |
| import com.google.gerrit.server.project.ProjectCache; |
| import com.google.gerrit.server.project.ProjectControl; |
| import com.google.gerrit.server.project.ProjectState; |
| import com.google.inject.Inject; |
| import com.google.inject.Provider; |
| import com.google.inject.Singleton; |
| import com.google.inject.internal.Nullable; |
| |
| import org.eclipse.jgit.lib.Config; |
| import org.eclipse.jgit.lib.Repository; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| import java.io.BufferedReader; |
| import java.io.File; |
| import java.io.IOException; |
| import java.io.InputStreamReader; |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Map.Entry; |
| import java.util.concurrent.ConcurrentHashMap; |
| |
| /** |
| * This class implements hooks for certain gerrit events. |
| */ |
| @Singleton |
| public class ChangeHookRunner { |
| /** A logger for this class. */ |
| private static final Logger log = LoggerFactory.getLogger(ChangeHookRunner.class); |
| |
| public static abstract class ChangeEvent { |
| } |
| |
| public static class ApprovalAttribute { |
| public String type; |
| public String description; |
| public String value; |
| } |
| |
| public static class AuthorAttribute { |
| public String name; |
| public String email; |
| } |
| |
| public static class ChangeAttribute { |
| public String project; |
| public String branch; |
| public String id; |
| public String number; |
| public String subject; |
| public AuthorAttribute owner; |
| public String url; |
| } |
| |
| public static class PatchSetAttribute { |
| public String number; |
| public String revision; |
| } |
| |
| public static class CommentAddedEvent extends ChangeEvent { |
| public final String type = "comment-added"; |
| public ChangeAttribute change; |
| public PatchSetAttribute patchSet; |
| public AuthorAttribute author; |
| public ApprovalAttribute[] approvals; |
| public String comment; |
| } |
| |
| public static class ChangeMergedEvent extends ChangeEvent { |
| public final String type = "change-merged"; |
| public ChangeAttribute change; |
| public PatchSetAttribute patchSet; |
| public AuthorAttribute submitter; |
| } |
| |
| public static class ChangeAbandonedEvent extends ChangeEvent { |
| public final String type = "change-abandoned"; |
| public ChangeAttribute change; |
| public PatchSetAttribute patchSet; |
| public AuthorAttribute abandoner; |
| public String reason; |
| } |
| |
| public static class PatchSetCreatedEvent extends ChangeEvent { |
| public final String type = "patchset-created"; |
| public ChangeAttribute change; |
| public PatchSetAttribute patchSet; |
| } |
| |
| private static class ChangeListenerHolder { |
| final ChangeListener listener; |
| final IdentifiedUser user; |
| |
| ChangeListenerHolder(ChangeListener l, IdentifiedUser u) { |
| listener = l; |
| user = u; |
| } |
| } |
| |
| /** Listeners to receive changes as they happen. */ |
| private final Map<ChangeListener, ChangeListenerHolder> listeners = |
| new ConcurrentHashMap<ChangeListener, ChangeListenerHolder>(); |
| |
| /** Filename of the new patchset hook. */ |
| private final File patchsetCreatedHook; |
| |
| /** Filename of the new comments hook. */ |
| private final File commentAddedHook; |
| |
| /** Filename of the change merged hook. */ |
| private final File changeMergedHook; |
| |
| /** Filename of the change abandoned hook. */ |
| private final File changeAbandonedHook; |
| |
| /** Repository Manager. */ |
| private final GitRepositoryManager repoManager; |
| |
| /** Queue of hooks that need to run. */ |
| private final WorkQueue.Executor hookQueue; |
| |
| private final ProjectCache projectCache; |
| |
| private final AccountCache accountCache; |
| |
| private final ApprovalTypes approvalTypes; |
| |
| private final Provider<String> urlProvider; |
| |
| |
| /** |
| * Create a new ChangeHookRunner. |
| * |
| * @param queue Queue to use when processing hooks. |
| * @param repoManager The repository manager. |
| * @param config Config file to use. |
| * @param sitePath The sitepath of this gerrit install. |
| * @param projectCache the project cache instance for the server. |
| */ |
| @Inject |
| public ChangeHookRunner(final WorkQueue queue, |
| final GitRepositoryManager repoManager, |
| @GerritServerConfig final Config config, final SitePaths sitePath, |
| final ProjectCache projectCache, |
| final AccountCache accountCache, |
| final ApprovalTypes approvalTypes, |
| @CanonicalWebUrl @Nullable final Provider<String> cwu) { |
| this.repoManager = repoManager; |
| this.hookQueue = queue.createQueue(1, "hook"); |
| this.projectCache = projectCache; |
| this.accountCache = accountCache; |
| this.approvalTypes = approvalTypes; |
| this.urlProvider = cwu; |
| |
| final File hooksPath = sitePath.resolve(getValue(config, "hooks", "path", sitePath.hooks_dir.getAbsolutePath())); |
| |
| patchsetCreatedHook = sitePath.resolve(new File(hooksPath, getValue(config, "hooks", "patchsetCreatedHook", "patchset-created")).getPath()); |
| commentAddedHook = sitePath.resolve(new File(hooksPath, getValue(config, "hooks", "commentAddedHook", "comment-added")).getPath()); |
| changeMergedHook = sitePath.resolve(new File(hooksPath, getValue(config, "hooks", "changeMergedHook", "change-merged")).getPath()); |
| changeAbandonedHook = sitePath.resolve(new File(hooksPath, getValue(config, "hooks", "changeAbandonedHook", "change-abandoned")).getPath()); |
| } |
| |
| public void addChangeListener(ChangeListener listener, IdentifiedUser user) { |
| listeners.put(listener, new ChangeListenerHolder(listener, user)); |
| } |
| |
| public void removeChangeListener(ChangeListener listener) { |
| listeners.remove(listener); |
| } |
| |
| /** |
| * Helper Method for getting values from the config. |
| * |
| * @param config Config file to get value from. |
| * @param section Section to look in. |
| * @param setting Setting to get. |
| * @param fallback Fallback value. |
| * @return Setting value if found, else fallback. |
| */ |
| private String getValue(final Config config, final String section, final String setting, final String fallback) { |
| final String result = config.getString(section, null, setting); |
| return (result == null) ? fallback : result; |
| } |
| |
| /** |
| * Get the Repository for the given change, or null on error. |
| * |
| * @param change Change to get repo for, |
| * @return Repository or null. |
| */ |
| private Repository getRepo(final Change change) { |
| try { |
| return repoManager.openRepository(change.getProject().get()); |
| } catch (Exception ex) { |
| return null; |
| } |
| } |
| |
| /** |
| * Fire the Patchset Created Hook. |
| * |
| * @param change The change itself. |
| * @param patchSet The Patchset that was created. |
| */ |
| public void doPatchsetCreatedHook(final Change change, final PatchSet patchSet) { |
| final PatchSetCreatedEvent event = new PatchSetCreatedEvent(); |
| |
| event.change = getChangeAttribute(change); |
| event.patchSet = getPatchSetAttribute(patchSet); |
| fireEvent(change, event); |
| |
| final List<String> args = new ArrayList<String>(); |
| args.add(patchsetCreatedHook.getAbsolutePath()); |
| |
| args.add("--change"); |
| args.add(event.change.id); |
| args.add("--project"); |
| args.add(event.change.project); |
| args.add("--branch"); |
| args.add(event.change.branch); |
| args.add("--commit"); |
| args.add(event.patchSet.revision); |
| args.add("--patchset"); |
| args.add(event.patchSet.number); |
| |
| runHook(getRepo(change), args); |
| } |
| |
| /** |
| * Fire the Comment Added Hook. |
| * |
| * @param change The change itself. |
| * @param patchSet The patchset this comment is related to. |
| * @param account The gerrit user who commited the change. |
| * @param comment The comment given. |
| * @param approvals Map of Approval Categories and Scores |
| */ |
| public void doCommentAddedHook(final Change change, final Account account, final PatchSet patchSet, final String comment, final Map<ApprovalCategory.Id, ApprovalCategoryValue.Id> approvals) { |
| final CommentAddedEvent event = new CommentAddedEvent(); |
| |
| event.change = getChangeAttribute(change); |
| event.author = getAccountAttribute(account); |
| event.patchSet = getPatchSetAttribute(patchSet); |
| event.comment = comment; |
| |
| if (approvals.size() > 0) { |
| event.approvals = new ApprovalAttribute[approvals.size()]; |
| int i = 0; |
| for (Map.Entry<ApprovalCategory.Id, ApprovalCategoryValue.Id> approval : approvals.entrySet()) { |
| event.approvals[i++] = getApprovalAttribute(approval); |
| } |
| } |
| |
| fireEvent(change, event); |
| |
| final List<String> args = new ArrayList<String>(); |
| args.add(commentAddedHook.getAbsolutePath()); |
| |
| args.add("--change"); |
| args.add(event.change.id); |
| args.add("--project"); |
| args.add(event.change.project); |
| args.add("--branch"); |
| args.add(event.change.branch); |
| args.add("--author"); |
| args.add(getDisplayName(account)); |
| args.add("--commit"); |
| args.add(event.patchSet.revision); |
| args.add("--comment"); |
| args.add(comment == null ? "" : comment); |
| for (Map.Entry<ApprovalCategory.Id, ApprovalCategoryValue.Id> approval : approvals.entrySet()) { |
| args.add("--" + approval.getKey().get()); |
| args.add(Short.toString(approval.getValue().get())); |
| } |
| |
| runHook(getRepo(change), args); |
| } |
| |
| /** |
| * Fire the Change Merged Hook. |
| * |
| * @param change The change itself. |
| * @param account The gerrit user who commited the change. |
| * @param patchSet The patchset that was merged. |
| */ |
| public void doChangeMergedHook(final Change change, final Account account, final PatchSet patchSet) { |
| final ChangeMergedEvent event = new ChangeMergedEvent(); |
| |
| event.change = getChangeAttribute(change); |
| event.submitter = getAccountAttribute(account); |
| event.patchSet = getPatchSetAttribute(patchSet); |
| fireEvent(change, event); |
| |
| final List<String> args = new ArrayList<String>(); |
| args.add(changeMergedHook.getAbsolutePath()); |
| |
| args.add("--change"); |
| args.add(event.change.id); |
| args.add("--project"); |
| args.add(event.change.project); |
| args.add("--branch"); |
| args.add(event.change.branch); |
| args.add("--submitter"); |
| args.add(getDisplayName(account)); |
| args.add("--commit"); |
| args.add(event.patchSet.revision); |
| |
| runHook(getRepo(change), args); |
| } |
| |
| /** |
| * Fire the Change Abandoned Hook. |
| * |
| * @param change The change itself. |
| * @param account The gerrit user who abandoned the change. |
| * @param reason Reason for abandoning the change. |
| */ |
| public void doChangeAbandonedHook(final Change change, final Account account, final String reason) { |
| final ChangeAbandonedEvent event = new ChangeAbandonedEvent(); |
| |
| event.change = getChangeAttribute(change); |
| event.abandoner = getAccountAttribute(account); |
| event.reason = reason; |
| fireEvent(change, event); |
| |
| final List<String> args = new ArrayList<String>(); |
| args.add(changeAbandonedHook.getAbsolutePath()); |
| |
| args.add("--change"); |
| args.add(event.change.id); |
| args.add("--project"); |
| args.add(event.change.project); |
| args.add("--branch"); |
| args.add(event.change.branch); |
| args.add("--abandoner"); |
| args.add(getDisplayName(account)); |
| args.add("--reason"); |
| args.add(reason == null ? "" : reason); |
| |
| runHook(getRepo(change), args); |
| } |
| |
| private void fireEvent(final Change change, final ChangeEvent event) { |
| for (ChangeListenerHolder holder : listeners.values()) { |
| if (isVisibleTo(change, holder.user)) { |
| holder.listener.onChangeEvent(event); |
| } |
| } |
| } |
| |
| private boolean isVisibleTo(Change change, IdentifiedUser user) { |
| final ProjectState pe = projectCache.get(change.getProject()); |
| if (pe == null) { |
| return false; |
| } |
| final ProjectControl pc = pe.controlFor(user); |
| return pc.controlFor(change).isVisible(); |
| } |
| |
| /** Get a link to the change; null if the server doesn't know its own address. */ |
| private String getChangeUrl(final Change change) { |
| if (change != null && getGerritUrl() != null) { |
| final StringBuilder r = new StringBuilder(); |
| r.append(getGerritUrl()); |
| r.append(change.getChangeId()); |
| return r.toString(); |
| } |
| return null; |
| } |
| |
| private String getGerritUrl() { |
| return urlProvider.get(); |
| } |
| |
| |
| /** |
| * Create an ApprovalAttribute for the given approval suitable for serialization to JSON. |
| * @param approval |
| * @return object suitable for serialization to JSON |
| */ |
| private ApprovalAttribute getApprovalAttribute( |
| Entry<ApprovalCategory.Id, ApprovalCategoryValue.Id> approval) { |
| ApprovalAttribute a = new ApprovalAttribute(); |
| a.type = approval.getKey().get(); |
| final ApprovalType at = approvalTypes.getApprovalType(approval.getKey()); |
| a.description = at.getCategory().getName(); |
| a.value = Short.toString(approval.getValue().get()); |
| return a; |
| } |
| |
| /** |
| * Create an AuthorAttribute for the given account suitable for serialization to JSON. |
| * |
| * @param account |
| * @return object suitable for serialization to JSON |
| */ |
| private AuthorAttribute getAccountAttribute(final Account account) { |
| AuthorAttribute author = new AuthorAttribute(); |
| author.name = account.getFullName(); |
| author.email = account.getPreferredEmail(); |
| return author; |
| } |
| |
| /** |
| * Create a ChangeAttribute for the given change suitable for serialization to JSON. |
| * |
| * @param change |
| * @return object suitable for serialization to JSON |
| */ |
| private ChangeAttribute getChangeAttribute(final Change change) { |
| ChangeAttribute a = new ChangeAttribute(); |
| a.project = change.getProject().get(); |
| a.branch = change.getDest().getShortName(); |
| a.id = change.getKey().get(); |
| a.number = change.getId().toString(); |
| a.subject = change.getSubject(); |
| final AccountState owner = accountCache.get(change.getOwner()); |
| a.owner = getAccountAttribute(owner.getAccount()); |
| a.url = getChangeUrl(change); |
| return a; |
| } |
| |
| /** |
| * Create an PatchSetAttribute for the given patchset suitable for serialization to JSON. |
| * |
| * @param patchSet |
| * @return object suitable for serialization to JSON |
| */ |
| private PatchSetAttribute getPatchSetAttribute(final PatchSet patchSet) { |
| PatchSetAttribute p = new PatchSetAttribute(); |
| p.revision = patchSet.getRevision().get(); |
| p.number = Integer.toString(patchSet.getPatchSetId()); |
| return p; |
| } |
| |
| /** |
| * Get the display name for the given account. |
| * |
| * @param account Account to get name for. |
| * @return Name for this account. |
| */ |
| private String getDisplayName(final Account account) { |
| if (account != null) { |
| String result = (account.getFullName() == null) ? "Anonymous Coward" : account.getFullName(); |
| if (account.getPreferredEmail() != null) { |
| result += " (" + account.getPreferredEmail() + ")"; |
| } |
| return result; |
| } |
| |
| return "Anonymous Coward"; |
| } |
| |
| /** |
| * Run a hook. |
| * |
| * @param repo Repo to run the hook for. |
| * @param args Arguments to use to run the hook. |
| */ |
| private synchronized void runHook(final Repository repo, final List<String> args) { |
| if (repo == null) { |
| log.error("No repo found for hook."); |
| return; |
| } |
| |
| hookQueue.execute(new Runnable() { |
| @Override |
| public void run() { |
| try { |
| if (new File(args.get(0)).exists()) { |
| final ProcessBuilder pb = new ProcessBuilder(args); |
| pb.redirectErrorStream(true); |
| pb.directory(repo.getDirectory()); |
| final Map<String, String> env = pb.environment(); |
| env.put("GIT_DIR", repo.getDirectory().getAbsolutePath()); |
| |
| Process ps = pb.start(); |
| ps.getOutputStream().close(); |
| |
| BufferedReader br = new BufferedReader(new InputStreamReader(ps.getInputStream())); |
| try { |
| String line; |
| while ((line = br.readLine()) != null) { |
| log.info("hook output: " + line); |
| } |
| } finally { |
| try { |
| br.close(); |
| } catch (IOException e2) { |
| } |
| |
| ps.waitFor(); |
| } |
| } |
| } catch (Throwable e) { |
| log.error("Unexpected error during hook execution", e); |
| } finally { |
| repo.close(); |
| } |
| } |
| }); |
| } |
| } |