diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt index 6d381dae04..87a332fadb 100644 --- a/Documentation/config-gerrit.txt +++ b/Documentation/config-gerrit.txt @@ -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 ~~~~~~~~~~~~~~~~~~~~ diff --git a/Documentation/config-hooks.txt b/Documentation/config-hooks.txt new file mode 100644 index 0000000000..9a103b0494 --- /dev/null +++ b/Documentation/config-hooks.txt @@ -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 --project --branch --commit --patchset +==== + +comment-added +~~~~~~~~~~~~~ + +This is called whenever a comment is added to a change. + +==== + comment-added --change --project --branch --author --comment [-- -- ...] +==== + +change-merged +~~~~~~~~~~~~~ + +Called whenever a change has been merged. + +==== + change-merged --change --project --branch --submitter --commit +==== + +change-abandoned +~~~~~~~~~~~~~~~~ + +Called whenever a change has been abandoned. + +==== + change-abandoned --change --project --branch --abandoner --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] \ No newline at end of file diff --git a/Documentation/index.txt b/Documentation/index.txt index e8c6858643..856c974eef 100644 --- a/Documentation/index.txt +++ b/Documentation/index.txt @@ -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 ----------------------- diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/changedetail/AbandonChange.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/changedetail/AbandonChange.java index f77da354be..4c3bd49283 100644 --- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/changedetail/AbandonChange.java +++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/changedetail/AbandonChange.java @@ -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 { @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 { this.patchSetId = patchSetId; this.message = message; + this.hooks = hooks; } @Override @@ -129,6 +133,8 @@ class AbandonChange extends Handler { cm.send(); } + hooks.doChangeAbandonedHook(change, currentUser.getAccount(), message); + return changeDetailFactory.create(changeId).call(); } } diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookRunner.java b/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookRunner.java new file mode 100644 index 0000000000..7aef477887 --- /dev/null +++ b/gerrit-server/src/main/java/com/google/gerrit/common/ChangeHookRunner.java @@ -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 args = new ArrayList(); + 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 approvals) { + final List args = new ArrayList(); + 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 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 args = new ArrayList(); + 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 args = new ArrayList(); + 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 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 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(); + } + } + }); + } +} diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePaths.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePaths.java index bd82f072e3..7ffa37e330 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePaths.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePaths.java @@ -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"); diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java index 811f70ca8b..adf2f65ccd 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java @@ -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 alreadyAccepted; private RefUpdate branchUpdate; + private final ChangeHookRunner hooks; + private final AccountCache accountCache; + @Inject MergeOp(final GitRepositoryManager grm, final SchemaFactory 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) { diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PublishComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PublishComments.java index 14c29f1972..38b192fa4d 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PublishComments.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PublishComments.java @@ -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 { 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 { 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 { 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 { touchChange(); email(); + fireHook(); return VoidResult.INSTANCE; } @@ -278,4 +283,14 @@ public class PublishComments implements Callable { log.error("Failed to obtain PatchSetInfo for patch set " + patchSetId, e); } } + + private void fireHook() { + final Map changed = + new HashMap(); + for (ApprovalCategoryValue.Id v : approvals) { + changed.put(v.getParentKey(), v); + } + + hooks.doCommentAddedHook(change, user.getAccount(), messageText, changed); + } } diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ApproveCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ApproveCommand.java index 6f3f104f97..d79766c378 100644 --- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ApproveCommand.java +++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ApproveCommand.java @@ -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 optionList; @Override @@ -154,6 +160,9 @@ public class ApproveCommand extends BaseCommand { msgBuf.append(patchSetId.get()); msgBuf.append(": "); + final Map approvalsMap = + new HashMap(); + 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 parsePatchSetId(final String patchIdentity) diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java index f6603e770f..dfc50c4271 100644 --- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java +++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java @@ -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); } }