Add event hook support

The following hooks are defined and implemented:

 * patchset-created --change <change id> --project <project name> --branch <branch> --commit <sha1> --patchset <patchset id>
 * comment-added --change <change id> --project <project name> --branch <branch> --author <comment author> --comment <comment> [--<approval category id> <score> --<approval category id> <score> ...]
 * change-merged --change <change id> --project <project name> --branch <branch> --submitter <submitter> --commit <sha1>
 * change-abandoned --change <change id> --project <project name> --branch <branch> --abandoner <abandoner> --reason <reason>

Bug: issue 368
Bug: issue 383
Change-Id: Ic2f041a71c744d0938d79b1106c9119d6318731a
Signed-off-by: Shawn O. Pearce <sop@google.com>
This commit is contained in:
Shane Mc Cormack
2010-01-12 21:56:44 +00:00
committed by Shawn O. Pearce
parent 48bc519a98
commit 6c2b677980
10 changed files with 464 additions and 5 deletions

View File

@@ -681,6 +681,35 @@ Valid replacements are `$\{project\}` for the project name in Gerrit
and `$\{branch\}` for the name of the branch.
[[hooks]]Section hooks
~~~~~~~~~~~~~~~~~~~~~~~~
See also link:config-hooks.html[Hooks].
[[hooks.path]]hooks.path::
+
Optional path to hooks, if not specified then `'$site_path'/hooks` will be used.
[[hooks.patchsetCreatedHook]]hooks.patchsetCreatedHook::
+
Optional filename for the patchset created hook, if not specified then
`patchset-created` will be used.
[[hooks.commentAddedHook]]hooks.commentAddedHook::
+
Optional filename for the comment added hook, if not specified then
`comment-added` will be used.
[[hooks.changeMergedHook]]hooks.changeMergedHook::
+
Optional filename for the change merged hook, if not specified then
`change-merged` will be used.
[[hooks.changeAbandonedHook]]hooks.changeAbandonedHook::
+
Optional filename for the change abandoned hook, if not specified then
`change-abandoned` will be used.
[[http]]Section http
~~~~~~~~~~~~~~~~~~~~

View File

@@ -0,0 +1,79 @@
Gerrit Code Review - Hooks
==========================
Gerrit does not run any of the standard git hooks in the
repositories it works with, but it does have its own hook mechanism
included. Gerrit looks in `'$site_path'/hooks` for executables with
names listed below.
The environment will have GIT_DIR set to the full path of the
affected git repository so that git commands can be easily run.
Make sure your hook scripts are executable if running on *nix.
Hooks are run in the background after the relevent change has
taken place so are unable to affect the outcome of any given
change. Because of the fact the hooks are run in the background
after the activity, a hook might not be notified about an event if
the server is shutdown before the hook can be invoked.
Supported Hooks
---------------
patchset-created
~~~~~~~~~~~~~~~~
This is called whenever a patchset is created (this includes new
changes)
====
patchset-created --change <change id> --project <project name> --branch <branch> --commit <sha1> --patchset <patchset id>
====
comment-added
~~~~~~~~~~~~~
This is called whenever a comment is added to a change.
====
comment-added --change <change id> --project <project name> --branch <branch> --author <comment author> --comment <comment> [--<approval category id> <score> --<approval category id> <score> ...]
====
change-merged
~~~~~~~~~~~~~
Called whenever a change has been merged.
====
change-merged --change <change id> --project <project name> --branch <branch> --submitter <submitter> --commit <sha1>
====
change-abandoned
~~~~~~~~~~~~~~~~
Called whenever a change has been abandoned.
====
change-abandoned --change <change id> --project <project name> --branch <branch> --abandoner <abandoner> --reason <reason>
====
Configuration Settings
----------------------
It is possible to change where gerrit looks for hooks, and what
filenames it looks for by adding a [hooks] section to gerrit.config.
Gerrit will use the value of hooks.path for the hooks directory, and
the values of hooks.patchsetCreatedHook, hooks.commentAddedHook,
hooks.changeMergedHook and hooks.changeAbandonedHook for the
filenames for the hooks.
See Also
--------
* link:config-gerrit.html#hooks[Section hooks]
GERRIT
------
Part of link:index.html[Gerrit Code Review]

View File

@@ -29,6 +29,7 @@ Configuration
* link:config-headerfooter.html[Site Header/Footer]
* link:config-sso.html[Single Sign-On Systems]
* link:config-apache2.html[Apache 2 Reverse Proxy]
* link:config-hooks.html[Hooks]
Developer Documentation
-----------------------

View File

@@ -14,6 +14,7 @@
package com.google.gerrit.httpd.rpc.changedetail;
import com.google.gerrit.common.ChangeHookRunner;
import com.google.gerrit.common.data.ChangeDetail;
import com.google.gerrit.common.errors.NoSuchEntityException;
import com.google.gerrit.httpd.rpc.Handler;
@@ -55,13 +56,15 @@ class AbandonChange extends Handler<ChangeDetail> {
@Nullable
private final String message;
private final ChangeHookRunner hooks;
@Inject
AbandonChange(final ChangeControl.Factory changeControlFactory,
final ReviewDb db, final IdentifiedUser currentUser,
final AbandonedSender.Factory abandonedSenderFactory,
final ChangeDetailFactory.Factory changeDetailFactory,
@Assisted final PatchSet.Id patchSetId,
@Assisted @Nullable final String message) {
@Assisted @Nullable final String message, final ChangeHookRunner hooks) {
this.changeControlFactory = changeControlFactory;
this.db = db;
this.currentUser = currentUser;
@@ -70,6 +73,7 @@ class AbandonChange extends Handler<ChangeDetail> {
this.patchSetId = patchSetId;
this.message = message;
this.hooks = hooks;
}
@Override
@@ -129,6 +133,8 @@ class AbandonChange extends Handler<ChangeDetail> {
cm.send();
}
hooks.doChangeAbandonedHook(change, currentUser.getAccount(), message);
return changeDetailFactory.create(changeId).call();
}
}

View File

@@ -0,0 +1,288 @@
// 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.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.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.inject.Inject;
import com.google.inject.Singleton;
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 org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.Repository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 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);
/** 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;
/**
* 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.
*/
@Inject
public ChangeHookRunner(final WorkQueue queue, final GitRepositoryManager repoManager, @GerritServerConfig final Config config, final SitePaths sitePath) {
this.repoManager = repoManager;
this.hookQueue = queue.createQueue(1, "hook");
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());
}
/**
* 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 List<String> args = new ArrayList<String>();
args.add(patchsetCreatedHook.getAbsolutePath());
args.add("--change");
args.add(change.getKey().get());
args.add("--project");
args.add(change.getProject().get());
args.add("--branch");
args.add(change.getDest().getShortName());
args.add("--commit");
args.add(patchSet.getRevision().get());
args.add("--patchset");
args.add(Integer.toString(patchSet.getPatchSetId()));
runHook(getRepo(change), args);
}
/**
* Fire the Comment Added Hook.
*
* @param change The change itself.
* @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 String comment, final Map<ApprovalCategory.Id, ApprovalCategoryValue.Id> approvals) {
final List<String> args = new ArrayList<String>();
args.add(commentAddedHook.getAbsolutePath());
args.add("--change");
args.add(change.getKey().get());
args.add("--project");
args.add(change.getProject().get());
args.add("--branch");
args.add(change.getDest().getShortName());
args.add("--author");
args.add(getDisplayName(account));
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 List<String> args = new ArrayList<String>();
args.add(changeMergedHook.getAbsolutePath());
args.add("--change");
args.add(change.getKey().get());
args.add("--project");
args.add(change.getProject().get());
args.add("--branch");
args.add(change.getDest().getShortName());
args.add("--submitter");
args.add(getDisplayName(account));
args.add("--commit");
args.add(patchSet.getRevision().get());
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 List<String> args = new ArrayList<String>();
args.add(changeAbandonedHook.getAbsolutePath());
args.add("--change");
args.add(change.getKey().get());
args.add("--project");
args.add(change.getProject().get());
args.add("--branch");
args.add(change.getDest().getShortName());
args.add("--abandoner");
args.add(getDisplayName(account));
args.add("--reason");
args.add(reason == null ? "" : reason);
runHook(getRepo(change), args);
}
/**
* 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();
}
}
});
}
}

View File

@@ -28,6 +28,7 @@ public final class SitePaths {
public final File etc_dir;
public final File lib_dir;
public final File logs_dir;
public final File hooks_dir;
public final File static_dir;
public final File gerrit_sh;
@@ -59,6 +60,7 @@ public final class SitePaths {
etc_dir = new File(site_path, "etc");
lib_dir = new File(site_path, "lib");
logs_dir = new File(site_path, "logs");
hooks_dir = new File(site_path, "hooks");
static_dir = new File(site_path, "static");
gerrit_sh = new File(bin_dir, "gerrit.sh");

View File

@@ -14,6 +14,8 @@
package com.google.gerrit.server.git;
import com.google.gerrit.common.ChangeHookRunner;
import com.google.gerrit.common.data.AccountInfoCache;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.concurrent.TimeUnit.MINUTES;
@@ -32,6 +34,7 @@ import com.google.gerrit.reviewdb.ReviewDb;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.config.CanonicalWebUrl;
import com.google.gerrit.server.config.Nullable;
import com.google.gerrit.server.mail.EmailException;
@@ -149,6 +152,9 @@ public class MergeOp {
private Set<RevCommit> alreadyAccepted;
private RefUpdate branchUpdate;
private final ChangeHookRunner hooks;
private final AccountCache accountCache;
@Inject
MergeOp(final GitRepositoryManager grm, final SchemaFactory<ReviewDb> sf,
final ProjectCache pc, final FunctionState.Factory fs,
@@ -158,7 +164,8 @@ public class MergeOp {
final ApprovalTypes approvalTypes, final PatchSetInfoFactory psif,
final IdentifiedUser.GenericFactory iuf,
@GerritPersonIdent final PersonIdent myIdent,
final MergeQueue mergeQueue, @Assisted final Branch.NameKey branch) {
final MergeQueue mergeQueue, @Assisted final Branch.NameKey branch,
final ChangeHookRunner hooks, final AccountCache accountCache) {
repoManager = grm;
schemaFactory = sf;
functionState = fs;
@@ -171,6 +178,8 @@ public class MergeOp {
patchSetInfoFactory = psif;
identifiedUserFactory = iuf;
this.mergeQueue = mergeQueue;
this.hooks = hooks;
this.accountCache = accountCache;
this.myIdent = myIdent;
destBranch = branch;
@@ -1151,6 +1160,12 @@ public class MergeOp {
} catch (EmailException e) {
log.error("Cannot send email for submitted patch set " + c.getId(), e);
}
try {
hooks.doChangeMergedHook(c, accountCache.get(submitter.getAccountId()).getAccount(), schema.patchSets().get(c.currentPatchSetId()));
} catch (OrmException ex) {
log.error("Cannot run hook for submitted patch set " + c.getId(), ex);
}
}
private void setNew(Change c, ChangeMessage msg) {

View File

@@ -14,6 +14,7 @@
package com.google.gerrit.server.patch;
import com.google.gerrit.common.ChangeHookRunner;
import com.google.gerrit.common.data.ApprovalType;
import com.google.gerrit.common.data.ApprovalTypes;
import com.google.gerrit.reviewdb.ApprovalCategory;
@@ -65,6 +66,7 @@ public class PublishComments implements Callable<VoidResult> {
private final PatchSetInfoFactory patchSetInfoFactory;
private final ChangeControl.Factory changeControlFactory;
private final FunctionState.Factory functionStateFactory;
private final ChangeHookRunner hooks;
private final PatchSet.Id patchSetId;
private final String messageText;
@@ -82,6 +84,7 @@ public class PublishComments implements Callable<VoidResult> {
final PatchSetInfoFactory patchSetInfoFactory,
final ChangeControl.Factory changeControlFactory,
final FunctionState.Factory functionStateFactory,
final ChangeHookRunner hooks,
@Assisted final PatchSet.Id patchSetId,
@Assisted final String messageText,
@@ -93,6 +96,7 @@ public class PublishComments implements Callable<VoidResult> {
this.commentSenderFactory = commentSenderFactory;
this.changeControlFactory = changeControlFactory;
this.functionStateFactory = functionStateFactory;
this.hooks = hooks;
this.patchSetId = patchSetId;
this.messageText = messageText;
@@ -121,6 +125,7 @@ public class PublishComments implements Callable<VoidResult> {
touchChange();
email();
fireHook();
return VoidResult.INSTANCE;
}
@@ -278,4 +283,14 @@ public class PublishComments implements Callable<VoidResult> {
log.error("Failed to obtain PatchSetInfo for patch set " + patchSetId, e);
}
}
private void fireHook() {
final Map<ApprovalCategory.Id, ApprovalCategoryValue.Id> changed =
new HashMap<ApprovalCategory.Id, ApprovalCategoryValue.Id>();
for (ApprovalCategoryValue.Id v : approvals) {
changed.put(v.getParentKey(), v);
}
hooks.doCommentAddedHook(change, user.getAccount(), messageText, changed);
}
}

View File

@@ -14,6 +14,7 @@
package com.google.gerrit.sshd.commands;
import com.google.gerrit.common.ChangeHookRunner;
import com.google.gerrit.common.data.ApprovalType;
import com.google.gerrit.common.data.ApprovalTypes;
import com.google.gerrit.reviewdb.ApprovalCategory;
@@ -49,8 +50,10 @@ import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class ApproveCommand extends BaseCommand {
@@ -106,6 +109,9 @@ public class ApproveCommand extends BaseCommand {
@Inject
private FunctionState.Factory functionStateFactory;
@Inject
private ChangeHookRunner hooks;
private List<ApproveOption> optionList;
@Override
@@ -154,6 +160,9 @@ public class ApproveCommand extends BaseCommand {
msgBuf.append(patchSetId.get());
msgBuf.append(": ");
final Map<ApprovalCategory.Id, ApprovalCategoryValue.Id> approvalsMap =
new HashMap<ApprovalCategory.Id, ApprovalCategoryValue.Id>();
for (ApproveOption co : optionList) {
final ApprovalCategory.Id category = co.getCategoryId();
PatchSetApproval.Key psaKey =
@@ -174,10 +183,12 @@ public class ApproveCommand extends BaseCommand {
}
}
String message =
db.approvalCategoryValues().get(
new ApprovalCategoryValue.Id(category, score)).getName();
final ApprovalCategoryValue.Id val =
new ApprovalCategoryValue.Id(category, score);
String message = db.approvalCategoryValues().get(val).getName();
msgBuf.append(" " + message + ";");
approvalsMap.put(category, val);
}
msgBuf.deleteCharAt(msgBuf.length() - 1);
@@ -196,6 +207,9 @@ public class ApproveCommand extends BaseCommand {
ChangeUtil.touch(change, db);
sendMail(change, change.currentPatchSetId(), cm);
hooks.doCommentAddedHook(change, currentUser.getAccount(), changeComment,
approvalsMap);
}
private Set<PatchSet.Id> parsePatchSetId(final String patchIdentity)

View File

@@ -14,6 +14,7 @@
package com.google.gerrit.sshd.commands;
import com.google.gerrit.common.ChangeHookRunner;
import static com.google.gerrit.reviewdb.ApprovalCategory.PUSH_HEAD;
import static com.google.gerrit.reviewdb.ApprovalCategory.PUSH_HEAD_REPLACE;
import static com.google.gerrit.reviewdb.ApprovalCategory.PUSH_HEAD_UPDATE;
@@ -153,6 +154,9 @@ final class Receive extends AbstractGitCommand {
@Inject
private PatchSetInfoFactory patchSetInfoFactory;
@Inject
private ChangeHookRunner hooks;
@Inject
@CanonicalWebUrl
@Nullable
@@ -870,6 +874,8 @@ final class Receive extends AbstractGitCommand {
} catch (EmailException e) {
log.error("Cannot send email for new change " + change.getId(), e);
}
hooks.doPatchsetCreatedHook(change, ps);
}
private static boolean isReviewer(final FooterLine candidateFooterLine) {
@@ -1132,6 +1138,8 @@ final class Receive extends AbstractGitCommand {
insertDummyApproval(result, reviewer, catId, db);
}
}
hooks.doPatchsetCreatedHook(result.change, ps);
}
final RefUpdate ru = repo.updateRef(ps.getRefName());
@@ -1447,6 +1455,8 @@ final class Receive extends AbstractGitCommand {
final PatchSet.Id psi = result.patchSet.getId();
log.error("Cannot send email for submitted patch set " + psi, e);
}
hooks.doChangeMergedHook(result.change, currentUser.getAccount(), result.patchSet);
}
}