summaryrefslogtreecommitdiffstats
path: root/src/main/java/com/gitblit/git/PatchsetReceivePack.java
diff options
context:
space:
mode:
authorJames Moger <james.moger@gitblit.com>2013-12-09 17:19:03 -0500
committerJames Moger <james.moger@gitblit.com>2014-03-03 21:34:32 -0500
commit5e3521f8496511db4df45f011ea72f25623ad90f (patch)
tree98b4f516d59833b5a8c1ccbcd45672e5b9f3add2 /src/main/java/com/gitblit/git/PatchsetReceivePack.java
parent94e12c168f5eec300fd23d0de25c7dc93a96c429 (diff)
downloadgitblit-5e3521f8496511db4df45f011ea72f25623ad90f.tar.gz
gitblit-5e3521f8496511db4df45f011ea72f25623ad90f.zip
Ticket tracker with patchset contributions
A basic issue tracker styled as a hybrid of GitHub and BitBucket issues. You may attach commits to an existing ticket or you can push a single commit to create a *proposal* ticket. Tickets keep track of patchsets (one or more commits) and allow patchset rewriting (rebase, amend, squash) by detecing the non-fast-forward update and assigning a new patchset number to the new commits. Ticket tracker -------------- The ticket tracker stores tickets as an append-only journal of changes. The journals are deserialized and a ticket is built by applying the journal entries. Tickets are indexed using Apache Lucene and all queries and searches are executed against this Lucene index. There is one trade-off to this persistence design: user attributions are non-relational. What does that mean? Each journal entry stores the username of the author. If the username changes in the user service, the journal entry will not reflect that change because the values are hard-coded. Here are a few reasons/justifications for this design choice: 1. commit identifications (author, committer, tagger) are non-relational 2. maintains the KISS principle 3. your favorite text editor can still be your administration tool Persistence Choices ------------------- **FileTicketService**: stores journals on the filesystem **BranchTicketService**: stores journals on an orphan branch **RedisTicketService**: stores journals in a Redis key-value datastore It should be relatively straight-forward to develop other backends (MongoDB, etc) as long as the journal design is preserved. Pushing Commits --------------- Each push to a ticket is identified as a patchset revision. A patchset revision may add commits to the patchset (fast-forward) OR a patchset revision may rewrite history (rebase, squash, rebase+squash, or amend). Patchset authors should not be afraid to polish, revise, and rewrite their code before merging into the proposed branch. Gitblit will create one ref for each patchset. These refs are updated for fast-forward pushes or created for rewrites. They are formatted as `refs/tickets/{shard}/{id}/{patchset}`. The *shard* is the last two digits of the id. If the id < 10, prefix a 0. The *shard* is always two digits long. The shard's purpose is to ensure Gitblit doesn't exceed any filesystem directory limits for file creation. **Creating a Proposal Ticket** You may create a new change proposal ticket just by pushing a **single commit** to `refs/for/{branch}` where branch is the proposed integration branch OR `refs/for/new` or `refs/for/default` which both will use the default repository branch. git push origin HEAD:refs/for/new **Updating a Patchset** The safe way to update an existing patchset is to push to the patchset ref. git push origin HEAD:refs/heads/ticket/{id} This ensures you do not accidentally create a new patchset in the event that the patchset was updated after you last pulled. The not-so-safe way to update an existing patchset is to push using the magic ref. git push origin HEAD:refs/for/{id} This push ref will update an exisitng patchset OR create a new patchset if the update is non-fast-forward. **Rebasing, Squashing, Amending** Gitblit makes rebasing, squashing, and amending patchsets easy. Normally, pushing a non-fast-forward update would require rewind (RW+) repository permissions. Gitblit provides a magic ref which will allow ticket participants to rewrite a ticket patchset as long as the ticket is open. git push origin HEAD:refs/for/{id} Pushing changes to this ref allows the patchset authors to rebase, squash, or amend the patchset commits without requiring client-side use of the *--force* flag on push AND without requiring RW+ permission to the repository. Since each patchset is tracked with a ref it is easy to recover from accidental non-fast-forward updates. Features -------- - Ticket tracker with status changes and responsible assignments - Patchset revision scoring mechanism - Update/Rewrite patchset handling - Close-on-push detection - Server-side Merge button for simple merges - Comments with Markdown syntax support - Rich mail notifications - Voting - Mentions - Watch lists - Querying - Searches - Partial miletones support - Multiple backend options
Diffstat (limited to 'src/main/java/com/gitblit/git/PatchsetReceivePack.java')
-rw-r--r--src/main/java/com/gitblit/git/PatchsetReceivePack.java1129
1 files changed, 1129 insertions, 0 deletions
diff --git a/src/main/java/com/gitblit/git/PatchsetReceivePack.java b/src/main/java/com/gitblit/git/PatchsetReceivePack.java
new file mode 100644
index 00000000..ae429d2e
--- /dev/null
+++ b/src/main/java/com/gitblit/git/PatchsetReceivePack.java
@@ -0,0 +1,1129 @@
+/*
+ * Copyright 2013 gitblit.com.
+ *
+ * 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.gitblit.git;
+
+import static org.eclipse.jgit.transport.BasePackPushConnection.CAPABILITY_SIDE_BAND_64K;
+
+import java.io.IOException;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.transport.ReceiveCommand.Result;
+import org.eclipse.jgit.transport.ReceiveCommand.Type;
+import org.eclipse.jgit.transport.ReceivePack;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.gitblit.Constants;
+import com.gitblit.Keys;
+import com.gitblit.manager.IGitblit;
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.TicketModel;
+import com.gitblit.models.TicketModel.Change;
+import com.gitblit.models.TicketModel.Field;
+import com.gitblit.models.TicketModel.Patchset;
+import com.gitblit.models.TicketModel.PatchsetType;
+import com.gitblit.models.TicketModel.Status;
+import com.gitblit.models.UserModel;
+import com.gitblit.tickets.ITicketService;
+import com.gitblit.tickets.TicketMilestone;
+import com.gitblit.tickets.TicketNotifier;
+import com.gitblit.utils.ArrayUtils;
+import com.gitblit.utils.DiffUtils;
+import com.gitblit.utils.DiffUtils.DiffStat;
+import com.gitblit.utils.JGitUtils;
+import com.gitblit.utils.JGitUtils.MergeResult;
+import com.gitblit.utils.JGitUtils.MergeStatus;
+import com.gitblit.utils.RefLogUtils;
+import com.gitblit.utils.StringUtils;
+
+
+/**
+ * PatchsetReceivePack processes receive commands and allows for creating, updating,
+ * and closing Gitblit tickets. It also executes Groovy pre- and post- receive
+ * hooks.
+ *
+ * The patchset mechanism defined in this class is based on the ReceiveCommits class
+ * from the Gerrit code review server.
+ *
+ * The general execution flow is:
+ * <ol>
+ * <li>onPreReceive()</li>
+ * <li>executeCommands()</li>
+ * <li>onPostReceive()</li>
+ * </ol>
+ *
+ * @author Android Open Source Project
+ * @author James Moger
+ *
+ */
+public class PatchsetReceivePack extends GitblitReceivePack {
+
+ protected static final List<String> MAGIC_REFS = Arrays.asList(Constants.R_FOR, Constants.R_TICKET);
+
+ protected static final Pattern NEW_PATCHSET =
+ Pattern.compile("^refs/tickets/(?:[0-9a-zA-Z][0-9a-zA-Z]/)?([1-9][0-9]*)(?:/new)?$");
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(PatchsetReceivePack.class);
+
+ protected final ITicketService ticketService;
+
+ protected final TicketNotifier ticketNotifier;
+
+ private boolean requireCleanMerge;
+
+ public PatchsetReceivePack(IGitblit gitblit, Repository db, RepositoryModel repository, UserModel user) {
+ super(gitblit, db, repository, user);
+ this.ticketService = gitblit.getTicketService();
+ this.ticketNotifier = ticketService.createNotifier();
+ }
+
+ /** Returns the patchset ref root from the ref */
+ private String getPatchsetRef(String refName) {
+ for (String patchRef : MAGIC_REFS) {
+ if (refName.startsWith(patchRef)) {
+ return patchRef;
+ }
+ }
+ return null;
+ }
+
+ /** Checks if the supplied ref name is a patchset ref */
+ private boolean isPatchsetRef(String refName) {
+ return !StringUtils.isEmpty(getPatchsetRef(refName));
+ }
+
+ /** Checks if the supplied ref name is a change ref */
+ private boolean isTicketRef(String refName) {
+ return refName.startsWith(Constants.R_TICKETS_PATCHSETS);
+ }
+
+ /** Extracts the integration branch from the ref name */
+ private String getIntegrationBranch(String refName) {
+ String patchsetRef = getPatchsetRef(refName);
+ String branch = refName.substring(patchsetRef.length());
+ if (branch.indexOf('%') > -1) {
+ branch = branch.substring(0, branch.indexOf('%'));
+ }
+
+ String defaultBranch = "master";
+ try {
+ defaultBranch = getRepository().getBranch();
+ } catch (Exception e) {
+ LOGGER.error("failed to determine default branch for " + repository.name, e);
+ }
+
+ long ticketId = 0L;
+ try {
+ ticketId = Long.parseLong(branch);
+ } catch (Exception e) {
+ // not a number
+ }
+ if (ticketId > 0 || branch.equalsIgnoreCase("default") || branch.equalsIgnoreCase("new")) {
+ return defaultBranch;
+ }
+ return branch;
+ }
+
+ /** Extracts the ticket id from the ref name */
+ private long getTicketId(String refName) {
+ if (refName.startsWith(Constants.R_FOR)) {
+ String ref = refName.substring(Constants.R_FOR.length());
+ if (ref.indexOf('%') > -1) {
+ ref = ref.substring(0, ref.indexOf('%'));
+ }
+ try {
+ return Long.parseLong(ref);
+ } catch (Exception e) {
+ // not a number
+ }
+ } else if (refName.startsWith(Constants.R_TICKET) ||
+ refName.startsWith(Constants.R_TICKETS_PATCHSETS)) {
+ return PatchsetCommand.getTicketNumber(refName);
+ }
+ return 0L;
+ }
+
+ /** Returns true if the ref namespace exists */
+ private boolean hasRefNamespace(String ref) {
+ Map<String, Ref> blockingFors;
+ try {
+ blockingFors = getRepository().getRefDatabase().getRefs(ref);
+ } catch (IOException err) {
+ sendError("Cannot scan refs in {0}", repository.name);
+ LOGGER.error("Error!", err);
+ return true;
+ }
+ if (!blockingFors.isEmpty()) {
+ sendError("{0} needs the following refs removed to receive patchsets: {1}",
+ repository.name, blockingFors.keySet());
+ return true;
+ }
+ return false;
+ }
+
+ /** Removes change ref receive commands */
+ private List<ReceiveCommand> excludeTicketCommands(Collection<ReceiveCommand> commands) {
+ List<ReceiveCommand> filtered = new ArrayList<ReceiveCommand>();
+ for (ReceiveCommand cmd : commands) {
+ if (!isTicketRef(cmd.getRefName())) {
+ // this is not a ticket ref update
+ filtered.add(cmd);
+ }
+ }
+ return filtered;
+ }
+
+ /** Removes patchset receive commands for pre- and post- hook integrations */
+ private List<ReceiveCommand> excludePatchsetCommands(Collection<ReceiveCommand> commands) {
+ List<ReceiveCommand> filtered = new ArrayList<ReceiveCommand>();
+ for (ReceiveCommand cmd : commands) {
+ if (!isPatchsetRef(cmd.getRefName())) {
+ // this is a non-patchset ref update
+ filtered.add(cmd);
+ }
+ }
+ return filtered;
+ }
+
+ /** Process receive commands EXCEPT for Patchset commands. */
+ @Override
+ public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
+ Collection<ReceiveCommand> filtered = excludePatchsetCommands(commands);
+ super.onPreReceive(rp, filtered);
+ }
+
+ /** Process receive commands EXCEPT for Patchset commands. */
+ @Override
+ public void onPostReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
+ Collection<ReceiveCommand> filtered = excludePatchsetCommands(commands);
+ super.onPostReceive(rp, filtered);
+
+ // send all queued ticket notifications after processing all patchsets
+ ticketNotifier.sendAll();
+ }
+
+ @Override
+ protected void validateCommands() {
+ // workaround for JGit's awful scoping choices
+ //
+ // set the patchset refs to OK to bypass checks in the super implementation
+ for (final ReceiveCommand cmd : filterCommands(Result.NOT_ATTEMPTED)) {
+ if (isPatchsetRef(cmd.getRefName())) {
+ if (cmd.getType() == ReceiveCommand.Type.CREATE) {
+ cmd.setResult(Result.OK);
+ }
+ }
+ }
+
+ super.validateCommands();
+ }
+
+ /** Execute commands to update references. */
+ @Override
+ protected void executeCommands() {
+ // workaround for JGit's awful scoping choices
+ //
+ // reset the patchset refs to NOT_ATTEMPTED (see validateCommands)
+ for (ReceiveCommand cmd : filterCommands(Result.OK)) {
+ if (isPatchsetRef(cmd.getRefName())) {
+ cmd.setResult(Result.NOT_ATTEMPTED);
+ }
+ }
+
+ List<ReceiveCommand> toApply = filterCommands(Result.NOT_ATTEMPTED);
+ if (toApply.isEmpty()) {
+ return;
+ }
+
+ ProgressMonitor updating = NullProgressMonitor.INSTANCE;
+ boolean sideBand = isCapabilityEnabled(CAPABILITY_SIDE_BAND_64K);
+ if (sideBand) {
+ SideBandProgressMonitor pm = new SideBandProgressMonitor(msgOut);
+ pm.setDelayStart(250, TimeUnit.MILLISECONDS);
+ updating = pm;
+ }
+
+ BatchRefUpdate batch = getRepository().getRefDatabase().newBatchUpdate();
+ batch.setAllowNonFastForwards(isAllowNonFastForwards());
+ batch.setRefLogIdent(getRefLogIdent());
+ batch.setRefLogMessage("push", true);
+
+ ReceiveCommand patchsetRefCmd = null;
+ PatchsetCommand patchsetCmd = null;
+ for (ReceiveCommand cmd : toApply) {
+ if (Result.NOT_ATTEMPTED != cmd.getResult()) {
+ // Already rejected by the core receive process.
+ continue;
+ }
+
+ if (isPatchsetRef(cmd.getRefName())) {
+ if (ticketService == null) {
+ sendRejection(cmd, "Sorry, the ticket service is unavailable and can not accept patchsets at this time.");
+ continue;
+ }
+
+ if (!ticketService.isReady()) {
+ sendRejection(cmd, "Sorry, the ticket service can not accept patchsets at this time.");
+ continue;
+ }
+
+ if (UserModel.ANONYMOUS.equals(user)) {
+ // server allows anonymous pushes, but anonymous patchset
+ // contributions are prohibited by design
+ sendRejection(cmd, "Sorry, anonymous patchset contributions are prohibited.");
+ continue;
+ }
+
+ final Matcher m = NEW_PATCHSET.matcher(cmd.getRefName());
+ if (m.matches()) {
+ // prohibit pushing directly to a patchset ref
+ long id = getTicketId(cmd.getRefName());
+ sendError("You may not directly push directly to a patchset ref!");
+ sendError("Instead, please push to one the following:");
+ sendError(" - {0}{1,number,0}", Constants.R_FOR, id);
+ sendError(" - {0}{1,number,0}", Constants.R_TICKET, id);
+ sendRejection(cmd, "protected ref");
+ continue;
+ }
+
+ if (hasRefNamespace(Constants.R_FOR)) {
+ // the refs/for/ namespace exists and it must not
+ LOGGER.error("{} already has refs in the {} namespace",
+ repository.name, Constants.R_FOR);
+ sendRejection(cmd, "Sorry, a repository administrator will have to remove the {} namespace", Constants.R_FOR);
+ continue;
+ }
+
+ if (patchsetRefCmd != null) {
+ sendRejection(cmd, "You may only push one patchset at a time.");
+ continue;
+ }
+
+ // responsible verification
+ String responsible = PatchsetCommand.getSingleOption(cmd, PatchsetCommand.RESPONSIBLE);
+ if (!StringUtils.isEmpty(responsible)) {
+ UserModel assignee = gitblit.getUserModel(responsible);
+ if (assignee == null) {
+ // no account by this name
+ sendRejection(cmd, "{0} can not be assigned any tickets because there is no user account by that name", responsible);
+ continue;
+ } else if (!assignee.canPush(repository)) {
+ // account does not have RW permissions
+ sendRejection(cmd, "{0} ({1}) can not be assigned any tickets because the user does not have RW permissions for {2}",
+ assignee.getDisplayName(), assignee.username, repository.name);
+ continue;
+ }
+ }
+
+ // milestone verification
+ String milestone = PatchsetCommand.getSingleOption(cmd, PatchsetCommand.MILESTONE);
+ if (!StringUtils.isEmpty(milestone)) {
+ TicketMilestone milestoneModel = ticketService.getMilestone(repository, milestone);
+ if (milestoneModel == null) {
+ // milestone does not exist
+ sendRejection(cmd, "Sorry, \"{0}\" is not a valid milestone!", milestone);
+ continue;
+ }
+ }
+
+ // watcher verification
+ List<String> watchers = PatchsetCommand.getOptions(cmd, PatchsetCommand.WATCH);
+ if (!ArrayUtils.isEmpty(watchers)) {
+ for (String watcher : watchers) {
+ UserModel user = gitblit.getUserModel(watcher);
+ if (user == null) {
+ // watcher does not exist
+ sendRejection(cmd, "Sorry, \"{0}\" is not a valid username for the watch list!", watcher);
+ continue;
+ }
+ }
+ }
+
+ patchsetRefCmd = cmd;
+ patchsetCmd = preparePatchset(cmd);
+ if (patchsetCmd != null) {
+ batch.addCommand(patchsetCmd);
+ }
+ continue;
+ }
+
+ batch.addCommand(cmd);
+ }
+
+ if (!batch.getCommands().isEmpty()) {
+ try {
+ batch.execute(getRevWalk(), updating);
+ } catch (IOException err) {
+ for (ReceiveCommand cmd : toApply) {
+ if (cmd.getResult() == Result.NOT_ATTEMPTED) {
+ sendRejection(cmd, "lock error: {0}", err.getMessage());
+ }
+ }
+ }
+ }
+
+ //
+ // set the results into the patchset ref receive command
+ //
+ if (patchsetRefCmd != null && patchsetCmd != null) {
+ if (!patchsetCmd.getResult().equals(Result.OK)) {
+ // patchset command failed!
+ LOGGER.error(patchsetCmd.getType() + " " + patchsetCmd.getRefName()
+ + " " + patchsetCmd.getResult());
+ patchsetRefCmd.setResult(patchsetCmd.getResult(), patchsetCmd.getMessage());
+ } else {
+ // all patchset commands were applied
+ patchsetRefCmd.setResult(Result.OK);
+
+ // update the ticket branch ref
+ RefUpdate ru = updateRef(patchsetCmd.getTicketBranch(), patchsetCmd.getNewId());
+ updateReflog(ru);
+
+ TicketModel ticket = processPatchset(patchsetCmd);
+ if (ticket != null) {
+ ticketNotifier.queueMailing(ticket);
+ }
+ }
+ }
+
+ //
+ // if there are standard ref update receive commands that were
+ // successfully processed, process referenced tickets, if any
+ //
+ List<ReceiveCommand> allUpdates = ReceiveCommand.filter(batch.getCommands(), Result.OK);
+ List<ReceiveCommand> refUpdates = excludePatchsetCommands(allUpdates);
+ List<ReceiveCommand> stdUpdates = excludeTicketCommands(refUpdates);
+ if (!stdUpdates.isEmpty()) {
+ int ticketsProcessed = 0;
+ for (ReceiveCommand cmd : stdUpdates) {
+ switch (cmd.getType()) {
+ case CREATE:
+ case UPDATE:
+ case UPDATE_NONFASTFORWARD:
+ Collection<TicketModel> tickets = processMergedTickets(cmd);
+ ticketsProcessed += tickets.size();
+ for (TicketModel ticket : tickets) {
+ ticketNotifier.queueMailing(ticket);
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ if (ticketsProcessed == 1) {
+ sendInfo("1 ticket updated");
+ } else if (ticketsProcessed > 1) {
+ sendInfo("{0} tickets updated", ticketsProcessed);
+ }
+ }
+
+ // reset the ticket caches for the repository
+ ticketService.resetCaches(repository);
+ }
+
+ /**
+ * Prepares a patchset command.
+ *
+ * @param cmd
+ * @return the patchset command
+ */
+ private PatchsetCommand preparePatchset(ReceiveCommand cmd) {
+ String branch = getIntegrationBranch(cmd.getRefName());
+ long number = getTicketId(cmd.getRefName());
+
+ TicketModel ticket = null;
+ if (number > 0 && ticketService.hasTicket(repository, number)) {
+ ticket = ticketService.getTicket(repository, number);
+ }
+
+ if (ticket == null) {
+ if (number > 0) {
+ // requested ticket does not exist
+ sendError("Sorry, {0} does not have ticket {1,number,0}!", repository.name, number);
+ sendRejection(cmd, "Invalid ticket number");
+ return null;
+ }
+ } else {
+ if (ticket.isMerged()) {
+ // ticket already merged & resolved
+ Change mergeChange = null;
+ for (Change change : ticket.changes) {
+ if (change.isMerge()) {
+ mergeChange = change;
+ break;
+ }
+ }
+ sendError("Sorry, {0} already merged {1} from ticket {2,number,0} to {3}!",
+ mergeChange.author, mergeChange.patchset, number, ticket.mergeTo);
+ sendRejection(cmd, "Ticket {0,number,0} already resolved", number);
+ return null;
+ } else if (!StringUtils.isEmpty(ticket.mergeTo)) {
+ // ticket specifies integration branch
+ branch = ticket.mergeTo;
+ }
+ }
+
+ final int shortCommitIdLen = settings.getInteger(Keys.web.shortCommitIdLength, 6);
+ final String shortTipId = cmd.getNewId().getName().substring(0, shortCommitIdLen);
+ final RevCommit tipCommit = JGitUtils.getCommit(getRepository(), cmd.getNewId().getName());
+ final String forBranch = branch;
+ RevCommit mergeBase = null;
+ Ref forBranchRef = getAdvertisedRefs().get(Constants.R_HEADS + forBranch);
+ if (forBranchRef == null || forBranchRef.getObjectId() == null) {
+ // unknown integration branch
+ sendError("Sorry, there is no integration branch named ''{0}''.", forBranch);
+ sendRejection(cmd, "Invalid integration branch specified");
+ return null;
+ } else {
+ // determine the merge base for the patchset on the integration branch
+ String base = JGitUtils.getMergeBase(getRepository(), forBranchRef.getObjectId(), tipCommit.getId());
+ if (StringUtils.isEmpty(base)) {
+ sendError("");
+ sendError("There is no common ancestry between {0} and {1}.", forBranch, shortTipId);
+ sendError("Please reconsider your proposed integration branch, {0}.", forBranch);
+ sendError("");
+ sendRejection(cmd, "no merge base for patchset and {0}", forBranch);
+ return null;
+ }
+ mergeBase = JGitUtils.getCommit(getRepository(), base);
+ }
+
+ // ensure that the patchset can be cleanly merged right now
+ MergeStatus status = JGitUtils.canMerge(getRepository(), tipCommit.getName(), forBranch);
+ switch (status) {
+ case ALREADY_MERGED:
+ sendError("");
+ sendError("You have already merged this patchset.", forBranch);
+ sendError("");
+ sendRejection(cmd, "everything up-to-date");
+ return null;
+ case MERGEABLE:
+ break;
+ default:
+ if (ticket == null || requireCleanMerge) {
+ sendError("");
+ sendError("Your patchset can not be cleanly merged into {0}.", forBranch);
+ sendError("Please rebase your patchset and push again.");
+ sendError("NOTE:", number);
+ sendError("You should push your rebase to refs/for/{0,number,0}", number);
+ sendError("");
+ sendError(" git push origin HEAD:refs/for/{0,number,0}", number);
+ sendError("");
+ sendRejection(cmd, "patchset not mergeable");
+ return null;
+ }
+ }
+
+ // check to see if this commit is already linked to a ticket
+ long id = identifyTicket(tipCommit, false);
+ if (id > 0) {
+ sendError("{0} has already been pushed to ticket {1,number,0}.", shortTipId, id);
+ sendRejection(cmd, "everything up-to-date");
+ return null;
+ }
+
+ PatchsetCommand psCmd;
+ if (ticket == null) {
+ /*
+ * NEW TICKET
+ */
+ Patchset patchset = newPatchset(null, mergeBase.getName(), tipCommit.getName());
+
+ int minLength = 10;
+ int maxLength = 100;
+ String minTitle = MessageFormat.format(" minimum length of a title is {0} characters.", minLength);
+ String maxTitle = MessageFormat.format(" maximum length of a title is {0} characters.", maxLength);
+
+ if (patchset.commits > 1) {
+ sendError("");
+ sendError("To create a proposal ticket, please squash your commits and");
+ sendError("provide a meaningful commit message with a short title &");
+ sendError("an optional description/body.");
+ sendError("");
+ sendError(minTitle);
+ sendError(maxTitle);
+ sendError("");
+ sendRejection(cmd, "please squash to one commit");
+ return null;
+ }
+
+ // require a reasonable title/subject
+ String title = tipCommit.getFullMessage().trim().split("\n")[0];
+ if (title.length() < minLength) {
+ // reject, title too short
+ sendError("");
+ sendError("Please supply a longer title in your commit message!");
+ sendError("");
+ sendError(minTitle);
+ sendError(maxTitle);
+ sendError("");
+ sendRejection(cmd, "ticket title is too short [{0}/{1}]", title.length(), maxLength);
+ return null;
+ }
+ if (title.length() > maxLength) {
+ // reject, title too long
+ sendError("");
+ sendError("Please supply a more concise title in your commit message!");
+ sendError("");
+ sendError(minTitle);
+ sendError(maxTitle);
+ sendError("");
+ sendRejection(cmd, "ticket title is too long [{0}/{1}]", title.length(), maxLength);
+ return null;
+ }
+
+ // assign new id
+ long ticketId = ticketService.assignNewId(repository);
+
+ // create the patchset command
+ psCmd = new PatchsetCommand(user.username, patchset);
+ psCmd.newTicket(tipCommit, forBranch, ticketId, cmd.getRefName());
+ } else {
+ /*
+ * EXISTING TICKET
+ */
+ Patchset patchset = newPatchset(ticket, mergeBase.getName(), tipCommit.getName());
+ psCmd = new PatchsetCommand(user.username, patchset);
+ psCmd.updateTicket(tipCommit, forBranch, ticket, cmd.getRefName());
+ }
+
+ // confirm user can push the patchset
+ boolean pushPermitted = ticket == null
+ || !ticket.hasPatchsets()
+ || ticket.isAuthor(user.username)
+ || ticket.isPatchsetAuthor(user.username)
+ || ticket.isResponsible(user.username)
+ || user.canPush(repository);
+
+ switch (psCmd.getPatchsetType()) {
+ case Proposal:
+ // proposals (first patchset) are always acceptable
+ break;
+ case FastForward:
+ // patchset updates must be permitted
+ if (!pushPermitted) {
+ // reject
+ sendError("");
+ sendError("To push a patchset to this ticket one of the following must be true:");
+ sendError(" 1. you created the ticket");
+ sendError(" 2. you created the first patchset");
+ sendError(" 3. you are specified as responsible for the ticket");
+ sendError(" 4. you are listed as a reviewer for the ticket");
+ sendError(" 5. you have push (RW) permission to {0}", repository.name);
+ sendError("");
+ sendRejection(cmd, "not permitted to push to ticket {0,number,0}", ticket.number);
+ return null;
+ }
+ break;
+ default:
+ // non-fast-forward push
+ if (!pushPermitted) {
+ // reject
+ sendRejection(cmd, "non-fast-forward ({0})", psCmd.getPatchsetType());
+ return null;
+ }
+ break;
+ }
+ return psCmd;
+ }
+
+ /**
+ * Creates or updates an ticket with the specified patchset.
+ *
+ * @param cmd
+ * @return a ticket if the creation or update was successful
+ */
+ private TicketModel processPatchset(PatchsetCommand cmd) {
+ Change change = cmd.getChange();
+
+ if (cmd.isNewTicket()) {
+ // create the ticket object
+ TicketModel ticket = ticketService.createTicket(repository, cmd.getTicketId(), change);
+ if (ticket != null) {
+ sendInfo("");
+ sendHeader("#{0,number,0}: {1}", ticket.number, StringUtils.trimString(ticket.title, Constants.LEN_SHORTLOG));
+ sendInfo("created proposal ticket from patchset");
+ sendInfo(ticketService.getTicketUrl(ticket));
+ sendInfo("");
+
+ // log the new patch ref
+ RefLogUtils.updateRefLog(user, getRepository(),
+ Arrays.asList(new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), cmd.getRefName())));
+
+ return ticket;
+ } else {
+ sendError("FAILED to create ticket");
+ }
+ } else {
+ // update an existing ticket
+ TicketModel ticket = ticketService.updateTicket(repository, cmd.getTicketId(), change);
+ if (ticket != null) {
+ sendInfo("");
+ sendHeader("#{0,number,0}: {1}", ticket.number, StringUtils.trimString(ticket.title, Constants.LEN_SHORTLOG));
+ if (change.patchset.rev == 1) {
+ // new patchset
+ sendInfo("uploaded patchset {0} ({1})", change.patchset.number, change.patchset.type.toString());
+ } else {
+ // updated patchset
+ sendInfo("added {0} {1} to patchset {2}",
+ change.patchset.added,
+ change.patchset.added == 1 ? "commit" : "commits",
+ change.patchset.number);
+ }
+ sendInfo(ticketService.getTicketUrl(ticket));
+ sendInfo("");
+
+ // log the new patchset ref
+ RefLogUtils.updateRefLog(user, getRepository(),
+ Arrays.asList(new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), cmd.getRefName())));
+
+ // return the updated ticket
+ return ticket;
+ } else {
+ sendError("FAILED to upload {0} for ticket {1,number,0}", change.patchset, cmd.getTicketId());
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Automatically closes open tickets that have been merged to their integration
+ * branch by a client.
+ *
+ * @param cmd
+ */
+ private Collection<TicketModel> processMergedTickets(ReceiveCommand cmd) {
+ Map<Long, TicketModel> mergedTickets = new LinkedHashMap<Long, TicketModel>();
+ final RevWalk rw = getRevWalk();
+ try {
+ rw.reset();
+ rw.markStart(rw.parseCommit(cmd.getNewId()));
+ if (!ObjectId.zeroId().equals(cmd.getOldId())) {
+ rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
+ }
+
+ RevCommit c;
+ while ((c = rw.next()) != null) {
+ rw.parseBody(c);
+ long ticketNumber = identifyTicket(c, true);
+ if (ticketNumber == 0L || mergedTickets.containsKey(ticketNumber)) {
+ continue;
+ }
+
+ TicketModel ticket = ticketService.getTicket(repository, ticketNumber);
+ String integrationBranch;
+ if (StringUtils.isEmpty(ticket.mergeTo)) {
+ // unspecified integration branch
+ integrationBranch = null;
+ } else {
+ // specified integration branch
+ integrationBranch = Constants.R_HEADS + ticket.mergeTo;
+ }
+
+ // ticket must be open and, if specified, the ref must match the integration branch
+ if (ticket.isClosed() || (integrationBranch != null && !integrationBranch.equals(cmd.getRefName()))) {
+ continue;
+ }
+
+ String baseRef = PatchsetCommand.getBasePatchsetBranch(ticket.number);
+ boolean knownPatchset = false;
+ Set<Ref> refs = getRepository().getAllRefsByPeeledObjectId().get(c.getId());
+ if (refs != null) {
+ for (Ref ref : refs) {
+ if (ref.getName().startsWith(baseRef)) {
+ knownPatchset = true;
+ break;
+ }
+ }
+ }
+
+ String mergeSha = c.getName();
+ String mergeTo = Repository.shortenRefName(cmd.getRefName());
+ Change change;
+ Patchset patchset;
+ if (knownPatchset) {
+ // identify merged patchset by the patchset tip
+ patchset = null;
+ for (Patchset ps : ticket.getPatchsets()) {
+ if (ps.tip.equals(mergeSha)) {
+ patchset = ps;
+ break;
+ }
+ }
+
+ if (patchset == null) {
+ // should not happen - unless ticket has been hacked
+ sendError("Failed to find the patchset for {0} in ticket {1,number,0}?!",
+ mergeSha, ticket.number);
+ continue;
+ }
+
+ // create a new change
+ change = new Change(user.username);
+ } else {
+ // new patchset pushed by user
+ String base = cmd.getOldId().getName();
+ patchset = newPatchset(ticket, base, mergeSha);
+ PatchsetCommand psCmd = new PatchsetCommand(user.username, patchset);
+ psCmd.updateTicket(c, mergeTo, ticket, null);
+
+ // create a ticket patchset ref
+ updateRef(psCmd.getPatchsetBranch(), c.getId());
+ RefUpdate ru = updateRef(psCmd.getTicketBranch(), c.getId());
+ updateReflog(ru);
+
+ // create a change from the patchset command
+ change = psCmd.getChange();
+ }
+
+ // set the common change data about the merge
+ change.setField(Field.status, Status.Merged);
+ change.setField(Field.mergeSha, mergeSha);
+ change.setField(Field.mergeTo, mergeTo);
+
+ if (StringUtils.isEmpty(ticket.responsible)) {
+ // unassigned tickets are assigned to the closer
+ change.setField(Field.responsible, user.username);
+ }
+
+ ticket = ticketService.updateTicket(repository, ticket.number, change);
+ if (ticket != null) {
+ sendInfo("");
+ sendHeader("#{0,number,0}: {1}", ticket.number, StringUtils.trimString(ticket.title, Constants.LEN_SHORTLOG));
+ sendInfo("closed by push of {0} to {1}", patchset, mergeTo);
+ sendInfo(ticketService.getTicketUrl(ticket));
+ sendInfo("");
+ mergedTickets.put(ticket.number, ticket);
+ } else {
+ String shortid = mergeSha.substring(0, settings.getInteger(Keys.web.shortCommitIdLength, 6));
+ sendError("FAILED to close ticket {0,number,0} by push of {1}", ticketNumber, shortid);
+ }
+ }
+ } catch (IOException e) {
+ LOGGER.error("Can't scan for changes to close", e);
+ } finally {
+ rw.reset();
+ }
+
+ return mergedTickets.values();
+ }
+
+ /**
+ * Try to identify a ticket id from the commit.
+ *
+ * @param commit
+ * @param parseMessage
+ * @return a ticket id or 0
+ */
+ private long identifyTicket(RevCommit commit, boolean parseMessage) {
+ // try lookup by change ref
+ Map<AnyObjectId, Set<Ref>> map = getRepository().getAllRefsByPeeledObjectId();
+ Set<Ref> refs = map.get(commit.getId());
+ if (!ArrayUtils.isEmpty(refs)) {
+ for (Ref ref : refs) {
+ long number = PatchsetCommand.getTicketNumber(ref.getName());
+ if (number > 0) {
+ return number;
+ }
+ }
+ }
+
+ if (parseMessage) {
+ // parse commit message looking for fixes/closes #n
+ Pattern p = Pattern.compile("(?:fixes|closes)[\\s-]+#?(\\d+)", Pattern.CASE_INSENSITIVE);
+ Matcher m = p.matcher(commit.getFullMessage());
+ while (m.find()) {
+ String val = m.group();
+ return Long.parseLong(val);
+ }
+ }
+ return 0L;
+ }
+
+ private int countCommits(String baseId, String tipId) {
+ int count = 0;
+ RevWalk walk = getRevWalk();
+ walk.reset();
+ walk.sort(RevSort.TOPO);
+ walk.sort(RevSort.REVERSE, true);
+ try {
+ RevCommit tip = walk.parseCommit(getRepository().resolve(tipId));
+ RevCommit base = walk.parseCommit(getRepository().resolve(baseId));
+ walk.markStart(tip);
+ walk.markUninteresting(base);
+ for (;;) {
+ RevCommit c = walk.next();
+ if (c == null) {
+ break;
+ }
+ count++;
+ }
+ } catch (IOException e) {
+ // Should never happen, the core receive process would have
+ // identified the missing object earlier before we got control.
+ LOGGER.error("failed to get commit count", e);
+ return 0;
+ } finally {
+ walk.release();
+ }
+ return count;
+ }
+
+ /**
+ * Creates a new patchset with metadata.
+ *
+ * @param ticket
+ * @param mergeBase
+ * @param tip
+ */
+ private Patchset newPatchset(TicketModel ticket, String mergeBase, String tip) {
+ int totalCommits = countCommits(mergeBase, tip);
+
+ Patchset newPatchset = new Patchset();
+ newPatchset.tip = tip;
+ newPatchset.base = mergeBase;
+ newPatchset.commits = totalCommits;
+
+ Patchset currPatchset = ticket == null ? null : ticket.getCurrentPatchset();
+ if (currPatchset == null) {
+ /*
+ * PROPOSAL PATCHSET
+ * patchset 1, rev 1
+ */
+ newPatchset.number = 1;
+ newPatchset.rev = 1;
+ newPatchset.type = PatchsetType.Proposal;
+
+ // diffstat from merge base
+ DiffStat diffStat = DiffUtils.getDiffStat(getRepository(), mergeBase, tip);
+ newPatchset.insertions = diffStat.getInsertions();
+ newPatchset.deletions = diffStat.getDeletions();
+ } else {
+ /*
+ * PATCHSET UPDATE
+ */
+ int added = totalCommits - currPatchset.commits;
+ boolean ff = JGitUtils.isMergedInto(getRepository(), currPatchset.tip, tip);
+ boolean squash = added < 0;
+ boolean rebase = !currPatchset.base.equals(mergeBase);
+
+ // determine type, number and rev of the patchset
+ if (ff) {
+ /*
+ * FAST-FORWARD
+ * patchset number preserved, rev incremented
+ */
+
+ boolean merged = JGitUtils.isMergedInto(getRepository(), currPatchset.tip, ticket.mergeTo);
+ if (merged) {
+ // current patchset was already merged
+ // new patchset, mark as rebase
+ newPatchset.type = PatchsetType.Rebase;
+ newPatchset.number = currPatchset.number + 1;
+ newPatchset.rev = 1;
+
+ // diffstat from parent
+ DiffStat diffStat = DiffUtils.getDiffStat(getRepository(), mergeBase, tip);
+ newPatchset.insertions = diffStat.getInsertions();
+ newPatchset.deletions = diffStat.getDeletions();
+ } else {
+ // FF update to patchset
+ newPatchset.type = PatchsetType.FastForward;
+ newPatchset.number = currPatchset.number;
+ newPatchset.rev = currPatchset.rev + 1;
+ newPatchset.parent = currPatchset.tip;
+
+ // diffstat from parent
+ DiffStat diffStat = DiffUtils.getDiffStat(getRepository(), currPatchset.tip, tip);
+ newPatchset.insertions = diffStat.getInsertions();
+ newPatchset.deletions = diffStat.getDeletions();
+ }
+ } else {
+ /*
+ * NON-FAST-FORWARD
+ * new patchset, rev 1
+ */
+ if (rebase && squash) {
+ newPatchset.type = PatchsetType.Rebase_Squash;
+ newPatchset.number = currPatchset.number + 1;
+ newPatchset.rev = 1;
+ } else if (squash) {
+ newPatchset.type = PatchsetType.Squash;
+ newPatchset.number = currPatchset.number + 1;
+ newPatchset.rev = 1;
+ } else if (rebase) {
+ newPatchset.type = PatchsetType.Rebase;
+ newPatchset.number = currPatchset.number + 1;
+ newPatchset.rev = 1;
+ } else {
+ newPatchset.type = PatchsetType.Amend;
+ newPatchset.number = currPatchset.number + 1;
+ newPatchset.rev = 1;
+ }
+
+ // diffstat from merge base
+ DiffStat diffStat = DiffUtils.getDiffStat(getRepository(), mergeBase, tip);
+ newPatchset.insertions = diffStat.getInsertions();
+ newPatchset.deletions = diffStat.getDeletions();
+ }
+
+ if (added > 0) {
+ // ignore squash (negative add)
+ newPatchset.added = added;
+ }
+ }
+
+ return newPatchset;
+ }
+
+ private RefUpdate updateRef(String ref, ObjectId newId) {
+ ObjectId ticketRefId = ObjectId.zeroId();
+ try {
+ ticketRefId = getRepository().resolve(ref);
+ } catch (Exception e) {
+ // ignore
+ }
+
+ try {
+ RefUpdate ru = getRepository().updateRef(ref, false);
+ ru.setRefLogIdent(getRefLogIdent());
+ ru.setForceUpdate(true);
+ ru.setExpectedOldObjectId(ticketRefId);
+ ru.setNewObjectId(newId);
+ RefUpdate.Result result = ru.update(getRevWalk());
+ if (result == RefUpdate.Result.LOCK_FAILURE) {
+ sendError("Failed to obtain lock when updating {0}:{1}", repository.name, ref);
+ sendError("Perhaps an administrator should remove {0}/{1}.lock?", getRepository().getDirectory(), ref);
+ return null;
+ }
+ return ru;
+ } catch (IOException e) {
+ LOGGER.error("failed to update ref " + ref, e);
+ sendError("There was an error updating ref {0}:{1}", repository.name, ref);
+ }
+ return null;
+ }
+
+ private void updateReflog(RefUpdate ru) {
+ if (ru == null) {
+ return;
+ }
+
+ ReceiveCommand.Type type = null;
+ switch (ru.getResult()) {
+ case NEW:
+ type = Type.CREATE;
+ break;
+ case FAST_FORWARD:
+ type = Type.UPDATE;
+ break;
+ case FORCED:
+ type = Type.UPDATE_NONFASTFORWARD;
+ break;
+ default:
+ LOGGER.error(MessageFormat.format("unexpected ref update type {0} for {1}",
+ ru.getResult(), ru.getName()));
+ return;
+ }
+ ReceiveCommand cmd = new ReceiveCommand(ru.getOldObjectId(), ru.getNewObjectId(), ru.getName(), type);
+ RefLogUtils.updateRefLog(user, getRepository(), Arrays.asList(cmd));
+ }
+
+ /**
+ * Merge the specified patchset to the integration branch.
+ *
+ * @param ticket
+ * @param patchset
+ * @return true, if successful
+ */
+ public MergeStatus merge(TicketModel ticket) {
+ PersonIdent committer = new PersonIdent(user.getDisplayName(), StringUtils.isEmpty(user.emailAddress) ? (user.username + "@gitblit") : user.emailAddress);
+ Patchset patchset = ticket.getCurrentPatchset();
+ String message = MessageFormat.format("Merged #{0,number,0} \"{1}\"", ticket.number, ticket.title);
+ Ref oldRef = null;
+ try {
+ oldRef = getRepository().getRef(ticket.mergeTo);
+ } catch (IOException e) {
+ LOGGER.error("failed to get ref for " + ticket.mergeTo, e);
+ }
+ MergeResult mergeResult = JGitUtils.merge(
+ getRepository(),
+ patchset.tip,
+ ticket.mergeTo,
+ committer,
+ message);
+
+ if (StringUtils.isEmpty(mergeResult.sha)) {
+ LOGGER.error("FAILED to merge {} to {} ({})", new Object [] { patchset, ticket.mergeTo, mergeResult.status.name() });
+ return mergeResult.status;
+ }
+ Change change = new Change(user.username);
+ change.setField(Field.status, Status.Merged);
+ change.setField(Field.mergeSha, mergeResult.sha);
+ change.setField(Field.mergeTo, ticket.mergeTo);
+
+ if (StringUtils.isEmpty(ticket.responsible)) {
+ // unassigned tickets are assigned to the closer
+ change.setField(Field.responsible, user.username);
+ }
+
+ long ticketId = ticket.number;
+ ticket = ticketService.updateTicket(repository, ticket.number, change);
+ if (ticket != null) {
+ ticketNotifier.queueMailing(ticket);
+
+ // update the reflog with the merge
+ if (oldRef != null) {
+ ReceiveCommand cmd = new ReceiveCommand(oldRef.getObjectId(),
+ ObjectId.fromString(mergeResult.sha), oldRef.getName());
+ RefLogUtils.updateRefLog(user, getRepository(), Arrays.asList(cmd));
+ }
+ return mergeResult.status;
+ } else {
+ LOGGER.error("FAILED to resolve ticket {} by merge from web ui", ticketId);
+ }
+ return mergeResult.status;
+ }
+
+ public void sendAll() {
+ ticketNotifier.sendAll();
+ }
+}