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