James Moger
2014-03-08 b335dda6b2a2254e2d4c3162ea945496d9a0eb58
commit | author | age
5e3521 1 /*
JM 2  * Copyright 2014 gitblit.com.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *     http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.gitblit.wicket.pages;
17
18 import java.text.DateFormat;
19 import java.text.MessageFormat;
20 import java.text.SimpleDateFormat;
21 import java.util.ArrayList;
22 import java.util.Arrays;
23 import java.util.Calendar;
24 import java.util.Collections;
25 import java.util.Date;
26 import java.util.List;
27 import java.util.Map;
28 import java.util.Set;
29 import java.util.TimeZone;
30 import java.util.TreeSet;
31
32 import javax.servlet.http.HttpServletRequest;
33
34 import org.apache.wicket.AttributeModifier;
35 import org.apache.wicket.Component;
36 import org.apache.wicket.MarkupContainer;
37 import org.apache.wicket.PageParameters;
38 import org.apache.wicket.RestartResponseException;
39 import org.apache.wicket.ajax.AjaxRequestTarget;
40 import org.apache.wicket.behavior.IBehavior;
41 import org.apache.wicket.behavior.SimpleAttributeModifier;
42 import org.apache.wicket.markup.html.basic.Label;
43 import org.apache.wicket.markup.html.image.ContextImage;
44 import org.apache.wicket.markup.html.link.BookmarkablePageLink;
45 import org.apache.wicket.markup.html.link.ExternalLink;
46 import org.apache.wicket.markup.html.panel.Fragment;
47 import org.apache.wicket.markup.repeater.Item;
48 import org.apache.wicket.markup.repeater.data.DataView;
49 import org.apache.wicket.markup.repeater.data.ListDataProvider;
50 import org.apache.wicket.model.Model;
51 import org.apache.wicket.protocol.http.WebRequest;
52 import org.eclipse.jgit.diff.DiffEntry.ChangeType;
53 import org.eclipse.jgit.lib.PersonIdent;
54 import org.eclipse.jgit.lib.Ref;
55 import org.eclipse.jgit.lib.Repository;
56 import org.eclipse.jgit.revwalk.RevCommit;
57 import org.eclipse.jgit.transport.URIish;
58
59 import com.gitblit.Constants;
60 import com.gitblit.Constants.AccessPermission;
61 import com.gitblit.Keys;
62 import com.gitblit.git.PatchsetCommand;
63 import com.gitblit.git.PatchsetReceivePack;
64 import com.gitblit.models.PathModel.PathChangeModel;
65 import com.gitblit.models.RegistrantAccessPermission;
66 import com.gitblit.models.RepositoryModel;
67 import com.gitblit.models.SubmoduleModel;
68 import com.gitblit.models.TicketModel;
69 import com.gitblit.models.TicketModel.Change;
70 import com.gitblit.models.TicketModel.CommentSource;
71 import com.gitblit.models.TicketModel.Field;
72 import com.gitblit.models.TicketModel.Patchset;
73 import com.gitblit.models.TicketModel.PatchsetType;
74 import com.gitblit.models.TicketModel.Review;
75 import com.gitblit.models.TicketModel.Score;
76 import com.gitblit.models.TicketModel.Status;
77 import com.gitblit.models.UserModel;
78 import com.gitblit.tickets.TicketIndexer.Lucene;
79 import com.gitblit.tickets.TicketLabel;
80 import com.gitblit.tickets.TicketMilestone;
81 import com.gitblit.tickets.TicketResponsible;
82 import com.gitblit.utils.JGitUtils;
83 import com.gitblit.utils.JGitUtils.MergeStatus;
84 import com.gitblit.utils.MarkdownUtils;
85 import com.gitblit.utils.StringUtils;
86 import com.gitblit.utils.TimeUtils;
87 import com.gitblit.wicket.GitBlitWebSession;
88 import com.gitblit.wicket.WicketUtils;
89 import com.gitblit.wicket.panels.BasePanel.JavascriptTextPrompt;
90 import com.gitblit.wicket.panels.CommentPanel;
91 import com.gitblit.wicket.panels.DiffStatPanel;
92 import com.gitblit.wicket.panels.GravatarImage;
93 import com.gitblit.wicket.panels.IconAjaxLink;
94 import com.gitblit.wicket.panels.LinkPanel;
95 import com.gitblit.wicket.panels.ShockWaveComponent;
96 import com.gitblit.wicket.panels.SimpleAjaxLink;
97
98 /**
99  * The ticket page handles viewing and updating a ticket.
100  *
101  * @author James Moger
102  *
103  */
104 public class TicketPage extends TicketBasePage {
105
106     static final String NIL = "<nil>";
107
108     static final String ESC_NIL = StringUtils.escapeForHtml(NIL,  false);
109
110     final int avatarWidth = 40;
111
112     final TicketModel ticket;
113
114     public TicketPage(PageParameters params) {
115         super(params);
116
117         final UserModel user = GitBlitWebSession.get().getUser() == null ? UserModel.ANONYMOUS : GitBlitWebSession.get().getUser();
118         final RepositoryModel repository = getRepositoryModel();
119         final String id = WicketUtils.getObject(params);
120         long ticketId = Long.parseLong(id);
121         ticket = app().tickets().getTicket(repository, ticketId);
122
123         if (ticket == null) {
124             // ticket not found
125             throw new RestartResponseException(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
126         }
127
128         final List<Change> revisions = new ArrayList<Change>();
129         List<Change> comments = new ArrayList<Change>();
130         List<Change> statusChanges = new ArrayList<Change>();
131         List<Change> discussion = new ArrayList<Change>();
132         for (Change change : ticket.changes) {
133             if (change.hasComment() || (change.isStatusChange() && (change.getStatus() != Status.New))) {
134                 discussion.add(change);
135             }
136             if (change.hasComment()) {
137                 comments.add(change);
138             }
139             if (change.hasPatchset()) {
140                 revisions.add(change);
141             }
142             if (change.isStatusChange() && !change.hasPatchset()) {
143                 statusChanges.add(change);
144             }
145         }
146
147         final Change currentRevision = revisions.isEmpty() ? null : revisions.get(revisions.size() - 1);
148         final Patchset currentPatchset = ticket.getCurrentPatchset();
149
150         /*
151          * TICKET HEADER
152          */
153         String href = urlFor(TicketsPage.class, params).toString();
154         add(new ExternalLink("ticketNumber", href, "#" + ticket.number));
155         Label headerStatus = new Label("headerStatus", ticket.status.toString());
156         WicketUtils.setCssClass(headerStatus, getLozengeClass(ticket.status, false));
157         add(headerStatus);
158         add(new Label("ticketTitle", ticket.title));
159         if (currentPatchset == null) {
160             add(new Label("diffstat").setVisible(false));
161         } else {
162             // calculate the current diffstat of the patchset
163             add(new DiffStatPanel("diffstat", ticket.insertions, ticket.deletions));
164         }
165
166
167         /*
168          * TAB TITLES
169          */
170         add(new Label("commentCount", "" + comments.size()).setVisible(!comments.isEmpty()));
171         add(new Label("commitCount", "" + (currentPatchset == null ? 0 : currentPatchset.commits)).setVisible(currentPatchset != null));
172
173
174         /*
175          * TICKET AUTHOR and DATE (DISCUSSION TAB)
176          */
177         UserModel createdBy = app().users().getUserModel(ticket.createdBy);
178         if (createdBy == null) {
179             add(new Label("whoCreated", ticket.createdBy));
180         } else {
181             add(new LinkPanel("whoCreated", null, createdBy.getDisplayName(),
182                     UserPage.class, WicketUtils.newUsernameParameter(createdBy.username)));
183         }
184
185         if (ticket.isProposal()) {
186             // clearly indicate this is a change ticket
187             add(new Label("creationMessage", getString("gb.proposedThisChange")));
188         } else {
189             // standard ticket
190             add(new Label("creationMessage", getString("gb.createdThisTicket")));
191         }
192
193         String dateFormat = app().settings().getString(Keys.web.datestampLongFormat, "EEEE, MMMM d, yyyy");
194         String timestampFormat = app().settings().getString(Keys.web.datetimestampLongFormat, "EEEE, MMMM d, yyyy");
195         final TimeZone timezone = getTimeZone();
196         final DateFormat df = new SimpleDateFormat(dateFormat);
197         df.setTimeZone(timezone);
198         final DateFormat tsf = new SimpleDateFormat(timestampFormat);
199         tsf.setTimeZone(timezone);
200         final Calendar cal = Calendar.getInstance(timezone);
201
202         String fuzzydate;
203         TimeUtils tu = getTimeUtils();
204         Date createdDate = ticket.created;
205         if (TimeUtils.isToday(createdDate, timezone)) {
206             fuzzydate = tu.today();
207         } else if (TimeUtils.isYesterday(createdDate, timezone)) {
208             fuzzydate = tu.yesterday();
209         } else {
210             // calculate a fuzzy time ago date
211             cal.setTime(createdDate);
212             cal.set(Calendar.HOUR_OF_DAY, 0);
213             cal.set(Calendar.MINUTE, 0);
214             cal.set(Calendar.SECOND, 0);
215             cal.set(Calendar.MILLISECOND, 0);
216             createdDate = cal.getTime();
217             fuzzydate = getTimeUtils().timeAgo(createdDate);
218         }
219         Label when = new Label("whenCreated", fuzzydate + ", " + df.format(createdDate));
220         WicketUtils.setHtmlTooltip(when, tsf.format(ticket.created));
221         add(when);
222
223         String exportHref = urlFor(ExportTicketPage.class, params).toString();
224         add(new ExternalLink("exportJson", exportHref, "json"));
225
226
227         /*
228          * RESPONSIBLE (DISCUSSION TAB)
229          */
230         if (StringUtils.isEmpty(ticket.responsible)) {
231             add(new Label("responsible"));
232         } else {
233             UserModel responsible = app().users().getUserModel(ticket.responsible);
234             if (responsible == null) {
235                 add(new Label("responsible", ticket.responsible));
236             } else {
237                 add(new LinkPanel("responsible", null, responsible.getDisplayName(),
238                         UserPage.class, WicketUtils.newUsernameParameter(responsible.username)));
239             }
240         }
241
242         /*
243          * MILESTONE PROGRESS (DISCUSSION TAB)
244          */
245         if (StringUtils.isEmpty(ticket.milestone)) {
246             add(new Label("milestone"));
247         } else {
248             // link to milestone query
249             TicketMilestone milestone = app().tickets().getMilestone(repository, ticket.milestone);
250             PageParameters milestoneParameters = new PageParameters();
251             milestoneParameters.put("r", repositoryName);
252             milestoneParameters.put(Lucene.milestone.name(), ticket.milestone);
253             int progress = 0;
254             int open = 0;
255             int closed = 0;
256             if (milestone != null) {
257                 progress = milestone.getProgress();
258                 open = milestone.getOpenTickets();
259                 closed = milestone.getClosedTickets();
260             }
261
262             Fragment milestoneProgress = new Fragment("milestone", "milestoneProgressFragment", this);
263             milestoneProgress.add(new LinkPanel("link", null, ticket.milestone, TicketsPage.class, milestoneParameters));
264             Label label = new Label("progress");
265             WicketUtils.setCssStyle(label, "width:" + progress + "%;");
266             milestoneProgress.add(label);
9f50fd 267             WicketUtils.setHtmlTooltip(milestoneProgress, MessageFormat.format(getString("gb.milestoneProgress"), open, closed));
5e3521 268             add(milestoneProgress);
JM 269         }
270
271
272         /*
273          * TICKET DESCRIPTION (DISCUSSION TAB)
274          */
275         String desc;
276         if (StringUtils.isEmpty(ticket.body)) {
277             desc = getString("gb.noDescriptionGiven");
278         } else {
279             desc = MarkdownUtils.transformGFM(app().settings(), ticket.body, ticket.repository);
280         }
281         add(new Label("ticketDescription", desc).setEscapeModelStrings(false));
282
283
284         /*
285          * PARTICIPANTS (DISCUSSION TAB)
286          */
287         if (app().settings().getBoolean(Keys.web.allowGravatar, true)) {
288             // gravatar allowed
289             List<String> participants = ticket.getParticipants();
290             add(new Label("participantsLabel", MessageFormat.format(getString(participants.size() > 1 ? "gb.nParticipants" : "gb.oneParticipant"),
291                     "<b>" + participants.size() + "</b>")).setEscapeModelStrings(false));
292             ListDataProvider<String> participantsDp = new ListDataProvider<String>(participants);
293             DataView<String> participantsView = new DataView<String>("participants", participantsDp) {
294                 private static final long serialVersionUID = 1L;
295
296                 @Override
297                 public void populateItem(final Item<String> item) {
298                     String username = item.getModelObject();
299                     UserModel user = app().users().getUserModel(username);
300                     if (user == null) {
301                         user = new UserModel(username);
302                     }
303                     item.add(new GravatarImage("participant", user.getDisplayName(),
304                             user.emailAddress, null, 25, true));
305                 }
306             };
307             add(participantsView);
308         } else {
309             // gravatar prohibited
310             add(new Label("participantsLabel").setVisible(false));
311             add(new Label("participants").setVisible(false));
312         }
313
314
315         /*
316          * LARGE STATUS INDICATOR WITH ICON (DISCUSSION TAB->SIDE BAR)
317          */
318         Fragment ticketStatus = new Fragment("ticketStatus", "ticketStatusFragment", this);
319         Label ticketIcon = getStateIcon("ticketIcon", ticket);
320         ticketStatus.add(ticketIcon);
321         ticketStatus.add(new Label("ticketStatus", ticket.status.toString()));
322         WicketUtils.setCssClass(ticketStatus, getLozengeClass(ticket.status, false));
323         add(ticketStatus);
324
325
326         /*
327          * UPDATE FORM (DISCUSSION TAB)
328          */
7ca053 329         if (user.canEdit(ticket, repository) && app().tickets().isAcceptingTicketUpdates(repository)) {
cc1c3f 330             if (user.canAdmin(ticket, repository) && ticket.isOpen()) {
85775a 331                 /*
JM 332                  * OPEN TICKET
333                  */
334                 Fragment controls = new Fragment("controls", "openControlsFragment", this);
5e3521 335
85775a 336                 /*
JM 337                  * STATUS
338                  */
339                 List<Status> choices = new ArrayList<Status>();
340                 if (ticket.isProposal()) {
341                     choices.addAll(Arrays.asList(TicketModel.Status.proposalWorkflow));
342                 } else if (ticket.isBug()) {
343                     choices.addAll(Arrays.asList(TicketModel.Status.bugWorkflow));
344                 } else {
345                     choices.addAll(Arrays.asList(TicketModel.Status.requestWorkflow));
5e3521 346                 }
85775a 347                 choices.remove(ticket.status);
5e3521 348
85775a 349                 ListDataProvider<Status> workflowDp = new ListDataProvider<Status>(choices);
JM 350                 DataView<Status> statusView = new DataView<Status>("newStatus", workflowDp) {
351                     private static final long serialVersionUID = 1L;
5e3521 352
85775a 353                     @Override
JM 354                     public void populateItem(final Item<Status> item) {
355                         SimpleAjaxLink<Status> link = new SimpleAjaxLink<Status>("link", item.getModel()) {
5e3521 356
85775a 357                             private static final long serialVersionUID = 1L;
5e3521 358
85775a 359                             @Override
JM 360                             public void onClick(AjaxRequestTarget target) {
361                                 Status status = getModel().getObject();
362                                 Change change = new Change(user.username);
363                                 change.setField(Field.status, status);
364                                 if (!ticket.isWatching(user.username)) {
365                                     change.watch(user.username);
5e3521 366                                 }
85775a 367                                 TicketModel update = app().tickets().updateTicket(repository, ticket.number, change);
JM 368                                 app().tickets().createNotifier().sendMailing(update);
369                                 setResponsePage(TicketsPage.class, getPageParameters());
5e3521 370                             }
85775a 371                         };
JM 372                         String css = getStatusClass(item.getModel().getObject());
373                         WicketUtils.setCssClass(link, css);
374                         item.add(link);
375                     }
376                 };
377                 controls.add(statusView);
5e3521 378
85775a 379                 /*
JM 380                  * RESPONSIBLE LIST
381                  */
382                 Set<String> userlist = new TreeSet<String>(ticket.getParticipants());
383                 for (RegistrantAccessPermission rp : app().repositories().getUserAccessPermissions(getRepositoryModel())) {
384                     if (rp.permission.atLeast(AccessPermission.PUSH) && !rp.isTeam()) {
385                         userlist.add(rp.registrant);
5e3521 386                     }
JM 387                 }
85775a 388                 List<TicketResponsible> responsibles = new ArrayList<TicketResponsible>();
JM 389                 if (!StringUtils.isEmpty(ticket.responsible)) {
390                     // exclude the current responsible
391                     userlist.remove(ticket.responsible);
5e3521 392                 }
85775a 393                 for (String username : userlist) {
JM 394                     UserModel u = app().users().getUserModel(username);
395                     if (u != null) {
396                         responsibles.add(new TicketResponsible(u));
397                     }
398                 }
399                 Collections.sort(responsibles);
400                 responsibles.add(new TicketResponsible(ESC_NIL, "", ""));
401                 ListDataProvider<TicketResponsible> responsibleDp = new ListDataProvider<TicketResponsible>(responsibles);
402                 DataView<TicketResponsible> responsibleView = new DataView<TicketResponsible>("newResponsible", responsibleDp) {
403                     private static final long serialVersionUID = 1L;
5e3521 404
85775a 405                     @Override
JM 406                     public void populateItem(final Item<TicketResponsible> item) {
407                         SimpleAjaxLink<TicketResponsible> link = new SimpleAjaxLink<TicketResponsible>("link", item.getModel()) {
5e3521 408
85775a 409                             private static final long serialVersionUID = 1L;
JM 410
411                             @Override
412                             public void onClick(AjaxRequestTarget target) {
413                                 TicketResponsible responsible = getModel().getObject();
414                                 Change change = new Change(user.username);
415                                 change.setField(Field.responsible, responsible.username);
416                                 if (!StringUtils.isEmpty(responsible.username)) {
417                                     if (!ticket.isWatching(responsible.username)) {
418                                         change.watch(responsible.username);
419                                     }
420                                 }
421                                 if (!ticket.isWatching(user.username)) {
422                                     change.watch(user.username);
423                                 }
424                                 TicketModel update = app().tickets().updateTicket(repository, ticket.number, change);
425                                 app().tickets().createNotifier().sendMailing(update);
426                                 setResponsePage(TicketsPage.class, getPageParameters());
427                             }
428                         };
429                         item.add(link);
430                     }
431                 };
432                 controls.add(responsibleView);
433
434                 /*
435                  * MILESTONE LIST
436                  */
437                 List<TicketMilestone> milestones = app().tickets().getMilestones(repository, Status.Open);
438                 if (!StringUtils.isEmpty(ticket.milestone)) {
439                     for (TicketMilestone milestone : milestones) {
440                         if (milestone.name.equals(ticket.milestone)) {
441                             milestones.remove(milestone);
442                             break;
443                         }
444                     }
445                 }
446                 milestones.add(new TicketMilestone(ESC_NIL));
447                 ListDataProvider<TicketMilestone> milestoneDp = new ListDataProvider<TicketMilestone>(milestones);
448                 DataView<TicketMilestone> milestoneView = new DataView<TicketMilestone>("newMilestone", milestoneDp) {
449                     private static final long serialVersionUID = 1L;
450
451                     @Override
452                     public void populateItem(final Item<TicketMilestone> item) {
453                         SimpleAjaxLink<TicketMilestone> link = new SimpleAjaxLink<TicketMilestone>("link", item.getModel()) {
454
455                             private static final long serialVersionUID = 1L;
456
457                             @Override
458                             public void onClick(AjaxRequestTarget target) {
459                                 TicketMilestone milestone = getModel().getObject();
460                                 Change change = new Change(user.username);
461                                 if (NIL.equals(milestone.name) || ESC_NIL.equals(milestone.name)) {
462                                     change.setField(Field.milestone, "");
463                                 } else {
464                                     change.setField(Field.milestone, milestone.name);
465                                 }
466                                 if (!ticket.isWatching(user.username)) {
467                                     change.watch(user.username);
468                                 }
469                                 TicketModel update = app().tickets().updateTicket(repository, ticket.number, change);
470                                 app().tickets().createNotifier().sendMailing(update);
471                                 setResponsePage(TicketsPage.class, getPageParameters());
472                             }
473                         };
474                         item.add(link);
475                     }
476                 };
477                 controls.add(milestoneView);
478
479                 String editHref = urlFor(EditTicketPage.class, params).toString();
480                 controls.add(new ExternalLink("editLink", editHref, getString("gb.edit")));
481
482                 add(controls);
483             } else {
484                 /*
485                  * CLOSED TICKET
486                  */
487                 Fragment controls = new Fragment("controls", "closedControlsFragment", this);
488
489                 String editHref = urlFor(EditTicketPage.class, params).toString();
490                 controls.add(new ExternalLink("editLink", editHref, getString("gb.edit")));
491
492                 add(controls);
493             }
5e3521 494         } else {
JM 495             add(new Label("controls").setVisible(false));
496         }
497
498
499         /*
500          * TICKET METADATA
501          */
502         add(new Label("ticketType", ticket.type.toString()));
503         if (StringUtils.isEmpty(ticket.topic)) {
504             add(new Label("ticketTopic").setVisible(false));
505         } else {
506             // process the topic using the bugtraq config to link things
fef234 507             String topic = bugtraqProcessor().processPlainCommitMessage(getRepository(), repositoryName, ticket.topic);
5e3521 508             add(new Label("ticketTopic", topic).setEscapeModelStrings(false));
JM 509         }
510
511
512         /*
513          * VOTERS
514          */
515         List<String> voters = ticket.getVoters();
516         Label votersCount = new Label("votes", "" + voters.size());
517         if (voters.size() == 0) {
518             WicketUtils.setCssClass(votersCount, "badge");
519         } else {
520             WicketUtils.setCssClass(votersCount, "badge badge-info");
521         }
522         add(votersCount);
523         if (user.isAuthenticated) {
524             Model<String> model;
525             if (ticket.isVoter(user.username)) {
526                 model = Model.of(getString("gb.removeVote"));
527             } else {
528                 model = Model.of(MessageFormat.format(getString("gb.vote"), ticket.type.toString()));
529             }
530             SimpleAjaxLink<String> link = new SimpleAjaxLink<String>("voteLink", model) {
531
532                 private static final long serialVersionUID = 1L;
533
534                 @Override
535                 public void onClick(AjaxRequestTarget target) {
536                     Change change = new Change(user.username);
537                     if (ticket.isVoter(user.username)) {
538                         change.unvote(user.username);
539                     } else {
540                         change.vote(user.username);
541                     }
542                     app().tickets().updateTicket(repository, ticket.number, change);
543                     setResponsePage(TicketsPage.class, getPageParameters());
544                 }
545             };
546             add(link);
547         } else {
548             add(new Label("voteLink").setVisible(false));
549         }
550
551
552         /*
553          * WATCHERS
554          */
555         List<String> watchers = ticket.getWatchers();
556         Label watchersCount = new Label("watchers", "" + watchers.size());
557         if (watchers.size() == 0) {
558             WicketUtils.setCssClass(watchersCount, "badge");
559         } else {
560             WicketUtils.setCssClass(watchersCount, "badge badge-info");
561         }
562         add(watchersCount);
563         if (user.isAuthenticated) {
564             Model<String> model;
565             if (ticket.isWatching(user.username)) {
566                 model = Model.of(getString("gb.stopWatching"));
567             } else {
568                 model = Model.of(MessageFormat.format(getString("gb.watch"), ticket.type.toString()));
569             }
570             SimpleAjaxLink<String> link = new SimpleAjaxLink<String>("watchLink", model) {
571
572                 private static final long serialVersionUID = 1L;
573
574                 @Override
575                 public void onClick(AjaxRequestTarget target) {
576                     Change change = new Change(user.username);
577                     if (ticket.isWatching(user.username)) {
578                         change.unwatch(user.username);
579                     } else {
580                         change.watch(user.username);
581                     }
582                     app().tickets().updateTicket(repository, ticket.number, change);
583                     setResponsePage(TicketsPage.class, getPageParameters());
584                 }
585             };
586             add(link);
587         } else {
588             add(new Label("watchLink").setVisible(false));
589         }
590
591
592         /*
593          * TOPIC & LABELS (DISCUSSION TAB->SIDE BAR)
594          */
595         ListDataProvider<String> labelsDp = new ListDataProvider<String>(ticket.getLabels());
596         DataView<String> labelsView = new DataView<String>("labels", labelsDp) {
597             private static final long serialVersionUID = 1L;
598
599             @Override
600             public void populateItem(final Item<String> item) {
601                 final String value = item.getModelObject();
602                 Label label = new Label("label", value);
603                 TicketLabel tLabel = app().tickets().getLabel(repository, value);
604                 String background = MessageFormat.format("background-color:{0};", tLabel.color);
605                 label.add(new SimpleAttributeModifier("style", background));
606                 item.add(label);
607             }
608         };
609
610         add(labelsView);
611
612
613         /*
614          * COMMENTS & STATUS CHANGES (DISCUSSION TAB)
615          */
616         if (comments.size() == 0) {
617             add(new Label("discussion").setVisible(false));
618         } else {
619             Fragment discussionFragment = new Fragment("discussion", "discussionFragment", this);
620             ListDataProvider<Change> discussionDp = new ListDataProvider<Change>(discussion);
621             DataView<Change> discussionView = new DataView<Change>("discussion", discussionDp) {
622                 private static final long serialVersionUID = 1L;
623
624                 @Override
625                 public void populateItem(final Item<Change> item) {
626                     final Change entry = item.getModelObject();
627                     if (entry.isMerge()) {
628                         /*
629                          * MERGE
630                          */
631                         String resolvedBy = entry.getString(Field.mergeSha);
632
633                         // identify the merged patch, it is likely the last
634                         Patchset mergedPatch = null;
635                         for (Change c : revisions) {
636                             if (c.patchset.tip.equals(resolvedBy)) {
637                                 mergedPatch = c.patchset;
638                                 break;
639                             }
640                         }
641
642                         String commitLink;
643                         if (mergedPatch == null) {
644                             // shouldn't happen, but just-in-case
645                             int len = app().settings().getInteger(Keys.web.shortCommitIdLength, 6);
646                             commitLink = resolvedBy.substring(0, len);
647                         } else {
648                             // expected result
649                             commitLink = mergedPatch.toString();
650                         }
651
652                         Fragment mergeFragment = new Fragment("entry", "mergeFragment", this);
653                         mergeFragment.add(new LinkPanel("commitLink", null, commitLink,
654                                 CommitPage.class, WicketUtils.newObjectParameter(repositoryName, resolvedBy)));
655                         mergeFragment.add(new Label("toBranch", MessageFormat.format(getString("gb.toBranch"),
656                                 "<b>" + ticket.mergeTo + "</b>")).setEscapeModelStrings(false));
657                         addUserAttributions(mergeFragment, entry, 0);
658                         addDateAttributions(mergeFragment, entry);
659
660                         item.add(mergeFragment);
661                     } else if (entry.isStatusChange()) {
662                         /*
663                          *  STATUS CHANGE
664                          */
665                         Fragment frag = new Fragment("entry", "statusFragment", this);
666                         Label status = new Label("statusChange", entry.getStatus().toString());
667                         String css = getLozengeClass(entry.getStatus(), false);
668                         WicketUtils.setCssClass(status, css);
669                         for (IBehavior b : status.getBehaviors()) {
670                             if (b instanceof SimpleAttributeModifier) {
671                                 SimpleAttributeModifier sam = (SimpleAttributeModifier) b;
672                                 if ("class".equals(sam.getAttribute())) {
673                                     status.add(new SimpleAttributeModifier("class", "status-change " + sam.getValue()));
674                                     break;
675                                 }
676                             }
677                         }
678                         frag.add(status);
679                         addUserAttributions(frag, entry, avatarWidth);
680                         addDateAttributions(frag, entry);
681                         item.add(frag);
682                     } else {
683                         /*
684                          * COMMENT
685                          */
686                         String comment = MarkdownUtils.transformGFM(app().settings(), entry.comment.text, repositoryName);
687                         Fragment frag = new Fragment("entry", "commentFragment", this);
688                         Label commentIcon = new Label("commentIcon");
689                         if (entry.comment.src == CommentSource.Email) {
690                             WicketUtils.setCssClass(commentIcon, "iconic-mail");
691                         } else {
692                             WicketUtils.setCssClass(commentIcon, "iconic-comment-alt2-stroke");
693                         }
694                         frag.add(commentIcon);
695                         frag.add(new Label("comment", comment).setEscapeModelStrings(false));
696                         addUserAttributions(frag, entry, avatarWidth);
697                         addDateAttributions(frag, entry);
698                         item.add(frag);
699                     }
700                 }
701             };
702             discussionFragment.add(discussionView);
703             add(discussionFragment);
704         }
705
706         /*
707          * ADD COMMENT PANEL
708          */
709         if (UserModel.ANONYMOUS.equals(user)
710                 || !repository.isBare
711                 || repository.isFrozen
712                 || repository.isMirror) {
713
714             // prohibit comments for anonymous users, local working copy repos,
715             // frozen repos, and mirrors
716             add(new Label("newComment").setVisible(false));
717         } else {
718             // permit user to comment
719             Fragment newComment = new Fragment("newComment", "newCommentFragment", this);
720             GravatarImage img = new GravatarImage("newCommentAvatar", user.username, user.emailAddress,
721                     "gravatar-round", avatarWidth, true);
722             newComment.add(img);
723             CommentPanel commentPanel = new CommentPanel("commentPanel", user, ticket, null, TicketsPage.class);
724             commentPanel.setRepository(repositoryName);
725             newComment.add(commentPanel);
726             add(newComment);
727         }
728
729
730         /*
731          *  PATCHSET TAB
732          */
733         if (currentPatchset == null) {
31e1bf 734             // no patchset available
JM 735             if (ticket.isOpen() && app().tickets().isAcceptingNewPatchsets(repository)) {
736                 // ticket & repo will accept a proposal patchset
737                 // show the instructions for proposing a patchset
738                 String repoUrl = getRepositoryUrl(user, repository);
739                 Fragment changeIdFrag = new Fragment("patchset", "proposeFragment", this);
740                 changeIdFrag.add(new Label("proposeInstructions", MarkdownUtils.transformMarkdown(getString("gb.proposeInstructions"))).setEscapeModelStrings(false));
741                 changeIdFrag.add(new Label("ptWorkflow", MessageFormat.format(getString("gb.proposeWith"), "Barnum")));
742                 changeIdFrag.add(new Label("ptWorkflowSteps", getProposeWorkflow("propose_pt.md", repoUrl, ticket.number)).setEscapeModelStrings(false));
743                 changeIdFrag.add(new Label("gitWorkflow", MessageFormat.format(getString("gb.proposeWith"), "Git")));
744                 changeIdFrag.add(new Label("gitWorkflowSteps", getProposeWorkflow("propose_git.md", repoUrl, ticket.number)).setEscapeModelStrings(false));
745                 add(changeIdFrag);
746             } else {
4affd0 747                 // explain why you can't propose a patchset
JM 748                 Fragment fragment = new Fragment("patchset", "canNotProposeFragment", this);
749                 String reason = "";
750                 if (ticket.isClosed()) {
751                     reason = getString("gb.ticketIsClosed");
752                 } else if (repository.isMirror) {
753                     reason = getString("gb.repositoryIsMirror");
754                 } else if (repository.isFrozen) {
755                     reason = getString("gb.repositoryIsFrozen");
756                 } else if (!repository.acceptNewPatchsets) {
757                     reason = getString("gb.repositoryDoesNotAcceptPatchsets");
758                 } else {
759                     reason = getString("gb.serverDoesNotAcceptPatchsets");
760                 }
761                 fragment.add(new Label("reason", reason));
762                 add(fragment);
31e1bf 763             }
5e3521 764         } else {
JM 765             // show current patchset
766             Fragment patchsetFrag = new Fragment("patchset", "patchsetFragment", this);
767             patchsetFrag.add(new Label("commitsInPatchset", MessageFormat.format(getString("gb.commitsInPatchsetN"), currentPatchset.number)));
768
769             // current revision
770             MarkupContainer panel = createPatchsetPanel("panel", repository, user);
771             patchsetFrag.add(panel);
772             addUserAttributions(patchsetFrag, currentRevision, avatarWidth);
773             addUserAttributions(panel, currentRevision, 0);
774             addDateAttributions(panel, currentRevision);
775
776             // commits
777             List<RevCommit> commits = JGitUtils.getRevLog(getRepository(), currentPatchset.base, currentPatchset.tip);
778             ListDataProvider<RevCommit> commitsDp = new ListDataProvider<RevCommit>(commits);
779             DataView<RevCommit> commitsView = new DataView<RevCommit>("commit", commitsDp) {
780                 private static final long serialVersionUID = 1L;
781
782                 @Override
783                 public void populateItem(final Item<RevCommit> item) {
784                     RevCommit commit = item.getModelObject();
785                     PersonIdent author = commit.getAuthorIdent();
786                     item.add(new GravatarImage("authorAvatar", author.getName(), author.getEmailAddress(), null, 16, false));
787                     item.add(new Label("author", commit.getAuthorIdent().getName()));
788                     item.add(new LinkPanel("commitId", null, getShortObjectId(commit.getName()),
789                             CommitPage.class, WicketUtils.newObjectParameter(repositoryName, commit.getName()), true));
790                     item.add(new LinkPanel("diff", "link", getString("gb.diff"), CommitDiffPage.class,
791                             WicketUtils.newObjectParameter(repositoryName, commit.getName()), true));
792                     item.add(new Label("title", StringUtils.trimString(commit.getShortMessage(), Constants.LEN_SHORTLOG_REFS)));
793                     item.add(WicketUtils.createDateLabel("commitDate", JGitUtils.getCommitDate(commit), GitBlitWebSession
794                             .get().getTimezone(), getTimeUtils(), false));
795                     item.add(new DiffStatPanel("commitDiffStat", 0, 0, true));
796                 }
797             };
798             patchsetFrag.add(commitsView);
799             add(patchsetFrag);
800         }
801
802
803         /*
804          * ACTIVITY TAB
805          */
806         Fragment revisionHistory = new Fragment("activity", "activityFragment", this);
807         List<Change> events = new ArrayList<Change>(ticket.changes);
808         Collections.sort(events);
809         Collections.reverse(events);
810         ListDataProvider<Change> eventsDp = new ListDataProvider<Change>(events);
811         DataView<Change> eventsView = new DataView<Change>("event", eventsDp) {
812             private static final long serialVersionUID = 1L;
813
814             @Override
815             public void populateItem(final Item<Change> item) {
816                 Change event = item.getModelObject();
817
818                 addUserAttributions(item, event, 16);
819
820                 if (event.hasPatchset()) {
821                     // patchset
822                     Patchset patchset = event.patchset;
823                     String what;
824                     if (event.isStatusChange() && (Status.New == event.getStatus())) {
825                         what = getString("gb.proposedThisChange");
826                     } else if (patchset.rev == 1) {
827                         what = MessageFormat.format(getString("gb.uploadedPatchsetN"), patchset.number);
828                     } else {
829                         if (patchset.added == 1) {
830                             what = getString("gb.addedOneCommit");
831                         } else {
832                             what = MessageFormat.format(getString("gb.addedNCommits"), patchset.added);
833                         }
834                     }
835                     item.add(new Label("what", what));
836
837                     LinkPanel psr = new LinkPanel("patchsetRevision", null, patchset.number + "-" + patchset.rev,
838                             ComparePage.class, WicketUtils.newRangeParameter(repositoryName, patchset.parent == null ? patchset.base : patchset.parent, patchset.tip), true);
839                     WicketUtils.setHtmlTooltip(psr, patchset.toString());
840                     item.add(psr);
841                     String typeCss = getPatchsetTypeCss(patchset.type);
842                     Label typeLabel = new Label("patchsetType", patchset.type.toString());
843                     if (typeCss == null) {
844                         typeLabel.setVisible(false);
845                     } else {
846                         WicketUtils.setCssClass(typeLabel, typeCss);
847                     }
848                     item.add(typeLabel);
849
850                     // show commit diffstat
851                     item.add(new DiffStatPanel("patchsetDiffStat", patchset.insertions, patchset.deletions, patchset.rev > 1));
852                 } else if (event.hasComment()) {
853                     // comment
854                     item.add(new Label("what", getString("gb.commented")));
855                     item.add(new Label("patchsetRevision").setVisible(false));
856                     item.add(new Label("patchsetType").setVisible(false));
857                     item.add(new Label("patchsetDiffStat").setVisible(false));
858                 } else if (event.hasReview()) {
859                     // review
860                     String score;
861                     switch (event.review.score) {
862                     case approved:
863                         score = "<span style='color:darkGreen'>" + getScoreDescription(event.review.score) + "</span>";
864                         break;
865                     case vetoed:
866                         score = "<span style='color:darkRed'>" + getScoreDescription(event.review.score) + "</span>";
867                         break;
868                     default:
869                         score = getScoreDescription(event.review.score);
870                     }
871                     item.add(new Label("what", MessageFormat.format(getString("gb.reviewedPatchsetRev"),
872                             event.review.patchset, event.review.rev, score))
873                             .setEscapeModelStrings(false));
874                     item.add(new Label("patchsetRevision").setVisible(false));
875                     item.add(new Label("patchsetType").setVisible(false));
876                     item.add(new Label("patchsetDiffStat").setVisible(false));
877                 } else {
878                     // field change
879                     item.add(new Label("patchsetRevision").setVisible(false));
880                     item.add(new Label("patchsetType").setVisible(false));
881                     item.add(new Label("patchsetDiffStat").setVisible(false));
882
883                     String what = "";
884                     if (event.isStatusChange()) {
885                     switch (event.getStatus()) {
886                     case New:
887                         if (ticket.isProposal()) {
888                             what = getString("gb.proposedThisChange");
889                         } else {
890                             what = getString("gb.createdThisTicket");
891                         }
892                         break;
893                     default:
894                         break;
895                     }
896                     }
897                     item.add(new Label("what", what).setVisible(what.length() > 0));
898                 }
899
900                 addDateAttributions(item, event);
901
902                 if (event.hasFieldChanges()) {
903                     StringBuilder sb = new StringBuilder();
904                     sb.append("<table class=\"summary\"><tbody>");
905                     for (Map.Entry<Field, String> entry : event.fields.entrySet()) {
906                         String value;
907                         switch (entry.getKey()) {
908                             case body:
909                                 String body = entry.getValue();
910                                 if (event.isStatusChange() && Status.New == event.getStatus() && StringUtils.isEmpty(body)) {
911                                     // ignore initial empty description
912                                     continue;
913                                 }
914                                 // trim body changes
915                                 if (StringUtils.isEmpty(body)) {
916                                     value = "<i>" + ESC_NIL + "</i>";
917                                 } else {
918                                     value = StringUtils.trimString(body, Constants.LEN_SHORTLOG_REFS);
919                                 }
920                                 break;
921                             case status:
922                                 // special handling for status
923                                 Status status = event.getStatus();
924                                 String css = getLozengeClass(status, true);
925                                 value = String.format("<span class=\"%1$s\">%2$s</span>", css, status.toString());
926                                 break;
927                             default:
928                                 value = StringUtils.isEmpty(entry.getValue()) ? ("<i>" + ESC_NIL + "</i>") : StringUtils.escapeForHtml(entry.getValue(), false);
929                                 break;
930                         }
931                         sb.append("<tr><th style=\"width:70px;\">");
3d1b42 932                         try {
JM 933                             sb.append(getString("gb." + entry.getKey().name()));
934                         } catch (Exception e) {
935                             sb.append(entry.getKey().name());
936                         }
5e3521 937                         sb.append("</th><td>");
JM 938                         sb.append(value);
939                         sb.append("</td></tr>");
940                     }
941                     sb.append("</tbody></table>");
942                     item.add(new Label("fields", sb.toString()).setEscapeModelStrings(false));
943                 } else {
944                     item.add(new Label("fields").setVisible(false));
945                 }
946             }
947         };
948         revisionHistory.add(eventsView);
949         add(revisionHistory);
950     }
951
952     protected void addUserAttributions(MarkupContainer container, Change entry, int avatarSize) {
953         UserModel commenter = app().users().getUserModel(entry.author);
954         if (commenter == null) {
955             // unknown user
956             container.add(new GravatarImage("changeAvatar", entry.author,
957                     entry.author, null, avatarSize, false).setVisible(avatarSize > 0));
958             container.add(new Label("changeAuthor", entry.author.toLowerCase()));
959         } else {
960             // known user
961             container.add(new GravatarImage("changeAvatar", commenter.getDisplayName(),
962                     commenter.emailAddress, avatarSize > 24 ? "gravatar-round" : null,
963                             avatarSize, true).setVisible(avatarSize > 0));
964             container.add(new LinkPanel("changeAuthor", null, commenter.getDisplayName(),
965                     UserPage.class, WicketUtils.newUsernameParameter(commenter.username)));
966         }
967     }
968
969     protected void addDateAttributions(MarkupContainer container, Change entry) {
970         container.add(WicketUtils.createDateLabel("changeDate", entry.date, GitBlitWebSession
971                 .get().getTimezone(), getTimeUtils(), false));
972
973         // set the id attribute
974         if (entry.hasComment()) {
975             container.setOutputMarkupId(true);
976             container.add(new AttributeModifier("id", Model.of(entry.getId())));
977             ExternalLink link = new ExternalLink("changeLink", "#" + entry.getId());
978             container.add(link);
979         } else {
980             container.add(new Label("changeLink").setVisible(false));
981         }
982     }
983
984     protected String getProposeWorkflow(String resource, String url, long ticketId) {
985         String md = readResource(resource);
986         md = md.replace("${url}", url);
987         md = md.replace("${repo}", StringUtils.getLastPathElement(StringUtils.stripDotGit(repositoryName)));
988         md = md.replace("${ticketId}", "" + ticketId);
989         md = md.replace("${patchset}", "" + 1);
990         md = md.replace("${reviewBranch}", Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticketId)));
b335dd 991         String integrationBranch = Repository.shortenRefName(getRepositoryModel().HEAD);
JM 992         if (!StringUtils.isEmpty(ticket.mergeTo)) {
993             integrationBranch = ticket.mergeTo;
994         }
995         md = md.replace("${integrationBranch}", integrationBranch);
5e3521 996         return MarkdownUtils.transformMarkdown(md);
JM 997     }
998
999     protected Fragment createPatchsetPanel(String wicketId, RepositoryModel repository, UserModel user) {
1000         final Patchset currentPatchset = ticket.getCurrentPatchset();
1001         List<Patchset> patchsets = new ArrayList<Patchset>(ticket.getPatchsetRevisions(currentPatchset.number));
1002         patchsets.remove(currentPatchset);
1003         Collections.reverse(patchsets);
1004
1005         Fragment panel = new Fragment(wicketId, "collapsiblePatchsetFragment", this);
1006
1007         // patchset header
1008         String ps = "<b>" + currentPatchset.number + "</b>";
1009         if (currentPatchset.rev == 1) {
1010             panel.add(new Label("uploadedWhat", MessageFormat.format(getString("gb.uploadedPatchsetN"), ps)).setEscapeModelStrings(false));
1011         } else {
1012             String rev = "<b>" + currentPatchset.rev + "</b>";
1013             panel.add(new Label("uploadedWhat", MessageFormat.format(getString("gb.uploadedPatchsetNRevisionN"), ps, rev)).setEscapeModelStrings(false));
1014         }
1015         panel.add(new LinkPanel("patchId", null, "rev " + currentPatchset.rev,
1016                 CommitPage.class, WicketUtils.newObjectParameter(repositoryName, currentPatchset.tip), true));
1017
1018         // compare menu
1019         panel.add(new LinkPanel("compareMergeBase", null, getString("gb.compareToMergeBase"),
1020                 ComparePage.class, WicketUtils.newRangeParameter(repositoryName, currentPatchset.base, currentPatchset.tip), true));
1021
1022         ListDataProvider<Patchset> compareMenuDp = new ListDataProvider<Patchset>(patchsets);
1023         DataView<Patchset> compareMenu = new DataView<Patchset>("comparePatch", compareMenuDp) {
1024             private static final long serialVersionUID = 1L;
1025             @Override
1026             public void populateItem(final Item<Patchset> item) {
1027                 Patchset patchset = item.getModelObject();
1028                 LinkPanel link = new LinkPanel("compareLink", null,
1029                         MessageFormat.format(getString("gb.compareToN"), patchset.number + "-" + patchset.rev),
1030                         ComparePage.class, WicketUtils.newRangeParameter(getRepositoryModel().name,
1031                                 patchset.tip, currentPatchset.tip), true);
1032                 item.add(link);
1033
1034             }
1035         };
1036         panel.add(compareMenu);
1037
1038
1039         // reviews
1040         List<Change> reviews = ticket.getReviews(currentPatchset);
1041         ListDataProvider<Change> reviewsDp = new ListDataProvider<Change>(reviews);
1042         DataView<Change> reviewsView = new DataView<Change>("reviews", reviewsDp) {
1043             private static final long serialVersionUID = 1L;
1044
1045             @Override
1046             public void populateItem(final Item<Change> item) {
1047                 Change change = item.getModelObject();
1048                 final String username = change.author;
1049                 UserModel user = app().users().getUserModel(username);
1050                 if (user == null) {
1051                     item.add(new Label("reviewer", username));
1052                 } else {
1053                     item.add(new LinkPanel("reviewer", null, user.getDisplayName(),
1054                             UserPage.class, WicketUtils.newUsernameParameter(username)));
1055                 }
1056
1057                 // indicate review score
1058                 Review review = change.review;
1059                 Label scoreLabel = new Label("score");
1060                 String scoreClass = getScoreClass(review.score);
1061                 String tooltip = getScoreDescription(review.score);
1062                 WicketUtils.setCssClass(scoreLabel, scoreClass);
1063                 if (!StringUtils.isEmpty(tooltip)) {
1064                     WicketUtils.setHtmlTooltip(scoreLabel, tooltip);
1065                 }
1066                 item.add(scoreLabel);
1067             }
1068         };
1069         panel.add(reviewsView);
1070
1071
1072         if (ticket.isOpen() && user.canReviewPatchset(repository)) {
1073             // can only review open tickets
1074             Review myReview = null;
1075             for (Change change : ticket.getReviews(currentPatchset)) {
1076                 if (change.author.equals(user.username)) {
1077                     myReview = change.review;
1078                 }
1079             }
1080
1081             // user can review, add review controls
1082             Fragment reviewControls = new Fragment("reviewControls", "reviewControlsFragment", this);
1083
1084             // show "approve" button if no review OR not current score
1085             if (user.canApprovePatchset(repository) && (myReview == null || Score.approved != myReview.score)) {
1086                 reviewControls.add(createReviewLink("approveLink", Score.approved));
1087             } else {
1088                 reviewControls.add(new Label("approveLink").setVisible(false));
1089             }
1090
1091             // show "looks good" button if no review OR not current score
1092             if (myReview == null || Score.looks_good != myReview.score) {
1093                 reviewControls.add(createReviewLink("looksGoodLink", Score.looks_good));
1094             } else {
1095                 reviewControls.add(new Label("looksGoodLink").setVisible(false));
1096             }
1097
1098             // show "needs improvement" button if no review OR not current score
1099             if (myReview == null || Score.needs_improvement != myReview.score) {
1100                 reviewControls.add(createReviewLink("needsImprovementLink", Score.needs_improvement));
1101             } else {
1102                 reviewControls.add(new Label("needsImprovementLink").setVisible(false));
1103             }
1104
1105             // show "veto" button if no review OR not current score
1106             if (user.canVetoPatchset(repository) && (myReview == null || Score.vetoed != myReview.score)) {
1107                 reviewControls.add(createReviewLink("vetoLink", Score.vetoed));
1108             } else {
1109                 reviewControls.add(new Label("vetoLink").setVisible(false));
1110             }
1111             panel.add(reviewControls);
1112         } else {
1113             // user can not review
1114             panel.add(new Label("reviewControls").setVisible(false));
1115         }
1116
1117         String insertions = MessageFormat.format("<span style=\"color:darkGreen;font-weight:bold;\">+{0}</span>", ticket.insertions);
1118         String deletions = MessageFormat.format("<span style=\"color:darkRed;font-weight:bold;\">-{0}</span>", ticket.deletions);
1119         panel.add(new Label("patchsetStat", MessageFormat.format(StringUtils.escapeForHtml(getString("gb.diffStat"), false),
1120                 insertions, deletions)).setEscapeModelStrings(false));
1121
1122         // changed paths list
1123         List<PathChangeModel> paths = JGitUtils.getFilesInRange(getRepository(), currentPatchset.base, currentPatchset.tip);
1124         ListDataProvider<PathChangeModel> pathsDp = new ListDataProvider<PathChangeModel>(paths);
1125         DataView<PathChangeModel> pathsView = new DataView<PathChangeModel>("changedPath", pathsDp) {
1126             private static final long serialVersionUID = 1L;
1127             int counter;
1128
1129             @Override
1130             public void populateItem(final Item<PathChangeModel> item) {
1131                 final PathChangeModel entry = item.getModelObject();
1132                 Label changeType = new Label("changeType", "");
1133                 WicketUtils.setChangeTypeCssClass(changeType, entry.changeType);
1134                 setChangeTypeTooltip(changeType, entry.changeType);
1135                 item.add(changeType);
1136                 item.add(new DiffStatPanel("diffStat", entry.insertions, entry.deletions, true));
1137
1138                 boolean hasSubmodule = false;
1139                 String submodulePath = null;
1140                 if (entry.isTree()) {
1141                     // tree
1142                     item.add(new LinkPanel("pathName", null, entry.path, TreePage.class,
1143                             WicketUtils
1144                                     .newPathParameter(repositoryName, currentPatchset.tip, entry.path), true));
1145                     item.add(new Label("diffStat").setVisible(false));
1146                 } else if (entry.isSubmodule()) {
1147                     // submodule
1148                     String submoduleId = entry.objectId;
1149                     SubmoduleModel submodule = getSubmodule(entry.path);
1150                     submodulePath = submodule.gitblitPath;
1151                     hasSubmodule = submodule.hasSubmodule;
1152
1153                     item.add(new LinkPanel("pathName", "list", entry.path + " @ " +
1154                             getShortObjectId(submoduleId), TreePage.class,
1155                             WicketUtils.newPathParameter(submodulePath, submoduleId, ""), true).setEnabled(hasSubmodule));
1156                     item.add(new Label("diffStat").setVisible(false));
1157                 } else {
1158                     // blob
1159                     String displayPath = entry.path;
1160                     String path = entry.path;
1161                     if (entry.isSymlink()) {
1162                         RevCommit commit = JGitUtils.getCommit(getRepository(), Constants.R_TICKETS_PATCHSETS + ticket.number);
1163                         path = JGitUtils.getStringContent(getRepository(), commit.getTree(), path);
1164                         displayPath = entry.path + " -> " + path;
1165                     }
1166
1167                     if (entry.changeType.equals(ChangeType.ADD)) {
1168                         // add show view
1169                         item.add(new LinkPanel("pathName", "list", displayPath, BlobPage.class,
1170                                 WicketUtils.newPathParameter(repositoryName, currentPatchset.tip, path), true));
1171                     } else if (entry.changeType.equals(ChangeType.DELETE)) {
1172                         // delete, show label
1173                         item.add(new Label("pathName", displayPath));
1174                     } else {
1175                         // mod, show diff
1176                         item.add(new LinkPanel("pathName", "list", displayPath, BlobDiffPage.class,
1177                                 WicketUtils.newPathParameter(repositoryName, currentPatchset.tip, path), true));
1178                     }
1179                 }
1180
1181                 // quick links
1182                 if (entry.isSubmodule()) {
1183                     // submodule
1184                     item.add(setNewTarget(new BookmarkablePageLink<Void>("diff", BlobDiffPage.class, WicketUtils
1185                             .newPathParameter(repositoryName, entry.commitId, entry.path)))
1186                             .setEnabled(!entry.changeType.equals(ChangeType.ADD)));
1187                     item.add(new BookmarkablePageLink<Void>("view", CommitPage.class, WicketUtils
1188                             .newObjectParameter(submodulePath, entry.objectId)).setEnabled(hasSubmodule));
1189                 } else {
1190                     // tree or blob
1191                     item.add(setNewTarget(new BookmarkablePageLink<Void>("diff", BlobDiffPage.class, WicketUtils
1192                             .newBlobDiffParameter(repositoryName, currentPatchset.base, currentPatchset.tip, entry.path)))
1193                             .setEnabled(!entry.changeType.equals(ChangeType.ADD)
1194                                     && !entry.changeType.equals(ChangeType.DELETE)));
1195                     item.add(setNewTarget(new BookmarkablePageLink<Void>("view", BlobPage.class, WicketUtils
1196                             .newPathParameter(repositoryName, currentPatchset.tip, entry.path)))
1197                             .setEnabled(!entry.changeType.equals(ChangeType.DELETE)));
1198                 }
1199
1200                 WicketUtils.setAlternatingBackground(item, counter);
1201                 counter++;
1202             }
1203         };
1204         panel.add(pathsView);
1205
1206         addPtReviewInstructions(user, repository, panel);
1207         addGitReviewInstructions(user, repository, panel);
1208         panel.add(createMergePanel(user, repository));
1209
1210         return panel;
1211     }
1212
1213     protected IconAjaxLink<String> createReviewLink(String wicketId, final Score score) {
1214         return new IconAjaxLink<String>(wicketId, getScoreClass(score), Model.of(getScoreDescription(score))) {
1215
1216             private static final long serialVersionUID = 1L;
1217
1218             @Override
1219             public void onClick(AjaxRequestTarget target) {
1220                 review(score);
1221             }
1222         };
1223     }
1224
1225     protected String getScoreClass(Score score) {
1226         switch (score) {
1227         case vetoed:
1228             return "fa fa-exclamation-circle";
1229         case needs_improvement:
1230             return "fa fa-thumbs-o-down";
1231         case looks_good:
1232             return "fa fa-thumbs-o-up";
1233         case approved:
1234             return "fa fa-check-circle";
1235         case not_reviewed:
1236         default:
1237             return "fa fa-minus-circle";
1238         }
1239     }
1240
1241     protected String getScoreDescription(Score score) {
1242         String description;
1243         switch (score) {
1244         case vetoed:
1245             description = getString("gb.veto");
1246             break;
1247         case needs_improvement:
1248             description = getString("gb.needsImprovement");
1249             break;
1250         case looks_good:
1251             description = getString("gb.looksGood");
1252             break;
1253         case approved:
1254             description = getString("gb.approve");
1255             break;
1256         case not_reviewed:
1257         default:
1258             description = getString("gb.hasNotReviewed");
1259         }
1260         return String.format("%1$s (%2$+d)", description, score.getValue());
1261     }
1262
1263     protected void review(Score score) {
1264         UserModel user = GitBlitWebSession.get().getUser();
1265         Patchset ps = ticket.getCurrentPatchset();
1266         Change change = new Change(user.username);
1267         change.review(ps, score, !ticket.isReviewer(user.username));
1268         if (!ticket.isWatching(user.username)) {
1269             change.watch(user.username);
1270         }
1271         TicketModel updatedTicket = app().tickets().updateTicket(getRepositoryModel(), ticket.number, change);
1272         app().tickets().createNotifier().sendMailing(updatedTicket);
1273         setResponsePage(TicketsPage.class, getPageParameters());
1274     }
1275
1276     protected <X extends MarkupContainer> X setNewTarget(X x) {
1277         x.add(new SimpleAttributeModifier("target", "_blank"));
1278         return x;
1279     }
1280
1281     protected void addGitReviewInstructions(UserModel user, RepositoryModel repository, MarkupContainer panel) {
1282         panel.add(new Label("gitStep1", MessageFormat.format(getString("gb.stepN"), 1)));
1283         panel.add(new Label("gitStep2", MessageFormat.format(getString("gb.stepN"), 2)));
1284
1285         String ticketBranch  = Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticket.number));
1286
bf426b 1287         String step1 = "git fetch";
2f8b4f 1288         String step2 = MessageFormat.format("git checkout {0} && git pull --ff-only\nOR\ngit checkout {0} && git reset --hard origin/{0}", ticketBranch);
5e3521 1289
JM 1290         panel.add(new Label("gitPreStep1", step1));
1291         panel.add(new Label("gitPreStep2", step2));
1292
1293         panel.add(createCopyFragment("gitCopyStep1", step1.replace("\n", " && ")));
1294         panel.add(createCopyFragment("gitCopyStep2", step2.replace("\n", " && ")));
1295     }
1296
1297     protected void addPtReviewInstructions(UserModel user, RepositoryModel repository, MarkupContainer panel) {
1298         String step1 = MessageFormat.format("pt checkout {0,number,0}", ticket.number);
1299         panel.add(new Label("ptPreStep", step1));
1300         panel.add(createCopyFragment("ptCopyStep", step1));
1301     }
1302
1303     /**
1304      * Adds a merge panel for the patchset to the markup container.  The panel
1305      * may just a message if the patchset can not be merged.
1306      *
1307      * @param c
1308      * @param user
1309      * @param repository
1310      */
1311     protected Component createMergePanel(UserModel user, RepositoryModel repository) {
1312         Patchset patchset = ticket.getCurrentPatchset();
1313         if (patchset == null) {
1314             // no patchset to merge
1315             return new Label("mergePanel");
1316         }
1317
1318         boolean allowMerge;
1319         if (repository.requireApproval) {
1320             // rpeository requires approval
1321             allowMerge = ticket.isOpen() && ticket.isApproved(patchset);
1322         } else {
1323             // vetos are binding
1324             allowMerge = ticket.isOpen() && !ticket.isVetoed(patchset);
1325         }
1326
1327         MergeStatus mergeStatus = JGitUtils.canMerge(getRepository(), patchset.tip, ticket.mergeTo);
1328         if (allowMerge) {
1329             if (MergeStatus.MERGEABLE == mergeStatus) {
1330                 // patchset can be cleanly merged to integration branch OR has already been merged
1331                 Fragment mergePanel = new Fragment("mergePanel", "mergeableFragment", this);
1332                 mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetMergeable"), ticket.mergeTo)));
1333                 if (user.canPush(repository)) {
1334                     // user can merge locally
1335                     SimpleAjaxLink<String> mergeButton = new SimpleAjaxLink<String>("mergeButton", Model.of(getString("gb.merge"))) {
1336
1337                         private static final long serialVersionUID = 1L;
1338
1339                         @Override
1340                         public void onClick(AjaxRequestTarget target) {
1341
1342                             // ensure the patchset is still current AND not vetoed
1343                             Patchset patchset = ticket.getCurrentPatchset();
1344                             final TicketModel refreshedTicket = app().tickets().getTicket(getRepositoryModel(), ticket.number);
1345                             if (patchset.equals(refreshedTicket.getCurrentPatchset())) {
1346                                 // patchset is current, check for recent veto
1347                                 if (!refreshedTicket.isVetoed(patchset)) {
1348                                     // patchset is not vetoed
1349
1350                                     // execute the merge using the ticket service
1351                                     app().tickets().exec(new Runnable() {
1352                                         @Override
1353                                         public void run() {
1354                                             PatchsetReceivePack rp = new PatchsetReceivePack(
1355                                                     app().gitblit(),
1356                                                     getRepository(),
1357                                                     getRepositoryModel(),
1358                                                     GitBlitWebSession.get().getUser());
1359                                             MergeStatus result = rp.merge(refreshedTicket);
1360                                             if (MergeStatus.MERGED == result) {
1361                                                 // notify participants and watchers
1362                                                 rp.sendAll();
1363                                             } else {
1364                                                 // merge failure
1365                                                 String msg = MessageFormat.format("Failed to merge ticket {0,number,0}: {1}", ticket.number, result.name());
1366                                                 logger.error(msg);
1367                                                 GitBlitWebSession.get().cacheErrorMessage(msg);
1368                                             }
1369                                         }
1370                                     });
1371                                 } else {
1372                                     // vetoed patchset
1373                                     String msg = MessageFormat.format("Can not merge ticket {0,number,0}, patchset {1,number,0} has been vetoed!",
1374                                             ticket.number, patchset.number);
1375                                     GitBlitWebSession.get().cacheErrorMessage(msg);
1376                                     logger.error(msg);
1377                                 }
1378                             } else {
1379                                 // not current patchset
1380                                 String msg = MessageFormat.format("Can not merge ticket {0,number,0}, the patchset has been updated!", ticket.number);
1381                                 GitBlitWebSession.get().cacheErrorMessage(msg);
1382                                 logger.error(msg);
1383                             }
1384
1385                             setResponsePage(TicketsPage.class, getPageParameters());
1386                         }
1387                     };
1388                     mergePanel.add(mergeButton);
1389                     Component instructions = getMergeInstructions(user, repository, "mergeMore", "gb.patchsetMergeableMore");
1390                     mergePanel.add(instructions);
1391                 } else {
1392                     mergePanel.add(new Label("mergeButton").setVisible(false));
1393                     mergePanel.add(new Label("mergeMore").setVisible(false));
1394                 }
1395                 return mergePanel;
1396             } else if (MergeStatus.ALREADY_MERGED == mergeStatus) {
1397                 // patchset already merged
1398                 Fragment mergePanel = new Fragment("mergePanel", "alreadyMergedFragment", this);
1399                 mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetAlreadyMerged"), ticket.mergeTo)));
1400                 return mergePanel;
1401             } else {
1402                 // patchset can not be cleanly merged
1403                 Fragment mergePanel = new Fragment("mergePanel", "notMergeableFragment", this);
1404                 mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetNotMergeable"), ticket.mergeTo)));
1405                 if (user.canPush(repository)) {
1406                     // user can merge locally
1407                     Component instructions = getMergeInstructions(user, repository, "mergeMore", "gb.patchsetNotMergeableMore");
1408                     mergePanel.add(instructions);
1409                 } else {
1410                     mergePanel.add(new Label("mergeMore").setVisible(false));
1411                 }
1412                 return mergePanel;
1413             }
1414         } else {
1415             // merge not allowed
1416             if (MergeStatus.ALREADY_MERGED == mergeStatus) {
1417                 // patchset already merged
1418                 Fragment mergePanel = new Fragment("mergePanel", "alreadyMergedFragment", this);
1419                 mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetAlreadyMerged"), ticket.mergeTo)));
1420                 return mergePanel;
1421             } else if (ticket.isVetoed(patchset)) {
1422                 // patchset has been vetoed
1423                 Fragment mergePanel =  new Fragment("mergePanel", "vetoedFragment", this);
1424                 mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetNotMergeable"), ticket.mergeTo)));
1425                 return mergePanel;
1426             } else if (repository.requireApproval) {
1427                 // patchset has been not been approved for merge
1428                 Fragment mergePanel = new Fragment("mergePanel", "notApprovedFragment", this);
1429                 mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetNotApproved"), ticket.mergeTo)));
1430                 mergePanel.add(new Label("mergeMore", MessageFormat.format(getString("gb.patchsetNotApprovedMore"), ticket.mergeTo)));
1431                 return mergePanel;
1432             } else {
1433                 // other case
1434                 return new Label("mergePanel");
1435             }
1436         }
1437     }
1438
1439     protected Component getMergeInstructions(UserModel user, RepositoryModel repository, String markupId, String infoKey) {
1440         Fragment cmd = new Fragment(markupId, "commandlineMergeFragment", this);
1441         cmd.add(new Label("instructions", MessageFormat.format(getString(infoKey), ticket.mergeTo)));
1442
1443         // git instructions
1444         cmd.add(new Label("mergeStep1", MessageFormat.format(getString("gb.stepN"), 1)));
1445         cmd.add(new Label("mergeStep2", MessageFormat.format(getString("gb.stepN"), 2)));
1446         cmd.add(new Label("mergeStep3", MessageFormat.format(getString("gb.stepN"), 3)));
1447
1448         String ticketBranch = Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticket.number));
1449         String reviewBranch = PatchsetCommand.getReviewBranch(ticket.number);
1450
bf426b 1451         String step1 = MessageFormat.format("git checkout -b {0} {1}", reviewBranch, ticket.mergeTo);
JM 1452         String step2 = MessageFormat.format("git pull origin {0}", ticketBranch);
1453         String step3 = MessageFormat.format("git checkout {0}\ngit merge {1}\ngit push origin {0}\ngit branch -d {1}", ticket.mergeTo, reviewBranch);
5e3521 1454
JM 1455         cmd.add(new Label("mergePreStep1", step1));
1456         cmd.add(new Label("mergePreStep2", step2));
1457         cmd.add(new Label("mergePreStep3", step3));
1458
1459         cmd.add(createCopyFragment("mergeCopyStep1", step1.replace("\n", " && ")));
1460         cmd.add(createCopyFragment("mergeCopyStep2", step2.replace("\n", " && ")));
1461         cmd.add(createCopyFragment("mergeCopyStep3", step3.replace("\n", " && ")));
1462
1463         // pt instructions
1464         String ptStep = MessageFormat.format("pt pull {0,number,0}", ticket.number);
1465         cmd.add(new Label("ptMergeStep", ptStep));
1466         cmd.add(createCopyFragment("ptMergeCopyStep", step1.replace("\n", " && ")));
1467         return cmd;
1468     }
1469
1470     /**
1471      * Returns the primary repository url
1472      *
1473      * @param user
1474      * @param repository
1475      * @return the primary repository url
1476      */
1477     protected String getRepositoryUrl(UserModel user, RepositoryModel repository) {
1478         HttpServletRequest req = ((WebRequest) getRequest()).getHttpServletRequest();
1479         String primaryurl = app().gitblit().getRepositoryUrls(req, user, repository).get(0).url;
1480         String url = primaryurl;
1481         try {
1482             url = new URIish(primaryurl).setUser(null).toString();
1483         } catch (Exception e) {
1484         }
1485         return url;
1486     }
1487
1488     /**
1489      * Returns the ticket (if any) that this commit references.
1490      *
1491      * @param commit
1492      * @return null or a ticket
1493      */
1494     protected TicketModel getTicket(RevCommit commit) {
1495         try {
1496             Map<String, Ref> refs = getRepository().getRefDatabase().getRefs(Constants.R_TICKETS_PATCHSETS);
1497             for (Map.Entry<String, Ref> entry : refs.entrySet()) {
1498                 if (entry.getValue().getObjectId().equals(commit.getId())) {
1499                     long id = PatchsetCommand.getTicketNumber(entry.getKey());
1500                     TicketModel ticket = app().tickets().getTicket(getRepositoryModel(), id);
1501                     return ticket;
1502                 }
1503             }
1504         } catch (Exception e) {
1505             logger().error("failed to determine ticket from ref", e);
1506         }
1507         return null;
1508     }
1509
1510     protected String getPatchsetTypeCss(PatchsetType type) {
1511         String typeCss;
1512         switch (type) {
1513             case Rebase:
1514             case Rebase_Squash:
1515                 typeCss = getLozengeClass(Status.Declined, false);
1516                 break;
1517             case Squash:
1518             case Amend:
1519                 typeCss = getLozengeClass(Status.On_Hold, false);
1520                 break;
1521             case Proposal:
1522                 typeCss = getLozengeClass(Status.New, false);
1523                 break;
1524             case FastForward:
1525             default:
1526                 typeCss = null;
1527             break;
1528         }
1529         return typeCss;
1530     }
1531
1532     @Override
1533     protected String getPageName() {
1534         return getString("gb.ticket");
1535     }
1536
1537     @Override
1538     protected Class<? extends BasePage> getRepoNavPageClass() {
1539         return TicketsPage.class;
1540     }
1541
1542     @Override
1543     protected String getPageTitle(String repositoryName) {
1544         return "#" + ticket.number + " - " + ticket.title;
1545     }
1546
1547     protected Fragment createCopyFragment(String wicketId, String text) {
1548         if (app().settings().getBoolean(Keys.web.allowFlashCopyToClipboard, true)) {
1549             // clippy: flash-based copy & paste
1550             Fragment copyFragment = new Fragment(wicketId, "clippyPanel", this);
1551             String baseUrl = WicketUtils.getGitblitURL(getRequest());
1552             ShockWaveComponent clippy = new ShockWaveComponent("clippy", baseUrl + "/clippy.swf");
1553             clippy.setValue("flashVars", "text=" + StringUtils.encodeURL(text));
1554             copyFragment.add(clippy);
1555             return copyFragment;
1556         } else {
1557             // javascript: manual copy & paste with modal browser prompt dialog
1558             Fragment copyFragment = new Fragment(wicketId, "jsPanel", this);
1559             ContextImage img = WicketUtils.newImage("copyIcon", "clippy.png");
1560             img.add(new JavascriptTextPrompt("onclick", "Copy to Clipboard (Ctrl+C, Enter)", text));
1561             copyFragment.add(img);
1562             return copyFragment;
1563         }
1564     }
1565 }