From c2188a840bc4153ae92112b04b2e06a90d3944aa Mon Sep 17 00:00:00 2001 From: Paul Martin <paul@paulsputer.com> Date: Wed, 27 Apr 2016 18:58:06 -0400 Subject: [PATCH] Ticket Reference handling #1048 --- src/main/java/com/gitblit/git/PatchsetReceivePack.java | 516 ++++++++++++++++++++++++++++++++++++++------------------ 1 files changed, 350 insertions(+), 166 deletions(-) diff --git a/src/main/java/com/gitblit/git/PatchsetReceivePack.java b/src/main/java/com/gitblit/git/PatchsetReceivePack.java index bdba8b9..33fa470 100644 --- a/src/main/java/com/gitblit/git/PatchsetReceivePack.java +++ b/src/main/java/com/gitblit/git/PatchsetReceivePack.java @@ -30,7 +30,6 @@ 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; @@ -51,6 +50,7 @@ import com.gitblit.Constants; import com.gitblit.Keys; +import com.gitblit.extensions.PatchsetHook; import com.gitblit.manager.IGitblit; import com.gitblit.models.RepositoryModel; import com.gitblit.models.TicketModel; @@ -59,6 +59,8 @@ import com.gitblit.models.TicketModel.Patchset; import com.gitblit.models.TicketModel.PatchsetType; import com.gitblit.models.TicketModel.Status; +import com.gitblit.models.TicketModel.TicketAction; +import com.gitblit.models.TicketModel.TicketLink; import com.gitblit.models.UserModel; import com.gitblit.tickets.BranchTicketService; import com.gitblit.tickets.ITicketService; @@ -72,6 +74,7 @@ import com.gitblit.utils.JGitUtils.MergeStatus; import com.gitblit.utils.RefLogUtils; import com.gitblit.utils.StringUtils; +import com.google.common.collect.Lists; /** @@ -147,6 +150,11 @@ defaultBranch = getRepository().getBranch(); } catch (Exception e) { LOGGER.error("failed to determine default branch for " + repository.name, e); + } + + if (!StringUtils.isEmpty(getRepositoryModel().mergeTo)) { + // repository settings specifies a default integration branch + defaultBranch = Repository.shortenRefName(getRepositoryModel().mergeTo); } long ticketId = 0L; @@ -308,6 +316,7 @@ } if (isPatchsetRef(cmd.getRefName()) && processPatchsets) { + if (ticketService == null) { sendRejection(cmd, "Sorry, the ticket service is unavailable and can not accept patchsets at this time."); continue; @@ -345,10 +354,27 @@ continue; } + if (cmd.getNewId().equals(ObjectId.zeroId())) { + // ref deletion request + if (cmd.getRefName().startsWith(Constants.R_TICKET)) { + if (user.canDeleteRef(repository)) { + batch.addCommand(cmd); + } else { + sendRejection(cmd, "Sorry, you do not have permission to delete {}", cmd.getRefName()); + } + } else { + sendRejection(cmd, "Sorry, you can not delete {}", cmd.getRefName()); + } + continue; + } + if (patchsetRefCmd != null) { sendRejection(cmd, "You may only push one patchset at a time."); continue; } + + LOGGER.info(MessageFormat.format("Verifying {0} push ref \"{1}\" received from {2}", + repository.name, cmd.getRefName(), user.username)); // responsible verification String responsible = PatchsetCommand.getSingleOption(cmd, PatchsetCommand.RESPONSIBLE); @@ -380,13 +406,18 @@ // watcher verification List<String> watchers = PatchsetCommand.getOptions(cmd, PatchsetCommand.WATCH); if (!ArrayUtils.isEmpty(watchers)) { + boolean verified = true; 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; + verified = false; + break; } + } + if (!verified) { + continue; } } @@ -429,7 +460,10 @@ patchsetRefCmd.setResult(Result.OK); // update the ticket branch ref - RefUpdate ru = updateRef(patchsetCmd.getTicketBranch(), patchsetCmd.getNewId()); + RefUpdate ru = updateRef( + patchsetCmd.getTicketBranch(), + patchsetCmd.getNewId(), + patchsetCmd.getPatchsetType()); updateReflog(ru); TicketModel ticket = processPatchset(patchsetCmd); @@ -452,9 +486,27 @@ switch (cmd.getType()) { case CREATE: case UPDATE: + if (cmd.getRefName().startsWith(Constants.R_HEADS)) { + Collection<TicketModel> tickets = processReferencedTickets(cmd); + ticketsProcessed += tickets.size(); + for (TicketModel ticket : tickets) { + ticketNotifier.queueMailing(ticket); + } + } + break; + case UPDATE_NONFASTFORWARD: if (cmd.getRefName().startsWith(Constants.R_HEADS)) { - Collection<TicketModel> tickets = processMergedTickets(cmd); + String base = JGitUtils.getMergeBase(getRepository(), cmd.getOldId(), cmd.getNewId()); + List<TicketLink> deletedRefs = JGitUtils.identifyTicketsBetweenCommits(getRepository(), settings, base, cmd.getOldId().name()); + for (TicketLink link : deletedRefs) { + link.isDelete = true; + } + Change deletion = new Change(user.username); + deletion.pendingLinks = deletedRefs; + ticketService.updateTicket(repository, 0, deletion); + + Collection<TicketModel> tickets = processReferencedTickets(cmd); ticketsProcessed += tickets.size(); for (TicketModel ticket : tickets) { ticketNotifier.queueMailing(ticket); @@ -484,8 +536,6 @@ * @return the patchset command */ private PatchsetCommand preparePatchset(ReceiveCommand cmd) { - LOGGER.info(MessageFormat.format("Preparing {0} patchset command for \"{1}\" received from {2}", - repository.name, cmd.getRefName(), user.username)); String branch = getIntegrationBranch(cmd.getRefName()); long number = getTicketId(cmd.getRefName()); @@ -511,8 +561,10 @@ break; } } - sendError("Sorry, {0} already merged {1} from ticket {2,number,0} to {3}!", + if (mergeChange != null) { + 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)) { @@ -571,15 +623,17 @@ 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); + if (ticket != null && + JGitUtils.getTicketNumberFromCommitBranch(getRepository(), tipCommit) == ticket.number) { + sendError("{0} has already been pushed to ticket {1,number,0}.", shortTipId, ticket.number); sendRejection(cmd, "everything up-to-date"); return null; } - + + List<TicketLink> ticketLinks = JGitUtils.identifyTicketsFromCommitMessage(getRepository(), settings, tipCommit); + PatchsetCommand psCmd; if (ticket == null) { /* @@ -594,14 +648,102 @@ 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("You may not create a ''{0}'' branch proposal ticket from {1} commits!", + forBranch, patchset.commits); sendError(""); - sendError(minTitle); - sendError(maxTitle); + // display an ellipsized log of the commits being pushed + RevWalk walk = getRevWalk(); + walk.reset(); + walk.sort(RevSort.TOPO); + int boundary = 3; + int count = 0; + try { + walk.markStart(tipCommit); + walk.markUninteresting(mergeBase); + + for (;;) { + + RevCommit c = walk.next(); + if (c == null) { + break; + } + + if (count < boundary || count >= (patchset.commits - boundary)) { + + walk.parseBody(c); + sendError(" {0} {1}", c.getName().substring(0, shortCommitIdLen), + StringUtils.trimString(c.getShortMessage(), 60)); + + } else if (count == boundary) { + + sendError(" ... more commits ..."); + + } + + 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); + } finally { + walk.close(); + } + sendError(""); - sendRejection(cmd, "please squash to one commit"); + sendError("Possible Solutions:"); + sendError(""); + int solution = 1; + String forSpec = cmd.getRefName().substring(Constants.R_FOR.length()); + if (forSpec.equals("default") || forSpec.equals("new")) { + try { + // determine other possible integration targets + List<String> bases = Lists.newArrayList(); + for (Ref ref : getRepository().getRefDatabase().getRefs(Constants.R_HEADS).values()) { + if (!ref.getName().startsWith(Constants.R_TICKET) + && !ref.getName().equals(forBranchRef.getName())) { + if (JGitUtils.isMergedInto(getRepository(), ref.getObjectId(), tipCommit)) { + bases.add(Repository.shortenRefName(ref.getName())); + } + } + } + + if (!bases.isEmpty()) { + + if (bases.size() == 1) { + // suggest possible integration targets + String base = bases.get(0); + sendError("{0}. Propose this change for the ''{1}'' branch.", solution++, base); + sendError(""); + sendError(" git push origin HEAD:refs/for/{0}", base); + sendError(" pt propose {0}", base); + sendError(""); + } else { + // suggest possible integration targets + sendError("{0}. Propose this change for a different branch.", solution++); + sendError(""); + for (String base : bases) { + sendError(" git push origin HEAD:refs/for/{0}", base); + sendError(" pt propose {0}", base); + sendError(""); + } + } + + } + } catch (IOException e) { + LOGGER.error(null, e); + } + } + sendError("{0}. Squash your changes into a single commit with a meaningful message.", solution++); + sendError(""); + sendError("{0}. Open a ticket for your changes and then push your {1} commits to the ticket.", + solution++, patchset.commits); + sendError(""); + sendError(" git push origin HEAD:refs/for/{id}"); + sendError(" pt propose {id}"); + sendError(""); + sendRejection(cmd, "too many commits"); return null; } @@ -681,6 +823,10 @@ } break; } + + Change change = psCmd.getChange(); + change.pendingLinks = ticketLinks; + return psCmd; } @@ -706,6 +852,15 @@ // log the new patch ref RefLogUtils.updateRefLog(user, getRepository(), Arrays.asList(new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), cmd.getRefName()))); + + // call any patchset hooks + for (PatchsetHook hook : gitblit.getExtensions(PatchsetHook.class)) { + try { + hook.onNewPatchset(ticket); + } catch (Exception e) { + LOGGER.error("Failed to execute extension", e); + } + } return ticket; } else { @@ -734,6 +889,20 @@ RefLogUtils.updateRefLog(user, getRepository(), Arrays.asList(new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), cmd.getRefName()))); + // call any patchset hooks + final boolean isNewPatchset = change.patchset.rev == 1; + for (PatchsetHook hook : gitblit.getExtensions(PatchsetHook.class)) { + try { + if (isNewPatchset) { + hook.onNewPatchset(ticket); + } else { + hook.onUpdatePatchset(ticket); + } + } catch (Exception e) { + LOGGER.error("Failed to execute extension", e); + } + } + // return the updated ticket return ticket; } else { @@ -746,11 +915,11 @@ /** * Automatically closes open tickets that have been merged to their integration - * branch by a client. + * branch by a client and adds references to tickets if made in the commit message. * * @param cmd */ - private Collection<TicketModel> processMergedTickets(ReceiveCommand cmd) { + private Collection<TicketModel> processReferencedTickets(ReceiveCommand cmd) { Map<Long, TicketModel> mergedTickets = new LinkedHashMap<Long, TicketModel>(); final RevWalk rw = getRevWalk(); try { @@ -763,102 +932,151 @@ RevCommit c; while ((c = rw.next()) != null) { rw.parseBody(c); - long ticketNumber = identifyTicket(c, true); - if (ticketNumber == 0L || mergedTickets.containsKey(ticketNumber)) { + List<TicketLink> ticketLinks = JGitUtils.identifyTicketsFromCommitMessage(getRepository(), settings, c); + if (ticketLinks == null) { 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); + for (TicketLink link : ticketLinks) { + + if (mergedTickets.containsKey(link.targetTicketId)) { continue; } + + TicketModel ticket = ticketService.getTicket(repository, link.targetTicketId); + if (ticket == null) { + continue; + } + String integrationBranch; + if (StringUtils.isEmpty(ticket.mergeTo)) { + // unspecified integration branch + integrationBranch = null; + } else { + // specified integration branch + integrationBranch = Constants.R_HEADS + ticket.mergeTo; + } + + Change change; + Patchset patchset = null; + String mergeSha = c.getName(); + String mergeTo = Repository.shortenRefName(cmd.getRefName()); - // 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); + if (link.action == TicketAction.Commit) { + //A commit can reference a ticket in any branch even if the ticket is closed. + //This allows developers to identify and communicate related issues + change = new Change(user.username); + change.referenceCommit(mergeSha); + } else { + // 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; + } + } + } + + if (knownPatchset) { + // identify merged patchset by the patchset tip + 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(), patchset.type); + RefUpdate ru = updateRef(psCmd.getTicketBranch(), c.getId(), patchset.type); + 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)); - // create a ticket patchset ref - updateRef(psCmd.getPatchsetBranch(), c.getId()); - RefUpdate ru = updateRef(psCmd.getTicketBranch(), c.getId()); - updateReflog(ru); + switch (link.action) { + case Commit: { + sendInfo("referenced by push of {0} to {1}", c.getName(), mergeTo); + } + break; - // create a change from the patchset command - change = psCmd.getChange(); - } + case Close: { + sendInfo("closed by push of {0} to {1}", patchset, mergeTo); + mergedTickets.put(ticket.number, ticket); + } + break; - // set the common change data about the merge - change.setField(Field.status, Status.Merged); - change.setField(Field.mergeSha, mergeSha); - change.setField(Field.mergeTo, mergeTo); + default: { + + } + } - if (StringUtils.isEmpty(ticket.responsible)) { - // unassigned tickets are assigned to the closer - change.setField(Field.responsible, user.username); - } + sendInfo(ticketService.getTicketUrl(ticket)); + sendInfo(""); - 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); + } else { + String shortid = mergeSha.substring(0, settings.getInteger(Keys.web.shortCommitIdLength, 6)); + + switch (link.action) { + case Commit: { + sendError("FAILED to reference ticket {0,number,0} by push of {1}", link.targetTicketId, shortid); + } + break; + case Close: { + sendError("FAILED to close ticket {0,number,0} by push of {1}", link.targetTicketId, shortid); + } break; + + default: { + + } + } + } } } + } catch (IOException e) { - LOGGER.error("Can't scan for changes to close", e); + LOGGER.error("Can't scan for changes to reference or close", e); } finally { rw.reset(); } @@ -866,66 +1084,9 @@ 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. @@ -935,7 +1096,7 @@ * @param tip */ private Patchset newPatchset(TicketModel ticket, String mergeBase, String tip) { - int totalCommits = countCommits(mergeBase, tip); + int totalCommits = JGitUtils.countCommits(getRepository(), getRevWalk(), mergeBase, tip); Patchset newPatchset = new Patchset(); newPatchset.tip = tip; @@ -1034,7 +1195,7 @@ return newPatchset; } - private RefUpdate updateRef(String ref, ObjectId newId) { + private RefUpdate updateRef(String ref, ObjectId newId, PatchsetType type) { ObjectId ticketRefId = ObjectId.zeroId(); try { ticketRefId = getRepository().resolve(ref); @@ -1045,7 +1206,17 @@ try { RefUpdate ru = getRepository().updateRef(ref, false); ru.setRefLogIdent(getRefLogIdent()); - ru.setForceUpdate(true); + switch (type) { + case Amend: + case Rebase: + case Rebase_Squash: + case Squash: + ru.setForceUpdate(true); + break; + default: + break; + } + ru.setExpectedOldObjectId(ticketRefId); ru.setNewObjectId(newId); RefUpdate.Result result = ru.update(getRevWalk()); @@ -1130,11 +1301,24 @@ 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)); + cmd.setResult(Result.OK); + List<ReceiveCommand> commands = Arrays.asList(cmd); + + logRefChange(commands); + updateIncrementalPushTags(commands); + updateGitblitRefLog(commands); + } + + // call patchset hooks + for (PatchsetHook hook : gitblit.getExtensions(PatchsetHook.class)) { + try { + hook.onMergePatchset(ticket); + } catch (Exception e) { + LOGGER.error("Failed to execute extension", e); + } } return mergeResult.status; } else { -- Gitblit v1.9.1